Compare commits
2 commits
835f8cbbbb
...
96ffadc89f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96ffadc89f | ||
|
|
acd9870890 |
24 changed files with 897 additions and 7 deletions
|
|
@ -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",
|
"dist": "electron-vite build && electron-builder --win nsis portable --publish never",
|
||||||
"dist:dir": "electron-vite build && electron-builder --dir"
|
"dist:dir": "electron-vite build && electron-builder --dir --publish never"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.20.5",
|
"@types/node": "^22.20.5",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
/* 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 { registerCore } from './ipc'
|
import { emit, handle, 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')
|
||||||
|
|
@ -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))
|
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({
|
||||||
|
|
@ -61,6 +64,9 @@ 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())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
88
src/main/themes.ts
Normal file
88
src/main/themes.ts
Normal file
|
|
@ -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://<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 } }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,31 @@
|
||||||
export function App() {
|
import { Background } from './components/Background'
|
||||||
return <main>Orbe Launcher</main>
|
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() {
|
||||||
|
return (
|
||||||
|
<SettingsProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<Preview />
|
||||||
|
</ThemeProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
src/renderer/src/api.ts
Normal file
12
src/renderer/src/api.ts
Normal file
|
|
@ -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
|
||||||
19
src/renderer/src/components/Background.tsx
Normal file
19
src/renderer/src/components/Background.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/renderer/src/components/Orb.tsx
Normal file
47
src/renderer/src/components/Orb.tsx
Normal file
|
|
@ -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<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} />
|
||||||
|
}
|
||||||
61
src/renderer/src/dev/mock.ts
Normal file
61
src/renderer/src/dev/mock.ts
Normal file
|
|
@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/renderer/src/state/settings.tsx
Normal file
25
src/renderer/src/state/settings.tsx
Normal file
|
|
@ -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<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)
|
||||||
49
src/renderer/src/styles/base.css
Normal file
49
src/renderer/src/styles/base.css
Normal file
|
|
@ -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; }
|
||||||
54
src/renderer/src/theme/theme.tsx
Normal file
54
src/renderer/src/theme/theme.tsx
Normal file
|
|
@ -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<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)
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
/* 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>
|
||||||
|
|
@ -11,12 +19,14 @@ 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
|
||||||
|
|
@ -31,10 +41,11 @@ 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'] 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 */
|
/* Ce que l'interface voit dans window.orbe */
|
||||||
export type OrbeApi = Api & {
|
export type OrbeApi = Api & {
|
||||||
|
|
|
||||||
141
src/shared/theme.ts
Normal file
141
src/shared/theme.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
/* 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
|
||||||
|
}
|
||||||
50
tests/theme.test.ts
Normal file
50
tests/theme.test.ts
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
BIN
themes/orbe/fonts/Archivo-100-900-latin-ext.woff2
Normal file
BIN
themes/orbe/fonts/Archivo-100-900-latin-ext.woff2
Normal file
Binary file not shown.
BIN
themes/orbe/fonts/Archivo-100-900-latin.woff2
Normal file
BIN
themes/orbe/fonts/Archivo-100-900-latin.woff2
Normal file
Binary file not shown.
BIN
themes/orbe/fonts/JetBrainsMono-400-latin-ext.woff2
Normal file
BIN
themes/orbe/fonts/JetBrainsMono-400-latin-ext.woff2
Normal file
Binary file not shown.
BIN
themes/orbe/fonts/JetBrainsMono-400-latin.woff2
Normal file
BIN
themes/orbe/fonts/JetBrainsMono-400-latin.woff2
Normal file
Binary file not shown.
93
themes/orbe/fonts/OFL-Archivo.txt
Normal file
93
themes/orbe/fonts/OFL-Archivo.txt
Normal file
|
|
@ -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.
|
||||||
93
themes/orbe/fonts/OFL-JetBrainsMono.txt
Normal file
93
themes/orbe/fonts/OFL-JetBrainsMono.txt
Normal file
|
|
@ -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.
|
||||||
31
themes/orbe/theme.css
Normal file
31
themes/orbe/theme.css
Normal file
|
|
@ -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); }
|
||||||
41
themes/orbe/theme.json
Normal file
41
themes/orbe/theme.json
Normal file
|
|
@ -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": {}
|
||||||
|
}
|
||||||
6
themes/papier/theme.css
Normal file
6
themes/papier/theme.css
Normal file
|
|
@ -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); }
|
||||||
35
themes/papier/theme.json
Normal file
35
themes/papier/theme.json
Normal file
|
|
@ -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": {}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue