diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4cdd802 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +** +!package.json +!server/ +!server/server.mjs +!server/telemetry.mjs +!server/Dockerfile +!ui/ +!ui/index.html +!ui/styles.css +!ui/app.js +!ui/nginx.conf +!ui/Dockerfile diff --git a/.gitignore b/.gitignore index 1f1c64b..9e35c66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ Thumbs.db .DS_Store +node_modules/ +.env +__pycache__/ diff --git a/README.md b/README.md index b903860..097ebad 100644 --- a/README.md +++ b/README.md @@ -1 +1,87 @@ -# CARIA-AUTOMOTIVE \ No newline at end of file +# CARIA Automotive · Cockpit + +Première version de l’interface intérieure : accueil multimédia et instrumentation automobile, indépendants du matériel. Le projet scolaire original reste dans `WebControl/` ; le cockpit utilise `ui/` et `server/`. + +## Démarrage avec Docker + +Prérequis : Docker Engine et Docker Compose, avec des conteneurs Linux. + +```sh +docker compose up -d --build +``` + +Ouvrir **http://localhost:8080** sur la machine hôte, ou `http://ADRESSE_DU_SERVEUR:8080` depuis un écran connecté au même réseau. + +```sh +docker compose ps +docker compose logs --tail=50 +docker compose down +``` + +Le port est configurable avec `CARIA_PORT` dans un fichier `.env` à la racine (exemple : `CARIA_PORT=8090`). Deux services : Nginx sert l’interface et relaie `/api/` vers le simulateur Node.js. Seul le port de l’interface est publié. Aucun périphérique ni privilège matériel n’est demandé. + +Les images de base ne forcent pas l’architecture. La construction sur l’ODroid utilisera les variantes disponibles pour son système. **Le modèle, l’OS et l’architecture de l’ODroid restent à confirmer avant de garantir la compatibilité**, notamment si son OS est en 32 bits. Le déploiement Docker n’a pas pu être exécuté sur le poste de développement, où Docker est absent. + +## Démarrage sans Docker + +Avec Node.js 22 ou supérieur, sans installation de paquet : + +```sh +node server/server.mjs +``` + +Le serveur local sert également les trois fichiers de l’interface, sur le même port 8080. `HOST` et `PORT` permettent de modifier l’écoute. Docker reste la méthode prévue pour le déploiement final. + +## Fonctions disponibles + +- **Accueil** : heure/date de l’appareil, météo de démonstration, carburant/autonomie et accès à la musique. +- **Conduite** : vitesse et régime moteur à aiguilles SVG, rapport, carburant, température moteur, tension batterie, consommation et trajet simulés. +- **Musique** : sélection multiple de fichiers audio locaux, liste de lecture, lecture/pause, précédent/suivant, progression et volume via le lecteur natif. Les fichiers restent sur l’appareil qui affiche le navigateur ; ils ne sont pas transférés au serveur. La sélection doit être refaite après un rechargement. Une bibliothèque persistante sur disque/USB côté serveur pourra être ajoutée ensuite. +- **Streaming** : lien Jellyfin préconfiguré vers `https://media.cunatbrule.fr/web/`, modifiable dans les réglages. Ouverture dans un autre onglet ; aucune intégration de compte ni lecteur vidéo Jellyfin embarqué à ce stade. +- **Réglages** : ambiance jour/nuit, adresse du streaming, raccourcis pour ouvrir deux écrans et bouton plein écran. Les réglages sont conservés dans le stockage local du navigateur. + +Le menu à gauche contient les quatre applications : accueil, conduite, musique et streaming. Les réglages sont en bas. La météo est fictive et explicitement marquée « DÉMO » ; l’heure est réelle. L’interface et la musique sélectionnée fonctionnent sans Internet tant que le serveur local est accessible. Jellyfin dépend de l’accès à son serveur. + +## Un ou plusieurs écrans + +- Instrumentation : `http://ADRESSE_DU_SERVEUR:8080/#drive` +- Multimédia : `http://ADRESSE_DU_SERVEUR:8080/#home` + +Chaque écran ouvre sa propre page. Les valeurs proviennent du même serveur. Le profil route/ville/arrêt se synchronise entre les onglets d’un même navigateur et d’une même origine ; sur deux appareils, sélectionner le même profil sur chacun. Les fichiers audio et réglages restent propres à chaque navigateur. + +Docker héberge l’application ; **l’affichage tactile, le son et le navigateur en mode kiosque restent gérés par l’OS de l’ODroid**. L’écran peut aussi utiliser un serveur distant. Aucun environnement graphique n’est embarqué dans les conteneurs. + +## Architecture et suite CAN + +```text +Navigateur ODroid / tablette / second écran + │ HTTP :8080 + ▼ + Interface Nginx (ui/) + │ /api/telemetry + ▼ + Simulateur Node.js (server/) + +Étape suivante : passerelle Raspberry Pi CAN → données normalisées → interface +``` + +L’interface utilise HTML/CSS/JavaScript sans framework, sans CDN ni police distante. Les compteurs SVG s’adaptent à la résolution, avec une actualisation toutes les 500 ms et une transition d’aiguille. Le serveur utilise uniquement les modules standard de Node.js. Aucun gain de performance sur ODroid n’est encore mesuré. + +La passerelle CAN est un futur composant : pas de décodage Mégane III, de SocketCAN ni de commande véhicule dans cette version. Elle pourra être écrite indépendamment en Python, Rust, C++ ou JavaScript. Le contrat proposé est décrit dans [docs/architecture.md](docs/architecture.md). Le passage à des données réelles nécessitera également une adaptation de leur validation et de leurs indicateurs dans l’interface. + +Navigation, météo réelle, alertes radar et bibliothèque audio sur le serveur sont des étapes ultérieures. Les composants techniques choisis sont open source ; le dépôt historique n’a pas de licence globale, à définir avant une distribution publique du projet complet en tenant compte des fichiers tiers. + +## Validation + +```sh +node --test server/telemetry.test.mjs +node --check ui/app.js +``` + +Tests exécutés : contrat HTTP, limites des données simulées, profil invalide, API en lecture seule et impossibilité d’accéder aux fichiers du projet historique via le nouveau serveur. Tests Chrome automatisés : quatre vues à 1280×800, 1024×600, 800×480 et 390×844, absence de débordement horizontal, réglages persistants, lien Jellyfin, lecture de deux WAV locaux, changement de piste et retour après coupure API. Les captures accueil et conduite ont été examinées. Défilement vertical possible sur petits écrans selon la vue. + +À valider sur la cible : Docker/Compose, architecture ARM exacte, résolution et densité de l’écran, tactile, sortie audio, démarrage kiosque, consommation CPU/RAM et lecture Jellyfin réelle avec le compte utilisateur. Le lien Jellyfin a été vérifié dans l’interface, sans connexion au service ni test de son contenu. + +Voir [l’audit du dossier historique](docs/audit-initial.md). + +Références de déploiement : [services et healthchecks Docker Compose](https://docs.docker.com/reference/compose-file/services/), [image officielle Nginx](https://hub.docker.com/_/nginx). diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..b8ddb3e --- /dev/null +++ b/compose.yaml @@ -0,0 +1,30 @@ +name: caria +services: + interface: + build: + context: . + dockerfile: ui/Dockerfile + ports: + - "${CARIA_PORT:-8080}:80" + depends_on: + telemetry: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] + interval: 30s + timeout: 3s + retries: 3 + telemetry: + build: + context: . + dockerfile: server/Dockerfile + restart: unless-stopped + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 15s + timeout: 3s + retries: 3 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..68d8a88 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,47 @@ +# Contrat et déploiement + +## API de démonstration v1 + +`GET /api/health` renvoie `{"status":"ok","source":"demo"}`. + +`GET /api/telemetry?profile=route` renvoie un instantané JSON. Profils : `route`, `ville`, `arret`. Un profil inconnu renvoie HTTP 400. Les écritures renvoient HTTP 405. L’API n’accepte aucune commande moteur. + +| Champ | Type / unité | Usage | +| --- | --- | --- | +| schemaVersion | entier, 1 | Version du contrat | +| source | `demo` | Provenance explicite | +| timestamp | millisecondes Unix | Fraîcheur des données | +| profile | texte | Profil simulé | +| speedKph | nombre, km/h | Vitesse | +| rpm | nombre, tr/min | Régime moteur | +| gear | texte | Rapport, N à l’arrêt | +| fuelPercent | nombre, % | Niveau carburant | +| coolantCelsius | nombre, °C | Liquide de refroidissement | +| batteryVolts | nombre, V | Batterie | +| outsideCelsius | nombre, °C | Température extérieure | +| rangeKm | nombre, km | Autonomie | +| tripKm | nombre, km | Trajet fictif constant | +| consumptionL100 | nombre, L/100 km | Consommation fictive | +| weather | objet | Source, description, température fictives | + +La vitesse et le régime évoluent ; les autres valeurs sont des exemples fixes, sauf la température moteur à l’arrêt. Il ne s’agit pas d’un modèle physique du véhicule. Le temps du serveur produit une animation cohérente entre clients utilisant le même profil. + +Le client refuse les instantanés mal formés ou vieux de plus de 10 secondes, avec une limite de 3 secondes pour chaque requête. En cas de coupure il masque les aiguilles, remplace les mesures par des tirets et retente automatiquement. Les horloges du serveur et des écrans doivent être synchronisées ; un écart de plus de 10 secondes sera traité comme une donnée invalide. + +## Passage à une source CAN + +Prévoir un processus de lecture sur le Raspberry Pi, avec accès au périphérique CAN uniquement sur cette machine. La découverte des identifiants, facteurs, unités et fréquences des trames de la Mégane III reste à réaliser à partir des données du véhicule. Ne pas déduire les identifiants CAN de cette démonstration. + +La passerelle produira le contrat normalisé, avec une source `can`, des valeurs manquantes explicites et un horodatage par signal si nécessaire. La version actuelle du client n’accepte volontairement que `demo` : adapter la validation, les limites par champ et les indicateurs source/fraîcheur lors de cette étape. Choisir ensuite le transport Raspberry → service (HTTP, MQTT ou WebSocket) selon le réseau et la fréquence nécessaires. + +Le simulateur et la future passerelle doivent être sélectionnés explicitement ; ne jamais substituer silencieusement des valeurs fictives à une liaison CAN perdue. + +## Évolutions indépendantes + +- Bibliothèque audio persistante : volume en lecture seule, catalogue et prise en charge des requêtes Range ; la musique serait alors lue depuis le serveur plutôt que choisie dans l’appareil client. +- Météo réelle : fournisseur à définir, position configurable, cache et date de dernière mise à jour. +- Cartographie/radar : fournisseur open source, besoin hors ligne, zone géographique et données à définir avec l’utilisateur. +- Affichage : kiosque Linux et configuration des moniteurs au niveau de l’OS ; paramètres propres à chaque écran si nécessaire. +- Distribution : figer les images par version/digest une fois la première construction ARM validée ; ajouter une construction automatisée multiarchitecture au dépôt si son hébergement CI le permet. + +Les tags Docker actuels suivent Node 22 Alpine et Nginx stable Alpine. Ils facilitent ce prototype mais ne rendent pas les builds strictement reproductibles. diff --git a/docs/audit-initial.md b/docs/audit-initial.md new file mode 100644 index 0000000..4ce0b2b --- /dev/null +++ b/docs/audit-initial.md @@ -0,0 +1,57 @@ +# Audit initial — 12 septembre 2026 + +## Périmètre + +Inventaire de tous les fichiers de travail de CARIA-AUTOMOTIVE, examen du serveur, des interfaces, des scripts de lancement, des modules et scénarios Python et des dépendances. `WebControl/` contient 53 fichiers, environ 2,33 Mo. Le projet comporte aussi `.gitignore`, un README initial minimal et les métadonnées Git. Les binaires, polices, bibliothèques JavaScript/CSS tierces et journaux ont été inventoriés ; ils ne font pas l’objet d’un audit exhaustif de leur contenu. Les certificats n’ont pas été reproduits dans le rapport. + +L’application matérielle n’a pas été exécutée : elle initialise les GPIO et le port série et certains scripts commandent les moteurs. Aucune connexion au véhicule n’a été tentée. + +## Organisation trouvée + +| Dossier / fichier | Rôle | +| --- | --- | +| `WebControl/main.py` | Serveur Bottle, commandes robot, pages, résultats d’accès et commandes système | +| `WebControl/MainCLIControl.py` | Menu CLI de lancement des scénarios/tests | +| `WebControl/templates/` | Administration, accès, page de tests presque vide | +| `WebControl/js/` et `css/` | jQuery, Bootstrap et compteur de vitesse bitmap | +| `WebControl/images/` et `fonts/` | Fond du compteur, aiguille, police LED et Glyphicons | +| `WebControl/modules/AlphaBot.py` | GPIO, moteurs PWM, servo, lidar série, obstacles et journalisation | +| `WebControl/modules/*MFRC522*` | Lecteur RFID et accès SPI | +| `WebControl/apps/` | RFID, scénario de porte, itinéraires, lidar et évitement d’obstacles | +| `WebControl/tests/` | Essais physiques, pas une suite de tests unitaires | +| `WebControl/logs/` | Journaux d’exécution, résultats et trajets JSON | +| `WebControl/install/` | Liste figée de nombreux paquets du poste Raspberry Pi | +| `WebControl/fixs/`, `webcontrol-launch.sh`, `NOTE.txt` | Redémarrage, Motion, RFID, crontab et sudo | +| `WebControl/sendMyIP.py` | Exemple d’envoi d’adresse IP vers un endpoint fictif | +| `WebControl/ssl/` | Certificat et clé présents dans le dépôt historique | + +## Constats qui déterminent la nouvelle architecture + +1. **Couplage matériel au démarrage** : `main.py` instancie `AlphaBot`, qui ouvre `/dev/ttyAMA0` et initialise les GPIO. L’interface ne peut donc pas être simplement transférée sur un PC ou ODroid sans le matériel et ces bibliothèques. +2. **Chemins et adresses figés** : `/home/christian/WebControl`, adresses `192.168.253.194`, commandes `sudo`, dépendance à Motion. Le nouveau cockpit utilise des URL relatives et une configuration Docker. +3. **Vitesse non mesurée** : le curseur historique pilote un rapport cyclique PWM ; ce n’est pas une vitesse réelle en km/h. Le nouveau compteur est alimenté par des valeurs explicitement simulées. Son mapping d’aiguille couvre directement -130° à +130°. +4. **Distribution des fichiers trop large** : la route générique de Bottle sert depuis `./`, avec en plus des routes vers modules, scripts et templates. Avec le répertoire de lancement prévu, les sources et fichiers SSL peuvent être demandés via HTTP. La nouvelle image web ne copie que ses fichiers publics ; l’ancien dossier est exclu du contexte Docker. +5. **Commandes système et robot accessibles par HTTP sans authentification apparente** : reboot, Motion, scripts et moteurs sont mélangés à l’administration. Ces fonctions ne font pas partie du nouveau cockpit, qui fournit une API en lecture seule. +6. **Dépendances non portables** : le fichier d’installation ressemble à un export complet du système scolaire, incluant GPIO, bureau et paquets anciens. La nouvelle version n’en dépend pas. +7. **Licence globale absente** : certains modules tiers contiennent des notices propres (MFRC522 notamment). Il faudra clarifier les licences avant diffusion publique du dépôt complet. + +## Anomalies historiques repérées (non corrigées dans ce lot) + +- `js/script.js` associe tous les boutons à `/cmd`, y compris ceux sans lien avec la conduite, et lie plusieurs événements aux curseurs. +- `js/test_status.js` appelle `/test_status`, sans route correspondante dans le serveur lu. +- Le template utilise `testRFIDCarDoor`, alors que le serveur attend `appRFIDCarDoor`. +- `modules/joyit_mfrc522/__init__.py` importe `.SimpleMFRC522`, absent de ce sous-dossier ; un fichier homonyme est au niveau supérieur. +- `itineraire_suivre_emergency_speed.py` attend sur `Ab.vitesse_queue.get()` avant de démarrer le producteur. `execute_maneuver` y a deux paramètres mais plusieurs appels en passent trois. +- `AlphaBot.monitor_obstacle` utilise une variable globale du module, distincte de celle des scénarios ; sa boucle d’attente d’un obstacle ne consulte pas l’événement d’arrêt. +- `AlphaBot.monitor_vitesse` peut boucler sans pause lorsqu’aucune trame n’est disponible. +- Le scénario RFID attribue `scenario successful` puis compare le statut final à `movement successful`. + +Ces éléments justifient de conserver le projet scolaire comme référence et de développer l’interface multimédia dans des dossiers dédiés. Ils ne constituent pas une certification du fonctionnement des autres chemins du robot. + +## Références DEMO-Interfaces + +Inventaire du launcher Python/Tkinter NUC (raccourcis Windows, applications, images, arrière-plans), de sa capture `Interface GUI 2025/interface.png` et des projets Tasker auto/moto (XML, APK et images). Le launcher repose sur des chemins Windows et des exécutables locaux. Inspiration retenue : applications clairement identifiables, grands boutons et thème sombre. La nouvelle interface réinterprète ces principes avec quatre accès latéraux et un accueil central ; les exécutables/APK n’ont pas été lancés et les assets tiers n’ont pas été copiés. + +## Livraison de cette étape + +`ui/` fournit les vues multimédia et conduite ; `server/` fournit les données de démonstration ; `compose.yaml` sépare les deux services. Le code historique reste inchangé. Voir le README pour les validations exécutées et les limites matérielles restantes. diff --git a/package.json b/package.json new file mode 100644 index 0000000..8a35b13 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "caria-cockpit", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { "start": "node server/server.mjs", "test": "node --test server/*.test.mjs" }, + "engines": { "node": ">=22" } +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..dfbb328 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +COPY server ./server +USER node +ENV PORT=8080 +EXPOSE 8080 +CMD ["node", "server/server.mjs"] diff --git a/server/server.mjs b/server/server.mjs new file mode 100644 index 0000000..5b73e7f --- /dev/null +++ b/server/server.mjs @@ -0,0 +1,38 @@ +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { telemetry, profiles } from './telemetry.mjs'; + +const assets = new Map([ + ['/', ['index.html', 'text/html']], ['/index.html', ['index.html', 'text/html']], + ['/app.js', ['app.js', 'text/javascript']], ['/styles.css', ['styles.css', 'text/css']] +]); +export function createServer() { + return http.createServer(async (req, res) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Cache-Control', 'no-store'); + const send = (status, value) => { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(value)); + }; + if (req.method !== 'GET' && req.method !== 'HEAD') return send(405, { error: 'Lecture seule' }); + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/api/health') return send(200, { status: 'ok', source: 'demo' }); + if (url.pathname === '/api/telemetry') { + const profile = url.searchParams.get('profile') || 'route'; + return profiles.includes(profile) ? send(200, telemetry(profile)) : send(400, { error: 'Profil inconnu' }); + } + const asset = assets.get(url.pathname); + if (!asset) return send(404, { error: 'Introuvable' }); + try { + const content = await readFile(new URL(`../ui/${asset[0]}`, import.meta.url)); + res.writeHead(200, { 'Content-Type': `${asset[1]}; charset=utf-8` }); + res.end(req.method === 'HEAD' ? undefined : content); + } catch { send(500, { error: 'Interface indisponible' }); } + }); +} +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const port = Number(process.env.PORT || 8080); + createServer().listen(port, process.env.HOST || '0.0.0.0', () => console.log(`CARIA : http://localhost:${port}`)); +} diff --git a/server/telemetry.mjs b/server/telemetry.mjs new file mode 100644 index 0000000..ff7c35f --- /dev/null +++ b/server/telemetry.mjs @@ -0,0 +1,18 @@ +export const profiles = ['route', 'ville', 'arret']; + +// All displays use the same clock: no GPIO, CAN or vehicle commands here. +export function telemetry(profile = 'route', now = Date.now()) { + if (!profiles.includes(profile)) throw new RangeError('Profil inconnu'); + const t = now / 1000; + const speed = profile === 'arret' ? 0 : Math.round(profile === 'ville' + ? 23 + 23 * Math.sin(t / 18) : 82 + 12 * Math.sin(t / 15)); + const gear = speed === 0 ? 'N' : String(Math.min(6, Math.max(1, Math.ceil(speed / 20)))); + return { + schemaVersion: 1, source: 'demo', timestamp: now, profile, + speedKph: speed, rpm: speed === 0 ? 850 : Math.round(1400 + speed * 13 + 100 * Math.sin(t / 3)), + fuelPercent: 68, coolantCelsius: profile === 'arret' ? 76 : 89, + batteryVolts: 14.2, outsideCelsius: 21, rangeKm: 540, + tripKm: 42.6, consumptionL100: 5.8, gear, + weather: { source: 'demo', label: 'Éclaircies', celsius: 21 } + }; +} diff --git a/server/telemetry.test.mjs b/server/telemetry.test.mjs new file mode 100644 index 0000000..29f3d19 --- /dev/null +++ b/server/telemetry.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { telemetry, profiles } from './telemetry.mjs'; +import { createServer } from './server.mjs'; + +test('demo values remain bounded and identical across displays', () => { + for (const profile of profiles) for (let t = 0; t < 300000; t += 250) { + const d = telemetry(profile, t); + assert.ok(d.speedKph >= 0 && d.speedKph <= 220); + assert.ok(d.rpm >= 0 && d.rpm <= 7000); + assert.equal(d.source, 'demo'); + assert.deepEqual(d, telemetry(profile, t)); + if (profile === 'arret') assert.equal(d.speedKph, 0); + } + assert.throws(() => telemetry('can'), RangeError); +}); + +test('HTTP contract, invalid profiles, isolation from legacy and read-only API', async () => { + const server = createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + try { + assert.equal((await (await fetch(`${base}/api/health`)).json()).status, 'ok'); + assert.equal((await (await fetch(`${base}/api/telemetry?profile=arret`)).json()).speedKph, 0); + assert.equal((await fetch(`${base}/api/telemetry?profile=wrong`)).status, 400); + assert.equal((await fetch(`${base}/api/telemetry`, { method: 'POST' })).status, 405); + for (const path of ['/WebControl/main.py', '/ssl/key.pem', '/package.json', '/cmd']) { + assert.equal((await fetch(base + path)).status, 404); + } + assert.match(await (await fetch(base)).text(), /CARIA/); + } finally { await new Promise(resolve => server.close(resolve)); } +}); diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..7a4ff6d --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:stable-alpine +COPY ui/nginx.conf /etc/nginx/conf.d/default.conf +COPY ui/index.html ui/styles.css ui/app.js /usr/share/nginx/html/ +EXPOSE 80 diff --git a/ui/app.js b/ui/app.js new file mode 100644 index 0000000..e0f786f --- /dev/null +++ b/ui/app.js @@ -0,0 +1,181 @@ +const $ = selector => document.querySelector(selector); +const all = selector => document.querySelectorAll(selector); +const storage = { + get(key, fallback = '') { try { return localStorage.getItem(`caria.${key}`) ?? fallback; } catch { return fallback; } }, + set(key, value) { try { localStorage.setItem(`caria.${key}`, value); return true; } catch { return false; } } +}; +function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 4000); } +function navigate() { + const view = ['home', 'drive', 'music', 'stream'].includes(location.hash.slice(1)) ? location.hash.slice(1) : 'home'; + all('.view').forEach(section => section.hidden = section.id !== view); + all('[data-view]').forEach(link => { + const active = link.dataset.view === view; + link.classList.toggle('active', active); + if (active) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current'); + }); + document.title = `CARIA · ${$(`[data-view="${view}"]`).textContent.trim()}`; +} +window.addEventListener('hashchange', navigate); +navigate(); + +function clock() { + const date = new Date(); + const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + ['#hero-clock', '#header-clock'].forEach(id => { $(id).textContent = time; $(id).dateTime = date.toISOString(); }); + $('#date').textContent = date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); +} +clock(); setInterval(clock, 1000); + +function makeGauge(id, max, step, unit, label) { + const point = (angle, radius) => { + const r = angle * Math.PI / 180; + return [150 + radius * Math.sin(r), 150 - radius * Math.cos(r)]; + }; + let ticks = ''; + for (let v = 0; v <= max; v += step / 4) { + const angle = -130 + v / max * 260; + const major = v % step === 0; + const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 97); + ticks += ``; + if (major) ticks += `${max === 7000 ? v / 1000 : v}`; + } + $(id).innerHTML = `${ticks}${unit}—${label}`; + return value => { + const valid = typeof value === 'number' && Number.isFinite(value); + $(`${id} .dial-value`).textContent = valid ? Math.round(value).toLocaleString('fr-FR') : '—'; + $(`${id} .needle`).style.opacity = valid ? '1' : '0'; + if (valid) $(`${id} .needle`).style.transform = `rotate(${-130 + Math.max(0, Math.min(max, value)) / max * 260}deg)`; + $(`${id} svg`).setAttribute('aria-label', `${label} : ${valid ? Math.round(value) : 'indisponible'} ${max === 7000 ? 'tr/min' : 'km/h'}`); + }; +} +const speedGauge = makeGauge('#speed-gauge', 220, 20, 'KM/H', 'VITESSE'); +const rpmGauge = makeGauge('#rpm-gauge', 7000, 1000, '× 1 000 TR/MIN', 'RÉGIME MOTEUR'); +const savedProfile = storage.get('profile', 'route'); +$('#profile').value = ['route', 'ville', 'arret'].includes(savedProfile) ? savedProfile : 'route'; +let generation = 0, timer; +function offline() { + $('#connection').textContent = 'Données indisponibles'; + $('#connection').classList.add('error'); + $('#footer-status').textContent = 'Connexion perdue · tentative de reconnexion'; + document.body.classList.add('stale'); + all('[data-value]').forEach(el => el.textContent = '—'); + all('[data-fuel]').forEach(el => el.style.width = '0%'); + speedGauge(null); rpmGauge(null); +} +async function poll() { + const current = ++generation; + clearTimeout(timer); + try { + const response = await fetch(`/api/telemetry?profile=${encodeURIComponent($('#profile').value)}`, { signal: AbortSignal.timeout(3000), cache: 'no-store' }); + if (!response.ok) throw new Error('API'); + const data = await response.json(); + if (current !== generation) return; + const numeric = ['speedKph', 'rpm', 'fuelPercent', 'coolantCelsius', 'batteryVolts', 'outsideCelsius', 'rangeKm', 'tripKm', 'consumptionL100']; + if (data.schemaVersion !== 1 || data.source !== 'demo' || !Number.isFinite(data.timestamp) || Math.abs(Date.now() - data.timestamp) > 10000 || numeric.some(key => !Number.isFinite(data[key]))) throw new Error('Données invalides'); + $('#connection').textContent = '● Démonstration'; + $('#connection').classList.remove('error'); + document.body.classList.remove('stale'); + $('#footer-status').textContent = 'Mode démonstration · CAN non connecté'; + all('[data-value]').forEach(el => { + const value = data[el.dataset.value]; + el.textContent = typeof value === 'number' ? value.toLocaleString('fr-FR', { maximumFractionDigits: 1 }) : value; + }); + all('[data-fuel]').forEach(el => el.style.width = `${Math.max(0, Math.min(100, data.fuelPercent))}%`); + speedGauge(data.speedKph); rpmGauge(data.rpm); + } catch { if (current === generation) offline(); } + finally { if (current === generation) timer = setTimeout(poll, 500); } +} +$('#profile').addEventListener('change', () => { storage.set('profile', $('#profile').value); poll(); }); +window.addEventListener('storage', event => { + if (event.key === 'caria.profile' && ['route', 'ville', 'arret'].includes(event.newValue)) { $('#profile').value = event.newValue; poll(); } + if (event.key === 'caria.stream') configureStream(); + if (event.key === 'caria.theme') document.body.classList.toggle('light', event.newValue === 'light'); +}); +poll(); + +function validUrl(value) { + try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : ''; } catch { return ''; } +} +const defaultStream = 'https://media.cunatbrule.fr/web/'; +function configureStream() { + const url = validUrl(storage.get('stream', defaultStream)); + $('#stream-link').hidden = !url; + if (url) $('#stream-link').href = url; else $('#stream-link').removeAttribute('href'); + $('#stream-description').textContent = url ? new URL(url).hostname : 'Ajoutez l’adresse de votre site dans les réglages.'; + $('#stream-configure').textContent = url ? 'Modifier le site' : 'Configurer mon site'; +} +configureStream(); +document.body.classList.toggle('light', storage.get('theme') === 'light'); +function openSettings() { + $('#stream-url').value = storage.get('stream', defaultStream); + $('#theme').value = document.body.classList.contains('light') ? 'light' : 'dark'; + $('#settings-message').textContent = ''; + $('#settings').showModal(); +} +$('#settings-open').onclick = openSettings; +$('#stream-configure').onclick = openSettings; +$('#settings-close').onclick = () => $('#settings').close(); +$('#settings-form').addEventListener('submit', event => { + event.preventDefault(); + const raw = $('#stream-url').value.trim(), url = validUrl(raw); + if (raw && !url) { $('#settings-message').textContent = 'Saisissez une adresse HTTP ou HTTPS, sans identifiant ni mot de passe.'; return; } + const streamSaved = storage.set('stream', url), themeSaved = storage.set('theme', $('#theme').value); + if (!streamSaved || !themeSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; } + document.body.classList.toggle('light', $('#theme').value === 'light'); + configureStream(); $('#settings').close(); toast('Réglages enregistrés'); +}); +$('#fullscreen').onclick = async () => { + try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } + catch { toast('Le plein écran n’est pas disponible dans ce navigateur.'); } +}; + +const audio = $('#audio'); +let tracks = [], selected = -1; +function renderPlaylist() { + $('#playlist').replaceChildren(); + tracks.forEach((track, index) => { + const li = document.createElement('li'), button = document.createElement('button'); + const number = document.createElement('span'), name = document.createElement('b'); + number.textContent = String(index + 1).padStart(2, '0'); name.textContent = track.name; + button.append(number, name); button.classList.toggle('selected', index === selected); + button.setAttribute('aria-pressed', String(index === selected)); button.onclick = () => selectTrack(index, true); + li.append(button); $('#playlist').append(li); + }); + $('#track-count').textContent = `${tracks.length} TITRE${tracks.length > 1 ? 'S' : ''}`; + $('#empty-library').hidden = tracks.length > 0; + ['#play', '#previous', '#next'].forEach(id => $(id).disabled = tracks.length === 0); +} +async function playAudio() { + try { await audio.play(); $('#audio-message').textContent = 'Lecture locale · aucun fichier envoyé au serveur.'; } + catch { $('#audio-message').textContent = 'Lecture impossible : réessayez ou choisissez un format audio pris en charge.'; } +} +function selectTrack(index, play = false) { + if (!tracks.length) return; + selected = (index + tracks.length) % tracks.length; + audio.src = tracks[selected].url; + $('#track-title').textContent = tracks[selected].name; + $('#home-track').textContent = tracks[selected].name; + $('#track-subtitle').textContent = `Morceau ${selected + 1} sur ${tracks.length} · Bibliothèque locale`; + $('#home-track-sub').textContent = 'Bibliothèque locale'; + renderPlaylist(); if (play) playAudio(); +} +$('#music-files').addEventListener('change', event => { + let added = 0; + for (const file of event.target.files) { + if (!file.type.startsWith('audio/') && !/\.(mp3|flac|ogg|wav|m4a|aac|opus)$/i.test(file.name)) continue; + tracks.push({ name: file.name.replace(/\.[^.]+$/, ''), url: URL.createObjectURL(file) }); added++; + } + renderPlaylist(); + if (selected === -1 && tracks.length) selectTrack(0); + toast(added ? `${added} morceau${added > 1 ? 'x' : ''} ajouté${added > 1 ? 's' : ''}` : 'Aucun fichier audio reconnu'); + event.target.value = ''; +}); +$('#play').onclick = () => { if (selected === -1) return; if (audio.paused) playAudio(); else audio.pause(); }; +$('#previous').onclick = () => selectTrack(selected - 1, true); +$('#next').onclick = () => selectTrack(selected + 1, true); +audio.addEventListener('play', () => $('#play').textContent = 'Pause'); +audio.addEventListener('pause', () => $('#play').textContent = 'Lecture'); +audio.addEventListener('ended', () => { if (selected + 1 < tracks.length) selectTrack(selected + 1, true); else $('#play').textContent = 'Lecture'; }); +audio.addEventListener('error', () => $('#audio-message').textContent = 'Ce fichier ne peut pas être lu. Essayez un autre format audio.'); +window.addEventListener('pagehide', event => { if (!event.persisted) tracks.forEach(track => URL.revokeObjectURL(track.url)); }); +renderPlaylist(); diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..051079f --- /dev/null +++ b/ui/index.html @@ -0,0 +1,51 @@ + + + + + CARIA · Cockpit + + + +
+ +
+
CARIAAUTOMOTIVE
Connexion…21° EXT. · DÉMO
+
+
+

VOTRE ESPACE À BORD

Le plaisir du trajet.

Cockpit personnel / 01
+
+
BONJOUR, CONDUCTEUR

—

Un nouvel horizon vous attend.CARIA / ON BOARD
+
À L’EXTÉRIEURDÉMO
☀21°

Quelques éclaircies

Une belle journée pour prendre la route.

Min. 16°Max. 24°Vent 8 km/h
+ + +
+
+ + + +
+
COCKPIT / VERSION 0.1Mode démonstration · CAN non connectéFAIT POUR LE VOYAGE ↗
+
+
+

Votre cockpit

Enregistrée dans ce navigateur. Laissez vide pour retirer le lien.

Plusieurs écrans : ouvrez la conduite sur l’un et l’accueil sur l’autre.

Météo et véhicule : démonstration. Navigation et radar seront ajoutés dans une prochaine étape.

+ + + diff --git a/ui/nginx.conf b/ui/nginx.conf new file mode 100644 index 0000000..5cbcb35 --- /dev/null +++ b/ui/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + add_header X-Content-Type-Options nosniff; + location /api/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $backend http://telemetry:8080; + proxy_pass $backend; + proxy_connect_timeout 2s; + proxy_read_timeout 5s; + } + location / { try_files $uri $uri/ =404; } +} diff --git a/ui/styles.css b/ui/styles.css new file mode 100644 index 0000000..33639b7 --- /dev/null +++ b/ui/styles.css @@ -0,0 +1,12 @@ +:root{color-scheme:dark;--bg:#101313;--surface:#191d1d;--surface2:#202626;--border:#303737;--text:#f0f3ed;--muted:#9aa7a4;--accent:#d2ef92;--ink:#182015;--shadow:0 12px 36px #0002;font-family:Inter,"Segoe UI",sans-serif} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button,a{touch-action:manipulation}button,select{cursor:pointer}a{color:inherit;text-decoration:none}button{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:12px;min-height:44px;padding:10px 15px}button:hover,a.pill:hover{filter:brightness(1.15)}:focus-visible{outline:3px solid var(--accent);outline-offset:4px}[hidden]{display:none!important}.shell{display:flex;min-height:100dvh}.rail{width:106px;flex-shrink:0;border-right:1px solid var(--border);display:flex;align-items:center;flex-direction:column;padding:22px 10px;gap:35px;background:#141818}.brand{font-size:37px;font-weight:800;font-style:italic;letter-spacing:-5px}.brand span{color:var(--accent)}.rail nav{display:flex;flex-direction:column;gap:14px;width:100%;margin:auto 0}.rail nav a,.settings-button{display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;min-height:78px;font-size:11px;font-weight:600;border-radius:15px;color:var(--muted);border:1px solid transparent;background:none;width:100%}.rail nav a:hover{background:var(--surface2)}.rail nav a.active{background:var(--accent);color:var(--ink)}.icon{font-size:27px;line-height:1}.workspace{flex:1;min-width:0;display:flex;flex-direction:column}header{height:85px;display:flex;align-items:center;justify-content:space-between;padding:0 36px;border-bottom:1px solid var(--border);gap:18px}.wordmark{font-weight:800;font-size:24px;letter-spacing:5px}.wordmark span{display:block;font-size:8px;letter-spacing:3px;color:var(--muted);margin-top:3px}.header-right{display:flex;align-items:center;gap:24px;font-variant-numeric:tabular-nums}.header-right time{font-size:18px}.header-temp{font-size:18px}.header-temp small{font-size:9px;color:var(--muted)}.badge,.label{font-size:9px;font-weight:650;letter-spacing:1px;border:1px solid var(--border);border-radius:6px;padding:6px 9px;white-space:nowrap}.badge{color:var(--accent)}.badge.error{color:#ffb397}.header-right button{background:none;font-size:25px;border:0;padding:4px 10px}main{padding:28px 36px;flex:1}.page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;gap:15px}.eyebrow{font-size:10px;letter-spacing:1.8px;color:var(--muted);font-weight:600;margin:0 0 10px}h1{font-weight:500;font-size:29px;letter-spacing:-1px;margin:0}h2{font-size:21px;font-weight:500;margin:0 0 10px}h3{font-weight:500}p{line-height:1.6}.subtle{color:var(--muted);font-size:12px}.accent{color:var(--accent)}.card{border:1px solid var(--border);border-radius:19px;background:var(--surface);padding:24px;overflow:hidden;box-shadow:var(--shadow)}.card-top{display:flex;align-items:center;justify-content:space-between;gap:10px}.card-top .eyebrow{margin:0}.tiny-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px #d2ef9240}.home-grid{display:grid;grid-template-columns:1.45fr 1fr;gap:18px}.clock-card{min-height:310px;position:relative;background:#222e2b}.clock-card>time{position:relative;z-index:2;display:block;font-size:clamp(60px,8vw,108px);font-weight:300;letter-spacing:-6px;margin-top:16px;line-height:1.1;font-variant-numeric:tabular-nums}.clock-card>p{position:relative;z-index:2;color:#bdc9c0;font-size:14px;margin-top:8px}.clock-bottom{position:absolute;bottom:22px;left:24px;right:24px;display:flex;justify-content:space-between;gap:12px;font-size:10px;color:#bbcabc;z-index:2}.clock-bottom span:last-child{font-size:8px;letter-spacing:1px}.horizon{position:absolute;right:0;bottom:0;width:70%;height:85%;overflow:hidden;opacity:.7}.sun{width:83px;height:83px;border-radius:50%;background:#c9db9a;position:absolute;right:15%;top:20%;box-shadow:0 0 80px #d2ef9230}.mountain{position:absolute;width:130%;height:80%;background:#344d41;clip-path:polygon(0 100%,40% 4%,100% 100%);bottom:-15%;right:-30%;transform:rotate(-6deg)}.mountain.two{right:-47%;bottom:-30%;background:#172b28;transform:rotate(20deg)}.road{position:absolute;bottom:-20px;left:0;width:100%;height:65%;background:#758372;clip-path:polygon(67% 10%,69% 10%,42% 65%,65% 100%,49% 100%,37% 65%)}.weather-card{display:flex;flex-direction:column}.weather-main{display:flex;align-items:center;gap:24px;margin:18px 0 10px}.weather-sun{color:var(--accent);font-size:65px}.weather-main strong{font-size:67px;line-height:1;font-weight:300}.weather-main strong span{color:var(--muted)}.weather-card p{color:var(--muted);font-size:12px;margin-top:0}.weather-bottom{display:flex;justify-content:space-between;padding-top:20px;border-top:1px solid var(--border);margin-top:auto;gap:8px;font-size:10px;color:var(--muted)}.weather-bottom b{color:var(--text);font-weight:500}.vehicle-card h2{margin-top:20px}.vehicle-stats{display:flex;justify-content:space-between;margin-top:16px}.vehicle-stats strong{display:block;font-size:30px;font-weight:400}.vehicle-stats div>span{font-size:11px;color:var(--muted)}small{font-size:14px}.fuel-track{height:5px;background:var(--border);border-radius:5px;margin:16px 0;overflow:hidden}.fuel-track span{height:100%;width:0;background:var(--accent);display:block;transition:width .4s}.text-link{display:flex;justify-content:space-between;font-size:12px;padding:5px 0;min-height:34px}.text-link span{color:var(--accent)}.music-card{display:flex;align-items:center;gap:22px}.music-card h2{font-size:21px;overflow-wrap:anywhere}.music-card p:not(.eyebrow){font-size:12px;color:var(--muted)}.album-art{position:relative;aspect-ratio:1;width:125px;flex-shrink:0;border-radius:12px;background:linear-gradient(140deg,#a4b793,#38544a);overflow:hidden;display:grid;place-items:center}.record{width:80%;height:80%;border-radius:50%;background:repeating-radial-gradient(circle,#14221f 0px,#14221f 2px,#2f423b 3px,#182821 4px);box-shadow:8px 12px 20px #0005;position:relative}.record:after{content:'';position:absolute;inset:36%;background:var(--accent);border:10px solid #6e8a62;border-radius:50%}.album-art>span{position:absolute;bottom:10px;left:10px;font-size:8px;letter-spacing:2px;color:#e9f0dd}.pill{display:inline-flex;justify-content:center;align-items:center;gap:12px;border:0;background:var(--accent);color:var(--ink);border-radius:10px;padding:13px 18px;min-height:44px;font-size:12px;font-weight:650}.profile-label{color:var(--muted);font-size:11px;display:flex;align-items:center;gap:10px}select,input[type=url]{background:var(--surface2);border:1px solid var(--border);border-radius:8px;min-height:44px;color:var(--text);padding:10px;max-width:100%}.cluster{padding:18px 24px 0}.cluster-top,.cluster-bottom{display:flex;justify-content:space-between;align-items:center;font-size:10px;letter-spacing:1px;gap:8px}.gauges{display:grid;grid-template-columns:minmax(0,1fr) 80px minmax(0,1fr);align-items:center;max-width:950px;margin:0 auto}.gauge svg{display:block;width:100%;max-height:330px}.gauge .tick{stroke:var(--muted);stroke-width:1}.gauge .major{stroke:var(--text);stroke-width:2}.gauge text{fill:var(--muted);font-family:inherit;font-size:12px;text-anchor:middle}.gauge .dial-value{fill:var(--text);font-size:39px;font-weight:500;font-variant-numeric:tabular-nums}.gauge .dial-label{font-size:10px;letter-spacing:2px}.needle{stroke:var(--accent);stroke-width:3;stroke-linecap:round;transition:transform .45s linear}.gear{text-align:center}.gear strong{font-size:52px;font-weight:300;display:block}.gear>span{font-size:8px;color:var(--accent);letter-spacing:2px}.cluster-bottom{border-top:1px solid var(--border);padding:16px 0;color:var(--muted);letter-spacing:0}.cluster-bottom b{color:var(--text);margin-left:8px;font-weight:500}.instrument-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-top:18px}.instrument-grid .card{padding:20px}.instrument-grid h2{font-size:28px;margin-top:20px}.instrument-grid p:last-child{font-size:10px;color:var(--muted);margin-bottom:0}.instrument-grid .eyebrow{font-size:8px;letter-spacing:1px}.player-grid{display:grid;grid-template-columns:1fr 1.2fr;gap:18px}.player{text-align:center}.album-art.large{width:180px;margin:0 auto 24px}.player audio{width:100%;margin:14px 0}.player h2{overflow-wrap:anywhere}.player p{font-size:12px;color:var(--muted)}.player-actions{display:flex;justify-content:center;gap:12px}.file-picker{cursor:pointer;position:relative}.file-picker input{position:absolute;inset:0;opacity:0;width:100%;cursor:pointer}.file-picker:focus-within{outline:3px solid var(--accent);outline-offset:3px}#playlist{list-style:none;padding:0;max-height:440px;overflow:auto}#playlist button{width:100%;display:flex;align-items:center;gap:14px;text-align:left;margin-bottom:7px;background:transparent;border-color:transparent}#playlist button span{color:var(--muted);font-size:11px}#playlist button b{font-size:13px;font-weight:400;overflow-wrap:anywhere}#playlist button.selected{background:var(--surface2);border-color:var(--accent)}#empty-library{text-align:center;padding:65px 8px;color:var(--muted)}#empty-library>span{font-size:55px;color:var(--accent)}#empty-library p{font-size:12px}.stream-card{text-align:center;padding:45px 25px}.stream-symbol{font-size:80px;color:var(--accent);display:block}.stream-card h2{font-size:28px}.stream-card>p{color:var(--muted);font-size:13px}.stream-card .pill{margin:15px 4px}.stream-card .subtle{font-size:11px}footer{display:flex;justify-content:space-between;gap:12px;padding:18px 36px;font-size:8px;letter-spacing:1px;border-top:1px solid var(--border);color:var(--muted)}footer .subtle{font-size:8px}footer .tiny-dot{margin-right:7px}dialog{background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:20px;width:min(500px,calc(100% - 32px));padding:25px;max-height:90dvh}dialog::backdrop{background:#000a;backdrop-filter:blur(5px)}dialog label{display:flex;flex-direction:column;gap:10px;margin-top:22px;font-size:13px}dialog p{font-size:12px;color:var(--muted)}dialog .screen-links{display:flex;gap:16px;font-size:12px;color:var(--accent)}#toast{position:fixed;bottom:35px;left:50%;transform:translateX(-50%);background:var(--accent);color:var(--ink);padding:14px 20px;border-radius:10px;z-index:10;max-width:90vw;font-size:13px}.stale [data-value]{color:var(--muted)} +body.light{color-scheme:light;--bg:#edf0e8;--surface:#fff;--surface2:#edf1e8;--border:#d7dfd2;--text:#202c25;--muted:#59685c;--accent:#567a2e;--ink:#fff;--shadow:0 12px 36px #253d1710}body.light .rail{background:#e4eadd}body.light .clock-card{color:#f0f3ed}body.light .clock-card .eyebrow{color:#c5d0c8}body.light .rail nav a.active{color:#fff} +@media(min-width:1600px){main{max-width:1550px;width:100%;margin:auto}.home-grid{grid-template-columns:1.45fr 1fr}.clock-card{min-height:370px}.card{padding:30px}.music-card{gap:35px}.album-art{width:165px}} +@media(max-width:1100px){header{padding:0 24px;height:70px}.header-right{gap:16px}main{padding:22px 24px}.rail{width:88px}.home-grid{gap:14px}.card{padding:20px}.clock-card{min-height:280px}.music-card{gap:14px}.album-art{width:95px}.music-card h2{font-size:18px}.music-card .eyebrow{font-size:8px}.music-card .pill{padding:12px}.clock-bottom span:last-child{display:none}footer{padding:15px 24px}.page-heading>.subtle{display:none}.gauge svg{max-height:270px}.instrument-grid .card{padding:15px}.instrument-grid .eyebrow{min-height:20px}} +@media(max-height:650px) and (min-width:760px){.rail{padding-top:12px;gap:10px}.rail nav{gap:6px}.rail nav a{min-height:64px}header{height:58px}main{padding:15px 22px}.page-heading{margin-bottom:15px}h1{font-size:24px}.eyebrow{font-size:9px;margin-bottom:6px}.clock-card{min-height:230px}.clock-card>time{font-size:72px;margin-top:8px}.weather-main{margin:8px 0}.weather-main strong{font-size:50px}.weather-sun{font-size:46px}.weather-bottom{padding-top:12px}.card{padding:16px}.vehicle-card h2{margin-top:12px}.vehicle-stats{margin-top:8px}.vehicle-stats strong{font-size:24px}.fuel-track{margin:12px 0}.music-card h2{font-size:17px}.clock-bottom{bottom:15px;left:16px}.gauge svg{max-height:220px}.instrument-grid{margin-top:12px;gap:10px}.instrument-grid h2{margin-top:7px}.instrument-grid .card{padding:12px}.instrument-grid .eyebrow{min-height:0}.cluster-bottom{padding:10px 0}footer{padding:10px 22px}.album-art.large{width:120px;margin-bottom:12px}.player audio{margin:4px 0}.stream-card{padding:20px}.stream-symbol{font-size:55px}} +@media(max-width:759px){.rail{width:72px;padding:15px 6px}.rail nav a{font-size:9px;min-height:68px}.icon{font-size:23px}.brand{font-size:30px}.settings-button{font-size:9px}header{padding:0 16px;gap:10px}.wordmark{font-size:18px;letter-spacing:3px}.wordmark span{font-size:6px}.header-right{gap:8px}.header-temp{display:none}.header-right time{font-size:13px}.badge{font-size:7px;padding:5px}main{padding:20px 16px}.page-heading{align-items:flex-start}h1{font-size:25px}.home-grid,.player-grid{grid-template-columns:1fr}.clock-card{min-height:275px}.weather-card{min-height:240px}.music-card{min-height:190px}.gauges{grid-template-columns:1fr 1fr;position:relative}.gear{grid-column:1/-1;grid-row:2;display:flex;align-items:center;justify-content:center;gap:12px;padding-bottom:15px}.gear strong{font-size:28px}.gear p{margin:0}.gauge svg{max-height:none}.cluster{padding:12px 8px 0}.cluster-top{flex-wrap:wrap;justify-content:center;line-height:1.6}.cluster-top>.label{font-size:7px}.instrument-grid{grid-template-columns:1fr 1fr}.cluster-bottom{font-size:9px;flex-wrap:wrap;justify-content:center}.page-heading{flex-wrap:wrap}.profile-label{margin-top:4px}.instrument-grid .eyebrow{font-size:8px}footer{padding:15px;font-size:7px;letter-spacing:0;flex-wrap:wrap}footer>span:last-child{display:none}.stream-card h2{font-size:24px}} +@media(min-width:760px){.vehicle-card h2{margin-top:12px;margin-bottom:4px}.vehicle-stats{margin-top:8px}.home-grid .vehicle-card{padding-top:18px;padding-bottom:18px}.home-grid .vehicle-card .fuel-track{margin:12px 0}.home-grid .vehicle-card .text-link{min-height:28px}} +@media(max-height:650px) and (min-width:760px){.home-grid .vehicle-card h2{display:none}.home-grid .vehicle-card{padding:14px 16px}.home-grid .vehicle-stats{margin-top:10px}.home-grid .vehicle-card .fuel-track{margin:10px 0}.clock-card{min-height:205px}.weather-card h2{font-size:18px;margin-bottom:4px}.weather-card p{font-size:11px;line-height:1.3;margin-bottom:10px}.weather-main{margin:8px 0}.weather-bottom{padding-top:10px}.gauge svg{max-height:185px}.cluster-bottom{padding:8px 0}.instrument-grid h2{font-size:25px}.instrument-grid p:last-child{margin-top:6px}} +@media(max-height:520px) and (min-width:760px){header{height:48px}main{padding:12px 18px}.page-heading{margin-bottom:10px}.page-heading .eyebrow{display:none}.page-heading h1{font-size:22px}.home-grid{gap:10px}.clock-card{min-height:160px}.clock-card>time{font-size:58px}.clock-card>p{font-size:11px}.clock-bottom{font-size:8px}.weather-main{margin:5px 0}.weather-main strong{font-size:38px}.weather-sun{font-size:35px}.weather-card h2{font-size:16px}.weather-card>p{display:none}.weather-bottom{padding-top:8px;font-size:8px}.home-grid .vehicle-card{padding:10px 14px}.vehicle-stats strong{font-size:20px}.music-card .eyebrow{display:none}.music-card h2{font-size:16px}.music-card p:not(.eyebrow){margin:5px 0;font-size:10px}.album-art{width:70px}.music-card .pill{padding:10px;font-size:10px}.gauge svg{max-height:145px}.cluster-top{font-size:8px}.cluster-top .label{font-size:7px}.instrument-grid .card{padding:10px}.instrument-grid h2{font-size:22px;margin:4px 0}.instrument-grid .eyebrow{font-size:7px;letter-spacing:.5px}.instrument-grid p:last-child{font-size:8px}.instrument-grid .fuel-track{margin:8px 0}.profile-label select{min-height:40px}footer{padding:8px 18px}.rail nav a{min-height:59px}.rail nav{gap:5px}.rail{gap:5px}.settings-button{min-height:50px}} +@media(min-height:651px) and (max-height:850px) and (min-width:1101px){.clock-card{min-height:270px}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{transition:none!important;scroll-behavior:auto!important}}