diff --git a/package.json b/package.json index c965f80..251bf4a 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", - "dist:dir": "electron-vite build && electron-builder --dir" + "dist": "electron-vite build && electron-builder --win nsis portable --publish never", + "dist:dir": "electron-vite build && electron-builder --dir --publish never" }, "devDependencies": { "@types/node": "^22.20.5", diff --git a/src/main/index.ts b/src/main/index.ts index 752040b..67d20a6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,10 @@ /* Processus principal : fenêtre plein écran, stockage, IPC. */ import { join } from 'node:path' import { app, BrowserWindow } from 'electron' -import { registerCore } from './ipc' +import { emit, handle, 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') @@ -14,9 +15,11 @@ 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({ @@ -61,6 +64,9 @@ 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 new file mode 100644 index 0000000..543d6e3 --- /dev/null +++ b/src/main/themes.ts @@ -0,0 +1,88 @@ +/* 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 a9412b0..266544f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,3 +1,31 @@ -export function App() { - return
Orbe Launcher
+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 ( + + + + + + ) } diff --git a/src/renderer/src/api.ts b/src/renderer/src/api.ts new file mode 100644 index 0000000..27a2d52 --- /dev/null +++ b/src/renderer/src/api.ts @@ -0,0 +1,12 @@ +/* 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 new file mode 100644 index 0000000..3cdb411 --- /dev/null +++ b/src/renderer/src/components/Background.tsx @@ -0,0 +1,19 @@ +/* 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 new file mode 100644 index 0000000..371c1ed --- /dev/null +++ b/src/renderer/src/components/Orb.tsx @@ -0,0 +1,47 @@ +/* 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 new file mode 100644 index 0000000..a5214de --- /dev/null +++ b/src/renderer/src/dev/mock.ts @@ -0,0 +1,61 @@ +/* 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 new file mode 100644 index 0000000..2ebdadf --- /dev/null +++ b/src/renderer/src/state/settings.tsx @@ -0,0 +1,25 @@ +/* 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 new file mode 100644 index 0000000..266ddb9 --- /dev/null +++ b/src/renderer/src/styles/base.css @@ -0,0 +1,49 @@ +/* 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 new file mode 100644 index 0000000..28d5ad5 --- /dev/null +++ b/src/renderer/src/theme/theme.tsx @@ -0,0 +1,54 @@ +/* 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 9b5a5f3..c16c542 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -1,5 +1,13 @@ /* 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 @@ -11,12 +19,14 @@ 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 @@ -31,10 +41,11 @@ export const CHANNELS = [ 'collections:rename', 'collections:delete', 'settings:get', - 'settings:set' + 'settings:set', + 'themes:list' ] as const satisfies readonly Channel[] -export const EVENTS = ['library:changed', 'settings:changed'] as const satisfies readonly EventName[] +export const EVENTS = ['library:changed', 'settings:changed', 'themes: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 new file mode 100644 index 0000000..6f0fee4 --- /dev/null +++ b/src/shared/theme.ts @@ -0,0 +1,141 @@ +/* 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 new file mode 100644 index 0000000..1392b2a --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..c0dbc07 Binary files /dev/null and b/themes/orbe/fonts/Archivo-100-900-latin-ext.woff2 differ diff --git a/themes/orbe/fonts/Archivo-100-900-latin.woff2 b/themes/orbe/fonts/Archivo-100-900-latin.woff2 new file mode 100644 index 0000000..e2448d0 Binary files /dev/null and b/themes/orbe/fonts/Archivo-100-900-latin.woff2 differ diff --git a/themes/orbe/fonts/JetBrainsMono-400-latin-ext.woff2 b/themes/orbe/fonts/JetBrainsMono-400-latin-ext.woff2 new file mode 100644 index 0000000..82f9668 Binary files /dev/null and b/themes/orbe/fonts/JetBrainsMono-400-latin-ext.woff2 differ diff --git a/themes/orbe/fonts/JetBrainsMono-400-latin.woff2 b/themes/orbe/fonts/JetBrainsMono-400-latin.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/themes/orbe/fonts/JetBrainsMono-400-latin.woff2 differ diff --git a/themes/orbe/fonts/OFL-Archivo.txt b/themes/orbe/fonts/OFL-Archivo.txt new file mode 100644 index 0000000..8597481 --- /dev/null +++ b/themes/orbe/fonts/OFL-Archivo.txt @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/themes/orbe/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..6d22f19 --- /dev/null +++ b/themes/orbe/theme.css @@ -0,0 +1,31 @@ +/* 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 new file mode 100644 index 0000000..e327f86 --- /dev/null +++ b/themes/orbe/theme.json @@ -0,0 +1,41 @@ +{ + "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 new file mode 100644 index 0000000..c15a6fe --- /dev/null +++ b/themes/papier/theme.css @@ -0,0 +1,6 @@ +/* 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 new file mode 100644 index 0000000..a95ddb0 --- /dev/null +++ b/themes/papier/theme.json @@ -0,0 +1,35 @@ +{ + "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": {} +}