Compare commits

..

No commits in common. "96ffadc89f8e834d68d580a2aa3eef06ca6a98f6" and "835f8cbbbb0c50f42d5a97ea86c8bc49af8ea8bc" have entirely different histories.

24 changed files with 6 additions and 896 deletions

View file

@ -12,8 +12,8 @@
"preview": "electron-vite preview", "preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run", "test": "vitest run",
"dist": "electron-vite build && electron-builder --win nsis portable --publish never", "dist": "electron-vite build && electron-builder --win nsis portable",
"dist:dir": "electron-vite build && electron-builder --dir --publish never" "dist:dir": "electron-vite build && electron-builder --dir"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.5", "@types/node": "^22.20.5",

View file

@ -1,10 +1,9 @@
/* Processus principal : fenêtre plein écran, stockage, IPC. */ /* Processus principal : fenêtre plein écran, stockage, IPC. */
import { join } from 'node:path' import { join } from 'node:path'
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import { emit, handle, registerCore } from './ipc' import { registerCore } from './ipc'
import { initLog, log } from './log' import { initLog, log } from './log'
import { Store } from './store' import { Store } from './store'
import { registerThemeScheme, Themes } from './themes'
// Toutes les données dans %APPDATA%/OrbeLauncher (réglages, bibliothèque, cache, logs) // Toutes les données dans %APPDATA%/OrbeLauncher (réglages, bibliothèque, cache, logs)
const dataDir = join(app.getPath('appData'), 'OrbeLauncher') 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)) process.on('unhandledRejection', (e) => L.error('promesse rejetée', e))
if (!app.requestSingleInstanceLock()) app.quit() if (!app.requestSingleInstanceLock()) app.quit()
registerThemeScheme()
let win: BrowserWindow | null = null let win: BrowserWindow | null = null
const store = new Store(dataDir) const store = new Store(dataDir)
const themes = new Themes(dataDir)
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const w = new BrowserWindow({ const w = new BrowserWindow({
@ -64,9 +61,6 @@ app.whenReady().then(() => {
if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen) 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() win = createWindow()
L.info('démarrage', app.getVersion()) L.info('démarrage', app.getVersion())
}) })

View file

@ -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://<id>/<chemin> ; 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<string, string>() // 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<string, ThemeEntry>()
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<string, NodeJS.Timeout>()
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 } }
])
}

View file

@ -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 (
<main style={{ padding: 'var(--pad)' }}>
<Background paused={false} />
<p className="kicker">Thèmes</p>
<h1 className="hero-title">Orbe <mark>Launcher</mark></h1>
<div style={{ display: 'flex', gap: 12, marginTop: 32 }}>
{themes.map((t) => (
<button key={t.manifest.id} className={'btn' + (t === theme ? ' btn--primary' : '')} onClick={() => update({ theme: t.manifest.id })}>{t.manifest.name}</button>
))}
</div>
</main>
)
}
export function App() { export function App() {
return ( return <main>Orbe Launcher</main>
<SettingsProvider>
<ThemeProvider>
<Preview />
</ThemeProvider>
</SettingsProvider>
)
} }

View file

@ -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

View file

@ -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<HTMLVideoElement>(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 (
<div className="bg-layer" aria-hidden>
{bg.type === 'orb' && <Orb />}
{bg.type === 'image' && <img src={url(bg.src)} alt="" />}
{bg.type === 'video' && <video ref={video} src={url(bg.src)} autoPlay muted loop playsInline />}
</div>
)
}

View file

@ -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<HTMLCanvasElement>(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 <canvas ref={ref} className={className} />
}

View file

@ -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<string, Set<(p: never) => void>>()
const fire = <E extends EventName>(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<ThemeEntry[]> {
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)
}
}

View file

@ -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<Settings>) => Promise<void>
}
const SettingsCtx = createContext<Ctx>({ settings: DEFAULT_SETTINGS, ready: false, update: async () => {} })
export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<Settings>(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<Settings>) => setSettings(await api['settings:set'](patch)), [])
return <SettingsCtx.Provider value={{ settings, ready, update }}>{children}</SettingsCtx.Provider>
}
export const useSettings = () => useContext(SettingsCtx)

View file

@ -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; }

View file

@ -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<Ctx>({ 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<ThemeEntry[]>([])
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 <ThemeCtx.Provider value={{ theme, themes, url }}>{children}</ThemeCtx.Provider>
}
export const useTheme = () => useContext(ThemeCtx)

View file

@ -1,13 +1,5 @@
/* Contrat IPC : une seule source de vérité pour le preload (window.orbe) et les handlers du processus principal. */ /* 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 { 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://<id>/)
builtin: boolean
warnings: string[]
}
export interface Api { export interface Api {
'app:info': () => Promise<AppInfo> 'app:info': () => Promise<AppInfo>
@ -19,14 +11,12 @@ export interface Api {
'collections:delete': (id: string) => Promise<void> 'collections:delete': (id: string) => Promise<void>
'settings:get': () => Promise<Settings> 'settings:get': () => Promise<Settings>
'settings:set': (patch: Partial<Settings>) => Promise<Settings> 'settings:set': (patch: Partial<Settings>) => Promise<Settings>
'themes:list': () => Promise<ThemeEntry[]>
} }
/* Événements poussés par le processus principal vers l'interface */ /* Événements poussés par le processus principal vers l'interface */
export interface Events { export interface Events {
'library:changed': Game[] 'library:changed': Game[]
'settings:changed': Settings 'settings:changed': Settings
'themes:changed': { id: string; themes: ThemeEntry[] }
} }
export type Channel = keyof Api export type Channel = keyof Api
@ -41,11 +31,10 @@ export const CHANNELS = [
'collections:rename', 'collections:rename',
'collections:delete', 'collections:delete',
'settings:get', 'settings:get',
'settings:set', 'settings:set'
'themes:list'
] as const satisfies readonly Channel[] ] 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 */ /* Ce que l'interface voit dans window.orbe */
export type OrbeApi = Api & { export type OrbeApi = Api & {

View file

@ -1,141 +0,0 @@
/* Format d'un thème (themes/<id>/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<string, string> // 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<Record<SoundName, string>>
}
/* 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<string, unknown> => !!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<string, string> = {}
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
}

View file

@ -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()
})
})

View file

@ -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.

View file

@ -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.

View file

@ -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); }

View file

@ -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": {}
}

View file

@ -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); }

View file

@ -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": {}
}