diff --git a/package.json b/package.json index 251bf4a..c965f80 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest run", - "dist": "electron-vite build && electron-builder --win nsis portable --publish never", - "dist:dir": "electron-vite build && electron-builder --dir --publish never" + "dist": "electron-vite build && electron-builder --win nsis portable", + "dist:dir": "electron-vite build && electron-builder --dir" }, "devDependencies": { "@types/node": "^22.20.5", diff --git a/src/main/index.ts b/src/main/index.ts index 67d20a6..752040b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,10 +1,9 @@ /* Processus principal : fenêtre plein écran, stockage, IPC. */ import { join } from 'node:path' import { app, BrowserWindow } from 'electron' -import { emit, handle, registerCore } from './ipc' +import { registerCore } from './ipc' import { initLog, log } from './log' import { Store } from './store' -import { registerThemeScheme, Themes } from './themes' // Toutes les données dans %APPDATA%/OrbeLauncher (réglages, bibliothèque, cache, logs) const dataDir = join(app.getPath('appData'), 'OrbeLauncher') @@ -15,11 +14,9 @@ process.on('uncaughtException', (e) => L.error('exception non gérée', e)) process.on('unhandledRejection', (e) => L.error('promesse rejetée', e)) if (!app.requestSingleInstanceLock()) app.quit() -registerThemeScheme() let win: BrowserWindow | null = null const store = new Store(dataDir) -const themes = new Themes(dataDir) function createWindow(): BrowserWindow { const w = new BrowserWindow({ @@ -64,9 +61,6 @@ app.whenReady().then(() => { if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen) } }) - themes.registerProtocol() - handle('themes:list', async () => themes.list()) - themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() })) win = createWindow() L.info('démarrage', app.getVersion()) }) diff --git a/src/main/themes.ts b/src/main/themes.ts deleted file mode 100644 index 543d6e3..0000000 --- a/src/main/themes.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* Thèmes : dossiers livrés (resources/themes) + dossiers de l'utilisateur (%APPDATA%/OrbeLauncher/themes, prioritaires). - Fichiers servis par le protocole orbe-theme:/// ; rechargement à chaud quand un fichier change. */ -import { existsSync, mkdirSync, readdirSync, readFileSync, watch, type FSWatcher } from 'node:fs' -import { join, sep } from 'node:path' -import { pathToFileURL } from 'node:url' -import { app, net, protocol } from 'electron' -import type { ThemeEntry } from '../shared/ipc' -import { validateTheme, safeJoin } from '../shared/theme' -import { log } from './log' - -const L = log('themes') - - -export class Themes { - private dirs = new Map() // id -> dossier - private watchers: FSWatcher[] = [] - readonly userDir: string - readonly builtinDir: string - - constructor(dataDir: string) { - this.userDir = join(dataDir, 'themes') - this.builtinDir = app.isPackaged ? join(process.resourcesPath, 'themes') : join(app.getAppPath(), 'themes') - mkdirSync(this.userDir, { recursive: true }) - } - - list(): ThemeEntry[] { - const out = new Map() - this.dirs.clear() - for (const [root, builtin] of [[this.builtinDir, true], [this.userDir, false]] as const) { - if (!existsSync(root)) continue - for (const d of readdirSync(root, { withFileTypes: true })) { - if (!d.isDirectory()) continue - const file = join(root, d.name, 'theme.json') - if (!existsSync(file)) continue - try { - const check = validateTheme(JSON.parse(readFileSync(file, 'utf8')), d.name) - if (!check.theme) { L.warn('thème refusé', d.name, check.errors); continue } - if (check.warnings.length) L.warn('thème', d.name, check.warnings) - out.set(d.name, { manifest: check.theme, base: `orbe-theme://${d.name}/`, builtin, warnings: check.warnings }) - this.dirs.set(d.name, join(root, d.name)) - } catch (e) { - L.error('theme.json illisible', file, e) // un thème cassé ne casse pas les autres - } - } - } - return [...out.values()] - } - - registerProtocol(): void { - protocol.handle('orbe-theme', (req) => { - const u = new URL(req.url) - if (!this.dirs.size) this.list() - const root = this.dirs.get(u.hostname) - const file = root && safeJoin(root, u.pathname, sep) - if (!file || !existsSync(file)) return new Response('introuvable', { status: 404 }) - return net.fetch(pathToFileURL(file).toString()) - }) - } - - /* Surveille les dossiers ; cb(id) quand un thème change. Dossier livré surveillé seulement en dev. */ - watch(cb: (id: string) => void): void { - const roots = app.isPackaged ? [this.userDir] : [this.builtinDir, this.userDir] - const timers = new Map() - for (const root of roots) { - if (!existsSync(root)) continue - try { - this.watchers.push( - watch(root, { recursive: true }, (_ev, name) => { - const id = String(name ?? '').split(/[\\/]/)[0] - if (!id) return - clearTimeout(timers.get(id)) - timers.set(id, setTimeout(() => cb(id), 200)) // un enregistrement = plusieurs événements - }) - ) - } catch (e) { - L.warn('surveillance impossible', root, e) - } - } - } -} - -/* À appeler avant app.ready */ -export function registerThemeScheme(): void { - protocol.registerSchemesAsPrivileged([ - { scheme: 'orbe-theme', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true } }, - { scheme: 'orbe-media', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } } - ]) -} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 266544f..a9412b0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,31 +1,3 @@ -import { Background } from './components/Background' -import { SettingsProvider, useSettings } from './state/settings' -import { ThemeProvider, useTheme } from './theme/theme' -import './styles/base.css' - -function Preview() { - const { themes, theme } = useTheme() - const { update } = useSettings() - return ( -
- -

Thèmes

-

Orbe Launcher

-
- {themes.map((t) => ( - - ))} -
-
- ) -} - export function App() { - return ( - - - - - - ) + return
Orbe Launcher
} diff --git a/src/renderer/src/api.ts b/src/renderer/src/api.ts deleted file mode 100644 index 27a2d52..0000000 --- a/src/renderer/src/api.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* Accès à l'API du processus principal. Hors Electron (aperçu navigateur), une maquette en mémoire prend le relais. */ -import type { OrbeApi } from '@shared/ipc' -import { mock } from './dev/mock' - -declare global { - interface Window { - orbe?: OrbeApi - } -} - -export const api: OrbeApi = window.orbe ?? mock -export const isMock = !window.orbe diff --git a/src/renderer/src/components/Background.tsx b/src/renderer/src/components/Background.tsx deleted file mode 100644 index 3cdb411..0000000 --- a/src/renderer/src/components/Background.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/* Fond défini par le thème : orbe, image, vidéo ou rien. La vidéo est mise en pause pendant un jeu. */ -import { useEffect, useRef } from 'react' -import { useTheme } from '../theme/theme' -import { Orb } from './Orb' - -export function Background({ paused }: { paused: boolean }) { - const { theme, url } = useTheme() - const video = useRef(null) - useEffect(() => { if (video.current) paused ? video.current.pause() : video.current.play().catch(() => {}) }, [paused]) - const bg = theme?.manifest.background - if (!bg || bg.type === 'none') return null - return ( -
- {bg.type === 'orb' && } - {bg.type === 'image' && } - {bg.type === 'video' &&
- ) -} diff --git a/src/renderer/src/components/Orb.tsx b/src/renderer/src/components/Orb.tsx deleted file mode 100644 index 371c1ed..0000000 --- a/src/renderer/src/components/Orb.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* Orbe de particules (sphère de Fibonacci en rotation), reprise du site. S'arrête quand l'appli est en pause. */ -import { useEffect, useRef } from 'react' - -interface Props { - count?: number - radius?: number // part de la plus petite dimension - x?: number // centre, 0…1 - y?: number - className?: string -} - -export function Orb({ count = 520, radius = 0.36, x = 0.72, y = 0.5, className }: Props) { - const ref = useRef(null) - useEffect(() => { - const cv = ref.current!, ctx = cv.getContext('2d')! - const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#d3fd50' - const text = getComputedStyle(document.documentElement).getPropertyValue('--text').trim() || '#f5f5f0' - const pts: [number, number, number, boolean][] = [] - for (let i = 0; i < count; i++) { - const yy = 1 - (i / (count - 1)) * 2, r = Math.sqrt(1 - yy * yy), th = i * 2.39996323 - pts.push([Math.cos(th) * r, yy, Math.sin(th) * r, i % 9 === 0]) - } - let raf = 0, W = 0, H = 0 - const size = () => { W = cv.width = cv.clientWidth * devicePixelRatio; H = cv.height = cv.clientHeight * devicePixelRatio } - size() - addEventListener('resize', size) - const t0 = performance.now() - const frame = (now: number) => { - raf = requestAnimationFrame(frame) - if (document.documentElement.dataset.paused !== undefined) return // jeu lancé : on ne dessine plus rien - const t = (now - t0) / 1000, a = t * 0.12, b = Math.sin(t * 0.17) * 0.3 - const ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b), R = Math.min(W, H) * radius - ctx.clearRect(0, 0, W, H) - for (const [px, py, pz, hot] of pts) { - const x1 = px * ca - pz * sa, z1 = px * sa + pz * ca, y1 = py * cb - z1 * sb, z2 = py * sb + z1 * cb, d = (z2 + 1.4) / 2.4 - ctx.globalAlpha = hot ? 0.2 + d * 0.7 : 0.06 + d * 0.42 - ctx.fillStyle = hot ? accent : text - ctx.beginPath() - ctx.arc(W * x + x1 * R, H * y + y1 * R, (hot ? 2.4 : 1.5) * devicePixelRatio * (0.6 + d * 0.7), 0, 6.283) - ctx.fill() - } - } - raf = requestAnimationFrame(frame) - return () => { cancelAnimationFrame(raf); removeEventListener('resize', size) } - }, [count, radius, x, y]) - return -} diff --git a/src/renderer/src/dev/mock.ts b/src/renderer/src/dev/mock.ts deleted file mode 100644 index a5214de..0000000 --- a/src/renderer/src/dev/mock.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* Maquette de l'API pour l'aperçu dans un navigateur (développement de l'interface, captures). Données fictives. */ -import type { Events, EventName, OrbeApi, ThemeEntry } from '@shared/ipc' -import { validateTheme } from '@shared/theme' -import { DEFAULT_SETTINGS, type Collection, type Game, type Settings } from '@shared/types' - -const listeners = new Map void>>() -const fire = (e: E, p: Events[E]) => listeners.get(e)?.forEach((cb) => (cb as (x: Events[E]) => void)(p)) - -let settings: Settings = { ...DEFAULT_SETTINGS } -const collections: Collection[] = [] -const games: Game[] = [] - -async function themes(): Promise { - const out: ThemeEntry[] = [] - for (const id of ['orbe', 'papier']) { - const base = `./themes/${id}/` - try { - const c = validateTheme(await (await fetch(base + 'theme.json')).json(), id) - if (c.theme) out.push({ manifest: c.theme, base, builtin: true, warnings: c.warnings }) - } catch { - /* thème absent de l'aperçu */ - } - } - return out -} - -export const mock: OrbeApi = { - 'app:info': async () => ({ version: 'aperçu', platform: 'web', dataDir: '-', dev: true }), - 'library:list': async () => games, - 'library:update': async (id, patch) => { - const g = games.find((x) => x.id === id)! - Object.assign(g, patch) - fire('library:changed', games) - return g - }, - 'collections:list': async () => collections, - 'collections:create': async (name) => { - const c = { id: String(Date.now()), name, createdAt: Date.now() } - collections.push(c) - return c - }, - 'collections:rename': async (id, name) => { - const c = collections.find((x) => x.id === id) - if (c) c.name = name - }, - 'collections:delete': async (id) => { - collections.splice(collections.findIndex((c) => c.id === id), 1) - }, - 'settings:get': async () => settings, - 'settings:set': async (patch) => { - settings = { ...settings, ...patch } - fire('settings:changed', settings) - return settings - }, - 'themes:list': themes, - on: (event, cb) => { - if (!listeners.has(event)) listeners.set(event, new Set()) - listeners.get(event)!.add(cb as (p: never) => void) - return () => listeners.get(event)!.delete(cb as (p: never) => void) - } -} diff --git a/src/renderer/src/state/settings.tsx b/src/renderer/src/state/settings.tsx deleted file mode 100644 index 2ebdadf..0000000 --- a/src/renderer/src/state/settings.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* Réglages partagés dans l'interface, synchronisés avec le processus principal. */ -import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react' -import { DEFAULT_SETTINGS, type Settings } from '@shared/types' -import { api } from '../api' - -interface Ctx { - settings: Settings - ready: boolean - update: (patch: Partial) => Promise -} - -const SettingsCtx = createContext({ settings: DEFAULT_SETTINGS, ready: false, update: async () => {} }) - -export function SettingsProvider({ children }: { children: ReactNode }) { - const [settings, setSettings] = useState(DEFAULT_SETTINGS) - const [ready, setReady] = useState(false) - useEffect(() => { - api['settings:get']().then((s) => { setSettings(s); setReady(true) }) - return api.on('settings:changed', setSettings) - }, []) - const update = useCallback(async (patch: Partial) => setSettings(await api['settings:set'](patch)), []) - return {children} -} - -export const useSettings = () => useContext(SettingsCtx) diff --git a/src/renderer/src/styles/base.css b/src/renderer/src/styles/base.css deleted file mode 100644 index 266ddb9..0000000 --- a/src/renderer/src/styles/base.css +++ /dev/null @@ -1,49 +0,0 @@ -/* Base de l'interface : uniquement des variables (définies par le thème ; valeurs Orbe par défaut ici). */ -:root { - --bg: #0a0a0a; --surface: #121212; --surface-2: #1a1a19; --text: #f5f5f0; --muted: #8f8f8a; --line: #262624; - --accent: #d3fd50; --on-accent: #0a0a0a; --danger: #ff6b5a; - --font-sans: system-ui, sans-serif; --font-mono: ui-monospace, monospace; - --radius-s: 4px; --radius-m: 4px; --radius-l: 6px; - --shadow-card: 0 18px 40px rgba(0, 0, 0, .55); --focus-ring: 0 0 0 2px var(--accent); --focus-scale: 1.06; - --dur-fast: 150ms; --dur-med: 300ms; --dur-slow: 600ms; --ease: cubic-bezier(.2, .8, .2, 1); - --pad: clamp(24px, 4vw, 64px); - color-scheme: dark; -} -:root[data-paused] *, :root[data-paused] *::before, :root[data-paused] *::after { animation-play-state: paused !important; } -* { box-sizing: border-box; } -html, body, #root { height: 100%; margin: 0; } -body { background: var(--bg); color: var(--text); font: 400 18px/1.5 var(--font-sans); overflow: hidden; user-select: none; -webkit-font-smoothing: antialiased; } -:root[data-input='pad'] body, :root[data-input='pad'] * { cursor: none !important; } -button, input { font: inherit; color: inherit; } -button { background: none; border: 0; padding: 0; cursor: pointer; } -img { display: block; } -:focus { outline: none; } - -/* Typo */ -.title { font-size: clamp(34px, 4vw, 64px); margin: 0; font-weight: 800; } -.hero-title { font-size: clamp(48px, 7vw, 120px); margin: 0; font-weight: 900; } -.kicker { font-size: 13px; color: var(--accent); margin: 0 0 12px; } -mark { background: none; color: var(--accent); } -.muted { color: var(--muted); } -.mono { font-family: var(--font-mono); } - -/* Boutons */ -.btn { display: inline-flex; align-items: center; gap: 10px; padding: 14px 22px; border-radius: var(--radius-s); background: var(--surface-2); color: var(--text); border: 1px solid var(--line); - transition: background var(--dur-fast), color var(--dur-fast), box-shadow var(--dur-fast), transform var(--dur-fast) var(--ease); white-space: nowrap; } -.btn--primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } -.btn--danger.is-focused { background: var(--danger); border-color: var(--danger); color: var(--on-accent); } -.btn.is-focused { box-shadow: var(--focus-ring); transform: translateY(-2px); } -.btn:disabled { opacity: .4; } - -/* Cartes de jeu */ -.card { position: relative; border-radius: var(--radius-m); background: var(--surface); overflow: hidden; aspect-ratio: 2 / 3; - transition: transform var(--dur-med) var(--ease), box-shadow var(--dur-med) var(--ease), border-color var(--dur-med); } -.card img { width: 100%; height: 100%; object-fit: cover; } -.card.is-focused { transform: scale(var(--focus-scale)); box-shadow: var(--focus-ring), var(--shadow-card); z-index: 2; } - -/* En-tête */ -.topbar { position: fixed; inset: 0 0 auto 0; height: 76px; display: flex; align-items: center; gap: 28px; padding: 0 var(--pad); z-index: 20; } - -/* Fond animé */ -.bg-layer { position: fixed; inset: 0; z-index: -1; pointer-events: none; } -.bg-layer canvas, .bg-layer video, .bg-layer img { width: 100%; height: 100%; object-fit: cover; } diff --git a/src/renderer/src/theme/theme.tsx b/src/renderer/src/theme/theme.tsx deleted file mode 100644 index 28d5ad5..0000000 --- a/src/renderer/src/theme/theme.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* Application du thème actif : variables CSS sur :root, polices, feuille theme.css. Changement et rechargement à chaud. */ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' -import type { ThemeEntry } from '@shared/ipc' -import { api } from '../api' -import { useSettings } from '../state/settings' - -interface Ctx { - theme: ThemeEntry | null - themes: ThemeEntry[] - url: (rel?: string) => string | undefined // chemin du thème -> URL -} - -const ThemeCtx = createContext({ theme: null, themes: [], url: () => undefined }) -let applied: string[] = [] - -function apply(t: ThemeEntry, version: number): void { - const root = document.documentElement, m = t.manifest - for (const k of applied) root.style.removeProperty(k) - for (const [k, v] of Object.entries(m.vars)) root.style.setProperty(k, v) - applied = Object.keys(m.vars) - root.dataset.theme = m.id - root.dataset.layout = m.layout.library - - const fonts = el('style', 'theme-fonts') as HTMLStyleElement - fonts.textContent = m.fonts - .map((f) => `@font-face{font-family:"${f.family}";src:url("${t.base + f.src}");font-weight:${f.weight ?? '400'};font-style:${f.style ?? 'normal'};font-display:swap;${f.unicodeRange ? `unicode-range:${f.unicodeRange};` : ''}}`) - .join('\n') - const link = el('link', 'theme-css') as HTMLLinkElement - link.rel = 'stylesheet' - if (m.css) link.href = `${t.base}${m.css}?v=${version}` // ?v= force le rechargement après modification - else link.removeAttribute('href') -} - -function el(tag: string, id: string): HTMLElement { - let n = document.getElementById(id) - if (!n) { n = document.createElement(tag); n.id = id; document.head.append(n) } - return n -} - -export function ThemeProvider({ children }: { children: ReactNode }) { - const { settings, ready } = useSettings() - const [themes, setThemes] = useState([]) - const [version, setVersion] = useState(Date.now()) - useEffect(() => { - api['themes:list']().then(setThemes) - return api.on('themes:changed', ({ themes }) => { setThemes(themes); setVersion(Date.now()) }) - }, []) - const theme = themes.find((t) => t.manifest.id === settings.theme) ?? themes.find((t) => t.manifest.id === 'orbe') ?? themes[0] ?? null - useEffect(() => { if (theme && ready) apply(theme, version) }, [theme, version, ready]) - const url = (rel?: string) => (rel && theme ? theme.base + rel : undefined) - return {children} -} - -export const useTheme = () => useContext(ThemeCtx) diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index c16c542..9b5a5f3 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -1,13 +1,5 @@ /* Contrat IPC : une seule source de vérité pour le preload (window.orbe) et les handlers du processus principal. */ import type { AppInfo, Collection, Game, Settings } from './types' -import type { ThemeManifest } from './theme' - -export interface ThemeEntry { - manifest: ThemeManifest - base: string // URL de base des fichiers du thème (orbe-theme:///) - builtin: boolean - warnings: string[] -} export interface Api { 'app:info': () => Promise @@ -19,14 +11,12 @@ export interface Api { 'collections:delete': (id: string) => Promise 'settings:get': () => Promise 'settings:set': (patch: Partial) => Promise - 'themes:list': () => Promise } /* Événements poussés par le processus principal vers l'interface */ export interface Events { 'library:changed': Game[] 'settings:changed': Settings - 'themes:changed': { id: string; themes: ThemeEntry[] } } export type Channel = keyof Api @@ -41,11 +31,10 @@ export const CHANNELS = [ 'collections:rename', 'collections:delete', 'settings:get', - 'settings:set', - 'themes:list' + 'settings:set' ] as const satisfies readonly Channel[] -export const EVENTS = ['library:changed', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[] +export const EVENTS = ['library:changed', 'settings:changed'] as const satisfies readonly EventName[] /* Ce que l'interface voit dans window.orbe */ export type OrbeApi = Api & { diff --git a/src/shared/theme.ts b/src/shared/theme.ts deleted file mode 100644 index 6f0fee4..0000000 --- a/src/shared/theme.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* Format d'un thème (themes//theme.json) et validation. Pur : utilisé par le processus principal et les tests. */ - -export type LibraryLayout = 'grid' | 'carousel' | 'list' -export type SoundName = 'move' | 'select' | 'back' | 'error' | 'launch' -export const SOUND_NAMES: SoundName[] = ['move', 'select', 'back', 'error', 'launch'] - -export interface ThemeFont { - family: string - src: string // chemin relatif au dossier du thème - weight?: string // "400" ou plage "100 900" - style?: string - unicodeRange?: string // ex. "U+0000-00FF" quand une police est découpée en plusieurs fichiers -} - -export type ThemeBackground = - | { type: 'none' } - | { type: 'orb' } // orbe de particules animée (thème Orbe) - | { type: 'image'; src: string } - | { type: 'video'; src: string } - -export interface ThemeSplash { - type: 'orb' | 'logo' | 'image' | 'video' | 'none' - src?: string - duration: number // ms -} - -export interface ThemeManifest { - id: string - name: string - author?: string - version?: string - description?: string - vars: Record // variables CSS posées sur :root - fonts: ThemeFont[] - css?: string // feuille de style du thème (theme.css par défaut) - layout: { library: LibraryLayout } - background: ThemeBackground - splash: ThemeSplash - sounds: Partial> -} - -/* Variables que l'interface utilise : un thème qui en oublie hérite de celles d'Orbe */ -export const REQUIRED_VARS = [ - '--bg', '--surface', '--surface-2', '--text', '--muted', '--line', '--accent', '--on-accent', '--danger', - '--font-sans', '--font-mono', - '--radius-s', '--radius-m', '--radius-l', - '--shadow-card', '--focus-ring', '--focus-scale', - '--dur-fast', '--dur-med', '--dur-slow', '--ease' -] as const - -export interface ThemeCheck { - theme: ThemeManifest | null - errors: string[] // bloquant : thème refusé - warnings: string[] // non bloquant : thème chargé avec des valeurs par défaut -} - -const SAFE_PATH = /^(?!.*\.\.)[\w\-./ ]{1,200}$/ // chemin relatif sans remontée -const isObj = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) - -export function validateTheme(raw: unknown, dirName: string): ThemeCheck { - const errors: string[] = [], warnings: string[] = [] - if (!isObj(raw)) return { theme: null, errors: ['theme.json : objet attendu'], warnings } - const id = typeof raw.id === 'string' ? raw.id : dirName - if (!/^[a-z0-9-]{1,40}$/.test(id)) errors.push('id : minuscules, chiffres et tirets uniquement') - if (id !== dirName) warnings.push(`id « ${id} » différent du dossier « ${dirName} », le dossier fait foi`) - const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim().slice(0, 60) : dirName - - const vars: Record = {} - if (isObj(raw.vars)) { - for (const [k, v] of Object.entries(raw.vars)) { - if (!/^--[a-z0-9-]{1,40}$/.test(k)) warnings.push(`variable ignorée : ${k}`) - else if (typeof v !== 'string' || v.length > 300 || /[;{}<>]|url\(/i.test(v)) warnings.push(`valeur ignorée pour ${k}`) - else vars[k] = v - } - } else warnings.push('vars absent : couleurs du thème Orbe utilisées') - const missing = REQUIRED_VARS.filter((k) => !(k in vars)) - if (missing.length && isObj(raw.vars)) warnings.push('variables manquantes (valeurs Orbe) : ' + missing.join(', ')) - - const path = (v: unknown, what: string): string | undefined => { - if (v === undefined) return undefined - if (typeof v === 'string' && SAFE_PATH.test(v)) return v - errors.push(`${what} : chemin relatif invalide`) - return undefined - } - - const fonts: ThemeFont[] = [] - if (Array.isArray(raw.fonts)) { - for (const f of raw.fonts) { - if (!isObj(f) || typeof f.family !== 'string' || !/^[\w\- ]{1,60}$/.test(f.family)) { warnings.push('police ignorée (family invalide)'); continue } - const src = path(f.src, 'police ' + f.family) - if (src) fonts.push({ family: f.family, src, weight: typeof f.weight === 'string' && /^\d{3}( \d{3})?$/.test(f.weight) ? f.weight : undefined, style: f.style === 'italic' ? 'italic' : undefined, unicodeRange: typeof f.unicodeRange === 'string' && /^[U+0-9A-Fa-f?, -]{1,400}$/.test(f.unicodeRange) ? f.unicodeRange : undefined }) - } - } - - const layoutRaw = isObj(raw.layout) ? raw.layout.library : undefined - const library: LibraryLayout = layoutRaw === 'carousel' || layoutRaw === 'list' ? layoutRaw : 'grid' - if (layoutRaw !== undefined && layoutRaw !== library) warnings.push('layout.library inconnu, grille utilisée') - - let background: ThemeBackground = { type: 'none' } - if (isObj(raw.background)) { - const t = raw.background.type - if (t === 'orb') background = { type: 'orb' } - else if (t === 'image' || t === 'video') { - const src = path(raw.background.src, 'fond') - if (src) background = { type: t, src } - } else if (t !== 'none') warnings.push('fond inconnu, aucun fond') - } - - let splash: ThemeSplash = { type: 'logo', duration: 1200 } - if (isObj(raw.splash)) { - const t = raw.splash.type - const type = t === 'orb' || t === 'image' || t === 'video' || t === 'none' || t === 'logo' ? t : 'logo' - const d = typeof raw.splash.duration === 'number' ? Math.min(Math.max(raw.splash.duration, 0), 6000) : 1200 - splash = { type, duration: d, src: type === 'image' || type === 'video' ? path(raw.splash.src, 'écran de démarrage') : undefined } - if ((type === 'image' || type === 'video') && !splash.src) splash = { type: 'logo', duration: d } - } - - const sounds: ThemeManifest['sounds'] = {} - if (isObj(raw.sounds)) for (const n of SOUND_NAMES) { const s = path(raw.sounds[n], 'son ' + n); if (s) sounds[n] = s } - - const css = raw.css === undefined ? 'theme.css' : path(raw.css, 'css') - const meta = (k: string) => (typeof raw[k] === 'string' ? (raw[k] as string).slice(0, 200) : undefined) - - if (errors.length) return { theme: null, errors, warnings } - return { - theme: { id: dirName, name, author: meta('author'), version: meta('version'), description: meta('description'), vars, fonts, css, layout: { library }, background, splash, sounds }, - errors, - warnings - } -} - -/* Résout un chemin demandé dans un thème ; null si on sort du dossier */ -export function safeJoin(root: string, rel: string, sep = '/'): string | null { - const parts: string[] = [] - for (const p of decodeURIComponent(rel).split(/[\\/]+/)) { - if (!p || p === '.') continue - if (p === '..' || p.includes(':')) return null - parts.push(p) - } - return parts.length ? root.replace(/[\\/]+$/, '') + sep + parts.join(sep) : null -} diff --git a/tests/theme.test.ts b/tests/theme.test.ts deleted file mode 100644 index 1392b2a..0000000 --- a/tests/theme.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { REQUIRED_VARS, safeJoin, validateTheme } from '../src/shared/theme' - -const themesDir = join(__dirname, '..', 'themes') - -describe('validateTheme', () => { - it('valide les thèmes livrés sans avertissement et avec toutes les variables', () => { - for (const id of readdirSync(themesDir)) { - const c = validateTheme(JSON.parse(readFileSync(join(themesDir, id, 'theme.json'), 'utf8')), id) - expect(c.errors, id).toEqual([]) - expect(c.warnings, id).toEqual([]) - for (const v of REQUIRED_VARS) expect(c.theme!.vars[v], `${id} ${v}`).toBeTruthy() - } - }) - - it('applique des valeurs par défaut sûres', () => { - const c = validateTheme({ name: 'X', layout: { library: 'spirale' }, splash: { type: 'video' } }, 'x') - expect(c.theme!.layout.library).toBe('grid') - expect(c.theme!.splash.type).toBe('logo') - expect(c.theme!.css).toBe('theme.css') - expect(c.warnings.length).toBeGreaterThan(0) - }) - - it('refuse les chemins qui sortent du dossier', () => { - expect(validateTheme({ background: { type: 'image', src: '../../secret.png' } }, 'x').theme).toBeNull() - expect(validateTheme({ sounds: { move: 'C:/Windows/x.wav' } }, 'x').theme).toBeNull() - }) - - it('ignore les variables dangereuses', () => { - const c = validateTheme({ vars: { '--bg': 'red; } body { display:none', '--ok': '#fff', bad: '1', '--img': 'url(http://x)' } }, 'x') - expect(c.theme!.vars).toEqual({ '--ok': '#fff' }) - }) - - it('refuse un id invalide', () => { - expect(validateTheme({}, 'Mon Thème').theme).toBeNull() - }) -}) - -describe('safeJoin', () => { - it('reste dans le dossier', () => { - expect(safeJoin('/t/orbe', '/fonts/a.woff2')).toBe('/t/orbe/fonts/a.woff2') - expect(safeJoin('/t/orbe', '/../x')).toBeNull() - expect(safeJoin('/t/orbe', '/%2e%2e/x')).toBeNull() - expect(safeJoin('C:\\t\\orbe', '/a/b.css', '\\')).toBe('C:\\t\\orbe\\a\\b.css') - expect(safeJoin('/t/orbe', '/c:/x')).toBeNull() - expect(safeJoin('/t/orbe', '/')).toBeNull() - }) -}) diff --git a/themes/orbe/fonts/Archivo-100-900-latin-ext.woff2 b/themes/orbe/fonts/Archivo-100-900-latin-ext.woff2 deleted file mode 100644 index c0dbc07..0000000 Binary files a/themes/orbe/fonts/Archivo-100-900-latin-ext.woff2 and /dev/null differ diff --git a/themes/orbe/fonts/Archivo-100-900-latin.woff2 b/themes/orbe/fonts/Archivo-100-900-latin.woff2 deleted file mode 100644 index e2448d0..0000000 Binary files a/themes/orbe/fonts/Archivo-100-900-latin.woff2 and /dev/null differ diff --git a/themes/orbe/fonts/JetBrainsMono-400-latin-ext.woff2 b/themes/orbe/fonts/JetBrainsMono-400-latin-ext.woff2 deleted file mode 100644 index 82f9668..0000000 Binary files a/themes/orbe/fonts/JetBrainsMono-400-latin-ext.woff2 and /dev/null differ diff --git a/themes/orbe/fonts/JetBrainsMono-400-latin.woff2 b/themes/orbe/fonts/JetBrainsMono-400-latin.woff2 deleted file mode 100644 index 4d09cda..0000000 Binary files a/themes/orbe/fonts/JetBrainsMono-400-latin.woff2 and /dev/null differ diff --git a/themes/orbe/fonts/OFL-Archivo.txt b/themes/orbe/fonts/OFL-Archivo.txt deleted file mode 100644 index 8597481..0000000 --- a/themes/orbe/fonts/OFL-Archivo.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/themes/orbe/fonts/OFL-JetBrainsMono.txt b/themes/orbe/fonts/OFL-JetBrainsMono.txt deleted file mode 100644 index 5ceee00..0000000 --- a/themes/orbe/fonts/OFL-JetBrainsMono.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://openfontlicense.org - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/themes/orbe/theme.css b/themes/orbe/theme.css deleted file mode 100644 index 6d22f19..0000000 --- a/themes/orbe/theme.css +++ /dev/null @@ -1,31 +0,0 @@ -/* Orbe : touches propres au thème, par-dessus la base de l'interface (qui n'utilise que les variables). */ - -/* Titres : Archivo 900 serré, comme le site */ -.title, .hero-title { font-weight: 900; letter-spacing: -.035em; line-height: .95; text-transform: uppercase; } - -/* Surtitre : petite capitale mono espacée, accent */ -.kicker { font-family: var(--font-mono); font-size: 13px; letter-spacing: .2em; text-transform: uppercase; color: var(--accent); } -.kicker::before { content: '// '; } - -/* Mot en contour accent dans un titre */ -.title mark, .hero-title mark { background: none; color: transparent; -webkit-text-stroke: max(1px, .02em) var(--accent); } - -/* Boutons : mono majuscule espacée, accent plein */ -.btn { font-family: var(--font-mono); font-weight: 700; letter-spacing: .16em; text-transform: uppercase; font-size: 13px; } -.btn--primary:is(.is-focused, :hover) { background: var(--text); color: var(--bg); } - -/* En-tête flouté avec filet */ -.topbar { backdrop-filter: blur(12px); background: color-mix(in srgb, var(--bg) 78%, transparent); border-bottom: 1px solid var(--line); } - -/* Cartes : filet qui vire à l'accent au focus, image qui zoome doucement */ -.card { border: 1px solid var(--line); } -.card.is-focused { border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); } -.card img { transition: transform var(--dur-slow) var(--ease); } -.card.is-focused img { transform: scale(1.04); } - -/* Lignes de liste : décalage à droite au focus (comme la liste d'articles du site) */ -.row-item { border-top: 1px solid var(--line); transition: padding-left var(--dur-med) var(--ease), color var(--dur-med); } -.row-item.is-focused { padding-left: 14px; color: var(--accent); } -.row-item.is-focused::before { content: '▸ '; color: var(--accent); } - -::selection { background: var(--accent); color: var(--on-accent); } diff --git a/themes/orbe/theme.json b/themes/orbe/theme.json deleted file mode 100644 index e327f86..0000000 --- a/themes/orbe/theme.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "id": "orbe", - "name": "Orbe", - "author": "Yasser Boulayoune", - "version": "1.0.0", - "description": "Le thème principal : noir profond, accent lime, Archivo et JetBrains Mono, orbe de particules.", - "vars": { - "--bg": "#0a0a0a", - "--surface": "#121212", - "--surface-2": "#1a1a19", - "--text": "#f5f5f0", - "--muted": "#8f8f8a", - "--line": "#262624", - "--accent": "#d3fd50", - "--on-accent": "#0a0a0a", - "--danger": "#ff6b5a", - "--font-sans": "Archivo, system-ui, sans-serif", - "--font-mono": "'JetBrains Mono', ui-monospace, monospace", - "--radius-s": "4px", - "--radius-m": "4px", - "--radius-l": "6px", - "--shadow-card": "0 18px 40px rgba(0, 0, 0, .55)", - "--focus-ring": "0 0 0 2px #d3fd50, 0 0 32px rgba(211, 253, 80, .28)", - "--focus-scale": "1.06", - "--dur-fast": "150ms", - "--dur-med": "300ms", - "--dur-slow": "600ms", - "--ease": "cubic-bezier(.2, .8, .2, 1)" - }, - "fonts": [ - { "family": "Archivo", "src": "fonts/Archivo-100-900-latin.woff2", "weight": "100 900", "unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD" }, - { "family": "Archivo", "src": "fonts/Archivo-100-900-latin-ext.woff2", "weight": "100 900", "unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF" }, - { "family": "JetBrains Mono", "src": "fonts/JetBrainsMono-400-latin.woff2", "weight": "400 700", "unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD" }, - { "family": "JetBrains Mono", "src": "fonts/JetBrainsMono-400-latin-ext.woff2", "weight": "400 700", "unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF" } - ], - "css": "theme.css", - "layout": { "library": "grid" }, - "background": { "type": "orb" }, - "splash": { "type": "orb", "duration": 1800 }, - "sounds": {} -} diff --git a/themes/papier/theme.css b/themes/papier/theme.css deleted file mode 100644 index c15a6fe..0000000 --- a/themes/papier/theme.css +++ /dev/null @@ -1,6 +0,0 @@ -/* Papier : fond légèrement texturé par un dégradé, titres en italique. */ -body { background-image: radial-gradient(circle at 20% 0%, #fffaf0 0, transparent 60%); } -.title, .hero-title { font-style: italic; font-weight: 700; } -.kicker { font-family: var(--font-mono); font-size: 13px; color: var(--accent); letter-spacing: .08em; } -.btn { font-weight: 700; } -.row-item { border-bottom: 1px dashed var(--line); } diff --git a/themes/papier/theme.json b/themes/papier/theme.json deleted file mode 100644 index a95ddb0..0000000 --- a/themes/papier/theme.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "id": "papier", - "name": "Papier", - "author": "Exemple", - "version": "1.0.0", - "description": "Thème d'exemple : clair, couleurs de papier, polices système, bibliothèque en liste.", - "vars": { - "--bg": "#f3efe6", - "--surface": "#fbf8f1", - "--surface-2": "#ebe5d8", - "--text": "#22201c", - "--muted": "#7a7468", - "--line": "#d9d1c0", - "--accent": "#c2410c", - "--on-accent": "#fffaf2", - "--danger": "#b91c1c", - "--font-sans": "Georgia, 'Times New Roman', serif", - "--font-mono": "Consolas, 'Courier New', monospace", - "--radius-s": "10px", - "--radius-m": "14px", - "--radius-l": "22px", - "--shadow-card": "0 10px 24px rgba(60, 45, 20, .16)", - "--focus-ring": "0 0 0 3px #c2410c", - "--focus-scale": "1.03", - "--dur-fast": "120ms", - "--dur-med": "220ms", - "--dur-slow": "400ms", - "--ease": "ease-out" - }, - "css": "theme.css", - "layout": { "library": "list" }, - "background": { "type": "none" }, - "splash": { "type": "logo", "duration": 900 }, - "sounds": {} -}