diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index af58810..266544f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,27 +1,21 @@ -import { useState } from 'react' import { Background } from './components/Background' -import { Keyboard } from './components/Keyboard' -import { HintBar } from './nav/glyphs' -import { NavProvider } from './nav/nav' -import { SettingsProvider } from './state/settings' -import { ThemeProvider } from './theme/theme' +import { SettingsProvider, useSettings } from './state/settings' +import { ThemeProvider, useTheme } from './theme/theme' import './styles/base.css' function Preview() { - const [kb, setKb] = useState(false) - const [text, setText] = useState('') + const { themes, theme } = useTheme() + const { update } = useSettings() return (
-

Navigation

+

Thèmes

Orbe Launcher

-

{text || '…'}

-
- {Array.from({ length: 12 }, (_, i) => + ))}
- - {kb && { setText(v); setKb(false) }} onCancel={() => setKb(false)} />} - {!kb && }
) } @@ -30,9 +24,7 @@ export function App() { return ( - - - + ) diff --git a/src/renderer/src/components/Keyboard.tsx b/src/renderer/src/components/Keyboard.tsx deleted file mode 100644 index 1c681b0..0000000 --- a/src/renderer/src/components/Keyboard.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* Clavier virtuel plein écran pour la manette. Le clavier physique tape aussi dedans. */ -import { useEffect, useRef, useState } from 'react' -import { HintBar } from '../nav/glyphs' -import { useNav } from '../nav/nav' -import { useSettings } from '../state/settings' -import { useT } from '../i18n/i18n' - -const LAYOUTS = { - fr: ['1234567890', 'azertyuiop', 'qsdfghjklm', "wxcvbn,.'-"], - en: ['1234567890', 'qwertyuiop', 'asdfghjkl-', "zxcvbnm,.'"] -} - -interface Props { - title?: string - value: string - max?: number - onChange?: (v: string) => void // à chaque frappe (recherche instantanée) - onSubmit: (v: string) => void - onCancel: () => void -} - -export function Keyboard({ title, value: initial, max = 120, onChange, onSubmit, onCancel }: Props) { - const t = useT() - const { settings } = useSettings() - const [value, setValue] = useState(initial) - const [shift, setShift] = useState(false) - const first = useRef(true) - const type = (c: string) => setValue((v) => (v + c).slice(0, max)) - const typeKey = (c: string) => type(shift ? c.toUpperCase() : c) - const del = () => setValue((v) => v.slice(0, -1)) - useEffect(() => { if (first.current) first.current = false; else onChange?.(value) }, [value]) - - /* Clavier physique : caractères et retour arrière vont dans le champ, avant la navigation */ - useEffect(() => { - const k = (e: KeyboardEvent) => { - if (e.ctrlKey || e.altKey || e.metaKey) return - if (e.key.length === 1) type(e.key) - else if (e.key === 'Backspace') del() - else if (e.key === 'Enter' && !(document.activeElement instanceof HTMLButtonElement)) onSubmit(value) - else return - e.preventDefault() - e.stopImmediatePropagation() - } - addEventListener('keydown', k, true) - return () => removeEventListener('keydown', k, true) - }) - - useNav((a) => { - if (a === 'back') { onCancel(); return true } - if (a === 'x') { del(); return true } - if (a === 'y') { type(' '); return true } - if (a === 'lb' || a === 'lt') { setShift((s) => !s); return true } - if (a === 'menu' || a === 'rt') { onSubmit(value); return true } - return false - }) - - return ( -
-
-

{title ?? t('kb.title')}

-
{value}
-
- {LAYOUTS[settings.language].map((row, i) => ( -
- {[...row].map((c, j) => ( - - ))} -
- ))} -
- - - - -
-
-
- -
- ) -} diff --git a/src/renderer/src/i18n/en.ts b/src/renderer/src/i18n/en.ts deleted file mode 100644 index 0a76169..0000000 --- a/src/renderer/src/i18n/en.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { fr } from './fr' - -export const en: Record = { - 'hint.select': 'Select', - 'hint.back': 'Back', - 'hint.ok': 'Confirm', - 'hint.delete': 'Delete', - 'hint.space': 'Space', - 'hint.shift': 'Shift', - 'kb.title': 'Input', - 'kb.ok': 'OK', - 'kb.space': 'Space', - 'kb.delete': 'Delete' -} diff --git a/src/renderer/src/i18n/fr.ts b/src/renderer/src/i18n/fr.ts deleted file mode 100644 index 940abf2..0000000 --- a/src/renderer/src/i18n/fr.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const fr = { - 'hint.select': 'Choisir', - 'hint.back': 'Retour', - 'hint.ok': 'Valider', - 'hint.delete': 'Effacer', - 'hint.space': 'Espace', - 'hint.shift': 'Majuscules', - 'kb.title': 'Saisie', - 'kb.ok': 'OK', - 'kb.space': 'Espace', - 'kb.delete': 'Effacer' -} diff --git a/src/renderer/src/i18n/i18n.tsx b/src/renderer/src/i18n/i18n.tsx deleted file mode 100644 index 622dcd1..0000000 --- a/src/renderer/src/i18n/i18n.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* Traductions : FR par défaut, EN. Clés plates, variables {nom}. */ -import { useCallback } from 'react' -import { useSettings } from '../state/settings' -import { en } from './en' -import { fr } from './fr' - -export type Key = keyof typeof fr -export const dicts = { fr, en } satisfies Record> - -export function translate(lang: 'fr' | 'en', key: string, vars?: Record): string { - const s = (dicts[lang] as Record)[key] ?? (fr as Record)[key] ?? key - return vars ? s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? '')) : s -} - -export function useT() { - const { settings } = useSettings() - return useCallback((key: string, vars?: Record) => translate(settings.language, key, vars), [settings.language]) -} diff --git a/src/renderer/src/nav/glyph-map.ts b/src/renderer/src/nav/glyph-map.ts deleted file mode 100644 index 8fff9ad..0000000 --- a/src/renderer/src/nav/glyph-map.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* Libellés des boutons selon la manette (pur, testé). */ -import type { Action, InputKind } from './input' - -const XBOX: Partial> = { accept: 'A', back: 'B', x: 'X', y: 'Y', lb: 'LB', rb: 'RB', lt: 'LT', rt: 'RT', menu: '☰', view: '⧉' } -const PS: Partial> = { accept: '✕', back: '○', x: '□', y: '△', lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', menu: 'OPTIONS', view: 'SHARE' } -const KEY: Partial> = { accept: 'Entrée', back: 'Échap', x: 'X', y: 'Y', lb: 'Q', rb: 'E', lt: 'PgPréc', rt: 'PgSuiv', menu: 'F1', view: 'Tab' } - -export function glyph(a: Action, kind: InputKind): string { - return (kind === 'ps' ? PS : kind === 'xbox' ? XBOX : KEY)[a] ?? a -} diff --git a/src/renderer/src/nav/glyphs.tsx b/src/renderer/src/nav/glyphs.tsx deleted file mode 100644 index 3c118ee..0000000 --- a/src/renderer/src/nav/glyphs.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* Boutons affichés selon la manette détectée (A/B Xbox, Croix/Rond PlayStation, touches clavier). */ -import { glyph } from './glyph-map' -import type { Action } from './input' -import { useNavCtx } from './nav' -import { useT } from '../i18n/i18n' - -export function Glyph({ action }: { action: Action }) { - const { kind } = useNavCtx() - return {glyph(action, kind)} -} - -/* Barre d'aide en bas d'écran : ce que fait chaque bouton ici */ -export function HintBar({ items }: { items: [Action, string][] }) { - const t = useT() - return ( -
- {items.map(([a, label]) => ( - {t(label)} - ))} -
- ) -} diff --git a/src/renderer/src/nav/input.ts b/src/renderer/src/nav/input.ts deleted file mode 100644 index bf82be8..0000000 --- a/src/renderer/src/nav/input.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* Entrées : manette (Gamepad API), clavier, souris, ramenées à des actions. Répétition quand une direction est tenue. */ - -export type Action = 'up' | 'down' | 'left' | 'right' | 'accept' | 'back' | 'x' | 'y' | 'lb' | 'rb' | 'lt' | 'rt' | 'menu' | 'view' -export type InputKind = 'xbox' | 'ps' | 'keyboard' | 'mouse' - -const REPEAT_DELAY = 380, REPEAT_RATE = 80 // ms -const DIRS: Action[] = ['up', 'down', 'left', 'right'] - -/* Boutons de la disposition standard (W3C) */ -const BUTTONS: [number, Action][] = [ - [0, 'accept'], [1, 'back'], [2, 'x'], [3, 'y'], [4, 'lb'], [5, 'rb'], [6, 'lt'], [7, 'rt'], [8, 'view'], [9, 'menu'], - [12, 'up'], [13, 'down'], [14, 'left'], [15, 'right'] -] - -const KEYS: Record = { - ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', - Enter: 'accept', ' ': 'accept', Escape: 'back', Backspace: 'back', - KeyX: 'x', KeyY: 'y', KeyQ: 'lb', KeyE: 'rb', PageUp: 'lt', PageDown: 'rt', F1: 'menu', Tab: 'view' -} - -export function padKind(id: string): InputKind { - return /054c|playstation|dualsense|dualshock|wireless controller/i.test(id) ? 'ps' : 'xbox' -} - -/* Gestion de la répétition pour un bouton tenu ; renvoie true si l'action doit partir à cet instant */ -export class Repeater { - private since = new Map() - private last = new Map() - step(a: Action, down: boolean, now: number): boolean { - if (!down) { this.since.delete(a); return false } - const s = this.since.get(a) - if (s === undefined) { this.since.set(a, now); this.last.set(a, now); return true } - if (!DIRS.includes(a)) return false // seules les directions se répètent - if (now - s >= REPEAT_DELAY && now - (this.last.get(a) ?? 0) >= REPEAT_RATE) { this.last.set(a, now); return true } - return false - } -} - -/* Lecture des états d'une manette en actions enfoncées */ -export function padActions(gp: Pick, dead = 0.5): Set { - const on = new Set() - for (const [i, a] of BUTTONS) if (gp.buttons[i]?.pressed) on.add(a) - const [x = 0, y = 0] = gp.axes - if (x < -dead) on.add('left') - if (x > dead) on.add('right') - if (y < -dead) on.add('up') - if (y > dead) on.add('down') - return on -} - -export interface InputHandlers { - action: (a: Action) => void - kind: (k: InputKind) => void - guideHeld?: () => void -} - -/* Démarre l'écoute ; renvoie une fonction d'arrêt */ -export function startInput(h: InputHandlers): () => void { - const rep = new Repeater() - let raf = 0, kind: InputKind | null = null, guideSince = 0 - const setKind = (k: InputKind) => { if (k !== kind) { kind = k; h.kind(k) } } - - const loop = () => { - raf = requestAnimationFrame(loop) - if (!document.hasFocus()) return - const now = performance.now() - const pads = navigator.getGamepads?.() ?? [] - const on = new Set() - let guide = false - for (const gp of pads) { - if (!gp || !gp.connected) continue - const acts = padActions(gp) - if (acts.size) setKind(padKind(gp.id)) - acts.forEach((a) => on.add(a)) - if (gp.buttons[16]?.pressed) guide = true - } - for (const [, a] of BUTTONS) if (rep.step(a, on.has(a), now)) h.action(a) - // bouton Guide / PS tenu 0,8 s (quand Chromium le remonte ; sinon le processus principal s'en charge via XInput) - if (guide) { if (!guideSince) guideSince = now; else if (now - guideSince > 800) { guideSince = Infinity; h.guideHeld?.() } } else guideSince = 0 - } - raf = requestAnimationFrame(loop) - - const onKey = (e: KeyboardEvent) => { - const a = KEYS[e.key] ?? KEYS[e.code] - const t = e.target as HTMLElement - if (t?.tagName === 'INPUT' && !['ArrowUp', 'ArrowDown', 'Enter', 'Escape', 'Tab'].includes(e.key)) return // saisie au clavier physique - if (!a) return - e.preventDefault() - setKind('keyboard') - h.action(a) // le clavier répète déjà tout seul - } - const onMouse = () => setKind('mouse') - addEventListener('keydown', onKey) - addEventListener('mousemove', onMouse) - return () => { cancelAnimationFrame(raf); removeEventListener('keydown', onKey); removeEventListener('mousemove', onMouse) } -} - -export function rumble(): void { - for (const gp of navigator.getGamepads?.() ?? []) { - const act = (gp as Gamepad & { vibrationActuator?: { playEffect: (t: string, p: object) => Promise } })?.vibrationActuator - act?.playEffect('dual-rumble', { duration: 35, weakMagnitude: 0.25, strongMagnitude: 0 }).catch(() => {}) - } -} diff --git a/src/renderer/src/nav/nav.tsx b/src/renderer/src/nav/nav.tsx deleted file mode 100644 index 423ae06..0000000 --- a/src/renderer/src/nav/nav.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/* Gestionnaire de navigation : focus spatial dans la portée active (écran ou fenêtre modale), actions manette/clavier, sons. */ -import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react' -import type { SoundName } from '@shared/theme' -import { useSettings } from '../state/settings' -import { startInput, rumble, type Action, type InputKind } from './input' -import { play } from './sounds' -import { pickNext, type Dir } from './spatial' - -type Handler = (a: Action) => boolean | void // true = action consommée - -interface Ctx { - kind: InputKind - focus: (el: Element | null) => void - push: (h: { current: Handler }) => () => void - sound: (s: SoundName) => void - current: () => HTMLElement | null -} - -const NavCtx = createContext(null!) -const SEL = '[data-nav]:not([disabled])' - -/* Portée active : la dernière fenêtre modale ouverte, sinon tout le document */ -const scope = (): ParentNode => { - const s = document.querySelectorAll('[data-nav-scope]') - return s.length ? s[s.length - 1] : document -} -const visible = (el: Element) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 } - -export function NavProvider({ children, onGuide }: { children: ReactNode; onGuide?: () => void }) { - const [kind, setKind] = useState('mouse') - const cur = useRef(null) - const stack = useRef<{ current: Handler }[]>([]) - const { settings } = useSettings() - const vib = useRef(settings.vibration) - vib.current = settings.vibration - - const focus = useCallback((el: Element | null) => { - if (!(el instanceof HTMLElement) || el === cur.current) return - cur.current?.classList.remove('is-focused') - cur.current = el - el.classList.add('is-focused') - el.focus({ preventScroll: true }) - el.scrollIntoView({ block: el.dataset.navScroll === 'center' ? 'center' : 'nearest', inline: 'nearest', behavior: 'smooth' }) - }, []) - - /* Remet le focus sur un élément valide (écran changé, élément supprimé, fenêtre fermée) */ - const refocus = useCallback(() => { - const sc = scope(), c = cur.current - if (c && c.isConnected && (sc === document || (sc as Element).contains(c)) && visible(c)) return - const auto = sc.querySelector('[data-nav-autofocus]' ) ?? [...sc.querySelectorAll(SEL)].find(visible) ?? null - if (c && !c.isConnected) cur.current = null - if (auto) focus(auto) - }, [focus]) - - const move = useCallback((dir: Dir) => { - const sc = scope(), from = cur.current - const els = [...sc.querySelectorAll(SEL)].filter(visible) - if (!from || !from.isConnected) { if (els[0]) focus(els[0]); return } - // un groupe (data-nav-group) garde le focus dans sa ligne pour gauche/droite - const r = from.getBoundingClientRect(), box = { x: r.left, y: r.top, w: r.width, h: r.height } - const next = pickNext(box, els.filter((e) => e !== from).map((el) => { const b = el.getBoundingClientRect(); return { el, box: { x: b.left, y: b.top, w: b.width, h: b.height } } }), dir) - if (next) { focus(next.el); play('move'); if (vib.current) rumble() } - }, [focus]) - - useEffect(() => { - const stop = startInput({ - kind: (k) => { setKind(k); document.documentElement.dataset.input = k === 'mouse' ? 'mouse' : k === 'keyboard' ? 'keyboard' : 'pad' }, - action: (a) => { - for (let i = stack.current.length - 1; i >= 0; i--) if (stack.current[i].current(a)) return - if (a === 'up' || a === 'down' || a === 'left' || a === 'right') move(a) - else if (a === 'accept' && cur.current) { play('select'); cur.current.click() } - }, - guideHeld: onGuide - }) - const over = (e: PointerEvent) => { const t = (e.target as Element).closest?.(SEL); if (t) focus(t) } - const mo = new MutationObserver(() => requestAnimationFrame(refocus)) - mo.observe(document.body, { childList: true, subtree: true }) - addEventListener('pointerover', over) - return () => { stop(); mo.disconnect(); removeEventListener('pointerover', over) } - }, [move, focus, refocus, onGuide]) - - const push = useCallback((h: { current: Handler }) => { stack.current.push(h); return () => { stack.current = stack.current.filter((x) => x !== h) } }, []) - return cur.current }}>{children} -} - -export const useNavCtx = () => useContext(NavCtx) - -/* Intercepte les actions tant que le composant est affiché (le plus récent passe en premier) */ -export function useNav(handler: Handler, active = true): void { - const { push } = useNavCtx() - const ref = useRef(handler) - ref.current = handler - useEffect(() => (active ? push(ref) : undefined), [push, active]) -} diff --git a/src/renderer/src/nav/sounds.ts b/src/renderer/src/nav/sounds.ts deleted file mode 100644 index 432fe9f..0000000 --- a/src/renderer/src/nav/sounds.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* Sons de navigation du thème (Web Audio : latence faible, plusieurs sons superposables). */ -import type { SoundName, ThemeManifest } from '@shared/theme' - -let ctx: AudioContext | null = null -const buffers = new Map() -let volume = 0.6, token = 0 - -export async function loadSounds(base: string, sounds: ThemeManifest['sounds']): Promise { - const my = ++token - buffers.clear() - ctx ??= new AudioContext() - await Promise.all( - Object.entries(sounds).map(async ([name, rel]) => { - try { - const data = await (await fetch(base + rel)).arrayBuffer() - const buf = await ctx!.decodeAudioData(data) - if (my === token) buffers.set(name as SoundName, buf) // un autre thème a pu être choisi entre-temps - } catch { - /* son manquant ou illisible : silence */ - } - }) - ) -} - -export function setVolume(v: number): void { - volume = v -} - -export function play(name: SoundName): void { - const buf = buffers.get(name) - if (!ctx || !buf || volume <= 0) return - if (ctx.state === 'suspended') ctx.resume() - const src = ctx.createBufferSource(), gain = ctx.createGain() - gain.gain.value = volume - src.buffer = buf - src.connect(gain).connect(ctx.destination) - src.start() -} diff --git a/src/renderer/src/nav/spatial.ts b/src/renderer/src/nav/spatial.ts deleted file mode 100644 index b7f0f65..0000000 --- a/src/renderer/src/nav/spatial.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* Focus spatial : choisit l'élément le plus naturel dans une direction. Pur (rectangles en entrée) pour être testé. */ - -export type Dir = 'up' | 'down' | 'left' | 'right' -export interface Box { x: number; y: number; w: number; h: number } - -/* Distance « ressentie » : on avance dans la direction, en pénalisant fort le décalage latéral. - Un candidat qui chevauche l'axe (même ligne / même colonne) passe devant un candidat en diagonale. */ -export function pickNext(from: Box, items: T[], dir: Dir): T | null { - const horiz = dir === 'left' || dir === 'right', sign = dir === 'right' || dir === 'down' ? 1 : -1 - let best: T | null = null, bestScore = Infinity - for (const it of items) { - const b = it.box - if (b === from) continue - // avance : bord de départ -> bord d'arrivée - const gap = horiz ? (sign > 0 ? b.x - (from.x + from.w) : from.x - (b.x + b.w)) : sign > 0 ? b.y - (from.y + from.h) : from.y - (b.y + b.h) - const centerAdvance = horiz ? (b.x + b.w / 2 - (from.x + from.w / 2)) * sign : (b.y + b.h / 2 - (from.y + from.h / 2)) * sign - if (centerAdvance <= 1 || gap < -Math.min(horiz ? from.w : from.h, horiz ? b.w : b.h) / 2) continue - // décalage latéral : 0 si les projections se chevauchent - const [a0, a1, b0, b1] = horiz ? [from.y, from.y + from.h, b.y, b.y + b.h] : [from.x, from.x + from.w, b.x, b.x + b.w] - const overlap = Math.min(a1, b1) - Math.max(a0, b0) - const lateral = overlap > 0 ? 0 : Math.max(b0 - a1, a0 - b1) - const lateralCenter = Math.abs(horiz ? b.y + b.h / 2 - (from.y + from.h / 2) : b.x + b.w / 2 - (from.x + from.w / 2)) - const score = Math.max(gap, 0) + lateral * 4 + lateralCenter * 0.1 + (overlap > 0 ? 0 : 1e4) - if (score < bestScore) { bestScore = score; best = it } - } - return best -} diff --git a/src/renderer/src/styles/base.css b/src/renderer/src/styles/base.css index 022b712..266ddb9 100644 --- a/src/renderer/src/styles/base.css +++ b/src/renderer/src/styles/base.css @@ -47,33 +47,3 @@ mark { background: none; color: var(--accent); } /* 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; } - -/* Barre d'aide (boutons) */ -.hintbar { position: fixed; inset: auto 0 0 0; height: 64px; display: flex; justify-content: flex-end; align-items: center; gap: 28px; padding: 0 var(--pad); - font: 400 14px/1 var(--font-mono); color: var(--muted); letter-spacing: .06em; z-index: 30; background: linear-gradient(transparent, color-mix(in srgb, var(--bg) 85%, transparent) 40%); } -.hint { display: inline-flex; align-items: center; gap: 10px; } -.glyph { display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 8px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface-2); color: var(--text); font: 700 13px/1 var(--font-mono); } -.glyph--xbox.glyph--accept { background: #107c10; border-color: #107c10; color: #fff; } -.glyph--xbox.glyph--back { background: #c4302b; border-color: #c4302b; color: #fff; } -.glyph--xbox.glyph--x { background: #1f6fd1; border-color: #1f6fd1; color: #fff; } -.glyph--xbox.glyph--y { background: #d6a400; border-color: #d6a400; color: #111; } -.glyph--keyboard, .glyph--mouse { border-radius: var(--radius-s); } - -/* Fenêtres modales */ -.modal { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px); animation: modal-in var(--dur-med) var(--ease); } -@keyframes modal-in { from { opacity: 0; } } - -/* Clavier virtuel */ -.osk__box { width: min(1100px, 92vw); padding-bottom: 60px; } -.osk__field { min-height: 76px; padding: 18px 22px; margin-bottom: 26px; border-radius: var(--radius-m); border: 1px solid var(--accent); background: var(--surface); font-size: 30px; white-space: pre; overflow: hidden; } -.osk__caret { display: inline-block; width: 3px; height: 1.1em; margin-left: 2px; vertical-align: -.15em; background: var(--accent); } -.osk__keys { display: grid; gap: 10px; } -.osk__row { display: flex; gap: 10px; justify-content: center; } -.osk__key { flex: 1; max-width: 96px; height: 68px; border-radius: var(--radius-s); background: var(--surface); border: 1px solid var(--line); font: 600 24px/1 var(--font-sans); - transition: transform var(--dur-fast) var(--ease), background var(--dur-fast), box-shadow var(--dur-fast); } -.osk__key--wide { max-width: 150px; } -.osk__key--space { max-width: 420px; flex: 4; font-size: 16px; } -.osk__key--ok { max-width: 180px; background: var(--accent); color: var(--on-accent); font-size: 16px; } -.osk__key.is-on { color: var(--accent); } -.osk__key.is-focused { box-shadow: var(--focus-ring); transform: scale(1.06); background: var(--surface-2); } -.osk__key--ok.is-focused { background: var(--accent); } diff --git a/src/renderer/src/theme/theme.tsx b/src/renderer/src/theme/theme.tsx index 2a4b33c..28d5ad5 100644 --- a/src/renderer/src/theme/theme.tsx +++ b/src/renderer/src/theme/theme.tsx @@ -2,7 +2,6 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' import type { ThemeEntry } from '@shared/ipc' import { api } from '../api' -import { loadSounds, setVolume } from '../nav/sounds' import { useSettings } from '../state/settings' interface Ctx { @@ -47,12 +46,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { 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) return - apply(theme, version) - loadSounds(theme.base, theme.manifest.sounds) - }, [theme, version, ready]) - useEffect(() => setVolume(settings.volume), [settings.volume]) + useEffect(() => { if (theme && ready) apply(theme, version) }, [theme, version, ready]) const url = (rel?: string) => (rel && theme ? theme.base + rel : undefined) return {children} } diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts deleted file mode 100644 index 2a05504..0000000 --- a/tests/i18n.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { en } from '../src/renderer/src/i18n/en' -import { fr } from '../src/renderer/src/i18n/fr' - -describe('traductions', () => { - it('même clés et mêmes variables en FR et EN', () => { - expect(Object.keys(en).sort()).toEqual(Object.keys(fr).sort()) - for (const [k, v] of Object.entries(fr)) { - const vars = (s: string) => (s.match(/\{\w+\}/g) ?? []).sort() - expect(vars(en[k as keyof typeof en]), k).toEqual(vars(v)) - } - }) -}) diff --git a/tests/input.test.ts b/tests/input.test.ts deleted file mode 100644 index 345ae1c..0000000 --- a/tests/input.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { padActions, padKind, Repeater } from '../src/renderer/src/nav/input' -import { glyph } from '../src/renderer/src/nav/glyph-map' - -const pad = (pressed: number[], axes = [0, 0]) => ({ buttons: Array.from({ length: 17 }, (_, i) => ({ pressed: pressed.includes(i), touched: false, value: 0 })), axes }) - -describe('entrées manette', () => { - it('traduit boutons et stick', () => { - expect([...padActions(pad([0, 13]))].sort()).toEqual(['accept', 'down']) - expect([...padActions(pad([], [-0.9, 0.1]))]).toEqual(['left']) - expect(padActions(pad([], [0.3, -0.3])).size).toBe(0) // zone morte - }) - it('reconnaît les manettes PlayStation', () => { - expect(padKind('054c-0ce6-Wireless Controller')).toBe('ps') - expect(padKind('DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)')).toBe('ps') - expect(padKind('Xbox 360 Controller (XInput STANDARD GAMEPAD)')).toBe('xbox') - }) - it('affiche le bon bouton', () => { - expect(glyph('accept', 'xbox')).toBe('A') - expect(glyph('accept', 'ps')).toBe('✕') - expect(glyph('back', 'ps')).toBe('○') - }) -}) - -describe('Repeater', () => { - it('répète une direction tenue, pas un bouton', () => { - const r = new Repeater() - expect(r.step('down', true, 0)).toBe(true) - expect(r.step('down', true, 200)).toBe(false) - expect(r.step('down', true, 400)).toBe(true) - expect(r.step('down', true, 430)).toBe(false) - expect(r.step('down', true, 490)).toBe(true) - expect(r.step('accept', true, 0)).toBe(true) - expect(r.step('accept', true, 1000)).toBe(false) - r.step('down', false, 500) - expect(r.step('down', true, 510)).toBe(true) - }) -}) diff --git a/tests/spatial.test.ts b/tests/spatial.test.ts deleted file mode 100644 index 5143ae5..0000000 --- a/tests/spatial.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { pickNext, type Box } from '../src/renderer/src/nav/spatial' - -/* grille 3x3 de cartes 100x150, espacées de 20 */ -const grid = Array.from({ length: 9 }, (_, i) => ({ id: i, box: { x: (i % 3) * 120, y: Math.floor(i / 3) * 170, w: 100, h: 150 } as Box })) -const at = (i: number) => grid[i].box -const next = (i: number, d: 'up' | 'down' | 'left' | 'right', items = grid) => pickNext(at(i), items.filter((g) => g.id !== i), d)?.id ?? null - -describe('pickNext', () => { - it('se déplace dans une grille', () => { - expect(next(4, 'right')).toBe(5) - expect(next(4, 'left')).toBe(3) - expect(next(4, 'up')).toBe(1) - expect(next(4, 'down')).toBe(7) - }) - it('ne sort pas de la grille', () => { - expect(next(2, 'right')).toBeNull() - expect(next(0, 'up')).toBeNull() - }) - it('préfère le même axe à la diagonale plus proche', () => { - const items = [{ id: 'loin', box: { x: 600, y: 0, w: 100, h: 50 } }, { id: 'diag', box: { x: 150, y: 80, w: 100, h: 50 } }] - expect(pickNext({ x: 0, y: 0, w: 100, h: 50 }, items, 'right')?.id).toBe('loin') - }) - it('descend vers un bouton large sous la grille', () => { - const items = [...grid, { id: 99, box: { x: 0, y: 600, w: 400, h: 60 } }] - expect(next(8, 'down', items)).toBe(99) - }) - it('remonte de la barre vers la carte la plus alignée', () => { - const items = [...grid, { id: 99, box: { x: 240, y: 600, w: 100, h: 60 } }] - expect(pickNext(items[9].box, grid, 'up')?.id).toBe(8) - }) -}) diff --git a/themes/orbe/sounds/back.wav b/themes/orbe/sounds/back.wav deleted file mode 100644 index aec33b3..0000000 Binary files a/themes/orbe/sounds/back.wav and /dev/null differ diff --git a/themes/orbe/sounds/error.wav b/themes/orbe/sounds/error.wav deleted file mode 100644 index 8966202..0000000 Binary files a/themes/orbe/sounds/error.wav and /dev/null differ diff --git a/themes/orbe/sounds/launch.wav b/themes/orbe/sounds/launch.wav deleted file mode 100644 index c1b95fe..0000000 Binary files a/themes/orbe/sounds/launch.wav and /dev/null differ diff --git a/themes/orbe/sounds/move.wav b/themes/orbe/sounds/move.wav deleted file mode 100644 index 19e594e..0000000 Binary files a/themes/orbe/sounds/move.wav and /dev/null differ diff --git a/themes/orbe/sounds/select.wav b/themes/orbe/sounds/select.wav deleted file mode 100644 index 7774f47..0000000 Binary files a/themes/orbe/sounds/select.wav and /dev/null differ diff --git a/themes/orbe/theme.json b/themes/orbe/theme.json index bf3de45..e327f86 100644 --- a/themes/orbe/theme.json +++ b/themes/orbe/theme.json @@ -28,47 +28,14 @@ "--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" - } + { "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": { - "move": "sounds/move.wav", - "select": "sounds/select.wav", - "back": "sounds/back.wav", - "error": "sounds/error.wav", - "launch": "sounds/launch.wav" - } + "layout": { "library": "grid" }, + "background": { "type": "orb" }, + "splash": { "type": "orb", "duration": 1800 }, + "sounds": {} } diff --git a/tools/gen-sounds.mjs b/tools/gen-sounds.mjs deleted file mode 100644 index 516a234..0000000 --- a/tools/gen-sounds.mjs +++ /dev/null @@ -1,49 +0,0 @@ -// Génère les sons de navigation du thème Orbe (WAV 16 bits mono, 44,1 kHz) : sobres, courts, sans dépendance. -// Usage : node tools/gen-sounds.mjs -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -const RATE = 44100 -const out = join(import.meta.dirname, '..', 'themes', 'orbe', 'sounds') - -/* voix : fréquence qui glisse de f0 à f1, enveloppe attaque/déclin, timbre sinus + un peu d'harmonique */ -function tone(dur, f0, f1, { gain = 0.5, attack = 0.004, harm = 0.15, delay = 0 } = {}) { - const n = Math.round((dur + delay) * RATE), s = new Float32Array(n) - let phase = 0 - for (let i = Math.round(delay * RATE); i < n; i++) { - const t = i / RATE - delay, p = t / dur - const f = f0 * Math.pow(f1 / f0, p) - phase += (2 * Math.PI * f) / RATE - const env = Math.min(1, t / attack) * Math.pow(1 - p, 2.2) - s[i] = gain * env * (Math.sin(phase) + harm * Math.sin(2 * phase)) - } - return s -} - -const mix = (...parts) => { - const n = Math.max(...parts.map((p) => p.length)), s = new Float32Array(n) - for (const p of parts) for (let i = 0; i < p.length; i++) s[i] += p[i] - return s -} - -function wav(samples) { - const b = Buffer.alloc(44 + samples.length * 2) - b.write('RIFF', 0); b.writeUInt32LE(36 + samples.length * 2, 4); b.write('WAVE', 8) - b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22) - b.writeUInt32LE(RATE, 24); b.writeUInt32LE(RATE * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34) - b.write('data', 36); b.writeUInt32LE(samples.length * 2, 40) - samples.forEach((v, i) => b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, v)) * 32767), 44 + i * 2)) - return b -} - -const sounds = { - move: tone(0.035, 2400, 2200, { gain: 0.18, harm: 0 }), // petit tic - select: mix(tone(0.07, 660, 680, { gain: 0.32 }), tone(0.09, 990, 1000, { gain: 0.28, delay: 0.045 })), // deux notes qui montent - back: mix(tone(0.07, 740, 720, { gain: 0.28 }), tone(0.09, 494, 480, { gain: 0.26, delay: 0.045 })), // deux notes qui descendent - error: mix(tone(0.08, 180, 170, { gain: 0.35, harm: 0.4 }), tone(0.08, 180, 170, { gain: 0.35, harm: 0.4, delay: 0.11 })), - launch: mix(tone(0.45, 330, 1320, { gain: 0.3, attack: 0.02 }), tone(0.5, 495, 1980, { gain: 0.12, attack: 0.05, delay: 0.06 })) -} - -mkdirSync(out, { recursive: true }) -for (const [name, s] of Object.entries(sounds)) writeFileSync(join(out, name + '.wav'), wav(s)) -console.log('sons écrits dans', out)