From 12f1b49bed96238c1db7aeb8b1051343d0c1fdc2 Mon Sep 17 00:00:00 2001 From: Thanh Chau <1320427+thannous@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:53:58 +0200 Subject: [PATCH 1/4] Refactor configurator architecture and expand tests --- .gitignore | 1 + package.json | 5 +- prototype/package.json | 2 + prototype/src/App.jsx | 1828 +++-------------- .../src/components/ConfiguratorWorkspace.jsx | 245 +++ prototype/src/components/JoystickDial.jsx | 132 ++ .../src/components/KeyAssignmentEditor.jsx | 209 ++ prototype/src/components/MappingDialog.jsx | 146 ++ .../src/components/ProfileExportPanel.jsx | 34 + prototype/src/components/ProfileLoader.jsx | 127 ++ prototype/src/components/ProfileReview.jsx | 125 ++ prototype/src/configurator-catalog.jsx | 339 +++ prototype/src/configurator-presenter.js | 45 + prototype/src/configurator-state.js | 223 ++ prototype/src/hooks/use-theme.js | 49 + prototype/src/i18n/de.js | 3 +- prototype/src/i18n/en.js | 3 +- prototype/src/i18n/es.js | 3 +- prototype/src/i18n/fr.js | 3 +- prototype/src/i18n/index.js | 14 +- prototype/src/profile-panel-loader.js | 6 + prototype/src/profile-session.js | 49 + prototype/src/profile-workflow.js | 69 + prototype/src/styles.css | 270 ++- prototype/tests/app-render.test.mjs | 119 ++ prototype/tests/architecture.test.mjs | 75 + prototype/tests/configurator-state.test.mjs | 223 ++ prototype/tests/i18n.test.mjs | 55 + prototype/tests/presentation.test.mjs | 13 + prototype/tests/profile-session.test.mjs | 131 ++ scripts/build-input-profile.mjs | 10 +- scripts/configure.mjs | 68 +- scripts/enable-agent-keys.mjs | 98 +- scripts/lib/gui-dependencies.mjs | 29 +- scripts/lib/hid-device.mjs | 140 +- scripts/lib/hid-frame.mjs | 70 +- scripts/lib/hid-lighting.mjs | 65 +- scripts/lib/thread-slots.mjs | 116 +- scripts/lighting-probe.mjs | 168 +- scripts/lighting.mjs | 158 +- scripts/prepare-gui.mjs | 2 +- scripts/thread-status.mjs | 222 +- shared/input-profile.mjs | 296 +-- shared/thread-status-palette.mjs | 24 +- tests/cli.test.mjs | 214 ++ tests/gui-dependencies.test.mjs | 95 + tests/helpers/input-profile-fixture.mjs | 81 + tests/hid-device.test.mjs | 173 ++ tests/hid-frame.test.mjs | 36 +- tests/hid-lighting.test.mjs | 30 +- tests/input-layer.test.mjs | 141 ++ tests/input-profile.test.mjs | 114 +- tests/thread-slots.test.mjs | 81 +- thread-status/bin/emit.mjs | 41 +- 54 files changed, 4424 insertions(+), 2594 deletions(-) create mode 100644 prototype/src/components/ConfiguratorWorkspace.jsx create mode 100644 prototype/src/components/JoystickDial.jsx create mode 100644 prototype/src/components/KeyAssignmentEditor.jsx create mode 100644 prototype/src/components/MappingDialog.jsx create mode 100644 prototype/src/components/ProfileExportPanel.jsx create mode 100644 prototype/src/components/ProfileLoader.jsx create mode 100644 prototype/src/components/ProfileReview.jsx create mode 100644 prototype/src/configurator-catalog.jsx create mode 100644 prototype/src/configurator-presenter.js create mode 100644 prototype/src/configurator-state.js create mode 100644 prototype/src/hooks/use-theme.js create mode 100644 prototype/src/profile-panel-loader.js create mode 100644 prototype/src/profile-session.js create mode 100644 prototype/src/profile-workflow.js create mode 100644 prototype/tests/app-render.test.mjs create mode 100644 prototype/tests/architecture.test.mjs create mode 100644 prototype/tests/configurator-state.test.mjs create mode 100644 prototype/tests/i18n.test.mjs create mode 100644 prototype/tests/presentation.test.mjs create mode 100644 prototype/tests/profile-session.test.mjs create mode 100644 tests/cli.test.mjs create mode 100644 tests/gui-dependencies.test.mjs create mode 100644 tests/helpers/input-profile-fixture.mjs create mode 100644 tests/hid-device.test.mjs diff --git a/.gitignore b/.gitignore index df31c4b..a43e520 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /.local/ /outputs/ /work/ +/coverage/ node_modules/ *.log diff --git a/package.json b/package.json index ee22cc9..8029103 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,11 @@ "agent-keys": "node scripts/enable-agent-keys.mjs", "validate": "node scripts/validate-profile.mjs && node scripts/validate-presets.mjs && node scripts/check-doc-links.mjs", "test": "node --test tests/*.test.mjs", + "test:coverage": "node --experimental-test-coverage --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=70 --test tests/*.test.mjs && npm run build:gui && npm --prefix prototype run test:coverage", "prepare:gui": "node scripts/prepare-gui.mjs", "build:gui": "npm run prepare:gui && npm --prefix prototype run build", - "test:gui": "npm --prefix prototype run test:sites", - "check": "npm run validate && npm test && npm run build:gui && npm run test:gui" + "test:gui": "npm --prefix prototype test", + "check": "npm run test:coverage" }, "devDependencies": { "ajv": "8.17.1" diff --git a/prototype/package.json b/prototype/package.json index d5914c9..d601f6b 100644 --- a/prototype/package.json +++ b/prototype/package.json @@ -7,6 +7,8 @@ "dev": "vite", "build": "vite build && node scripts/prepare-sites-build.mjs", "preview": "vite preview", + "test": "node --test tests/*.test.mjs", + "test:coverage": "node --experimental-test-coverage --test-coverage-exclude='../shared/**' --test-coverage-exclude='tests/**' --test-coverage-lines=95 --test-coverage-branches=90 --test-coverage-functions=95 --test tests/*.test.mjs", "test:sites": "node --test tests/sites-worker.test.mjs" }, "dependencies": { diff --git a/prototype/src/App.jsx b/prototype/src/App.jsx index 80277a0..8b75dae 100644 --- a/prototype/src/App.jsx +++ b/prototype/src/App.jsx @@ -1,947 +1,310 @@ -import { useEffect, useMemo, useRef, useState } from "react"; import { - ArrowDownToLine, - ArrowDownUp, - ArrowLeft, - ArrowRight, - Check, - ChevronRight, - CircleAlert, - Command, - CopyPlus, - ChevronsLeft, - ChevronsRight, - Diff, - FileJson, - Gauge, - Keyboard, - Mic, - Minus, - Monitor, - Moon, - MousePointer2, - Move, - RotateCcw, - RotateCw, - Search, - Settings, - ShieldCheck, - SlidersHorizontal, - Square, - Sun, - Volume2, - X, - ZoomIn, - ZoomOut, -} from "lucide-react"; + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import { Check } from "lucide-react"; import { - addClaudeLayer, - buildInputProfile, - deriveMappingFromProfile, DEFAULT_MAPPING, - FINAL_KEYCODES, - inspectInputProfile, PRINTABLE_KEYS, - WHEEL_MODES, } from "../../shared/input-profile.mjs"; -import { LEGEND_ORDER, STATE_COLORS } from "../../shared/thread-status-palette.mjs"; import { - LOCALES, - LOCALE_LABELS, + ACTIONS_BY_CONTROL_TYPE, + CONTROL_BY_ID, + CONTROLS, +} from "./configurator-catalog.jsx"; +import { entryFor } from "./configurator-presenter.js"; +import { + assignMappingEntry, + indexDuplicateKeyControls, + isCustom, + isCustomJoystick, + loadStoredState, + mappingsEqual, + replaceShortcutFinalKey, + saveStoredState, + setJoystickMappingMode, + toggleShortcutModifier, +} from "./configurator-state.js"; +import { ConfiguratorWorkspace } from "./components/ConfiguratorWorkspace.jsx"; +import { MappingDialog } from "./components/MappingDialog.jsx"; +import { createTranslator, detectLocale, saveLocale, } from "./i18n/index.js"; +import { + createProfileSession, + profileSessionReducer, +} from "./profile-session.js"; +import { loadProfileExportPanel } from "./profile-panel-loader.js"; +import { useTheme } from "./hooks/use-theme.js"; -const THEME_STORAGE_KEY = "codex-micro-theme"; -const THEME_ORDER = ["auto", "light", "dark"]; -const THEME_ICONS = { auto: Monitor, light: Sun, dark: Moon }; - -function ClaudeMarkIcon({ size = 19, className = "" }) { - return ( - - ); -} - -function detectTheme() { - try { - const saved = window.localStorage.getItem(THEME_STORAGE_KEY); - return THEME_ORDER.includes(saved) ? saved : "auto"; - } catch (error) { - return "auto"; - } -} - -// Doit rester aligné sur le breakpoint mobile de styles.css. -const MOBILE_LAYOUT_QUERY = "(max-width: 760px)"; - -function useMobileLayout() { - const [isMobile, setIsMobile] = useState( - () => window.matchMedia(MOBILE_LAYOUT_QUERY).matches, - ); - - useEffect(() => { - const query = window.matchMedia(MOBILE_LAYOUT_QUERY); - const handleChange = (event) => setIsMobile(event.matches); - query.addEventListener("change", handleChange); - return () => query.removeEventListener("change", handleChange); - }, []); - - return isMobile; -} - -const GUIDE_URL = - "https://github.com/thannous/claude-codex-micro/blob/main/docs/installation.md"; -const INPUT_RELEASES_URL = "https://github.com/worklouder/input-releases/releases"; - -const CONTROLS = [ - { - id: "wheel", - // Identité du contrôle, pas un mode : la molette n'est plus en défilement - // par défaut. Affiché uniquement quand aucune action n'est assignée. - shortLabel: "DIAL", - type: "dial", - x: 9.36, - y: 9.48, - w: 19.6, - h: 19.95, - }, - { - id: "key-13", - shortLabel: "PRESS", - type: "key", - className: "key-hotspot--encoder-button", - x: 13.93, - y: 14.17, - w: 10.61, - h: 10.56, - }, - { - id: "key-9", - shortLabel: "A1", - type: "key", - x: 29.84, - y: 9.92, - w: 20.19, - h: 18.63, - }, - { - id: "key-10", - shortLabel: "A2", - type: "key", - x: 50.77, - y: 9.92, - w: 20.19, - h: 18.63, - }, - { - id: "joystick", - shortLabel: "NAV", - type: "joystick", - x: 73.31, - y: 10.06, - w: 17.98, - h: 19.36, - }, - { id: "key-5", shortLabel: "A3", type: "key", x: 8.77, y: 31.18, w: 20.19, h: 18.63 }, - { id: "key-6", shortLabel: "A4", type: "key", x: 28.81, y: 31.18, w: 20.19, h: 18.63 }, - { id: "key-7", shortLabel: "A5", type: "key", x: 51.06, y: 31.18, w: 20.19, h: 18.63 }, - { id: "key-8", shortLabel: "A6", type: "key", x: 72.13, y: 31.18, w: 20.19, h: 18.63 }, - { id: "key-1", shortLabel: "C1", type: "key", x: 8.77, y: 53.04, w: 20.19, h: 18.63 }, - { id: "key-2", shortLabel: "C2", type: "key", x: 28.81, y: 53.04, w: 20.19, h: 18.63 }, - { id: "key-3", shortLabel: "C3", type: "key", x: 51.06, y: 53.04, w: 20.19, h: 18.63 }, - { id: "key-4", shortLabel: "C4", type: "key", x: 72.13, y: 53.04, w: 20.19, h: 18.63 }, - { id: "key-11", shortLabel: "C5", type: "key", x: 28.81, y: 74.3, w: 42.44, h: 18.63 }, - { id: "key-12", shortLabel: "C6", type: "key", x: 72.13, y: 74.3, w: 20.19, h: 18.63 }, -]; - -const KEY_CONTROL_IDS = new Set( - CONTROLS.filter((control) => control.type === "key").map((control) => control.id), -); - -const RESERVED_ZONES = [ - { id: "sensor", x: 8.77, y: 74.3, w: 20.19, h: 18.63, round: true }, -]; - -const KEYCAP_TONES = { - "key-9": "186 235 211", - "key-10": "250 218 166", - "key-5": "205 187 244", - "key-6": "215 216 240", - "key-7": "187 201 241", - "key-8": "242 181 213", - "key-1": "235 233 230", - "key-2": "235 233 230", - "key-3": "231 232 236", - "key-4": "235 233 230", - "key-11": "235 233 230", - "key-12": "235 233 230", - "key-13": "38 36 34", -}; - -const ACTIONS = { - navigation: { - id: "navigation", - shortcut: "↑ → ↓ ←", - icon: Move, - controlTypes: ["joystick"], - exportLabel: "NAV", - }, - scroll: { - id: "scroll", - shortcut: "Page ↑ Page ↓", - icon: MousePointer2, - controlTypes: ["dial"], - exportLabel: "SCROLL", - }, - lines: { - id: "lines", - shortcut: "↑ ↓", - icon: ArrowDownUp, - controlTypes: ["dial"], - exportLabel: "LINES", - }, - effort: { - id: "effort", - shortcut: "⌘ ⇧ E · ← / →", - icon: SlidersHorizontal, - controlTypes: ["dial"], - exportLabel: "EFFORT", - }, - volume: { - id: "volume", - shortcut: "Vol − +", - icon: Volume2, - controlTypes: ["dial"], - exportLabel: "VOL", - experimental: true, - }, - newSession: { - id: "newSession", - shortcut: "⌘ N", - icon: Command, - controlTypes: ["key"], - exportLabel: "NEW", - }, - send: { - id: "send", - shortcut: "↩", - icon: ClaudeMarkIcon, - controlTypes: ["key"], - exportLabel: "SEND", - }, - sendInDuplicateSession: { - id: "sendInDuplicateSession", - shortcut: "⌥ ⌘ ↩", - icon: CopyPlus, - controlTypes: ["key"], - exportLabel: "DUP", - }, - voice: { - id: "voice", - shortcut: "⌘ D", - icon: Mic, - controlTypes: ["key"], - exportLabel: "VOICE", - }, - diff: { - id: "diff", - shortcut: "⌘ ⇧ D", - icon: Diff, - controlTypes: ["key"], - exportLabel: "DIFF", - }, - // Cycle entre les sessions du Code tab. Control sur toutes les plateformes, - // comme le documente Claude Desktop. Aucun raccourci ne choisit une session par - // son rang : seul le cycle est adressable. - nextSession: { - id: "nextSession", - shortcut: "⌃ ⇥", - icon: ChevronsRight, - controlTypes: ["key"], - exportLabel: "NEXT", - }, - previousSession: { - id: "previousSession", - shortcut: "⌃ ⇧ ⇥", - icon: ChevronsLeft, - controlTypes: ["key"], - exportLabel: "PREV", - }, - effortMenu: { - id: "effortMenu", - shortcut: "⌘ ⇧ E", - icon: Gauge, - controlTypes: ["key"], - exportLabel: "EFF", - }, - stop: { - id: "stop", - shortcut: "Esc", - icon: Square, - controlTypes: ["key"], - exportLabel: "ESC", - }, - settings: { - id: "settings", - shortcut: "⌘ ,", - icon: Settings, - controlTypes: ["key"], - exportLabel: "SET", - }, - find: { - id: "find", - shortcut: "⌘ F", - icon: Search, - controlTypes: ["key"], - exportLabel: "FIND", - }, - findNext: { - id: "findNext", - shortcut: "⌘ G", - icon: Search, - controlTypes: ["key"], - exportLabel: "NEXT", - }, - findPrevious: { - id: "findPrevious", - shortcut: "⌘ ⇧ G", - icon: Search, - controlTypes: ["key"], - exportLabel: "PREV", - }, - back: { - id: "back", - shortcut: "⌘ [", - icon: ArrowLeft, - controlTypes: ["key"], - exportLabel: "BACK", - }, - forward: { - id: "forward", - shortcut: "⌘ ]", - icon: ArrowRight, - controlTypes: ["key"], - exportLabel: "FWD", - }, - reload: { - id: "reload", - shortcut: "⌘ R", - icon: RotateCw, - controlTypes: ["key"], - exportLabel: "LOAD", - }, - closeWindow: { - id: "closeWindow", - shortcut: "⌘ W", - icon: X, - controlTypes: ["key"], - exportLabel: "CLOSE", - }, - zoomIn: { - id: "zoomIn", - shortcut: "⌘ +", - icon: ZoomIn, - controlTypes: ["key"], - exportLabel: "ZOOM+", - }, - zoomOut: { - id: "zoomOut", - shortcut: "⌘ −", - icon: ZoomOut, - controlTypes: ["key"], - exportLabel: "ZOOM−", - }, - resetZoom: { - id: "resetZoom", - shortcut: "⌘ 0", - icon: Monitor, - controlTypes: ["key"], - exportLabel: "100%", - }, - none: { - id: "none", - shortcut: null, - icon: Minus, - controlTypes: ["key", "dial", "joystick"], - exportLabel: "NONE", - }, -}; - -const KEY_ACTION_IDS = new Set( - Object.values(ACTIONS) - .filter((action) => action.controlTypes.includes("key")) - .map((action) => action.id), -); -const JOYSTICK_ACTION_IDS = new Set(["navigation", "none"]); - -const MODIFIERS = ["Command", "Shift", "Option", "Control"]; -const MODIFIER_SYMBOLS = { Command: "⌘", Shift: "⇧", Option: "⌥", Control: "⌃" }; -const KEY_SYMBOLS = { - ArrowUp: "↑", - ArrowDown: "↓", - ArrowLeft: "←", - ArrowRight: "→", - PageUp: "Pg↑", - PageDown: "Pg↓", - Escape: "Esc", - Space: "␣", - Comma: ",", - BracketLeft: "[", - BracketRight: "]", - Equal: "=", - Minus: "−", -}; -const FINAL_KEY_OPTIONS = Object.keys(FINAL_KEYCODES); -const DEFAULT_CUSTOM = { type: "custom", keys: ["Command", "K"] }; - -const MAPPING_STORAGE_KEY = "codex-micro-mapping"; +const DEFAULT_CONTROL_ID = "key-1"; const TOAST_DURATION_MS = 6000; +let profileWorkflowPromise; -const isCustom = (entry) => typeof entry === "object" && entry !== null && entry.type === "custom"; - -// Le joystick accepte deux préréglages (`navigation`, `none`) ou une affectation -// par direction. 45° restent pris par la zone de fermeture en haut, les 315° -// restants se partagent : 78,75° à quatre directions, 39,4° à huit. Au-delà, -// viser au pouce devient hasardeux — la borne est ergonomique, pas technique. -const JOYSTICK_DIRECTION_COUNTS = [4, 8]; - -const isCustomJoystick = (entry) => - typeof entry === "object" && entry !== null && Array.isArray(entry.sectors); - -// Géométrie reprise de `radialSectors` dans shared/input-profile.mjs : 45° pris -// par la zone de fermeture centrée sur le haut, puis les 315° restants partagés. -// Le dessin doit rester dérivé de ces constantes, jamais recopié à la main — -// sinon il finirait par mentir sur les angles réellement écrits dans le profil. -const JOYSTICK_CLOSE_ANGLE = 45 / 360; -const JOYSTICK_START_ANGLE = (90 - 45 / 2) / 360; - -function joystickGeometry(directions) { - const sectorAngle = (1 - JOYSTICK_CLOSE_ANGLE) / directions; - return Array.from({ length: directions }, (_, index) => { - const a1 = JOYSTICK_START_ANGLE + JOYSTICK_CLOSE_ANGLE + sectorAngle * index; - return { index, a1: a1 % 1, a2: (a1 + sectorAngle) % 1 }; - }); -} - -// Repère mathématique : 0 à l'est, angles croissants dans le sens antihoraire. -// L'ordonnée est inversée pour compenser l'axe y descendant du SVG, ce qui place -// bien la zone de fermeture en haut comme le veut `radialSectors`. -function polar(cx, cy, radius, angle) { - const radians = angle * 2 * Math.PI; - return [cx + radius * Math.cos(radians), cy - radius * Math.sin(radians)]; -} - -function sectorPath(cx, cy, inner, outer, a1, a2) { - const span = (a2 - a1 + 1) % 1; - const large = span > 0.5 ? 1 : 0; - const [x1, y1] = polar(cx, cy, outer, a1); - const [x2, y2] = polar(cx, cy, outer, a2); - const [x3, y3] = polar(cx, cy, inner, a2); - const [x4, y4] = polar(cx, cy, inner, a1); - return [ - `M ${x1.toFixed(2)} ${y1.toFixed(2)}`, - `A ${outer} ${outer} 0 ${large} 0 ${x2.toFixed(2)} ${y2.toFixed(2)}`, - `L ${x3.toFixed(2)} ${y3.toFixed(2)}`, - `A ${inner} ${inner} 0 ${large} 1 ${x4.toFixed(2)} ${y4.toFixed(2)}`, - "Z", - ].join(" "); -} - -function sectorCentroid(cx, cy, inner, outer, a1, a2) { - const span = (a2 - a1 + 1) % 1; - return polar(cx, cy, (inner + outer) / 2, (a1 + span / 2) % 1); -} - -// Les pastilles reprennent la convention des keycaps : le numéro du secteur tant -// qu'il est libre, le libellé de l'action une fois affectée. Ce sont des `span` -// sans événements de pointeur : le seul contrôle est le secteur SVG dessous, ce -// qui évite deux éléments interactifs pour une même chose. -function JoystickDial({ - directions, - sectors, - selectedIndex, - onSelect, - badgeFor, - sectorTitle, - closeTitle, -}) { - const size = 140; - const center = size / 2; - const inner = 32; - const outer = 64; - const geometry = joystickGeometry(directions); - const closeA1 = JOYSTICK_START_ANGLE; - const closeA2 = (JOYSTICK_START_ANGLE + JOYSTICK_CLOSE_ANGLE) % 1; - const [closeX, closeY] = sectorCentroid(center, center, inner, outer, closeA1, closeA2); - const percent = (value) => `${(value / size) * 100}%`; - - return ( -
- - {closeTitle} - - - ✕ - - - {geometry.map(({ index, a1, a2 }) => { - const active = selectedIndex === index; - return ( - onSelect(index)} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onSelect(index); - }} - > - - - ); - })} - - - {geometry.map(({ index, a1, a2 }) => { - const [x, y] = sectorCentroid(center, center, inner, outer, a1, a2); - const badge = badgeFor(sectors[index], index); - return ( - - ); - })} -
- ); -} - -function makeCustomJoystick(directions, previous = []) { - return { - directions, - sectors: Array.from({ length: directions }, (_, index) => previous[index] ?? "none"), - }; -} - -function formatCustomKeys(keys) { - const modifiers = keys.slice(0, -1); - const finalKey = keys.at(-1); - const modifierText = modifiers.map((key) => MODIFIER_SYMBOLS[key] ?? key).join(""); - return `${modifierText}${KEY_SYMBOLS[finalKey] ?? finalKey}`; -} - -function isValidEntry(controlId, entry) { - if (controlId === "joystick") { - if (isCustomJoystick(entry)) { - return ( - JOYSTICK_DIRECTION_COUNTS.includes(entry.directions) && - entry.sectors.length === entry.directions && - entry.sectors.every((sector) => isValidEntry("key-1", sector)) - ); - } - return JOYSTICK_ACTION_IDS.has(entry); - } - if (controlId === "wheel") return typeof entry === "string" && entry in WHEEL_MODES; - if (!KEY_CONTROL_IDS.has(controlId)) return false; - if (isCustom(entry)) { - return Array.isArray(entry.keys) && entry.keys.every((key) => typeof key === "string"); - } - return KEY_ACTION_IDS.has(entry); -} - -function mappingsEqual(a, b) { - const controlIds = new Set([...Object.keys(a), ...Object.keys(b)]); - for (const controlId of controlIds) { - const entryA = JSON.stringify(a[controlId] ?? "none"); - const entryB = JSON.stringify(b[controlId] ?? "none"); - if (entryA !== entryB) return false; - } - return true; -} - -function loadStoredState() { - const fallback = { mapping: DEFAULT_MAPPING }; - try { - const raw = window.localStorage.getItem(MAPPING_STORAGE_KEY); - if (!raw) return fallback; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed.mapping !== "object") return fallback; - const mapping = {}; - for (const [controlId, entry] of Object.entries(parsed.mapping)) { - if (isValidEntry(controlId, entry)) mapping[controlId] = entry; - } - for (const controlId of Object.keys(DEFAULT_MAPPING)) { - if (!(controlId in mapping)) mapping[controlId] = DEFAULT_MAPPING[controlId]; - } - return { mapping }; - } catch { - return fallback; - } -} - -async function sha256Hex(text) { - try { - const digest = await window.crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(text), - ); - return Array.from(new Uint8Array(digest)) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); - } catch { - return ""; - } +function loadProfileWorkflow() { + profileWorkflowPromise ??= import("./profile-workflow.js"); + return profileWorkflowPromise; } export function App() { - const initialState = useMemo(loadStoredState, []); const [locale, setLocale] = useState(detectLocale); - const [theme, setTheme] = useState(detectTheme); - const [mapping, setMapping] = useState(initialState.mapping); - const [selectedControlId, setSelectedControlId] = useState("key-1"); + const { theme, cycleTheme } = useTheme(); + const [mapping, setMapping] = useState(() => loadStoredState().mapping); + const [selectedControlId, setSelectedControlId] = useState(DEFAULT_CONTROL_ID); const [panelOpen, setPanelOpen] = useState(false); - // "key" : édition de la touche sélectionnée. "export" : profil, vérification - // et génération du JSON. Deux intentions distinctes, un seul panneau. const [panelMode, setPanelMode] = useState("key"); const [toast, setToast] = useState(""); - const [sourceProfile, setSourceProfile] = useState(null); - const [sourceFileName, setSourceFileName] = useState(""); - const [profileInfo, setProfileInfo] = useState(null); - const [profileError, setProfileError] = useState(null); - const [mappingConflict, setMappingConflict] = useState(null); - const [layerCreated, setLayerCreated] = useState(null); - const [review, setReview] = useState(null); - // Références AppSense saisies par l'utilisateur. Chaînes vides = option non - // passée au générateur, qui reprend alors ce que contient la sauvegarde. - const [appSenseIds, setAppSenseIds] = useState({ claude: "", base: "" }); - // Direction du joystick en cours d'édition, `null` quand on choisit le mode. const [joystickSlot, setJoystickSlot] = useState(null); + const [profile, dispatchProfile] = useReducer( + profileSessionReducer, + undefined, + createProfileSession, + ); + const dialogRef = useRef(null); const closeButtonRef = useRef(null); const returnFocusRef = useRef(null); const profileInputRef = useRef(null); const loaderRef = useRef(null); const reviewRef = useRef(null); + const reviewRequestRef = useRef(0); + const dialogRefs = useMemo( + () => ({ + dialog: dialogRef, + closeButton: closeButtonRef, + profileInput: profileInputRef, + loader: loaderRef, + review: reviewRef, + }), + [], + ); const t = useMemo(() => createTranslator(locale), [locale]); - const isMobile = useMobileLayout(); - - const controls = CONTROLS; - const controlLabel = (control) => t(`controls.${control.id}`); - const entryFor = (controlId) => mapping[controlId] ?? "none"; - const entryExportLabel = (entry) => { - if (isCustomJoystick(entry)) return `${entry.directions} DIR`; - return isCustom(entry) ? formatCustomKeys(entry.keys) : ACTIONS[entry].exportLabel; - }; - const controlBadgeLabel = (control, entry) => - entry === "none" ? control.shortLabel : entryExportLabel(entry); - const entryLabel = (entry) => { - if (isCustomJoystick(entry)) return t("actions.joystickCustom.label"); - return isCustom(entry) ? t("actions.custom.label") : t(`actions.${entry}.label`); - }; - const entryIcon = (entry) => { - if (isCustomJoystick(entry)) return Move; - return isCustom(entry) ? Keyboard : ACTIONS[entry].icon; - }; - const entryShortcut = (entry) => { - if (isCustomJoystick(entry)) { - return t("actions.joystickCustom.shortcut", { count: entry.directions }); - } - if (isCustom(entry)) return formatCustomKeys(entry.keys); - return ACTIONS[entry].shortcut ?? t(`actions.${entry}.shortcut`); - }; - - const describeError = (error) => { - if (error instanceof SyntaxError) return { message: t("errors.invalidJson"), code: null }; - if (error?.code && LOCALES.fr.errors[error.code]) { - return { message: t(`errors.${error.code}`), code: error.code }; - } - return { - message: error instanceof Error ? error.message : t("errors.invalidFile"), - code: null, - }; - }; - - const selectedControl = useMemo( - () => controls.find((control) => control.id === selectedControlId) ?? controls[0], - [controls, selectedControlId], - ); - const selectedEntry = entryFor(selectedControl.id); - const availableActions = Object.values(ACTIONS).filter((action) => - action.controlTypes.includes(selectedControl.type), - ); + const selectedControl = CONTROL_BY_ID.get(selectedControlId) ?? CONTROLS[0]; + const selectedEntry = entryFor(mapping, selectedControl.id); - // Quand une direction du joystick est ouverte, le sélecteur travaille sur - // elle et propose le catalogue des touches. Sans direction ouverte, le - // joystick n'affiche que ses modes : la liste d'actions est alors vide. + // A joystick direction is edited with the key catalogue while the joystick + // itself remains one mapping entry. const editingJoystickSlot = selectedControl.type === "joystick" && joystickSlot !== null && isCustomJoystick(selectedEntry); - const activeEntry = editingJoystickSlot ? selectedEntry.sectors[joystickSlot] : selectedEntry; + const activeEntry = editingJoystickSlot + ? selectedEntry.sectors[joystickSlot] + : selectedEntry; const pickerActions = editingJoystickSlot - ? Object.values(ACTIONS).filter((action) => action.controlTypes.includes("key")) + ? ACTIONS_BY_CONTROL_TYPE.key : selectedControl.type === "joystick" ? [] - : availableActions; + : ACTIONS_BY_CONTROL_TYPE[selectedControl.type]; - const duplicateControlFor = (entry) => { - if (entry === "none" || selectedControl.type !== "key") return null; - const serialized = JSON.stringify(entry); - return ( - controls.find( - (control) => - control.id !== selectedControl.id && - control.type === "key" && - JSON.stringify(entryFor(control.id)) === serialized, - ) ?? null - ); - }; + const duplicateKeyControls = useMemo( + () => indexDuplicateKeyControls(CONTROLS, mapping, selectedControl.id), + [mapping, selectedControl.id], + ); - const openConfigurator = (controlId = selectedControlId) => { + const customNeedsModifier = + isCustom(activeEntry) && + activeEntry.keys.length === 1 && + PRINTABLE_KEYS.has(activeEntry.keys.at(-1)); + + const openConfigurator = useCallback((controlId) => { returnFocusRef.current = document.activeElement; setSelectedControlId(controlId); setPanelMode("key"); setPanelOpen(true); - }; - - const switchToExport = () => { - setPanelMode("export"); - if (sourceProfile) runReview(); - }; - - const closeConfigurator = () => setPanelOpen(false); - - // Éditer une direction écrit dans le secteur visé, pas sur le contrôle : le - // joystick reste une seule entrée du mapping. - const assignEntry = (entry) => { - setMapping((current) => { - if (selectedControl.id !== "joystick" || joystickSlot === null) { - return { ...current, [selectedControl.id]: entry }; - } - const joystick = current.joystick; - if (!isCustomJoystick(joystick)) return current; - const sectors = joystick.sectors.map((sector, index) => - index === joystickSlot ? entry : sector, - ); - return { ...current, joystick: { ...joystick, sectors } }; - }); - }; - - const setJoystickMode = (mode) => { - setJoystickSlot(null); - setMapping((current) => { - if (mode === "navigation" || mode === "none") { - return { ...current, joystick: mode }; - } - const previous = isCustomJoystick(current.joystick) ? current.joystick.sectors : []; - return { ...current, joystick: makeCustomJoystick(mode, previous) }; - }); - }; + }, []); - const resetMapping = () => { - setMapping(DEFAULT_MAPPING); - setMappingConflict(null); - setToast(t("toasts.reset")); - }; + const closeConfigurator = useCallback(() => setPanelOpen(false), []); - const changeLocale = (nextLocale) => { + const changeLocale = useCallback((nextLocale) => { setLocale(nextLocale); saveLocale(nextLocale); - }; + }, []); - useEffect(() => { - const media = window.matchMedia("(prefers-color-scheme: dark)"); - const apply = () => { - document.documentElement.dataset.theme = - theme === "auto" ? (media.matches ? "dark" : "light") : theme; - }; - apply(); - try { - window.localStorage.setItem(THEME_STORAGE_KEY, theme); - } catch (error) { - // Private browsing: the preference just won't persist. - } - if (theme === "auto") { - media.addEventListener("change", apply); - return () => media.removeEventListener("change", apply); - } - }, [theme]); + const scrollToLoader = useCallback(() => { + window.requestAnimationFrame(() => { + loaderRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }, []); - const cycleTheme = () => { - setTheme( - (current) => - THEME_ORDER[(THEME_ORDER.indexOf(current) + 1) % THEME_ORDER.length], - ); - }; + const scrollToReview = useCallback(() => { + window.requestAnimationFrame(() => { + reviewRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }, []); - const ThemeIcon = THEME_ICONS[theme]; + const assignEntry = useCallback( + (entry) => { + setMapping((current) => + assignMappingEntry(current, selectedControl.id, joystickSlot, entry), + ); + }, + [joystickSlot, selectedControl.id], + ); - const updateCustomKeys = (keys) => { - assignEntry({ type: "custom", keys }); - }; + const setJoystickMode = useCallback((mode) => { + setJoystickSlot(null); + setMapping((current) => setJoystickMappingMode(current, mode)); + }, []); - const toggleModifier = (modifier) => { - if (!isCustom(selectedEntry)) return; - const keys = selectedEntry.keys; - const finalKey = keys.at(-1); - const active = new Set(keys.slice(0, -1)); - if (active.has(modifier)) active.delete(modifier); - else active.add(modifier); - const modifiers = MODIFIERS.filter((candidate) => active.has(candidate)); - updateCustomKeys([...modifiers, finalKey]); - }; + const selectJoystickSlot = useCallback((index) => { + setJoystickSlot((current) => (current === index ? null : index)); + }, []); - const changeFinalKey = (finalKey) => { - if (!isCustom(selectedEntry)) return; - updateCustomKeys([...selectedEntry.keys.slice(0, -1), finalKey]); - }; + const updateCustomKeys = useCallback( + (keys) => assignEntry({ type: "custom", keys }), + [assignEntry], + ); - const customNeedsModifier = - isCustom(selectedEntry) && - selectedEntry.keys.length === 1 && - PRINTABLE_KEYS.has(selectedEntry.keys.at(-1)); + const toggleModifier = useCallback( + (modifier) => { + if (!isCustom(activeEntry)) return; + updateCustomKeys(toggleShortcutModifier(activeEntry.keys, modifier)); + }, + [activeEntry, updateCustomKeys], + ); - const scrollToLoader = () => { - window.requestAnimationFrame(() => { - loaderRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); - }); - }; + const changeFinalKey = useCallback( + (finalKey) => { + if (!isCustom(activeEntry)) return; + updateCustomKeys(replaceShortcutFinalKey(activeEntry.keys, finalKey)); + }, + [activeEntry, updateCustomKeys], + ); - const scrollToReview = () => { - window.requestAnimationFrame(() => { - reviewRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); - }); - }; + const resetMapping = useCallback(() => { + setMapping(DEFAULT_MAPPING); + dispatchProfile({ type: "conflict-resolved" }); + setToast(t("toasts.reset")); + }, [t]); - const loadSourceProfile = async (event) => { - const file = event.target.files?.[0]; - event.target.value = ""; - if (!file) return; + const loadSourceProfile = useCallback( + async (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; - try { - const parsed = JSON.parse(await file.text()); - let source = parsed; - let created = null; - let inspection; + let workflow; try { - inspection = inspectInputProfile(source, { requireAppSense: false }); + const [text, loadedWorkflow] = await Promise.all([ + file.text(), + loadProfileWorkflow(), + ]); + workflow = loadedWorkflow; + const imported = workflow.prepareImportedProfile(JSON.parse(text)); + const derivedMapping = { ...DEFAULT_MAPPING, ...imported.derived.mapping }; + let mappingConflict = null; + + if (imported.derived.assigned === 0 || mappingsEqual(mapping, derivedMapping)) { + setToast(t("toasts.loaded")); + } else if (mappingsEqual(mapping, DEFAULT_MAPPING)) { + setMapping(derivedMapping); + setToast(t("toasts.loadedMapping")); + } else { + mappingConflict = { mapping: derivedMapping }; + } + + dispatchProfile({ + type: "profile-loaded", + payload: { + source: imported.source, + fileName: file.name, + info: imported.info, + layerCreated: imported.layerCreated, + mappingConflict, + }, + }); } catch (error) { - if (error?.code !== "NO_CLAUDE_LAYER") throw error; - const synthesized = addClaudeLayer(parsed); - source = synthesized.source; - created = { templateName: synthesized.templateName }; - inspection = inspectInputProfile(source, { requireAppSense: false }); + dispatchProfile({ + type: "profile-failed", + error: workflow?.describeProfileError(error, t) ?? { + message: error instanceof Error ? error.message : t("errors.invalidFile"), + code: null, + }, + }); } - const derived = deriveMappingFromProfile(source); - setSourceProfile(source); - setSourceFileName(file.name); - setProfileInfo(inspection); - setLayerCreated(created); - setProfileError(null); - setMappingConflict(null); - const derivedMapping = { ...DEFAULT_MAPPING, ...derived.mapping }; - if (derived.assigned === 0 || mappingsEqual(mapping, derivedMapping)) { - setToast(t("toasts.loaded")); - } else if (mappingsEqual(mapping, DEFAULT_MAPPING)) { - setMapping(derivedMapping); + }, + [mapping, t], + ); + + const resolveMappingConflict = useCallback( + (adoptDerived) => { + if (!profile.mappingConflict) return; + if (adoptDerived) { + setMapping(profile.mappingConflict.mapping); setToast(t("toasts.loadedMapping")); } else { - // Le mapping local a été personnalisé : ne pas l'écraser sans demander. - setMappingConflict({ - mapping: derivedMapping, - }); + setToast(t("toasts.keptMapping")); } - } catch (error) { - setProfileError(describeError(error)); - } - }; - - const resolveMappingConflict = (adoptDerived) => { - if (!mappingConflict) return; - if (adoptDerived) { - setMapping(mappingConflict.mapping); - setToast(t("toasts.loadedMapping")); - } else { - setToast(t("toasts.keptMapping")); - } - setMappingConflict(null); - }; + dispatchProfile({ type: "conflict-resolved" }); + }, + [profile.mappingConflict, t], + ); - const runReview = async () => { - if (!sourceProfile) { + const runReview = useCallback(async () => { + if (!profile.source) { scrollToLoader(); setToast(t("toasts.needProfile")); return; } - const parseAppSenseId = (value) => { - const trimmed = value.trim(); - if (trimmed === "") return undefined; - const parsed = Number(trimmed); - if (!Number.isInteger(parsed) || parsed < 0) { - const error = new Error(t("errors.INVALID_APPSENSE_ID")); - error.code = "INVALID_APPSENSE_ID"; - throw error; - } - return parsed; - }; - + const requestId = reviewRequestRef.current + 1; + reviewRequestRef.current = requestId; + let workflow; try { - const { profile, report } = buildInputProfile(sourceProfile, mapping, { - requireAppSense: false, - appSenseId: parseAppSenseId(appSenseIds.claude), - baseLayerAppSenseId: parseAppSenseId(appSenseIds.base), - }); - const json = `${JSON.stringify(profile, null, 2)}\n`; - const sha = await sha256Hex(json); - setReview({ json, sha, report }); - setProfileError(null); + workflow = await loadProfileWorkflow(); + const review = await workflow.createProfileReview( + profile.source, + mapping, + profile.appSenseIds, + ); + if (reviewRequestRef.current !== requestId) return; + dispatchProfile({ type: "review-ready", review }); scrollToReview(); } catch (error) { - setProfileError(describeError(error)); - setReview(null); + if (reviewRequestRef.current !== requestId) return; + dispatchProfile({ + type: "review-failed", + error: workflow?.describeProfileError(error, t) ?? { + message: error instanceof Error ? error.message : t("errors.invalidFile"), + code: null, + }, + }); } - }; + }, [mapping, profile.appSenseIds, profile.source, scrollToLoader, scrollToReview, t]); + + const switchToExport = useCallback(() => { + void loadProfileWorkflow(); + void loadProfileExportPanel(); + setPanelMode("export"); + if (profile.source) void runReview(); + }, [profile.source, runReview]); - const openReviewFromHero = () => { + const openReviewFromHero = useCallback(() => { + void loadProfileWorkflow(); + void loadProfileExportPanel(); returnFocusRef.current = document.activeElement; setPanelOpen(true); - switchToExport(); - if (!sourceProfile) scrollToLoader(); - }; + setPanelMode("export"); + if (profile.source) void runReview(); + else scrollToLoader(); + }, [profile.source, runReview, scrollToLoader]); + + const changeAppSense = useCallback((field, value) => { + reviewRequestRef.current += 1; + dispatchProfile({ type: "appsense-changed", field, value }); + }, []); - const downloadReview = () => { - if (!review) return; - const blob = new Blob([review.json], { type: "application/json" }); + const downloadReview = useCallback(() => { + if (!profile.review) return; + const blob = new Blob([profile.review.json], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; @@ -951,30 +314,26 @@ export function App() { anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); setToast(t("toasts.generated")); - }; + }, [profile.review, t]); useEffect(() => { document.documentElement.lang = locale; document.title = t("meta.title"); - const description = document.querySelector('meta[name="description"]'); - if (description) description.setAttribute("content", t("meta.description")); + document + .querySelector('meta[name="description"]') + ?.setAttribute("content", t("meta.description")); }, [locale, t]); useEffect(() => { - try { - window.localStorage.setItem( - MAPPING_STORAGE_KEY, - JSON.stringify({ mapping }), - ); - } catch { - // Stockage indisponible : la configuration ne sera pas mémorisée. - } + saveStoredState(mapping); }, [mapping]); - // Le rapport décrit un mapping précis : toute modification l'invalide. + // A review belongs to one exact source and mapping. Cancel slow hashes when + // either changes so stale JSON can never reappear after an edit. useEffect(() => { - setReview(null); - }, [mapping, sourceProfile]); + reviewRequestRef.current += 1; + dispatchProfile({ type: "review-invalidated" }); + }, [mapping, profile.source]); useEffect(() => { if (!toast) return undefined; @@ -982,9 +341,6 @@ export function App() { return () => window.clearTimeout(timeout); }, [toast]); - // Remonter la liste à chaque changement de contexte : nouveau mode, ouverture, - // ou touche différente. Sans ça la liste reste au décalage précédent et son - // premier élément apparaît coupé sous l'en-tête. useEffect(() => { dialogRef.current?.querySelector(".dialog-scroll")?.scrollTo({ top: 0 }); setJoystickSlot(null); @@ -999,678 +355,60 @@ export function App() { } window.requestAnimationFrame(() => closeButtonRef.current?.focus()); - - // Le panneau est non modal sur les deux formats : le clavier reste visible - // et utilisable à côté sur desktop, au-dessus sur mobile. Pas de piège à - // focus, donc, seulement Échap pour fermer. const handleKeyDown = (event) => { if (event.key !== "Escape") return; event.preventDefault(); closeConfigurator(); }; - document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [panelOpen, panelMode]); + }, [closeConfigurator, panelOpen]); return (
-
-
- - C - Codex Micro - -
- - - - -
-
- -
-
-

{t("hero.subtitle")}

-
- -
-
- {t("device.alt")} - {controls.map((control) => { - const entry = entryFor(control.id); - const EntryIcon = entryIcon(entry); - return ( - - ); - })} - {RESERVED_ZONES.map((zone) => ( - - ))} -
- - -
- - - -
- {controls.map((control) => { - const entry = entryFor(control.id); - const exportLabel = entryExportLabel(entry); - return ( - - ); - })} -
- -

{t("device.reservedNote")}

-

{t("quietNote")}

-
-
- - - {/* Légende des couleurs d'état poussées sur les six touches Agent par - `npm run lighting -- watch`. Les teintes viennent de la source unique - partagée avec l'outillage Node, jamais réécrites ici. - - Hors du flux, en bas à gauche : la coque ne défile pas et la colonne - centrale n'a plus un pixel avant la ligne de flottaison. Repliée par - défaut, elle s'ouvre vers le haut. */} -
- {t("stateLegend.title")} - -

{t("stateLegend.note")}

-
- - + + + {toast && (
diff --git a/prototype/src/components/ConfiguratorWorkspace.jsx b/prototype/src/components/ConfiguratorWorkspace.jsx new file mode 100644 index 0000000..fd3ac8a --- /dev/null +++ b/prototype/src/components/ConfiguratorWorkspace.jsx @@ -0,0 +1,245 @@ +import { memo } from "react"; +import { + ArrowDownToLine, + ChevronRight, + Monitor, + Moon, + RotateCcw, + RotateCw, + SlidersHorizontal, + Sun, +} from "lucide-react"; +import { LEGEND_ORDER, STATE_COLORS } from "../../../shared/thread-status-palette.mjs"; +import { + CONTROLS, + KEYCAP_TONES, + RESERVED_ZONES, +} from "../configurator-catalog.jsx"; +import { + controlBadgeLabel, + controlLabel, + entryExportLabel, + entryFor, + entryIcon, + entryLabel, +} from "../configurator-presenter.js"; +import { LOCALE_LABELS } from "../i18n/index.js"; + +const THEME_ICONS = Object.freeze({ auto: Monitor, light: Sun, dark: Moon }); + +const Topbar = memo(function Topbar({ + t, + locale, + theme, + sourceProfileLoaded, + onCycleTheme, + onLocaleChange, +}) { + const ThemeIcon = THEME_ICONS[theme] ?? Monitor; + return ( +
+ + C + Codex Micro + +
+ + + + +
+
+ ); +}); + +const StateLegend = memo(function StateLegend({ t }) { + return ( +
+ + {t("stateLegend.title")} + {t("stateLegend.experimental")} + +
    + {LEGEND_ORDER.map((state) => ( +
  • +
  • + ))} +
+

{t("stateLegend.note")}

+
+ ); +}); + +export const ConfiguratorWorkspace = memo(function ConfiguratorWorkspace({ + t, + locale, + theme, + mapping, + selectedControlId, + panelOpen, + sourceProfileLoaded, + onCycleTheme, + onLocaleChange, + onOpenConfigurator, + onOpenExport, +}) { + return ( + <> +
+ + +
+
+

{t("hero.subtitle")}

+
+ +
+
+ {t("device.alt")} + {CONTROLS.map((control) => { + const entry = entryFor(mapping, control.id); + const EntryIcon = entryIcon(entry); + return ( + + ); + })} + {RESERVED_ZONES.map((zone) => ( + + ))} +
+ + +
+ + + +
+ {CONTROLS.map((control) => { + const entry = entryFor(mapping, control.id); + const exportLabel = entryExportLabel(entry); + return ( + + ); + })} +
+ +

{t("device.reservedNote")}

+

{t("quietNote")}

+
+
+ + + + ); +}); diff --git a/prototype/src/components/JoystickDial.jsx b/prototype/src/components/JoystickDial.jsx new file mode 100644 index 0000000..c1e7b9f --- /dev/null +++ b/prototype/src/components/JoystickDial.jsx @@ -0,0 +1,132 @@ +import { JOYSTICK_DIRECTION_COUNTS, radialSectorGeometry } from "../../../shared/input-profile.mjs"; + +const SIZE = 140; +const CENTER = SIZE / 2; +const INNER_RADIUS = 32; +const OUTER_RADIUS = 64; + +function polar(radius, angle) { + const radians = angle * 2 * Math.PI; + return [ + CENTER + radius * Math.cos(radians), + CENTER - radius * Math.sin(radians), + ]; +} + +function sectorPath(a1, a2) { + const span = (a2 - a1 + 1) % 1; + const large = span > 0.5 ? 1 : 0; + const [x1, y1] = polar(OUTER_RADIUS, a1); + const [x2, y2] = polar(OUTER_RADIUS, a2); + const [x3, y3] = polar(INNER_RADIUS, a2); + const [x4, y4] = polar(INNER_RADIUS, a1); + return [ + `M ${x1.toFixed(2)} ${y1.toFixed(2)}`, + `A ${OUTER_RADIUS} ${OUTER_RADIUS} 0 ${large} 0 ${x2.toFixed(2)} ${y2.toFixed(2)}`, + `L ${x3.toFixed(2)} ${y3.toFixed(2)}`, + `A ${INNER_RADIUS} ${INNER_RADIUS} 0 ${large} 1 ${x4.toFixed(2)} ${y4.toFixed(2)}`, + "Z", + ].join(" "); +} + +function sectorCentroid(a1, a2) { + const span = (a2 - a1 + 1) % 1; + return polar((INNER_RADIUS + OUTER_RADIUS) / 2, (a1 + span / 2) % 1); +} + +export function createJoystickDialGeometry(directions) { + const geometry = radialSectorGeometry(directions); + const decorate = ({ index, a1, a2 }) => ({ + index, + path: sectorPath(a1, a2), + centroid: sectorCentroid(a1, a2), + }); + return { + close: decorate({ index: -1, ...geometry.close }), + sectors: geometry.sectors.map(decorate), + }; +} + +const GEOMETRY_BY_DIRECTION = new Map( + JOYSTICK_DIRECTION_COUNTS.map((directions) => [ + directions, + createJoystickDialGeometry(directions), + ]), +); + +const percent = (value) => `${(value / SIZE) * 100}%`; + +// The badges are non-interactive spans. The SVG sectors remain the single +// accessible control for each direction. +export function JoystickDial({ + directions, + sectors, + selectedIndex, + onSelect, + badgeFor, + sectorTitle, + closeTitle, +}) { + const geometry = + GEOMETRY_BY_DIRECTION.get(directions) ?? createJoystickDialGeometry(directions); + const badges = sectors.map(badgeFor); + + return ( +
+ + {closeTitle} + + + ✕ + + + {geometry.sectors.map(({ index, path }) => { + const active = selectedIndex === index; + return ( + onSelect(index)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onSelect(index); + }} + > + + + ); + })} + + + {geometry.sectors.map(({ index, centroid }) => { + const badge = badges[index]; + return ( + + ); + })} +
+ ); +} diff --git a/prototype/src/components/KeyAssignmentEditor.jsx b/prototype/src/components/KeyAssignmentEditor.jsx new file mode 100644 index 0000000..9b522ea --- /dev/null +++ b/prototype/src/components/KeyAssignmentEditor.jsx @@ -0,0 +1,209 @@ +import { Check, Keyboard } from "lucide-react"; +import { JOYSTICK_DIRECTION_COUNTS } from "../../../shared/input-profile.mjs"; +import { + DEFAULT_CUSTOM, + KEY_SYMBOLS, + MODIFIERS, + MODIFIER_SYMBOLS, + entryFingerprint, + formatCustomKeys, + isCustom, + isCustomJoystick, +} from "../configurator-state.js"; +import { FINAL_KEY_OPTIONS } from "../configurator-catalog.jsx"; +import { + controlLabel, + entryExportLabel, + entryLabel, +} from "../configurator-presenter.js"; +import { JoystickDial } from "./JoystickDial.jsx"; + +export function KeyAssignmentEditor({ + t, + selectedControl, + selectedEntry, + activeEntry, + editingJoystickSlot, + joystickSlot, + pickerActions, + duplicateKeyControls, + customNeedsModifier, + onSetJoystickMode, + onSelectJoystickSlot, + onAssignEntry, + onToggleModifier, + onChangeFinalKey, +}) { + const duplicateControlFor = (entry) => { + if (entry === "none" || selectedControl.type !== "key") return null; + return duplicateKeyControls.get(entryFingerprint(entry)) ?? null; + }; + + return ( + <> + {selectedControl.type === "joystick" && ( +
+
+ {["navigation", ...JOYSTICK_DIRECTION_COUNTS, "none"].map((mode) => { + const active = + typeof mode === "number" + ? isCustomJoystick(selectedEntry) && selectedEntry.directions === mode + : selectedEntry === mode; + return ( + + ); + })} +
+ + {isCustomJoystick(selectedEntry) && ( + <> +

{t("joystick.hint")}

+ ({ + assigned: sector !== "none", + label: sector === "none" ? index + 1 : entryExportLabel(sector), + title: entryLabel(sector, t), + })} + sectorTitle={t("joystick.sector")} + closeTitle={t("joystick.closeZone")} + /> + + )} +
+ )} + + {pickerActions.length > 0 && ( +
+
+ {pickerActions.map((action) => { + const Icon = action.icon; + const active = !isCustom(activeEntry) && activeEntry === action.id; + const duplicateControl = duplicateControlFor(action.id); + return ( + + ); + })} + + {(selectedControl.type === "key" || editingJoystickSlot) && ( + + )} +
+ + {isCustom(activeEntry) && ( +
+
+ {t("picker.modifiersLabel")} +
+ {MODIFIERS.map((modifier) => { + const active = activeEntry.keys.slice(0, -1).includes(modifier); + return ( + + ); + })} +
+
+
+ + +
+ {customNeedsModifier && ( +

+ {t("picker.customHint")} +

+ )} +

{t("picker.customSafety")}

+
+ )} +
+ )} + + ); +} diff --git a/prototype/src/components/MappingDialog.jsx b/prototype/src/components/MappingDialog.jsx new file mode 100644 index 0000000..287d92b --- /dev/null +++ b/prototype/src/components/MappingDialog.jsx @@ -0,0 +1,146 @@ +import { lazy, Suspense } from "react"; +import { ArrowDownToLine, ChevronRight, RotateCcw, X } from "lucide-react"; +import { controlLabel, entryLabel, entryShortcut } from "../configurator-presenter.js"; +import { loadProfileExportPanel } from "../profile-panel-loader.js"; +import { KeyAssignmentEditor } from "./KeyAssignmentEditor.jsx"; + +const ProfileExportPanel = lazy(() => + loadProfileExportPanel().then((module) => ({ default: module.ProfileExportPanel })), +); + +export function MappingDialog({ + t, + refs, + panelOpen, + panelMode, + selectedControl, + selectedEntry, + activeEntry, + editingJoystickSlot, + joystickSlot, + pickerActions, + duplicateKeyControls, + customNeedsModifier, + profile, + onClose, + onSwitchToExport, + onResetMapping, + onScrollToLoader, + onLoadSourceProfile, + onResolveMappingConflict, + onSetJoystickMode, + onSelectJoystickSlot, + onAssignEntry, + onToggleModifier, + onChangeFinalKey, + onAppSenseChange, + onRunReview, + onDownloadReview, +}) { + return ( + + ); +} diff --git a/prototype/src/components/ProfileExportPanel.jsx b/prototype/src/components/ProfileExportPanel.jsx new file mode 100644 index 0000000..d07c025 --- /dev/null +++ b/prototype/src/components/ProfileExportPanel.jsx @@ -0,0 +1,34 @@ +import { ProfileLoader } from "./ProfileLoader.jsx"; +import { ProfileReview } from "./ProfileReview.jsx"; + +export function ProfileExportPanel({ + t, + refs, + profile, + onLoadSourceProfile, + onResolveMappingConflict, + onAppSenseChange, + onRunReview, + onDownloadReview, +}) { + return ( + <> + + + + ); +} diff --git a/prototype/src/components/ProfileLoader.jsx b/prototype/src/components/ProfileLoader.jsx new file mode 100644 index 0000000..1ba7689 --- /dev/null +++ b/prototype/src/components/ProfileLoader.jsx @@ -0,0 +1,127 @@ +import { FileJson, ShieldCheck } from "lucide-react"; +import { + GUIDE_URL, + INPUT_RELEASES_URL, +} from "../configurator-catalog.jsx"; +import { LOCALES } from "../i18n/index.js"; + +export function ProfileLoader({ + t, + loaderRef, + profileInputRef, + profile, + onLoadSourceProfile, + onResolveMappingConflict, +}) { + return ( +
+

+ {t("wizard.step1Title")} +

+ +
+
+ + + + {profile.info ? t("loader.titleVerified") : t("loader.titleLoad")} + + + {profile.info + ? t("loader.meta", { + file: profile.fileName, + layer: profile.info.layerName, + appSense: profile.info.appSenseLinked + ? t("loader.appSenseKept") + : t("loader.appSenseNotLinked"), + }) + : t("loader.hint")} + + +
+ + + {profile.error && ( +
+

{profile.error.message}

+ {profile.error.code && LOCALES.fr.hints?.[profile.error.code] && ( +

{t(`hints.${profile.error.code}`)}

+ )} + {profile.source && ( +

{t("errors.previousKept")}

+ )} +
+ )} + {profile.mappingConflict && ( +
+

{t("conflict.message")}

+
+ + +
+
+ )} + {profile.info && !profile.info.appSenseLinked && ( +
+ {profile.layerCreated && ( +

+ {t("notice.layerCreated", { + template: profile.layerCreated.templateName, + })} +

+ )} +

{t("notice.appSenseTodo")}

+
+ )} +
+ +

+

+ +
+ {t("help.summary")} +
    +
  1. {t("help.step1")}
  2. +
  3. {t("help.step2")}
  4. +
  5. {t("help.step3")}
  6. +
+

+ + {t("help.guideLink")} + + + {t("help.releasesLink")} + +

+
+
+ ); +} diff --git a/prototype/src/components/ProfileReview.jsx b/prototype/src/components/ProfileReview.jsx new file mode 100644 index 0000000..ab205d9 --- /dev/null +++ b/prototype/src/components/ProfileReview.jsx @@ -0,0 +1,125 @@ +import { ArrowDownToLine, Check, CircleAlert } from "lucide-react"; + +export function ProfileReview({ + t, + reviewRef, + profile, + onAppSenseChange, + onRunReview, + onDownloadReview, +}) { + const review = profile.review; + + return ( + <> +
+

+ {t("wizard.step3Title")} +

+ +
+ {t("appSense.legend")} +

{t("appSense.hint")}

+ + +

{t("appSense.warning")}

+
+ + {review ? ( +
+
    +
  • +
  • + {review.report.appSenseLinked ? ( +
  • +
  • + ) : ( +
  • +
  • + )} +
  • +
  • +
  • +
  • + {review.report.assignedSwitches > 0 && ( +
  • +
  • + )} + {review.report.baseLayerAppSenseId !== null && ( +
  • +
  • + )} +
+ {review.sha && ( +

+ {t("review.shaLabel")} + {review.sha} +

+ )} + +
+ ) : ( +
+

{profile.source ? t("review.ready") : t("review.needProfile")}

+ +
+ )} +
+ +

{t("panelNote")}

+ + ); +} diff --git a/prototype/src/configurator-catalog.jsx b/prototype/src/configurator-catalog.jsx new file mode 100644 index 0000000..e2ac04f --- /dev/null +++ b/prototype/src/configurator-catalog.jsx @@ -0,0 +1,339 @@ +import { + ArrowDownUp, + ArrowLeft, + ArrowRight, + ChevronsLeft, + ChevronsRight, + Command, + CopyPlus, + Diff, + Gauge, + Mic, + Minus, + Monitor, + MousePointer2, + Move, + RotateCw, + Search, + Settings, + SlidersHorizontal, + Square, + Volume2, + X, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { FINAL_KEYCODES } from "../../shared/input-profile.mjs"; +import { assertKeyActionCatalog } from "./configurator-state.js"; + +export const GUIDE_URL = + "https://github.com/thannous/claude-codex-micro/blob/main/docs/installation.md"; +export const INPUT_RELEASES_URL = + "https://github.com/worklouder/input-releases/releases"; + +export function ClaudeMarkIcon({ size = 19, className = "" }) { + return ( + + ); +} + +export const CONTROLS = Object.freeze([ + { + id: "wheel", + // The control's identity, not a mode: the wheel is no longer on scroll by + // default. Shown only when no action is assigned. + shortLabel: "DIAL", + type: "dial", + x: 9.36, + y: 9.48, + w: 19.6, + h: 19.95, + }, + { + id: "key-13", + shortLabel: "PRESS", + type: "key", + className: "key-hotspot--encoder-button", + x: 13.93, + y: 14.17, + w: 10.61, + h: 10.56, + }, + { + id: "key-9", + shortLabel: "A1", + type: "key", + x: 29.84, + y: 9.92, + w: 20.19, + h: 18.63, + }, + { + id: "key-10", + shortLabel: "A2", + type: "key", + x: 50.77, + y: 9.92, + w: 20.19, + h: 18.63, + }, + { + id: "joystick", + shortLabel: "NAV", + type: "joystick", + x: 73.31, + y: 10.06, + w: 17.98, + h: 19.36, + }, + { id: "key-5", shortLabel: "A3", type: "key", x: 8.77, y: 31.18, w: 20.19, h: 18.63 }, + { id: "key-6", shortLabel: "A4", type: "key", x: 28.81, y: 31.18, w: 20.19, h: 18.63 }, + { id: "key-7", shortLabel: "A5", type: "key", x: 51.06, y: 31.18, w: 20.19, h: 18.63 }, + { id: "key-8", shortLabel: "A6", type: "key", x: 72.13, y: 31.18, w: 20.19, h: 18.63 }, + { id: "key-1", shortLabel: "C1", type: "key", x: 8.77, y: 53.04, w: 20.19, h: 18.63 }, + { id: "key-2", shortLabel: "C2", type: "key", x: 28.81, y: 53.04, w: 20.19, h: 18.63 }, + { id: "key-3", shortLabel: "C3", type: "key", x: 51.06, y: 53.04, w: 20.19, h: 18.63 }, + { id: "key-4", shortLabel: "C4", type: "key", x: 72.13, y: 53.04, w: 20.19, h: 18.63 }, + { id: "key-11", shortLabel: "C5", type: "key", x: 28.81, y: 74.3, w: 42.44, h: 18.63 }, + { id: "key-12", shortLabel: "C6", type: "key", x: 72.13, y: 74.3, w: 20.19, h: 18.63 }, +].map(Object.freeze)); + +export const CONTROL_BY_ID = new Map( + CONTROLS.map((control) => [control.id, control]), +); + +export const RESERVED_ZONES = Object.freeze([ + Object.freeze({ id: "sensor", x: 8.77, y: 74.3, w: 20.19, h: 18.63, round: true }), +]); + +export const KEYCAP_TONES = Object.freeze({ + "key-9": "186 235 211", + "key-10": "250 218 166", + "key-5": "205 187 244", + "key-6": "215 216 240", + "key-7": "187 201 241", + "key-8": "242 181 213", + "key-1": "235 233 230", + "key-2": "235 233 230", + "key-3": "231 232 236", + "key-4": "235 233 230", + "key-11": "235 233 230", + "key-12": "235 233 230", + "key-13": "38 36 34", +}); + +const actions = { + navigation: { + id: "navigation", + shortcut: "↑ → ↓ ←", + icon: Move, + controlTypes: ["joystick"], + exportLabel: "NAV", + }, + scroll: { + id: "scroll", + shortcut: "Page ↑ Page ↓", + icon: MousePointer2, + controlTypes: ["dial"], + exportLabel: "SCROLL", + }, + lines: { + id: "lines", + shortcut: "↑ ↓", + icon: ArrowDownUp, + controlTypes: ["dial"], + exportLabel: "LINES", + }, + effort: { + id: "effort", + shortcut: "⌘ ⇧ E · ← / →", + icon: SlidersHorizontal, + controlTypes: ["dial"], + exportLabel: "EFFORT", + }, + volume: { + id: "volume", + shortcut: "Vol − +", + icon: Volume2, + controlTypes: ["dial"], + exportLabel: "VOL", + experimental: true, + }, + newSession: { + id: "newSession", + shortcut: "⌘ N", + icon: Command, + controlTypes: ["key"], + exportLabel: "NEW", + }, + send: { + id: "send", + shortcut: "↩", + icon: ClaudeMarkIcon, + controlTypes: ["key"], + exportLabel: "SEND", + }, + sendInDuplicateSession: { + id: "sendInDuplicateSession", + shortcut: "⌥ ⌘ ↩", + icon: CopyPlus, + controlTypes: ["key"], + exportLabel: "DUP", + }, + voice: { + id: "voice", + shortcut: "⌘ D", + icon: Mic, + controlTypes: ["key"], + exportLabel: "VOICE", + }, + diff: { + id: "diff", + shortcut: "⌘ ⇧ D", + icon: Diff, + controlTypes: ["key"], + exportLabel: "DIFF", + }, + // Claude Desktop exposes cycling, not rank-based session selection. + nextSession: { + id: "nextSession", + shortcut: "⌃ ⇥", + icon: ChevronsRight, + controlTypes: ["key"], + exportLabel: "NEXT", + }, + previousSession: { + id: "previousSession", + shortcut: "⌃ ⇧ ⇥", + icon: ChevronsLeft, + controlTypes: ["key"], + exportLabel: "PREV", + }, + effortMenu: { + id: "effortMenu", + shortcut: "⌘ ⇧ E", + icon: Gauge, + controlTypes: ["key"], + exportLabel: "EFF", + }, + stop: { + id: "stop", + shortcut: "Esc", + icon: Square, + controlTypes: ["key"], + exportLabel: "ESC", + }, + settings: { + id: "settings", + shortcut: "⌘ ,", + icon: Settings, + controlTypes: ["key"], + exportLabel: "SET", + }, + find: { + id: "find", + shortcut: "⌘ F", + icon: Search, + controlTypes: ["key"], + exportLabel: "FIND", + }, + findNext: { + id: "findNext", + shortcut: "⌘ G", + icon: Search, + controlTypes: ["key"], + exportLabel: "NEXT", + }, + findPrevious: { + id: "findPrevious", + shortcut: "⌘ ⇧ G", + icon: Search, + controlTypes: ["key"], + exportLabel: "PREV", + }, + back: { + id: "back", + shortcut: "⌘ [", + icon: ArrowLeft, + controlTypes: ["key"], + exportLabel: "BACK", + }, + forward: { + id: "forward", + shortcut: "⌘ ]", + icon: ArrowRight, + controlTypes: ["key"], + exportLabel: "FWD", + }, + reload: { + id: "reload", + shortcut: "⌘ R", + icon: RotateCw, + controlTypes: ["key"], + exportLabel: "LOAD", + }, + closeWindow: { + id: "closeWindow", + shortcut: "⌘ W", + icon: X, + controlTypes: ["key"], + exportLabel: "CLOSE", + }, + zoomIn: { + id: "zoomIn", + shortcut: "⌘ +", + icon: ZoomIn, + controlTypes: ["key"], + exportLabel: "ZOOM+", + }, + zoomOut: { + id: "zoomOut", + shortcut: "⌘ −", + icon: ZoomOut, + controlTypes: ["key"], + exportLabel: "ZOOM−", + }, + resetZoom: { + id: "resetZoom", + shortcut: "⌘ 0", + icon: Monitor, + controlTypes: ["key"], + exportLabel: "100%", + }, + none: { + id: "none", + shortcut: null, + icon: Minus, + controlTypes: ["key", "dial", "joystick"], + exportLabel: "NONE", + }, +}; + +for (const action of Object.values(actions)) { + Object.freeze(action.controlTypes); + Object.freeze(action); +} + +export const ACTIONS = Object.freeze(actions); +assertKeyActionCatalog(ACTIONS); + +const actionsByControlType = { key: [], dial: [], joystick: [] }; +for (const action of Object.values(ACTIONS)) { + for (const controlType of action.controlTypes) { + actionsByControlType[controlType].push(action); + } +} +for (const groupedActions of Object.values(actionsByControlType)) { + Object.freeze(groupedActions); +} + +export const ACTIONS_BY_CONTROL_TYPE = Object.freeze(actionsByControlType); +export const FINAL_KEY_OPTIONS = Object.freeze(Object.keys(FINAL_KEYCODES)); diff --git a/prototype/src/configurator-presenter.js b/prototype/src/configurator-presenter.js new file mode 100644 index 0000000..b8724b4 --- /dev/null +++ b/prototype/src/configurator-presenter.js @@ -0,0 +1,45 @@ +import { Keyboard, Move } from "lucide-react"; +import { ACTIONS } from "./configurator-catalog.jsx"; +import { + formatCustomKeys, + isCustom, + isCustomJoystick, +} from "./configurator-state.js"; + +export function entryFor(mapping, controlId) { + return mapping[controlId] ?? "none"; +} + +export function entryExportLabel(entry) { + if (isCustomJoystick(entry)) return `${entry.directions} DIR`; + if (isCustom(entry)) return formatCustomKeys(entry.keys); + return ACTIONS[entry]?.exportLabel ?? ACTIONS.none.exportLabel; +} + +export function entryLabel(entry, t) { + if (isCustomJoystick(entry)) return t("actions.joystickCustom.label"); + if (isCustom(entry)) return t("actions.custom.label"); + return t(`actions.${entry}.label`); +} + +export function entryIcon(entry) { + if (isCustomJoystick(entry)) return Move; + if (isCustom(entry)) return Keyboard; + return ACTIONS[entry]?.icon ?? ACTIONS.none.icon; +} + +export function entryShortcut(entry, t) { + if (isCustomJoystick(entry)) { + return t("actions.joystickCustom.shortcut", { count: entry.directions }); + } + if (isCustom(entry)) return formatCustomKeys(entry.keys); + return ACTIONS[entry]?.shortcut ?? t(`actions.${entry}.shortcut`); +} + +export function controlLabel(control, t) { + return t(`controls.${control.id}`); +} + +export function controlBadgeLabel(control, entry) { + return entry === "none" ? control.shortLabel : entryExportLabel(entry); +} diff --git a/prototype/src/configurator-state.js b/prototype/src/configurator-state.js new file mode 100644 index 0000000..e3fd76d --- /dev/null +++ b/prototype/src/configurator-state.js @@ -0,0 +1,223 @@ +import { + ACTION_DEFINITIONS, + DEFAULT_MAPPING, + FINAL_KEYCODES, + JOYSTICK_DIRECTION_COUNTS, + WHEEL_MODES, +} from "../../shared/input-profile.mjs"; + +export const MAPPING_STORAGE_KEY = "codex-micro-mapping"; +export const MODIFIERS = Object.freeze(["Command", "Shift", "Option", "Control"]); +export const MODIFIER_SYMBOLS = Object.freeze({ + Command: "⌘", + Shift: "⇧", + Option: "⌥", + Control: "⌃", +}); +export const KEY_SYMBOLS = Object.freeze({ + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + PageUp: "Pg↑", + PageDown: "Pg↓", + Escape: "Esc", + Space: "␣", + Comma: ",", + BracketLeft: "[", + BracketRight: "]", + Equal: "=", + Minus: "−", +}); +export const DEFAULT_CUSTOM = Object.freeze({ + type: "custom", + keys: Object.freeze(["Command", "K"]), +}); + +const KEY_CONTROL_IDS = new Set( + Object.keys(DEFAULT_MAPPING).filter((controlId) => controlId.startsWith("key-")), +); +const KEY_ACTION_IDS = new Set(Object.keys(ACTION_DEFINITIONS)); +const JOYSTICK_ACTION_IDS = new Set(["navigation", "none"]); + +export function assertKeyActionCatalog(actions) { + const missing = [...KEY_ACTION_IDS].filter( + (actionId) => !actions[actionId]?.controlTypes?.includes("key"), + ); + if (missing.length > 0) { + throw new Error(`The configurator is missing key actions: ${missing.join(", ")}`); + } +} + +export function isCustom(entry) { + return typeof entry === "object" && entry !== null && entry.type === "custom"; +} + +export function isCustomJoystick(entry) { + return typeof entry === "object" && entry !== null && Array.isArray(entry.sectors); +} + +export function makeCustomJoystick(directions, previous = []) { + return { + directions, + sectors: Array.from({ length: directions }, (_, index) => previous[index] ?? "none"), + }; +} + +export function formatCustomKeys(keys) { + const modifiers = keys.slice(0, -1); + const finalKey = keys.at(-1); + const modifierText = modifiers.map((key) => MODIFIER_SYMBOLS[key] ?? key).join(""); + return `${modifierText}${KEY_SYMBOLS[finalKey] ?? finalKey}`; +} + +export function isValidEntry(controlId, entry) { + if (controlId === "joystick") { + if (isCustomJoystick(entry)) { + return ( + JOYSTICK_DIRECTION_COUNTS.includes(entry.directions) && + entry.sectors.length === entry.directions && + entry.sectors.every((sector) => isValidEntry("key-1", sector)) + ); + } + return JOYSTICK_ACTION_IDS.has(entry); + } + if (controlId === "wheel") { + return typeof entry === "string" && Object.hasOwn(WHEEL_MODES, entry); + } + if (!KEY_CONTROL_IDS.has(controlId)) return false; + if (isCustom(entry)) { + if (!Array.isArray(entry.keys) || entry.keys.length === 0) return false; + const modifiers = entry.keys.slice(0, -1); + const finalKey = entry.keys.at(-1); + return ( + typeof finalKey === "string" && + Object.hasOwn(FINAL_KEYCODES, finalKey) && + modifiers.every((modifier, index) => + typeof modifier === "string" && + MODIFIERS.includes(modifier) && + modifiers.indexOf(modifier) === index, + ) + ); + } + return KEY_ACTION_IDS.has(entry); +} + +export function entryFingerprint(entry) { + if (isCustom(entry)) return JSON.stringify(["custom", entry.keys]); + if (isCustomJoystick(entry)) { + return JSON.stringify([ + "joystick", + entry.directions, + entry.sectors.map((sector) => entryFingerprint(sector)), + ]); + } + return JSON.stringify(entry ?? "none"); +} + +export function mappingsEqual(left, right) { + const controlIds = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const controlId of controlIds) { + if (entryFingerprint(left[controlId]) !== entryFingerprint(right[controlId])) return false; + } + return true; +} + +export function loadStoredState(storage) { + const fallback = { mapping: DEFAULT_MAPPING }; + try { + const target = storage ?? globalThis.window?.localStorage; + const raw = target?.getItem(MAPPING_STORAGE_KEY); + if (!raw) return fallback; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed.mapping !== "object" || Array.isArray(parsed.mapping)) { + return fallback; + } + const mapping = {}; + for (const [controlId, entry] of Object.entries(parsed.mapping)) { + if (isValidEntry(controlId, entry)) mapping[controlId] = entry; + } + for (const controlId of Object.keys(DEFAULT_MAPPING)) { + if (!(controlId in mapping)) mapping[controlId] = DEFAULT_MAPPING[controlId]; + } + return { mapping }; + } catch { + return fallback; + } +} + +export function saveStoredState(mapping, storage) { + try { + const target = storage ?? globalThis.window?.localStorage; + target?.setItem(MAPPING_STORAGE_KEY, JSON.stringify({ mapping })); + return true; + } catch { + return false; + } +} + +export function assignMappingEntry(mapping, controlId, joystickSlot, entry) { + if (controlId !== "joystick" || joystickSlot === null) { + return { ...mapping, [controlId]: entry }; + } + + const joystick = mapping.joystick; + if (!isCustomJoystick(joystick) || !Number.isInteger(joystickSlot)) return mapping; + if (joystickSlot < 0 || joystickSlot >= joystick.sectors.length) return mapping; + + const sectors = joystick.sectors.map((sector, index) => + index === joystickSlot ? entry : sector, + ); + return { ...mapping, joystick: { ...joystick, sectors } }; +} + +export function setJoystickMappingMode(mapping, mode) { + if (mode === "navigation" || mode === "none") { + return { ...mapping, joystick: mode }; + } + if (!JOYSTICK_DIRECTION_COUNTS.includes(mode)) return mapping; + const previous = isCustomJoystick(mapping.joystick) ? mapping.joystick.sectors : []; + return { ...mapping, joystick: makeCustomJoystick(mode, previous) }; +} + +export function toggleShortcutModifier(keys, modifier) { + if (!MODIFIERS.includes(modifier) || keys.length === 0) return keys; + const finalKey = keys.at(-1); + const active = new Set(keys.slice(0, -1)); + if (active.has(modifier)) active.delete(modifier); + else active.add(modifier); + return [...MODIFIERS.filter((candidate) => active.has(candidate)), finalKey]; +} + +export function replaceShortcutFinalKey(keys, finalKey) { + if ( + keys.length === 0 || + typeof finalKey !== "string" || + !Object.hasOwn(FINAL_KEYCODES, finalKey) + ) { + return keys; + } + return [...keys.slice(0, -1), finalKey]; +} + +export function indexDuplicateKeyControls(controls, mapping, selectedControlId) { + const byEntry = new Map(); + for (const control of controls) { + if (control.type !== "key" || control.id === selectedControlId) continue; + const fingerprint = entryFingerprint(mapping[control.id]); + if (!byEntry.has(fingerprint)) byEntry.set(fingerprint, control); + } + return byEntry; +} + +export function parseOptionalNonNegativeInteger(value) { + const trimmed = String(value).trim(); + if (trimmed === "") return undefined; + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed < 0) { + const error = new Error("Expected a non-negative integer."); + error.code = "INVALID_APPSENSE_ID"; + throw error; + } + return parsed; +} diff --git a/prototype/src/hooks/use-theme.js b/prototype/src/hooks/use-theme.js new file mode 100644 index 0000000..c096cd8 --- /dev/null +++ b/prototype/src/hooks/use-theme.js @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from "react"; + +export const THEME_STORAGE_KEY = "codex-micro-theme"; +export const THEME_ORDER = Object.freeze(["auto", "light", "dark"]); + +export function detectTheme(storage) { + try { + const target = storage ?? globalThis.window?.localStorage; + const saved = target?.getItem(THEME_STORAGE_KEY); + return THEME_ORDER.includes(saved) ? saved : "auto"; + } catch { + return "auto"; + } +} + +export function nextTheme(theme) { + const index = THEME_ORDER.indexOf(theme); + return THEME_ORDER[(index + 1) % THEME_ORDER.length] ?? THEME_ORDER[0]; +} + +export function useTheme() { + const [theme, setTheme] = useState(detectTheme); + + useEffect(() => { + const media = window.matchMedia?.("(prefers-color-scheme: dark)"); + const apply = () => { + const prefersDark = media?.matches ?? false; + document.documentElement.dataset.theme = + theme === "auto" ? (prefersDark ? "dark" : "light") : theme; + }; + + apply(); + try { + window.localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + // Private browsing: the preference just will not persist. + } + + if (theme !== "auto" || !media?.addEventListener) return undefined; + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, [theme]); + + const cycleTheme = useCallback(() => { + setTheme((current) => nextTheme(current)); + }, []); + + return { theme, cycleTheme }; +} diff --git a/prototype/src/i18n/de.js b/prototype/src/i18n/de.js index 775b1d6..d0ebbbd 100644 --- a/prototype/src/i18n/de.js +++ b/prototype/src/i18n/de.js @@ -28,6 +28,7 @@ export default { }, stateLegend: { title: "Farben der Agent-Tasten", + experimental: "experimentell", off: "aus", states: { blocked: "Eine Entscheidung wird erwartet", @@ -38,7 +39,7 @@ export default { free: "Keine Sitzung auf dieser Taste", }, note: - "Von „npm run lighting -- watch“ aus dem echten Zustand der Claude-Code-Sitzungen gesendet. Die ChatGPT-App muss beendet sein: sie überschreibt diese LEDs alle 35 bis 40 Sekunden und fängt die Tastendrücke ab.", + "Von „npm run lighting -- watch“ aus dem echten Zustand der Claude-Code-Sitzungen gesendet. Die ChatGPT-App muss beendet sein: sie überschreibt diese LEDs alle 35 bis 40 Sekunden und fängt die Tastendrücke ab. Nichts anderes hängt davon ab: Schlägt die Beleuchtung fehl, senden die Tasten weiterhin ihre Kürzel.", }, controls: { joystick: "Richtungs-Joystick – ohne Druckfunktion", diff --git a/prototype/src/i18n/en.js b/prototype/src/i18n/en.js index 719a48c..356994a 100644 --- a/prototype/src/i18n/en.js +++ b/prototype/src/i18n/en.js @@ -28,6 +28,7 @@ export default { }, stateLegend: { title: "Agent key colours", + experimental: "experimental", off: "unlit", states: { blocked: "A decision is waiting for you", @@ -38,7 +39,7 @@ export default { free: "No session on this key", }, note: - "Pushed by \"npm run lighting -- watch\" from live Claude Code session state. Quit the ChatGPT app first: it rewrites these LEDs every 35 to 40 seconds and intercepts the key presses.", + "Pushed by \"npm run lighting -- watch\" from live Claude Code session state. Quit the ChatGPT app first: it rewrites these LEDs every 35 to 40 seconds and intercepts the key presses. Nothing else depends on this: if the lighting fails, your keys keep sending their shortcuts.", }, controls: { joystick: "Directional joystick — no press", diff --git a/prototype/src/i18n/es.js b/prototype/src/i18n/es.js index 7d7f197..f0e5c1f 100644 --- a/prototype/src/i18n/es.js +++ b/prototype/src/i18n/es.js @@ -28,6 +28,7 @@ export default { }, stateLegend: { title: "Colores de las teclas Agent", + experimental: "experimental", off: "apagada", states: { blocked: "Se espera una decisión", @@ -38,7 +39,7 @@ export default { free: "Ninguna sesión en esta tecla", }, note: - "Enviados por «npm run lighting -- watch» según el estado real de las sesiones de Claude Code. Cierra la app ChatGPT: reescribe estos LED cada 35 a 40 segundos e intercepta las pulsaciones.", + "Enviados por «npm run lighting -- watch» según el estado real de las sesiones de Claude Code. Cierra la app ChatGPT: reescribe estos LED cada 35 a 40 segundos e intercepta las pulsaciones. Nada más depende de esto: si la iluminación falla, tus teclas siguen enviando sus atajos.", }, controls: { joystick: "Joystick direccional — sin clic", diff --git a/prototype/src/i18n/fr.js b/prototype/src/i18n/fr.js index b9b9773..0979f9a 100644 --- a/prototype/src/i18n/fr.js +++ b/prototype/src/i18n/fr.js @@ -28,6 +28,7 @@ export default { }, stateLegend: { title: "Couleurs des touches Agent", + experimental: "expérimental", off: "éteinte", states: { blocked: "Une décision est attendue", @@ -38,7 +39,7 @@ export default { free: "Aucune session sur cette touche", }, note: - "Poussées par « npm run lighting -- watch » d'après l'état réel des sessions Claude Code. L'app ChatGPT doit être quittée : elle réécrit ces LED toutes les 35 à 40 secondes et intercepte les appuis.", + "Poussées par « npm run lighting -- watch » d'après l'état réel des sessions Claude Code. L'app ChatGPT doit être quittée : elle réécrit ces LED toutes les 35 à 40 secondes et intercepte les appuis. Rien d'autre n'en dépend : si l'éclairage échoue, vos touches continuent d'envoyer leurs raccourcis.", }, controls: { joystick: "Joystick directionnel — sans clic", diff --git a/prototype/src/i18n/index.js b/prototype/src/i18n/index.js index 9a5019b..c158a0e 100644 --- a/prototype/src/i18n/index.js +++ b/prototype/src/i18n/index.js @@ -15,21 +15,23 @@ export const LOCALE_LABELS = { const STORAGE_KEY = "codex-micro-locale"; const DEFAULT_LOCALE = "en"; -export function detectLocale() { +export function detectLocale(storage) { try { - const saved = window.localStorage.getItem(STORAGE_KEY); + const target = storage ?? globalThis.window?.localStorage; + const saved = target?.getItem(STORAGE_KEY); if (saved && LOCALES[saved]) return saved; } catch { - // Stockage indisponible : on retombe sur la langue par défaut. + // Storage unavailable: fall back to the default language. } return DEFAULT_LOCALE; } -export function saveLocale(locale) { +export function saveLocale(locale, storage) { try { - window.localStorage.setItem(STORAGE_KEY, locale); + const target = storage ?? globalThis.window?.localStorage; + target?.setItem(STORAGE_KEY, locale); } catch { - // Ignoré : la préférence ne sera simplement pas mémorisée. + // Ignored: the preference simply will not be remembered. } } diff --git a/prototype/src/profile-panel-loader.js b/prototype/src/profile-panel-loader.js new file mode 100644 index 0000000..5379c02 --- /dev/null +++ b/prototype/src/profile-panel-loader.js @@ -0,0 +1,6 @@ +let profilePanelPromise; + +export function loadProfileExportPanel() { + profilePanelPromise ??= import("./components/ProfileExportPanel.jsx"); + return profilePanelPromise; +} diff --git a/prototype/src/profile-session.js b/prototype/src/profile-session.js new file mode 100644 index 0000000..acd6065 --- /dev/null +++ b/prototype/src/profile-session.js @@ -0,0 +1,49 @@ +export function createProfileSession() { + return { + source: null, + fileName: "", + info: null, + error: null, + mappingConflict: null, + layerCreated: null, + review: null, + appSenseIds: { claude: "", base: "" }, + }; +} + +export function profileSessionReducer(state, action) { + switch (action.type) { + case "profile-loaded": + return { + ...state, + source: action.payload.source, + fileName: action.payload.fileName, + info: action.payload.info, + layerCreated: action.payload.layerCreated, + mappingConflict: action.payload.mappingConflict, + error: null, + review: null, + }; + case "profile-failed": + return { ...state, error: action.error }; + case "conflict-resolved": + return state.mappingConflict ? { ...state, mappingConflict: null } : state; + case "appsense-changed": + if (action.field !== "claude" && action.field !== "base") { + throw new Error(`Unknown AppSense field: ${action.field}`); + } + return { + ...state, + appSenseIds: { ...state.appSenseIds, [action.field]: action.value }, + review: null, + }; + case "review-ready": + return { ...state, review: action.review, error: null }; + case "review-failed": + return { ...state, review: null, error: action.error }; + case "review-invalidated": + return state.review ? { ...state, review: null } : state; + default: + throw new Error(`Unknown profile session action: ${action.type}`); + } +} diff --git a/prototype/src/profile-workflow.js b/prototype/src/profile-workflow.js new file mode 100644 index 0000000..307265c --- /dev/null +++ b/prototype/src/profile-workflow.js @@ -0,0 +1,69 @@ +import { + addClaudeLayer, + buildInputProfile, + deriveMappingFromProfile, + inspectInputProfile, +} from "../../shared/input-profile.mjs"; +import { parseOptionalNonNegativeInteger } from "./configurator-state.js"; +import { LOCALES } from "./i18n/index.js"; + +export function prepareImportedProfile(parsed) { + let source = parsed; + let layerCreated = null; + let info; + + try { + info = inspectInputProfile(source, { requireAppSense: false }); + } catch (error) { + if (error?.code !== "NO_CLAUDE_LAYER") throw error; + const synthesized = addClaudeLayer(parsed); + source = synthesized.source; + layerCreated = { templateName: synthesized.templateName }; + info = inspectInputProfile(source, { requireAppSense: false }); + } + + return { + source, + info, + layerCreated, + derived: deriveMappingFromProfile(source), + }; +} + +export async function sha256Hex(text, crypto = globalThis.window?.crypto) { + try { + const digest = await crypto?.subtle.digest( + "SHA-256", + new TextEncoder().encode(text), + ); + if (!digest) return ""; + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + } catch { + return ""; + } +} + +export async function createProfileReview(source, mapping, appSenseIds, crypto) { + const { profile, report } = buildInputProfile(source, mapping, { + requireAppSense: false, + appSenseId: parseOptionalNonNegativeInteger(appSenseIds.claude), + baseLayerAppSenseId: parseOptionalNonNegativeInteger(appSenseIds.base), + }); + const json = `${JSON.stringify(profile, null, 2)}\n`; + return { json, sha: await sha256Hex(json, crypto), report }; +} + +export function describeProfileError(error, t) { + if (error instanceof SyntaxError) { + return { message: t("errors.invalidJson"), code: null }; + } + if (error?.code && LOCALES.fr.errors[error.code]) { + return { message: t(`errors.${error.code}`), code: error.code }; + } + return { + message: error instanceof Error ? error.message : t("errors.invalidFile"), + code: null, + }; +} diff --git a/prototype/src/styles.css b/prototype/src/styles.css index 9af86e5..b0fc399 100644 --- a/prototype/src/styles.css +++ b/prototype/src/styles.css @@ -15,6 +15,8 @@ --shadow: rgba(126, 73, 52, 0.2); --glass-border: rgba(255, 255, 255, 0.55); --glass-blur: blur(18px) saturate(1.6); + --solid-glass-background: #fffaf6; + --solid-toast-background: #453a36; } * { @@ -58,9 +60,9 @@ a:focus-visible { --panel-reserve: 0px; } -/* Panneau non modal sur desktop : la page se décale vers la gauche pour que - le clavier reste entièrement visible à côté du configurateur. Sur mobile la - sheet recouvre la page, aucune réservation (voir la media query dédiée). */ +/* Non-modal panel on desktop: the page shifts left so the keyboard stays fully + visible next to the configurator. On mobile the sheet covers the page, with no + reservation (see the dedicated media query). */ @media (min-width: 761px) { .app-shell.is-panel-open { --panel-reserve: calc(min(470px, 46vw) + 30px); @@ -91,8 +93,8 @@ a:focus-visible { position: absolute; z-index: 3; top: 0; - /* Position absolue : le padding de .page-content ne s'applique pas, - le décalage du panneau passe donc par `right`. */ + /* Absolutely positioned: .page-content's padding does not apply, so the + panel offset goes through `right` instead. */ right: var(--panel-reserve); left: 0; transition: right 280ms cubic-bezier(0.2, 0.9, 0.25, 1); @@ -236,22 +238,21 @@ a:focus-visible { .device-stage { position: relative; width: min(510px, 72vh, 88vw); - /* Quand le panneau réserve la droite de l'écran, le device se réduit pour - rester entièrement visible dans la colonne restante. */ + /* When the panel reserves the right of the screen, the device shrinks to stay + fully visible in the remaining column. */ max-width: 100%; margin-top: 20px; } -/* La boîte est recadrée sur le device lui-même, pas sur le cadre du PNG. - Le fichier est un détourage de 1254x1254 dont la partie opaque n'occupe que - 851x855, soit 68% : cadrer sur le fichier gaspillait 32% de la largeur en - transparent et rendait le clavier d'autant plus petit. Le recadrage se fait - ici, sans toucher à l'asset — l'image est agrandie et décalée pour que sa - zone opaque remplisse exactement la boîte. +/* The box is cropped to the device itself, not to the PNG's frame. The file is a + 1254x1254 cut-out whose opaque part only occupies 851x855, so 68%: framing on + the file wasted 32% of the width on transparency and made the keyboard that + much smaller. The crop happens here, without touching the asset — the image is + scaled up and offset so its opaque area fills the box exactly. - Conséquence : les coordonnées des hotspots dans App.jsx sont des pourcentages - de CE cadre, pas du fichier. Remplacer l'image impose de les recalculer, et - les valeurs en `cqw` ci-dessous suivent la même référence. */ + Consequence: the hotspot coordinates in App.jsx are percentages of THIS frame, + not of the file. Replacing the image forces them to be recomputed, and the + `cqw` values below follow the same reference. */ .device-wrap { position: relative; width: 100%; @@ -313,8 +314,8 @@ a:focus-visible { border-color: rgb(var(--selected-glow) / 1); background: rgba(255, 255, 255, 0.36); transform: translateY(-1px) scale(1.03); - /* Liseré blanc entre la touche et l'anneau accent : la sélection reste - lisible même sur les zones claires ou chargées de la photo. */ + /* White hairline between the key and the accent ring: the selection stays + readable even over light or busy areas of the photo. */ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.95), 0 0 0 7px rgb(var(--selected-glow) / 0.95), @@ -352,11 +353,10 @@ a:focus-visible { box-shadow: 0 3px 10px rgba(45, 28, 20, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.16); - /* Taille indexée sur la photo, pas sur le viewport : le plafond `max-width` - de la pastille se réduit avec la touche, donc un libellé en pixels fixes - finit tronqué aux petites tailles — « VOICE » se coupait en « VO… » dès que - le device passait sous ~330px. Les bornes du clamp reproduisent exactement - l'aspect actuel à la grande taille. */ + /* Sized against the photo, not the viewport: the pill's `max-width` ceiling + shrinks with the key, so a label in fixed pixels ends up truncated at small + sizes — "VOICE" was cut to "VO…" as soon as the device went below ~330px. + The clamp bounds reproduce exactly the current look at the large size. */ padding: clamp(2px, 1.18cqw, 4px) clamp(3px, 1.77cqw, 6px); font: 800 clamp(7px, 2.73cqw, 9px) / 1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.025em; @@ -365,12 +365,11 @@ a:focus-visible { transition: background 140ms ease, box-shadow 140ms ease; } -/* Empilement de la molette, en trois étages qui ne peuvent pas se croiser : - le libellé du clic au-dessus du cercle, l'icône du clic centrée dedans, le - libellé de rotation sous le cercle. Tout est exprimé en `cqw` pour que les - trois gardent leurs écarts quelle que soit la taille de la photo — c'est le - mélange de pixels fixes et de pourcentages qui faisait chevaucher l'icône du - clic et le libellé de rotation. */ +/* The wheel stack, in three tiers that cannot cross: the press label above the + circle, the press icon centred inside it, the rotation label below the circle. + Everything is expressed in `cqw` so the three keep their spacing whatever the + size of the photo — it was the mix of fixed pixels and percentages that made + the press icon and the rotation label overlap. */ .key-hotspot-label--dial { display: inline-flex; bottom: -2.06cqw; @@ -385,14 +384,14 @@ a:focus-visible { flex: none; } -/* Les deux libellés de la molette débordent volontairement de la boîte de leur - bouton, et celui du clic recouvre le cercle de rotation. Avec - `pointer-events: none` le pointeur les traversait et atteignait le contrôle - du dessous : survoler « PRESS » mettait la molette en avant. Les rendre - sensibles au pointeur suffit — ce sont des enfants de leur bouton, le survol - et le clic remontent donc au bon contrôle, et le contrôle voisin n'est plus - atteint. Les autres libellés gardent `none` : ils ne dépassent que de - quelques pixels sous leur touche, où il n'y a rien à protéger. */ +/* Both wheel labels deliberately overflow their button's box, and the press one + covers the rotation circle. With `pointer-events: none` the pointer went + straight through them and reached the control underneath: hovering "PRESS" + highlighted the wheel. Making them pointer-sensitive is enough — they are + children of their own button, so hover and click bubble to the right control + and the neighbouring one is no longer reached. The other labels keep `none`: + they only overhang their key by a few pixels, where there is nothing to + protect. */ .key-hotspot--dial > .key-hotspot-label, .key-hotspot--encoder-button > .key-hotspot-label { pointer-events: auto; @@ -412,20 +411,25 @@ a:focus-visible { .keycap-action-asset { position: absolute; - top: 43%; + /* Centré sur le hotspot, qui recouvre la face de la keycap. */ + top: 50%; left: 50%; display: grid; - width: 10.98cqw; - height: 10.98cqw; + /* Le disque doit couvrir la sérigraphie de la keycap sur la photo, sinon le + picto d'origine reste visible derrière l'icône de l'action. Il est donc + nettement plus large que l'icône, et sa zone pleine est poussée vers + l'extérieur — le dégradé ne s'éteint qu'au bord. */ + width: 13.2cqw; + height: 13.2cqw; place-items: center; transform: translate(-50%, -50%); border: 0; border-radius: 50%; background: radial-gradient( circle, - rgb(var(--keycap-tone) / 0.98) 0 48%, - rgb(var(--keycap-tone) / 0.9) 62%, - rgb(var(--keycap-tone) / 0) 82% + rgb(var(--keycap-tone) / 0.99) 0 62%, + rgb(var(--keycap-tone) / 0.94) 76%, + rgb(var(--keycap-tone) / 0) 94% ); color: rgba(62, 46, 39, 0.96); box-shadow: none; @@ -438,8 +442,8 @@ a:focus-visible { transform: translate(-50%, -50%) scale(1.06); } -/* Étage du milieu : nettement plus petit que le cercle du clic, pour laisser - respirer les deux libellés au-dessus et en dessous. */ +/* Middle tier: clearly smaller than the press circle, to let both labels above + and below breathe. */ .key-hotspot--encoder-button > .keycap-action-asset { top: 50%; width: 6.04cqw; @@ -447,13 +451,18 @@ a:focus-visible { color: #fffaf6; } -/* Les icônes sont rendues avec une taille en attribut par React : les - contraindre en CSS pour qu'elles suivent leur disque, lui-même indexé sur la - photo. Les proportions reprennent celles d'origine, 19px dans 38px pour une - keycap et 13px dans 24px pour le clic de molette. */ -.keycap-action-asset > svg { - width: 50%; - height: 50%; +/* React renders the icons with a size attribute: constrain them in CSS so they + follow their disc, which is itself sized against the photo. The proportions + keep the original ones, 19px within 38px for a keycap and 13px within 24px for + the wheel press. */ +/* La marque Claude est un `img` porteur d'attributs de taille, pas un `svg` : + sans cette règle la touche Envoyer gardait une icône figée pendant que les + autres suivaient la photo. Le pourcentage est calculé pour que l'icône garde + sa taille absolue après l'élargissement du disque. */ +.keycap-action-asset > svg, +.keycap-action-asset > .claude-mark-icon { + width: 35%; + height: 35%; } .key-hotspot--encoder-button > .keycap-action-asset > svg { @@ -461,7 +470,7 @@ a:focus-visible { height: 54%; } -/* Étage du haut : posé sur le bandeau vide au-dessus de la molette. */ +/* Top tier: sits on the empty band above the wheel. */ .key-hotspot--encoder-button > .key-hotspot-label { top: -5.01cqw; bottom: auto; @@ -514,18 +523,15 @@ a:focus-visible { font-size: 12px; } -/* Légende des couleurs d'état des six touches Agent. Les teintes ne sont pas - écrites ici : elles viennent en style inline de shared/thread-status-palette.mjs, - source unique partagée avec l'outillage Node. */ -/* Légende des couleurs d'état des six touches Agent. Les teintes ne sont pas - écrites ici : elles arrivent en style inline depuis - shared/thread-status-palette.mjs, source unique partagée avec l'outillage Node. +/* State colour legend for the six Agent keys. The hues are not written here: + they arrive as inline styles from shared/thread-status-palette.mjs, the single + source shared with the Node tooling. - Hors du flux, dans la marge gauche du clavier, à mi-hauteur. La colonne centrale - du héros n'a plus de budget vertical avant la ligne de flottaison et la coque ne - défile pas : une légende dans le flux y serait inatteignable. Le bas de page - n'est pas libre non plus — les deux notes centrées y courent sur presque toute - la largeur. Reste la marge latérale, face au clavier. */ + Out of the flow, in the left margin of the keyboard, at mid-height. The hero's + centre column has no vertical budget left above the fold and the shell does not + scroll: a legend in the flow would be unreachable there. The bottom of the page + is not free either — the two centred notes run across almost its whole width. + That leaves the side margin, facing the keyboard. */ .state-legend { position: fixed; z-index: 4; @@ -553,8 +559,24 @@ a:focus-visible { margin-bottom: 10px; } -/* Une seule colonne : la carte est étroite, et l'ordre de lecture va du plus - urgent au plus inerte. */ +/* Lighting is the experimental half of the project: say so here, and not only in + the README — otherwise the interface is what users go by, and it promises a + stable feature. */ +.state-legend-badge { + margin-left: 8px; + padding: 1px 6px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: lowercase; + vertical-align: 1px; +} + +/* A single column: the card is narrow, and the reading order runs from the most + urgent to the most inert. */ .state-legend-list { display: grid; gap: 6px; @@ -578,8 +600,7 @@ a:focus-visible { border-radius: 4px; } -/* Un emplacement libre n'a pas de couleur : la pastille montre une touche - éteinte, pas une teinte de plus. */ +/* A free slot has no colour: the swatch shows an unlit key, not one more hue. */ .state-legend-swatch.is-off { background: transparent; border-style: dashed; @@ -604,10 +625,10 @@ a:focus-visible { line-height: 1.45; } -/* La carte n'apparaît que là où la marge la contient réellement. Sous cette - largeur elle recouvrirait le clavier : mieux vaut ne rien montrer qu'un panneau - posé par-dessus le contenu. Même raison quand le panneau de configuration est - ouvert : le héros se décale vers la gauche et la marge disparaît. */ +/* The card only appears where the margin actually holds it. Below this width it + would cover the keyboard: better to show nothing than a panel laid over the + content. Same reason when the configuration panel is open: the hero shifts left + and the margin disappears. */ @media (max-width: 1239px) { .state-legend { display: none; @@ -619,11 +640,10 @@ a:focus-visible { } -/* Dans le flux, sous le clavier. Ce bouton était auparavant en position absolue - par-dessus le bas du stage, qui était vide : le cadre de l'image laissait - 18,6% de transparent sous le device. Depuis que la boîte est recadrée sur le - device, ce vide n'existe plus et le bouton recouvrirait les touches. */ -.configure-button { +/* Both primary actions share one stable footprint, so changing copy or language + does not move the device stage. */ +.configure-button, +.generate-button { position: relative; z-index: 3; display: inline-flex; @@ -631,10 +651,17 @@ a:focus-visible { min-height: 50px; align-items: center; justify-content: center; + border-radius: 999px; + font-size: 13px; + transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +/* In the flow, below the keyboard. Its old absolute position covered keys once + the transparent image margin was removed. */ +.configure-button { gap: 9px; margin: 18px auto 0; border: 1px solid rgba(255, 255, 255, 0.78); - border-radius: 999px; background: rgba(255, 251, 247, 0.6); padding: 0 17px; color: var(--ink); @@ -642,10 +669,8 @@ a:focus-visible { 0 16px 42px rgba(112, 70, 51, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.92), inset 0 -1px 1px rgba(255, 255, 255, 0.28); - font-size: 13px; font-weight: 720; backdrop-filter: var(--glass-blur); - transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease; } .configure-button:hover { @@ -661,19 +686,11 @@ a:focus-visible { } .generate-button { - position: relative; - z-index: 3; - display: inline-flex; - width: min(240px, calc(100vw - 32px)); - min-height: 50px; - align-items: center; - justify-content: center; gap: 8px; - /* Marge positive : ce bouton remontait de 44px pour combler le vide sous le - device, vide supprimé par le recadrage de la boîte sur le clavier. */ + /* Positive margin: this button used to pull up by 44px to fill the empty space + under the device, space removed by cropping the box to the keyboard. */ margin-top: 12px; border: 1px solid rgba(255, 255, 255, 0.45); - border-radius: 999px; background: rgba(201, 95, 67, 0.82); padding: 0 20px; color: #fffaf6; @@ -682,9 +699,7 @@ a:focus-visible { inset 0 1px 0 rgba(255, 255, 255, 0.42), inset 0 -1px 1px rgba(255, 255, 255, 0.14); backdrop-filter: blur(14px) saturate(1.5); - font-size: 13px; font-weight: 750; - transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease; } .generate-button:hover { @@ -754,9 +769,8 @@ a:focus-visible { right: 12px; bottom: 12px; display: flex; - /* 46vw : sur les desktops étroits, panneau et clavier se partagent - l'écran au lieu de se recouvrir (la media query mobile prend le relais - sous 760px). */ + /* 46vw: on narrow desktops, panel and keyboard share the screen instead of + overlapping (the mobile media query takes over below 760px). */ width: min(470px, 46vw); flex-direction: column; overflow: hidden; @@ -805,8 +819,8 @@ a:focus-visible { letter-spacing: -0.045em; } -/* Mode « touche » : l'en-tête EST la touche sélectionnée — badge, nom et - action actuelle — à la place d'un titre générique. */ +/* "Key" mode: the header IS the selected key — badge, name and current action — + instead of a generic title. */ .key-header { display: flex; min-width: 0; @@ -851,8 +865,8 @@ a:focus-visible { } } -/* Le contenu du panneau glisse en place à l'ouverture et au changement - de mode (touche ↔ export). */ +/* The panel content slides into place on open and on mode change + (key ↔ export). */ .dialog-scroll > * { animation: panel-swap-in 240ms cubic-bezier(0.2, 0.9, 0.25, 1); } @@ -946,10 +960,9 @@ kbd { font-size: 13px; } -/* Deux lignes plutôt qu'une seule tronquée : la colonne du raccourci est en - `auto`, donc les raccourcis larges comme celui du mode Effort écrasaient la - description jusqu'à la rendre illisible. La hauteur de rangée de 67px les - accueille sans décaler la liste. */ +/* Two lines rather than a single truncated one: the shortcut column is `auto`, + so wide shortcuts like the Effort one squeezed the description until it became + unreadable. The 67px row height fits them without shifting the list. */ .action-copy small { display: -webkit-box; overflow: hidden; @@ -1379,8 +1392,8 @@ kbd { line-height: 1.45; } -/* Roue de secteurs, dessinée depuis les mêmes angles que le profil : le - secteur cliqué est celui qui sera écrit, à l'angle près. */ +/* Sector wheel, drawn from the same angles as the profile: the sector clicked is + the one that will be written, down to the angle. */ .joystick-dial-wrap { position: relative; width: min(228px, 78%); @@ -1392,9 +1405,9 @@ kbd { width: 100%; } -/* Même convention que les keycaps : le numéro du secteur tant qu'il est libre, - le libellé de l'action une fois affectée. Purement décoratif — le secteur SVG - dessous reste le seul contrôle. */ +/* Same convention as the keycaps: the sector number while it is free, the action + label once assigned. Purely decorative — the SVG sector underneath stays the + only control. */ .joystick-dial-badge { position: absolute; max-width: 30%; @@ -1694,10 +1707,10 @@ kbd { line-height: 1.4; } - /* Pas de bottom sheet sur mobile. La photo du device reste visible en haut - et le panneau prend le reste de la hauteur : seule sa liste défile. C'est - ce qui a permis de supprimer le mini-schéma du clavier, le piège à focus - et le scrim — le vrai clavier est là, et il reste cliquable. */ + /* No bottom sheet on mobile. The device photo stays visible at the top and the + panel takes the rest of the height: only its list scrolls. That is what + allowed removing the mini keyboard diagram, the focus trap and the scrim — + the real keyboard is right there, and it stays clickable. */ .app-shell.is-panel-open { display: flex; height: 100dvh; @@ -1715,7 +1728,7 @@ kbd { padding: 56px 14px 0; } - /* Tout ce qui n'aide pas à choisir une touche laisse la place au panneau. */ + /* Anything that does not help pick a key gives way to the panel. */ .app-shell.is-panel-open .hero-copy, .app-shell.is-panel-open .configure-button, .app-shell.is-panel-open .generate-button, @@ -1725,9 +1738,8 @@ kbd { display: none; } - /* La boîte est désormais recadrée sur le device, donc plus de vide à - récupérer par une marge négative : la largeur du stage est directement - celle du clavier. */ + /* The box is now cropped to the device, so there is no empty space left to + reclaim with a negative margin: the stage width is the keyboard's width. */ .app-shell.is-panel-open .device-stage { width: min(285px, 78vw); margin: 0 auto; @@ -1833,7 +1845,7 @@ kbd { .mapping-preview button, .brand-mark, .mapping-dialog { - background: #fffaf6; + background: var(--solid-glass-background); backdrop-filter: none; } @@ -1843,12 +1855,9 @@ kbd { } .toast { + background: var(--solid-toast-background); backdrop-filter: none; } - - .toast { - background: #453a36; - } } /* Dark theme is driven by data-theme on (set by the inline script in @@ -1869,6 +1878,8 @@ kbd { --line-strong: rgba(240, 225, 214, 0.28); --shadow: rgba(0, 0, 0, 0.45); --glass-border: rgba(255, 255, 255, 0.24); + --solid-glass-background: #2e231d; + --solid-toast-background: #362a23; } body { @@ -2225,21 +2236,4 @@ kbd { inset 0 1px 0 rgba(255, 255, 255, 0.14); } - @media (prefers-reduced-transparency: reduce) { - .local-status, - .language-select, - .theme-toggle, - .configure-button, - .mapping-preview button, - .brand-mark, - .mapping-dialog { - background: #2e231d; - backdrop-filter: none; - } - - .toast { - background: #362a23; - backdrop-filter: none; - } - } } diff --git a/prototype/tests/app-render.test.mjs b/prototype/tests/app-render.test.mjs new file mode 100644 index 0000000..ad79a8e --- /dev/null +++ b/prototype/tests/app-render.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import React from "../node_modules/react/index.js"; +import { renderToString } from "../node_modules/react-dom/server.node.js"; +import { createServer } from "../node_modules/vite/dist/node/index.js"; +import { LOCALES } from "../src/i18n/index.js"; +import { createProfileSession } from "../src/profile-session.js"; + +const values = new Map(); +const storage = { + getItem(key) { + return values.get(key) ?? null; + }, + setItem(key, value) { + values.set(key, value); + }, +}; + +let server; +let App; +let createJoystickDialGeometry; +let presenter; +let ProfileExportPanel; + +before(async () => { + globalThis.window = { localStorage: storage }; + server = await createServer({ + root: new URL("..", import.meta.url).pathname, + server: { middlewareMode: true }, + appType: "custom", + logLevel: "error", + }); + ({ App } = await server.ssrLoadModule("/src/App.jsx")); + ({ createJoystickDialGeometry } = await server.ssrLoadModule( + "/src/components/JoystickDial.jsx", + )); + presenter = await server.ssrLoadModule("/src/configurator-presenter.js"); + ({ ProfileExportPanel } = await server.ssrLoadModule( + "/src/components/ProfileExportPanel.jsx", + )); +}); + +after(async () => { + await server?.close(); + delete globalThis.window; +}); + +test("renders the complete configurator from the default state", () => { + values.clear(); + const html = renderToString(React.createElement(App)); + + assert.match(html, /Codex Micro/); + assert.ok(html.includes(LOCALES.en.hero.subtitle)); + assert.equal((html.match(/class="key-hotspot /g) ?? []).length, 15); + assert.match(html, /class="mapping-dialog "/); + assert.match(html, /aria-hidden="true"/); +}); + +test("renders a saved locale while malformed mapping storage fails closed", () => { + values.clear(); + values.set("codex-micro-locale", "fr"); + values.set("codex-micro-mapping", "not-json"); + + const html = renderToString(React.createElement(App)); + assert.ok(html.includes(LOCALES.fr.hero.subtitle)); + assert.ok(html.includes(LOCALES.fr.buttons.configureKeys)); +}); + +test("precomputes stable joystick paths for supported direction counts", () => { + const four = createJoystickDialGeometry(4); + const eight = createJoystickDialGeometry(8); + assert.equal(four.sectors.length, 4); + assert.equal(eight.sectors.length, 8); + assert.match(four.close.path, /^M /); + assert.equal(four.close.centroid.length, 2); + assert.notEqual(four.sectors[0].path, four.sectors[1].path); +}); + +test("presents catalogue, custom, joystick, and fallback entries consistently", () => { + const t = (key, values) => values ? `${key}:${JSON.stringify(values)}` : key; + const custom = { type: "custom", keys: ["Command", "Shift", "K"] }; + const joystick = { directions: 4, sectors: ["none", "none", "none", "none"] }; + assert.equal(presenter.entryFor({ "key-1": "voice" }, "key-1"), "voice"); + assert.equal(presenter.entryFor({}, "key-1"), "none"); + assert.equal(presenter.entryExportLabel(custom), "⌘⇧K"); + assert.equal(presenter.entryExportLabel(joystick), "4 DIR"); + assert.equal(presenter.entryExportLabel("unknown"), "NONE"); + assert.equal(presenter.entryLabel(custom, t), "actions.custom.label"); + assert.equal(presenter.entryLabel(joystick, t), "actions.joystickCustom.label"); + assert.equal(presenter.entryShortcut(custom, t), "⌘⇧K"); + assert.match(presenter.entryShortcut(joystick, t), /^actions\.joystickCustom\.shortcut:/); + assert.ok(presenter.entryIcon(custom)); + assert.ok(presenter.entryIcon(joystick)); + assert.ok(presenter.entryIcon("unknown")); + const control = { id: "key-1", shortLabel: "C1" }; + assert.equal(presenter.controlLabel(control, t), "controls.key-1"); + assert.equal(presenter.controlBadgeLabel(control, "none"), "C1"); + assert.equal(presenter.controlBadgeLabel(control, "voice"), "VOICE"); +}); + +test("renders the deferred export workflow independently", () => { + const profile = createProfileSession(); + const ref = { current: null }; + const html = renderToString( + React.createElement(ProfileExportPanel, { + t: (key) => key, + refs: { loader: ref, profileInput: ref, review: ref }, + profile, + onLoadSourceProfile() {}, + onResolveMappingConflict() {}, + onAppSenseChange() {}, + onRunReview() {}, + onDownloadReview() {}, + }), + ); + assert.match(html, /wizard-step-1-title/); + assert.match(html, /wizard-step-3-title/); + assert.match(html, /review.needProfile/); +}); diff --git a/prototype/tests/architecture.test.mjs b/prototype/tests/architecture.test.mjs new file mode 100644 index 0000000..06e1686 --- /dev/null +++ b/prototype/tests/architecture.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const sourceRoot = fileURLToPath(new URL("../src", import.meta.url)); +const sourceExtensions = new Set([".js", ".jsx"]); + +async function sourceFiles(directory = sourceRoot) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all(entries.map(async (entry) => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(target); + return sourceExtensions.has(path.extname(entry.name)) ? [target] : []; + })); + return nested.flat(); +} + +function localDependencies(file, source) { + const dependencies = []; + const imports = source.matchAll(/(?:from\s+|import\s*\()(["'])(\.\.?\/[^"']+)\1/g); + for (const match of imports) { + const resolved = path.resolve(path.dirname(file), match[2]); + dependencies.push(resolved); + } + return dependencies; +} + +test("the configurator source graph stays acyclic", async () => { + const files = await sourceFiles(); + const knownFiles = new Set(files); + const graph = new Map(); + + for (const file of files) { + const source = await readFile(file, "utf8"); + graph.set( + file, + localDependencies(file, source).filter((dependency) => knownFiles.has(dependency)), + ); + } + + const visiting = new Set(); + const visited = new Set(); + function visit(file, ancestry = []) { + if (visiting.has(file)) { + assert.fail(`Circular dependency: ${[...ancestry, file].map(path.basename).join(" -> ")}`); + } + if (visited.has(file)) return; + visiting.add(file); + for (const dependency of graph.get(file) ?? []) visit(dependency, [...ancestry, file]); + visiting.delete(file); + visited.add(file); + } + + for (const file of files) visit(file); + assert.equal(visited.size, files.length); +}); + +test("hardware profile mutation remains behind the profile workflow boundary", async () => { + const files = await sourceFiles(); + const forbiddenOutsideWorkflow = + /\b(?:addClaudeLayer|buildInputProfile|deriveMappingFromProfile|inspectInputProfile)\b/; + const violations = []; + + for (const file of files) { + if (path.basename(file) === "profile-workflow.js") continue; + const source = await readFile(file, "utf8"); + if (forbiddenOutsideWorkflow.test(source)) { + violations.push(path.relative(sourceRoot, file)); + } + } + + assert.deepEqual(violations, []); +}); diff --git a/prototype/tests/configurator-state.test.mjs b/prototype/tests/configurator-state.test.mjs new file mode 100644 index 0000000..dd7692b --- /dev/null +++ b/prototype/tests/configurator-state.test.mjs @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ACTION_DEFINITIONS, DEFAULT_MAPPING } from "../../shared/input-profile.mjs"; +import { + DEFAULT_CUSTOM, + MAPPING_STORAGE_KEY, + assignMappingEntry, + assertKeyActionCatalog, + entryFingerprint, + formatCustomKeys, + indexDuplicateKeyControls, + isCustom, + isCustomJoystick, + isValidEntry, + loadStoredState, + makeCustomJoystick, + mappingsEqual, + parseOptionalNonNegativeInteger, + replaceShortcutFinalKey, + saveStoredState, + setJoystickMappingMode, + toggleShortcutModifier, +} from "../src/configurator-state.js"; + +function storageFor(value) { + return { + getItem(key) { + assert.equal(key, MAPPING_STORAGE_KEY); + return value; + }, + }; +} + +test("loads only valid stored assignments and restores every missing default", () => { + const state = loadStoredState( + storageFor( + JSON.stringify({ + mapping: { + "key-1": "settings", + "key-2": { type: "custom", keys: ["Command", "K"] }, + wheel: "lines", + joystick: { + directions: 4, + sectors: ["newSession", "voice", "diff", "none"], + }, + unknown: "settings", + }, + }), + ), + ); + + assert.equal(state.mapping["key-1"], "settings"); + assert.deepEqual(state.mapping["key-2"], { type: "custom", keys: ["Command", "K"] }); + assert.equal(state.mapping.wheel, "lines"); + assert.equal(state.mapping.joystick.directions, 4); + assert.equal("unknown" in state.mapping, false); + assert.equal(state.mapping["key-13"], DEFAULT_MAPPING["key-13"]); +}); + +test("fails closed for unavailable, malformed, or structurally invalid storage", () => { + assert.deepEqual(loadStoredState(storageFor(null)), { mapping: DEFAULT_MAPPING }); + assert.deepEqual(loadStoredState(storageFor("not-json")), { mapping: DEFAULT_MAPPING }); + assert.deepEqual(loadStoredState(storageFor(JSON.stringify({ mapping: [] }))), { + mapping: DEFAULT_MAPPING, + }); + assert.deepEqual( + loadStoredState({ + getItem() { + throw new Error("storage blocked"); + }, + }), + { mapping: DEFAULT_MAPPING }, + ); +}); + +test("validates key, wheel, and joystick entries from shared sources of truth", () => { + assert.equal(isValidEntry("key-1", "sendInDuplicateSession"), true); + assert.equal(isValidEntry("key-1", DEFAULT_CUSTOM), true); + assert.equal(isValidEntry("key-1", { type: "custom", keys: ["Command", 1] }), false); + assert.equal(isValidEntry("key-1", { type: "custom", keys: [] }), false); + assert.equal(isValidEntry("key-1", { type: "custom", keys: ["Command", "Enter"] }), false); + assert.equal( + isValidEntry("key-1", { type: "custom", keys: ["Command", "Command", "K"] }), + false, + ); + assert.equal(isValidEntry("key-99", "settings"), false); + assert.equal(isValidEntry("wheel", "effort"), true); + assert.equal(isValidEntry("wheel", "navigation"), false); + assert.equal(isValidEntry("wheel", "toString"), false); + assert.equal(isValidEntry("joystick", "navigation"), true); + assert.equal( + isValidEntry("joystick", { + directions: 8, + sectors: new Array(8).fill("voice"), + }), + true, + ); + assert.equal( + isValidEntry("joystick", { directions: 6, sectors: new Array(6).fill("voice") }), + false, + ); +}); + +test("fails fast when the UI catalogue drifts from shared key actions", () => { + const allActions = Object.fromEntries( + Object.keys(ACTION_DEFINITIONS).map((id) => [id, { controlTypes: ["key"] }]), + ); + assert.doesNotThrow(() => assertKeyActionCatalog(allActions)); + delete allActions.voice; + assert.throws(() => assertKeyActionCatalog(allActions), /missing key actions: voice/); +}); + +test("builds joystick mappings and formats custom shortcuts without mutation", () => { + const previous = ["voice", "diff"]; + const joystick = makeCustomJoystick(4, previous); + assert.deepEqual(joystick, { + directions: 4, + sectors: ["voice", "diff", "none", "none"], + }); + assert.deepEqual(previous, ["voice", "diff"]); + assert.equal(isCustomJoystick(joystick), true); + assert.equal(isCustom(DEFAULT_CUSTOM), true); + assert.equal(formatCustomKeys(["Command", "Shift", "BracketLeft"]), "⌘⇧["); +}); + +test("compares normalized mappings and indexes duplicate key assignments once", () => { + assert.equal(mappingsEqual({ "key-1": "voice" }, { "key-1": "voice", "key-2": "none" }), true); + assert.equal( + mappingsEqual( + { "key-1": { type: "custom", keys: ["Command", "K"] } }, + { "key-1": { keys: ["Command", "K"], type: "custom" } }, + ), + true, + ); + assert.equal( + mappingsEqual( + { joystick: { directions: 4, sectors: ["voice", "diff", "none", "none"] } }, + { joystick: { sectors: ["voice", "diff", "none", "none"], directions: 4 } }, + ), + true, + ); + assert.equal(mappingsEqual({ "key-1": "voice" }, { "key-1": "diff" }), false); + assert.equal(entryFingerprint(undefined), JSON.stringify("none")); + + const controls = [ + { id: "key-1", type: "key" }, + { id: "key-2", type: "key" }, + { id: "key-3", type: "key" }, + { id: "wheel", type: "dial" }, + ]; + const index = indexDuplicateKeyControls( + controls, + { "key-1": "voice", "key-2": "voice", "key-3": "voice", wheel: "effort" }, + "key-1", + ); + assert.equal(index.get(entryFingerprint("voice")).id, "key-2"); + assert.equal(index.has(entryFingerprint("effort")), false); +}); + +test("parses optional AppSense ids and preserves the stable error code", () => { + assert.equal(parseOptionalNonNegativeInteger(""), undefined); + assert.equal(parseOptionalNonNegativeInteger(" 12 "), 12); + assert.equal(parseOptionalNonNegativeInteger(0), 0); + for (const value of ["-1", "1.5", "not-a-number"]) { + assert.throws(() => parseOptionalNonNegativeInteger(value), { + code: "INVALID_APPSENSE_ID", + }); + } +}); + +test("persists mappings defensively", () => { + const writes = []; + assert.equal( + saveStoredState({ "key-1": "voice" }, { setItem: (...args) => writes.push(args) }), + true, + ); + assert.deepEqual(writes, [ + [MAPPING_STORAGE_KEY, JSON.stringify({ mapping: { "key-1": "voice" } })], + ]); + assert.equal( + saveStoredState({}, { setItem: () => { throw new Error("quota"); } }), + false, + ); +}); + +test("updates direct and joystick assignments without mutating previous mappings", () => { + const initial = { + ...DEFAULT_MAPPING, + joystick: { directions: 4, sectors: ["voice", "none", "none", "none"] }, + }; + const direct = assignMappingEntry(initial, "key-1", null, "settings"); + assert.equal(direct["key-1"], "settings"); + assert.equal(initial["key-1"], DEFAULT_MAPPING["key-1"]); + + const joystick = assignMappingEntry(initial, "joystick", 1, "diff"); + assert.deepEqual(joystick.joystick.sectors, ["voice", "diff", "none", "none"]); + assert.deepEqual(initial.joystick.sectors, ["voice", "none", "none", "none"]); + assert.equal(assignMappingEntry(initial, "joystick", 9, "diff"), initial); + assert.equal( + assignMappingEntry({ ...initial, joystick: "navigation" }, "joystick", 0, "diff").joystick, + "navigation", + ); +}); + +test("changes joystick modes and custom shortcuts through pure transformations", () => { + const custom = { + ...DEFAULT_MAPPING, + joystick: { directions: 4, sectors: ["voice", "diff", "none", "none"] }, + }; + assert.equal(setJoystickMappingMode(custom, "navigation").joystick, "navigation"); + assert.equal(setJoystickMappingMode(custom, 6), custom); + assert.deepEqual(setJoystickMappingMode(custom, 8).joystick.sectors, [ + "voice", "diff", "none", "none", "none", "none", "none", "none", + ]); + + const keys = ["Command", "K"]; + assert.deepEqual(toggleShortcutModifier(keys, "Shift"), ["Command", "Shift", "K"]); + assert.deepEqual(toggleShortcutModifier(["Command", "Shift", "K"], "Command"), ["Shift", "K"]); + assert.equal(toggleShortcutModifier(keys, "unknown"), keys); + assert.deepEqual(replaceShortcutFinalKey(keys, "F1"), ["Command", "F1"]); + assert.equal(replaceShortcutFinalKey(keys, "Enter"), keys); + assert.equal(replaceShortcutFinalKey(keys, 1), keys); +}); diff --git a/prototype/tests/i18n.test.mjs b/prototype/tests/i18n.test.mjs new file mode 100644 index 0000000..a9fcb08 --- /dev/null +++ b/prototype/tests/i18n.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + LOCALES, + createTranslator, + detectLocale, + saveLocale, +} from "../src/i18n/index.js"; + +test("detects a supported saved locale and otherwise falls back to English", () => { + assert.equal(detectLocale({ getItem: () => "de" }), "de"); + assert.equal(detectLocale({ getItem: () => "unknown" }), "en"); + assert.equal(detectLocale({ getItem: () => null }), "en"); + assert.equal( + detectLocale({ + getItem() { + throw new Error("storage unavailable"); + }, + }), + "en", + ); +}); + +test("saves the preference when storage is available and ignores storage failures", () => { + const writes = []; + saveLocale("es", { setItem: (...args) => writes.push(args) }); + assert.deepEqual(writes, [["codex-micro-locale", "es"]]); + assert.doesNotThrow(() => + saveLocale("fr", { + setItem() { + throw new Error("private mode"); + }, + }), + ); +}); + +test("translates nested keys, interpolates values, and keeps deterministic fallbacks", () => { + const english = createTranslator("en"); + assert.equal(english("meta.title"), LOCALES.en.meta.title); + assert.equal( + english("loader.meta", { + file: "profile.json", + layer: "Claude", + appSense: "linked", + }), + LOCALES.en.loader.meta + .replaceAll("{file}", "profile.json") + .replaceAll("{layer}", "Claude") + .replaceAll("{appSense}", "linked"), + ); + + const unknownLocale = createTranslator("xx"); + assert.equal(unknownLocale("meta.title"), LOCALES.fr.meta.title); + assert.equal(english("missing.translation.key"), "missing.translation.key"); +}); diff --git a/prototype/tests/presentation.test.mjs b/prototype/tests/presentation.test.mjs new file mode 100644 index 0000000..260bd4a --- /dev/null +++ b/prototype/tests/presentation.test.mjs @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { detectTheme, nextTheme, THEME_STORAGE_KEY } from "../src/hooks/use-theme.js"; + +test("detects and cycles themes with storage failures isolated", () => { + assert.equal(detectTheme({ getItem: (key) => key === THEME_STORAGE_KEY ? "dark" : null }), "dark"); + assert.equal(detectTheme({ getItem: () => "unknown" }), "auto"); + assert.equal(detectTheme({ getItem: () => { throw new Error("blocked"); } }), "auto"); + assert.equal(nextTheme("auto"), "light"); + assert.equal(nextTheme("light"), "dark"); + assert.equal(nextTheme("dark"), "auto"); + assert.equal(nextTheme("unknown"), "auto"); +}); diff --git a/prototype/tests/profile-session.test.mjs b/prototype/tests/profile-session.test.mjs new file mode 100644 index 0000000..f2ca5a7 --- /dev/null +++ b/prototype/tests/profile-session.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DEFAULT_MAPPING } from "../../shared/input-profile.mjs"; +import { sourceProfile } from "../../tests/helpers/input-profile-fixture.mjs"; +import { + createProfileSession, + profileSessionReducer, +} from "../src/profile-session.js"; +import { + createProfileReview, + describeProfileError, + prepareImportedProfile, + sha256Hex, +} from "../src/profile-workflow.js"; + +const t = (key) => `translated:${key}`; + +test("keeps profile workflow transitions atomic and invalidates stale reviews", () => { + const initial = createProfileSession(); + const source = sourceProfile(); + const loaded = profileSessionReducer(initial, { + type: "profile-loaded", + payload: { + source, + fileName: "profile.json", + info: { layerName: "Claude" }, + layerCreated: null, + mappingConflict: { mapping: DEFAULT_MAPPING }, + }, + }); + assert.equal(loaded.source, source); + assert.equal(loaded.fileName, "profile.json"); + + const reviewed = profileSessionReducer(loaded, { + type: "review-ready", + review: { json: "{}" }, + }); + const changed = profileSessionReducer(reviewed, { + type: "appsense-changed", + field: "claude", + value: "7", + }); + assert.equal(changed.appSenseIds.claude, "7"); + assert.equal(changed.review, null); + assert.equal(profileSessionReducer(changed, { type: "review-invalidated" }), changed); + assert.throws( + () => profileSessionReducer(changed, { + type: "appsense-changed", + field: "unknown", + value: "1", + }), + /Unknown AppSense field/, + ); + + const resolved = profileSessionReducer(changed, { type: "conflict-resolved" }); + assert.equal(resolved.mappingConflict, null); + const failed = profileSessionReducer(resolved, { + type: "review-failed", + error: { message: "failed" }, + }); + assert.equal(failed.error.message, "failed"); + assert.equal( + profileSessionReducer(failed, { type: "profile-failed", error: { message: "bad file" } }) + .error.message, + "bad file", + ); + assert.throws(() => profileSessionReducer(initial, { type: "unknown" }), /Unknown profile/); +}); + +test("prepares existing and newly synthesized Claude layers", () => { + const existing = prepareImportedProfile(sourceProfile()); + assert.equal(existing.info.layerName, "Claude"); + assert.equal(existing.layerCreated, null); + assert.ok(existing.derived.assigned > 0); + + const missing = sourceProfile(); + missing.profile.layers[1].name = "Template"; + const synthesized = prepareImportedProfile(missing); + assert.equal(synthesized.info.layerName, "Claude"); + assert.equal(synthesized.layerCreated.templateName, "Template"); + assert.equal(synthesized.source.profile.layers.length, 3); +}); + +test("hashes and builds a review with injectable browser crypto", async () => { + const crypto = { + subtle: { + async digest(algorithm, bytes) { + assert.equal(algorithm, "SHA-256"); + assert.ok(bytes.length > 0); + return Uint8Array.from([0, 15, 255]).buffer; + }, + }, + }; + assert.equal(await sha256Hex("profile", crypto), "000fff"); + assert.equal(await sha256Hex("profile", null), ""); + assert.equal( + await sha256Hex("profile", { subtle: { digest: async () => { throw new Error("blocked"); } } }), + "", + ); + + const review = await createProfileReview( + sourceProfile(), + DEFAULT_MAPPING, + { claude: "", base: "" }, + crypto, + ); + assert.match(review.json, /"Claude macOS"/); + assert.equal(review.sha, "000fff"); + assert.equal(review.report.nativeLayerPreserved, true); +}); + +test("normalizes syntax, coded, generic, and unknown profile errors", () => { + assert.deepEqual(describeProfileError(new SyntaxError("bad"), t), { + message: "translated:errors.invalidJson", + code: null, + }); + const coded = new Error("wrong"); + coded.code = "WRONG_DEVICE"; + assert.deepEqual(describeProfileError(coded, t), { + message: "translated:errors.WRONG_DEVICE", + code: "WRONG_DEVICE", + }); + assert.deepEqual(describeProfileError(new Error("plain"), t), { + message: "plain", + code: null, + }); + assert.deepEqual(describeProfileError(null, t), { + message: "translated:errors.invalidFile", + code: null, + }); +}); diff --git a/scripts/build-input-profile.mjs b/scripts/build-input-profile.mjs index 72f8635..1639a3a 100644 --- a/scripts/build-input-profile.mjs +++ b/scripts/build-input-profile.mjs @@ -15,7 +15,7 @@ for (const argument of process.argv.slice(2)) { } const value = Number(match[2]); if (!Number.isInteger(value) || value < 0) { - console.error(`Valeur invalide pour --${match[1]} : ${match[2]}`); + console.error(`Invalid value for --${match[1]}: ${match[2]}`); process.exit(1); } options[match[1] === "app-sense-id" ? "appSenseId" : "baseLayerAppSenseId"] = value; @@ -52,13 +52,13 @@ await writeFile(outputPath, `${JSON.stringify(profile, null, 2)}\n`, { flag: "wx", }); -console.log(`OK: profil Input créé dans ${outputPath}`); +console.log(`OK: Input profile written to ${outputPath}`); console.log( - `Layer ${report.layerName}; ${report.preservedLayers} layer(s) préservé(s); AppSense préservé`, + `Layer ${report.layerName}; ${report.preservedLayers} layer(s) preserved; AppSense preserved`, ); console.log( - `Lien AppSense du layer Claude : ${report.appSenseId ?? "aucun"}` + - `${report.appSenseForced ? " (forcé)" : ""}`, + `Claude layer AppSense link: ${report.appSenseId ?? "none"}` + + `${report.appSenseForced ? " (forced)" : ""}`, ); if (report.baseLayerAppSenseId !== null) { console.log( diff --git a/scripts/configure.mjs b/scripts/configure.mjs index 484fcf0..40ad753 100644 --- a/scripts/configure.mjs +++ b/scripts/configure.mjs @@ -6,42 +6,42 @@ import { ensureGuiDependencies } from "./lib/gui-dependencies.mjs"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const guiDirectory = resolve(repositoryRoot, "prototype"); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const args = process.argv.slice(2); -try { - ensureGuiDependencies(guiDirectory); -} catch (error) { - console.error(error.message); - process.exit(error.exitCode ?? 1); -} +if (args.includes("--help") || args.includes("-h")) { + console.log(`Usage: npm run configure -- [vite options] -console.log("Ouverture du configurateur Codex Micro…"); +Prepares the locked GUI dependencies, starts the local configurator, and opens it +in the default browser. Extra options are passed to Vite.`); +} else { + try { + ensureGuiDependencies(guiDirectory); + } catch (error) { + console.error(error.message); + process.exit(error.exitCode ?? 1); + } -const gui = spawn( - npmCommand, - [ - "run", - "dev", - "--", - "--host", - "127.0.0.1", - "--open", - ...process.argv.slice(2), - ], - { - cwd: guiDirectory, - stdio: "inherit", - }, -); + console.log("Ouverture du configurateur Codex Micro…"); -gui.on("error", (error) => { - console.error(`Impossible de lancer l’interface : ${error.message}`); - process.exit(1); -}); + const gui = spawn( + npmCommand, + ["run", "dev", "--", "--host", "127.0.0.1", "--open", ...args], + { + cwd: guiDirectory, + stdio: "inherit", + }, + ); -gui.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 0); -}); + gui.on("error", (error) => { + console.error(`Could not start the interface: ${error.message}`); + process.exit(1); + }); + + gui.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); +} diff --git a/scripts/enable-agent-keys.mjs b/scripts/enable-agent-keys.mjs index 844d356..d97f5a6 100644 --- a/scripts/enable-agent-keys.mjs +++ b/scripts/enable-agent-keys.mjs @@ -1,34 +1,34 @@ #!/usr/bin/env node -// Assigne les keycodes Agent natifs aux six positions vides du layer `Claude`, -// ce qui **débloque l'éclairage par thread sur ce layer**. +// Assigns the native Agent keycodes to the six empty positions of the `Claude` +// layer, which **unlocks per-thread lighting on that layer**. // -// Le fait, confirmé sur matériel : l'éclairage par thread (`v.oai.thstatus`) ne -// rend que sur les touches dont le keycode est `KV_OAI_AG00` à `KV_OAI_AG05`. Le -// prédicat du firmware est le keycode, pas l'index du layer : c'est le keycode qui -// dit au firmware quelle touche physique est l'emplacement N. Sur un layer où ces -// positions valent `KC_NONE`, il n'y a aucun emplacement à peindre, et les -// écritures sont acquittées sans effet visible. +// The fact, confirmed on hardware: per-thread lighting (`v.oai.thstatus`) only +// renders on keys whose keycode is `KV_OAI_AG00` through `KV_OAI_AG05`. The +// firmware's predicate is the keycode, not the layer index: the keycode is what +// tells the firmware which physical key is slot N. On a layer where those +// positions are `KC_NONE`, there is no slot to paint, and the writes are +// acknowledged with no visible effect. // -// L'indice se lisait dans le bundle d'Input, où le layer natif définit exactement -// `base[0] = [AG00, AG01]` et `base[1] = [AG02..AG05]`, soit deux puis quatre -// touches — la géométrie confirmée à l'œil sur ce matériel. Ces keycodes -// n'apparaissent qu'une fois chacun dans l'`app.asar` : Input ne les propose pas -// dans son sélecteur, d'où ce script. +// The clue was in Input's bundle, where the native layer defines exactly +// `base[0] = [AG00, AG01]` and `base[1] = [AG02..AG05]`, so two keys then four — +// the geometry confirmed by eye on this hardware. These keycodes appear only +// once each in the `app.asar`: Input does not offer them in its picker, hence +// this script. // -// Aucun raccourci Claude n'est perdu : les six positions sont `no-action` dans le -// preset Claude, et les raccourcis vivent sur la rangée suivante. +// No Claude shortcut is lost: the six positions are `no-action` in the Claude +// preset, and the shortcuts live on the next row. // -// Le coût est ailleurs, et il faut le savoir avant d'importer : ces keycodes font -// émettre au firmware une notification `v.oai.hid`, à laquelle **l'app ChatGPT -// réagit en changeant de thread Codex**. Elle contend donc les deux moitiés de la -// fonction — elle réécrit les LED toutes les 35 à 40 s et elle intercepte les -// appuis. Quitter l'app ChatGPT résout les deux d'un coup, et c'est la condition -// d'usage réelle. +// The cost is elsewhere, and it is worth knowing before importing: these +// keycodes make the firmware emit a `v.oai.hid` notification, which **the +// ChatGPT app reacts to by switching Codex thread**. It therefore contends for +// both halves of the feature — it rewrites the LEDs every 35 to 40s and it +// intercepts the presses. Quitting the ChatGPT app solves both at once, and +// that is the real condition of use. // -// Ce script ne touche jamais le layer d'index 0, n'écrit pas dans le stockage -// d'Input et n'écrit pas sur le périphérique. Il produit un fichier à importer -// par le flux officiel **Import Profile**, et `--revert` défait l'opération. +// This script never touches the layer at index 0, does not write into Input's +// storage and does not write to the device. It produces a file to import through +// the official **Import Profile** flow, and `--revert` undoes the operation. import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; @@ -36,9 +36,9 @@ import os from "node:os"; import path from "node:path"; import { redactHome } from "./lib/input-export.mjs"; -// Relevés dans /Applications/Input.app/Contents/Resources/app.asar, définition -// câblée du layer Codex natif. Ils n'apparaissent qu'une seule fois dans le -// bundle : Input ne les propose pas dans son sélecteur de touches, d'où ce script. +// Read from /Applications/Input.app/Contents/Resources/app.asar, in the +// hard-wired definition of the native Codex layer. They appear only once in the +// bundle: Input does not offer them in its key picker, hence this script. const AGENT_KEYCODES = Object.freeze([ ["KV_OAI_AG00", "KV_OAI_AG01"], ["KV_OAI_AG02", "KV_OAI_AG03", "KV_OAI_AG04", "KV_OAI_AG05"], @@ -47,24 +47,22 @@ const AGENT_KEYCODES = Object.freeze([ const CLAUDE_LAYER_NAME = "Claude"; function usage() { - return `Usage: node scripts/enable-agent-keys.mjs [sortie.json] [--revert] + return `Usage: node scripts/enable-agent-keys.mjs [output.json] [--revert] -Assigne les keycodes Agent natifs aux six positions vides du layer Claude, pour -éprouver le rendu de l'éclairage par thread hors du layer Codex. +Assigns the native Agent keycodes to the six empty positions of the Claude +layer, so per-thread lighting can be exercised outside the Codex layer. - --revert remet KC_NONE sur les six positions + --revert put KC_NONE back on the six positions -Sans destination, le fichier est écrit dans ~/Downloads, là où Input ouvre sa -boîte de dialogue Import Profile, en conservant le suffixe « -profile.json » -qu'Input attend. +With no destination, the file is written to ~/Downloads, where Input opens its +Import Profile dialog, keeping the "-profile.json" suffix Input expects. -Le layer d'index 0 n'est jamais modifié. +The layer at index 0 is never modified. `; } -// Input ouvre Import Profile sur ~/Downloads et reconnaît le suffixe -// « -profile.json » de ses propres exports : la destination par défaut respecte -// les deux. +// Input opens Import Profile on ~/Downloads and recognises the "-profile.json" +// suffix of its own exports: the default destination honours both. function defaultDestination(source, { revert }) { const suffix = revert ? "Revert" : "AgentKeys"; const base = path.basename(source).replace(/(-profile)?\.json$/i, ""); @@ -78,22 +76,22 @@ function assert(condition, message) { } } -// Les six cellules visées, et rien d'autre : deux rangées, aux longueurs -// attendues. Une disposition inattendue est refusée plutôt qu'interprétée. +// The six targeted cells, and nothing else: two rows, at the expected lengths. +// An unexpected layout is refused rather than interpreted. function patchAgentRows(layer, { revert }) { const changes = []; AGENT_KEYCODES.forEach((row, rowIndex) => { const cells = layer.layout?.base?.[rowIndex]; assert( Array.isArray(cells) && cells.length === row.length, - `Disposition inattendue pour la rangée ${rowIndex} : ${row.length} cellules attendues.`, + `Unexpected layout for row ${rowIndex}: ${row.length} cells expected.`, ); row.forEach((keycode, columnIndex) => { const target = revert ? "KC_NONE" : keycode; const cell = cells[columnIndex]; assert( cell && typeof cell === "object", - `Cellule ${rowIndex}/${columnIndex} illisible dans le layer Claude.`, + `Cell ${rowIndex}/${columnIndex} is unreadable in the Claude layer.`, ); if (cell.keycode !== target) { changes.push(` base[${rowIndex}][${columnIndex}] : ${cell.keycode} → ${target}`); @@ -115,35 +113,35 @@ async function main(argv) { const raw = JSON.parse(await fs.readFile(source, "utf8")); const layers = raw.profile?.layers; - assert(Array.isArray(layers) && layers.length > 1, "Cet export ne contient pas de liste de layers exploitable."); + assert(Array.isArray(layers) && layers.length > 1, "This export has no usable list of layers."); const matching = layers.filter((layer) => layer?.name === CLAUDE_LAYER_NAME); assert( matching.length === 1, matching.length === 0 - ? `Aucun layer « ${CLAUDE_LAYER_NAME} » dans cet export.` - : `Plusieurs layers « ${CLAUDE_LAYER_NAME} » : n'en garder qu'un avant l'expérience.`, + ? `No "${CLAUDE_LAYER_NAME}" layer in this export.` + : `Several "${CLAUDE_LAYER_NAME}" layers: keep only one before running this.`, ); const index = layers.indexOf(matching[0]); - assert(index > 0, "Le layer Claude est à l'index 0 : refus, ce layer est protégé."); + assert(index > 0, "The Claude layer sits at index 0: refused, that layer is protected."); const before = JSON.stringify(layers[0]); const changes = patchAgentRows(matching[0], { revert }); - assert(JSON.stringify(layers[0]) === before, "Le layer d'index 0 a été modifié : abandon."); + assert(JSON.stringify(layers[0]) === before, "The layer at index 0 was modified: aborting."); await fs.mkdir(path.dirname(path.resolve(destination)), { recursive: true }); const output = `${JSON.stringify(raw, null, 2)}\n`; await fs.writeFile(destination, output); process.stdout.write( - `Layer « ${CLAUDE_LAYER_NAME} » à l'index ${index}, lien AppSense ${matching[0].linkedAppId ?? "absent"} conservé.\n`, + `"${CLAUDE_LAYER_NAME}" layer at index ${index}, AppSense link ${matching[0].linkedAppId ?? "absent"} preserved.\n`, ); - process.stdout.write(changes.length ? `${changes.join("\n")}\n` : " aucune modification nécessaire\n"); + process.stdout.write(changes.length ? `${changes.join("\n")}\n` : " no change needed\n"); process.stdout.write( `\n${redactHome(path.resolve(destination))}\n` + `SHA-256 ${createHash("sha256").update(output).digest("hex")}\n\n` + - "Importer par Input > Import Profile, puis vérifier sur le layer Claude :\n" + + "Import through Input > Import Profile, then check on the Claude layer:\n" + " npm run lighting -- set all #00FF00 --effect=solid\n", ); } diff --git a/scripts/lib/gui-dependencies.mjs b/scripts/lib/gui-dependencies.mjs index 99e4857..2afb324 100644 --- a/scripts/lib/gui-dependencies.mjs +++ b/scripts/lib/gui-dependencies.mjs @@ -2,27 +2,34 @@ import { existsSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; - -export function ensureGuiDependencies(guiDirectory) { +export function ensureGuiDependencies( + guiDirectory, + { + exists = existsSync, + spawn = spawnSync, + platform = process.platform, + log = console.log, + } = {}, +) { + const npmCommand = platform === "win32" ? "npm.cmd" : "npm"; const packageJson = resolve(guiDirectory, "package.json"); const viteExecutable = resolve( guiDirectory, "node_modules", ".bin", - process.platform === "win32" ? "vite.cmd" : "vite", + platform === "win32" ? "vite.cmd" : "vite", ); - if (!existsSync(packageJson)) { - throw new Error("Interface introuvable : prototype/package.json est absent."); + if (!exists(packageJson)) { + throw new Error("Interface not found: prototype/package.json is missing."); } - if (existsSync(viteExecutable)) { + if (exists(viteExecutable)) { return { installed: false }; } - console.log("Préparation des dépendances verrouillées de l’interface…"); - const install = spawnSync( + log("Preparing the interface's locked dependencies…"); + const install = spawn( npmCommand, ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], { @@ -32,11 +39,11 @@ export function ensureGuiDependencies(guiDirectory) { ); if (install.error) { - throw new Error(`Impossible de préparer l’interface : ${install.error.message}`); + throw new Error(`Could not prepare the interface: ${install.error.message}`); } if (install.status !== 0) { - const error = new Error(`La préparation de l’interface a échoué avec le code ${install.status ?? 1}.`); + const error = new Error(`Preparing the interface failed with code ${install.status ?? 1}.`); error.exitCode = install.status ?? 1; throw error; } diff --git a/scripts/lib/hid-device.mjs b/scripts/lib/hid-device.mjs index 6efa8bf..e6ce59e 100644 --- a/scripts/lib/hid-device.mjs +++ b/scripts/lib/hid-device.mjs @@ -1,17 +1,17 @@ -// Transport HID du Codex Micro au-dessus de node-hid (dépendance optionnelle, -// chargée paresseusement : le reste du dépôt fonctionne sans elle). +// Codex Micro HID transport on top of node-hid (an optional dependency, loaded +// lazily: the rest of the repository works without it). // -// Réimplémentation originale d'après le format observé et documenté dans -// docs/research/hid-lighting-protocol.md : +// Original reimplementation from the format observed and documented in +// docs/research/hid-lighting-protocol.md: // -// - interface vendeur : VID 0x303a, usage page 0xFF00 ; -// - ouverture non exclusive sur macOS : les lectures sont diffusées à tous -// les lecteurs, les écritures se disputent (dernière écriture gagnante) ; -// - une requête en vol à la fois, 50 ms de pause entre appels, 10 s de -// garde-fou par réponse ; -// - une réponse dont l'identifiant n'est pas le nôtre est le signe qu'un -// autre écrivain (l'app ChatGPT) vient de pousser : elle sert de -// déclencheur de réapplication au mode --hold. +// - vendor interface: VID 0x303a, usage page 0xFF00; +// - non-exclusive open on macOS: reads are broadcast to every reader, writes +// compete (last write wins); +// - one request in flight at a time, 50ms of spacing between calls, a 10s +// guard per response; +// - a response whose id is not ours is the sign that another writer (the +// ChatGPT app) has just pushed: it serves as the reapply trigger in --hold +// mode. import { CHANNEL_DEBUG, @@ -39,44 +39,45 @@ export class DeviceError extends Error { } } -// Import paresseux : node-hid est une optionalDependency native. Le message -// d'erreur doit dire quoi faire, pas seulement que ça manque. +// Lazy import: node-hid is a native optionalDependency. The error message has to +// say what to do, not only that it is missing. export async function loadHid() { try { return await import("node-hid"); } catch { throw new DeviceError( "HID_UNAVAILABLE", - "node-hid est absente : lancer `npm install` (optionalDependencies) pour activer le pilotage HID.", + "node-hid is missing: run `npm install` (optionalDependencies) to enable HID control.", ); } } -// Énumère les interfaces vendeur du Codex Micro. Le clavier expose plusieurs -// collections HID ; seule la page d'usage 0xFF00 transporte le canal RPC. +// Lists the Codex Micro vendor interfaces. The keyboard exposes several HID +// collections; only usage page 0xFF00 carries the RPC channel. +export function isCodexVendorInterface(device) { + return ( + device?.vendorId === VENDOR_ID && + device?.productId === PRODUCT_ID && + device?.usagePage === VENDOR_USAGE_PAGE + ); +} + export async function listInterfaces() { const hid = await loadHid(); - return hid - .devices() - .filter( - (device) => - device.vendorId === VENDOR_ID && - device.productId === PRODUCT_ID && - device.usagePage === VENDOR_USAGE_PAGE, - ); + return hid.devices().filter(isCodexVendorInterface); } async function openHandle(path) { const hid = await loadHid(); - // Non exclusif sur macOS : coexister avec Input et l'app ChatGPT, qui - // tiennent le même périphérique. Ailleurs, ouverture standard. + // Non-exclusive on macOS: coexist with Input and the ChatGPT app, which hold + // the same device. Elsewhere, a standard open. if (process.platform === "darwin") return hid.HIDAsync.open(path, { nonExclusive: true }); return hid.HIDAsync.open(path); } -// Session RPC : file séquentielle cadencée, corrélation des réponses par -// identifiant, distribution des notifications, détection des écritures -// étrangères. Une session = une requête en vol, comme le firmware l'attend. +// RPC session: paced sequential queue, responses correlated by id, notifications +// dispatched, foreign writes detected. One session = one request in flight, the +// way the firmware expects it. export class DeviceSession { #handle; #assembler = createLineAssembler(); @@ -88,6 +89,7 @@ export class DeviceSession { #queue = []; #running = false; #closed = false; + #lastCallStartedAt = 0; constructor(handle, { onForeignWrite, onDebugLine } = {}) { this.#handle = handle; @@ -97,7 +99,7 @@ export class DeviceSession { handle.on("error", (error) => this.#failAll(new DeviceError("DEVICE_ERROR", error.message))); handle.on("close", () => { this.#closed = true; - this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Périphérique déconnecté.")); + this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Device disconnected.")); }); } @@ -106,7 +108,7 @@ export class DeviceSession { if (!target) { throw new DeviceError( "DEVICE_NOT_FOUND", - "Codex Micro introuvable sur l'interface vendeur (VID 0x303a, usage 0xFF00). Vérifier la connexion, puis `list`.", + "Codex Micro not found on the vendor interface (VID 0x303a, usage 0xFF00). Check the connection, then run `list`.", ); } return new DeviceSession(await openHandle(target), options); @@ -114,12 +116,14 @@ export class DeviceSession { onNotification(method, handler) { this.#notifyHandlers.set(method, handler); - return () => this.#notifyHandlers.delete(method); + return () => { + if (this.#notifyHandlers.get(method) === handler) this.#notifyHandlers.delete(method); + }; } - // Enfile un appel et attend sa réponse. Les tâches s'exécutent une par une - // avec CALL_SPACING_MS de pause, le firmware traitant les commandes au - // compte-goutte. + // Queues a call and waits for its response. Tasks run one at a time with + // CALL_SPACING_MS of spacing, since the firmware handles commands in a + // trickle. call(method, params = null, id = createRpcId()) { return new Promise((resolve, reject) => { this.#queue.push({ method, params, id, resolve, reject }); @@ -133,31 +137,44 @@ export class DeviceSession { try { let task; while ((task = this.#queue.shift())) { - await this.#run(task); - await new Promise((resolve) => setTimeout(resolve, CALL_SPACING_MS)); + const remainingSpacing = + CALL_SPACING_MS - (Date.now() - this.#lastCallStartedAt); + if (remainingSpacing > 0) { + await new Promise((resolve) => setTimeout(resolve, remainingSpacing)); + } + try { + task.resolve(await this.#run(task)); + } catch (error) { + task.reject(error); + } } } finally { this.#running = false; } } - async #run({ method, params, id, resolve, reject }) { - if (this.#closed) return reject(new DeviceError("DEVICE_DISCONNECTED", "Session fermée.")); + async #run({ method, params, id }) { + if (this.#closed) throw new DeviceError("DEVICE_DISCONNECTED", "Session closed."); + this.#lastCallStartedAt = Date.now(); const key = String(id); - const timer = setTimeout(() => { - this.#resolvers.delete(key); - reject(new DeviceError("TIMEOUT", `Pas de réponse à ${method} en ${CALL_TIMEOUT_MS / 1000} s.`)); - }, CALL_TIMEOUT_MS); - this.#resolvers.set(key, { resolve, reject, timer }); - try { - for (const frame of encodeFrames(buildRequest({ method, params, id }))) { - await this.#handle.write(frame); - } - } catch (error) { - clearTimeout(timer); - this.#resolvers.delete(key); - reject(new DeviceError("WRITE_FAILED", error.message)); - } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#resolvers.delete(key); + reject(new DeviceError("TIMEOUT", `No answer to ${method} within ${CALL_TIMEOUT_MS / 1000}s.`)); + }, CALL_TIMEOUT_MS); + this.#resolvers.set(key, { resolve, reject, timer }); + + const write = async () => { + for (const frame of encodeFrames(buildRequest({ method, params, id }))) { + await this.#handle.write(frame); + } + }; + void write().catch((error) => { + clearTimeout(timer); + this.#resolvers.delete(key); + reject(new DeviceError("WRITE_FAILED", error.message)); + }); + }); } #dispatch(data) { @@ -165,7 +182,7 @@ export class DeviceSession { try { lines = this.#assembler(data); } catch { - return; // Rapport malformé : ignoré, le flux suivant resynchronisera. + return; // Malformed report: ignored; the next valid report resynchronizes the stream. } for (const { channel, line } of lines) { if (channel === CHANNEL_DEBUG) { @@ -177,7 +194,7 @@ export class DeviceSession { if (!message) continue; if (message.kind === "response") this.#resolve(message); else if (message.kind === "notification") this.#notifyHandlers.get(message.method)?.(message.params); - // Les messages sans id ni méthode sont abandonnés par l'accumulateur. + // Messages with neither id nor method are dropped by the accumulator. } } @@ -193,10 +210,9 @@ export class DeviceSession { } return; } - // Réponse orpheline sur une méthode d'éclairage : un autre écrivain vient - // de pousser sa configuration. C'est le seul signal fiable de - // coexistence, et il est gratuit — les rapports d'entrée sont diffusés à - // tous les lecteurs. + // Orphan response on a lighting method: another writer has just pushed its + // configuration. This is the only reliable coexistence signal, and it is + // free — input reports are broadcast to every reader. if (message.method === METHODS.threadsLighting || message.method === METHODS.rgbConfig) { this.#onForeignWrite?.(message.method, message.parsed); } @@ -213,11 +229,11 @@ export class DeviceSession { async close() { this.#closed = true; - this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Session fermée.")); + this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Session closed.")); try { await this.#handle.close(); } catch { - // Fermeture déjà effective côté pile HID. + // Already closed on the HID stack side. } } } diff --git a/scripts/lib/hid-frame.mjs b/scripts/lib/hid-frame.mjs index aee24f9..cc44d2c 100644 --- a/scripts/lib/hid-frame.mjs +++ b/scripts/lib/hid-frame.mjs @@ -1,33 +1,33 @@ -// Cadrage HID du canal RPC du Codex Micro, réimplémenté d'après le format -// observé et documenté dans docs/research/hid-lighting-protocol.md. Code -// original, sans aucune reprise du SDK Work Louder : seuls les faits du -// format (constantes, positions d'octets, champs JSON) sont exploités. +// HID framing of the Codex Micro RPC channel, reimplemented from the format +// observed and documented in docs/research/hid-lighting-protocol.md. Original +// code, with nothing taken from the Work Louder SDK: only the facts of the +// format (constants, byte positions, JSON fields) are used. // -// Le module est volontairement pur : aucun I/O, aucune dépendance au -// périphérique. Toute la logique est testable sans matériel. +// The module is deliberately pure: no I/O, no dependency on the device. All the +// logic is testable without hardware. import { randomInt } from "node:crypto"; -// Rapport de 64 octets : [0] identifiant de rapport, [1] canal, [2] longueur -// du fragment transporté par CE rapport, [3..63] charge utile UTF-8. +// 64-byte report: [0] report id, [1] channel, [2] length of the chunk carried by +// THIS report, [3..63] UTF-8 payload. export const REPORT_SIZE = 64; export const REPORT_ID = 0x06; export const CHANNEL_DEBUG = 1; export const CHANNEL_RPC = 2; -export const CHUNK_PAYLOAD = REPORT_SIZE - 3; // 61 octets par rapport +export const CHUNK_PAYLOAD = REPORT_SIZE - 3; // 61 bytes per report -// L'identifiant d'appel est borné par le firmware, qui refuse les valeurs hors -// de [0, 999). Des identifiants courts évitent aussi de faire passer une -// requête d'un rapport à deux pour quelques octets. +// The call id is bounded by the firmware, which rejects values outside +// [0, 999). Short ids also avoid pushing a request from one report to two for +// the sake of a few bytes. export const RPC_ID_LIMIT = 999; export function createRpcId() { return randomInt(0, RPC_ID_LIMIT); } -// Le canal remplace tout caractère non ASCII par son échappement \uXXXX (ou la -// paire de substitution au-delà du BMP). Les charges d'éclairage sont en -// pratique déjà ASCII ; l'échappement est appliqué pour coller au format. +// The channel replaces every non-ASCII character with its \uXXXX escape (or the +// surrogate pair beyond the BMP). Lighting payloads are already ASCII in +// practice; the escaping is applied to match the format. export function escapeUnicode(text) { return text.replace(/[^\x00-\x7F]/gu, (char) => { const codePoint = char.codePointAt(0); @@ -40,19 +40,19 @@ export function escapeUnicode(text) { }); } -// Enveloppe JSON-RPC du canal : { method, params, id }. `params` vaut null -// quand la méthode n'en attend pas. +// The channel's JSON-RPC envelope: { method, params, id }. `params` is null when +// the method expects none. export function buildRequest({ method, params = null, id }) { - if (typeof method !== "string" || !method) throw new Error("Nom de méthode RPC attendu."); + if (typeof method !== "string" || !method) throw new Error("Expected an RPC method name."); if (!Number.isInteger(id) || id < 0 || id >= RPC_ID_LIMIT) { - throw new Error(`Identifiant RPC attendu entre 0 et ${RPC_ID_LIMIT - 1}.`); + throw new Error(`Expected an RPC id between 0 and ${RPC_ID_LIMIT - 1}.`); } return escapeUnicode(JSON.stringify({ method, params, id })); } -// Découpe un message en rapports de 64 octets. Un message plus long que -// CHUNK_PAYLOAD est fragmenté en rapports consécutifs qui répètent le même -// en-tête ; seul l'octet de longueur varie. Un message vide n'émet rien. +// Splits a message into 64-byte reports. A message longer than CHUNK_PAYLOAD is +// fragmented into consecutive reports that repeat the same header; only the +// length byte varies. An empty message emits nothing. export function encodeFrames(message) { const buffer = Buffer.from(message, "utf8"); const frames = []; @@ -70,11 +70,11 @@ export function encodeFrames(message) { return frames; } -// Extrait canal et charge utile d'un rapport entrant. Le tampon livré par la -// pile HID inclut l'identifiant de rapport en octet 0, comme à l'émission. +// Extracts channel and payload from an incoming report. The buffer delivered by +// the HID stack includes the report id in byte 0, as it does on the way out. export function decodeReport(report) { const data = Buffer.isBuffer(report) ? report : Buffer.from(report); - if (data.length < 3) throw new Error(`Rapport HID trop court : ${data.length} octet(s).`); + if (data.length < 3) throw new Error(`HID report too short: ${data.length} byte(s).`); const channel = data[1]; const length = data[2]; return { @@ -84,10 +84,10 @@ export function decodeReport(report) { }; } -// Réassemble le flux de rapports en lignes. Le périphérique termine chaque -// message par un saut de ligne ; un message long arrive fragmenté sur -// plusieurs rapports et n'est complet qu'au saut de ligne final. Chaque canal -// a son propre tampon : les journaux de débogage ne se mélangent pas au RPC. +// Reassembles the stream of reports into lines. The device terminates each +// message with a newline; a long message arrives fragmented over several +// reports and is only complete at the final newline. Each channel has its own +// buffer: debug logs do not mix into the RPC. export function createLineAssembler() { const buffers = new Map(); return function push(report) { @@ -99,13 +99,13 @@ export function createLineAssembler() { }; } -// Reconstitue les messages JSON-RPC à partir des lignes du canal RPC. Un -// document peut lui-même arriver en plusieurs lignes (JSON indenté) : on -// accumule jusqu'à ce que l'analyse réussisse. Trois formes sur le canal : +// Rebuilds JSON-RPC messages from the lines of the RPC channel. A document can +// itself arrive over several lines (indented JSON): accumulate until parsing +// succeeds. Three shapes on the channel: // -// - réponse : { "result": …, "id": n } (id aussi sous « i ») -// - notification : { "method": "v.oai.hid", "params": … } (aussi « m »/« p ») -// - invalide : ni id ni méthode — tampon abandonné +// - response: { "result": …, "id": n } (id also carried as "i") +// - notification: { "method": "v.oai.hid", "params": … } (also "m"/"p") +// - invalid: neither id nor method — buffer dropped export function createRpcAccumulator() { let pending = ""; return function push(text) { diff --git a/scripts/lib/hid-lighting.mjs b/scripts/lib/hid-lighting.mjs index f7212ba..7f7d6ee 100644 --- a/scripts/lib/hid-lighting.mjs +++ b/scripts/lib/hid-lighting.mjs @@ -1,16 +1,15 @@ -// Modèle d'éclairage du Codex Micro, réimplémenté d'après le format observé -// et documenté dans docs/research/hid-lighting-protocol.md. Code original. +// Codex Micro lighting model, reimplemented from the format observed and +// documented in docs/research/hid-lighting-protocol.md. Original code. // -// Deux méthodes JSON-RPC vendeur couvrent l'éclairage : +// Two vendor JSON-RPC methods cover the lighting: // -// v.oai.thstatus éclairage par emplacement (« thread ») : les six touches -// Agent, adressées par `id`, mises à jour partielles -// possibles — un champ omis reste inchangé sur le -// périphérique ; -// v.oai.rgbcfg deux zones globales : `ambient` (anneau extérieur) et -// `keys` (sous les keycaps). +// v.oai.thstatus per-slot ("thread") lighting: the six Agent keys, addressed +// by `id`, with partial updates possible — an omitted field +// stays unchanged on the device; +// v.oai.rgbcfg two global zones: `ambient` (outer ring) and `keys` +// (under the keycaps). // -// Le module est pur : il ne produit que des objets de paramètres, sans I/O. +// The module is pure: it only produces parameter objects, with no I/O. import { SLOT_CONTROLS, STATE_COLORS, STATES } from "./thread-slots.mjs"; @@ -21,8 +20,8 @@ export const METHODS = Object.freeze({ notifyJoystick: "v.oai.rad", }); -// Effets d'animation du firmware. `solid` est le seul utile pour un témoin -// d'état stable ; les autres sont exposés pour les usages libres. +// Firmware animation effects. `solid` is the only useful one for a steady state +// light; the others are exposed for free-form use. export const EFFECTS = Object.freeze({ off: 0, solid: 1, @@ -33,40 +32,40 @@ export const EFFECTS = Object.freeze({ shallowBreath: 6, }); -// Correspondance emplacement → identifiant de thread sur le canal. CONFIRMÉE sur -// matériel par `node scripts/lighting.mjs probe --delay=3000`, qui allume les -// touches une par une : la séquence des identifiants 0 à 5 suit exactement -// l'ordre physique de SLOT_CONTROLS — les deux touches de la rangée du haut, de -// gauche à droite, puis les quatre de la rangée suivante. +// Slot → thread id mapping on the channel. CONFIRMED on hardware by +// `node scripts/lighting.mjs probe --delay=3000`, which lights the keys one by +// one: the sequence of ids 0 to 5 follows exactly the physical order of +// SLOT_CONTROLS — the two keys of the top row, left to right, then the four of +// the next row. export const SLOT_THREAD_IDS = Object.freeze(SLOT_CONTROLS.map((_, index) => index)); -// « #D97757 » → 0xD97757. Le canal attend un entier RGB compacté. +// "#D97757" → 0xD97757. The channel expects a packed RGB integer. export function colorToInt(color) { if (typeof color === "number" && Number.isInteger(color) && color >= 0 && color <= 0xffffff) { return color; } const match = typeof color === "string" && color.match(/^#?([0-9a-fA-F]{6})$/); - if (!match) throw new Error(`Couleur attendue au format #RRGGBB : ${color}`); + if (!match) throw new Error(`Expected a colour in #RRGGBB format: ${color}`); return Number.parseInt(match[1], 16); } function clampUnit(name, value) { if (typeof value !== "number" || Number.isNaN(value) || value < 0 || value > 1) { - throw new Error(`${name} attendu entre 0 et 1 : ${value}`); + throw new Error(`${name} expected between 0 and 1: ${value}`); } return value; } -// Une entrée d'éclairage par emplacement. Seul `id` est obligatoire ; chaque -// champ optionnel omis laisse le paramètre correspondant inchangé. Les clés -// minimisées (`c`, `b`, `e`, `s`, `sk`, `sa`) sont le format du canal. +// One per-slot lighting entry. Only `id` is required; every optional field +// omitted leaves the corresponding parameter unchanged. The minified keys +// (`c`, `b`, `e`, `s`, `sk`, `sa`) are the channel's own format. export function threadEntry({ id, color, brightness, effect, speed, syncKeysLighting, syncAmbientLighting }) { - if (!Number.isInteger(id) || id < 0) throw new Error(`Identifiant de thread attendu entier ≥ 0 : ${id}`); + if (!Number.isInteger(id) || id < 0) throw new Error(`Expected an integer thread id ≥ 0: ${id}`); const entry = { id }; if (color !== undefined && color !== null) entry.c = colorToInt(color); if (brightness !== undefined) entry.b = clampUnit("brightness", brightness); if (effect !== undefined) { - if (!Object.values(EFFECTS).includes(effect)) throw new Error(`Effet inconnu : ${effect}`); + if (!Object.values(EFFECTS).includes(effect)) throw new Error(`Unknown effect: ${effect}`); entry.e = effect; } if (speed !== undefined) entry.s = clampUnit("speed", speed); @@ -75,13 +74,13 @@ export function threadEntry({ id, color, brightness, effect, speed, syncKeysLigh return entry; } -// Paramètres de v.oai.thstatus : un tableau d'entrées, une par emplacement. +// v.oai.thstatus parameters: an array of entries, one per slot. export function threadsLightingParams(entries) { return entries.map(threadEntry); } -// Une zone de v.oai.rgbcfg. Les cinq champs sont obligatoires : la méthode -// décrit une configuration complète de zone, pas une mise à jour partielle. +// One v.oai.rgbcfg zone. All five fields are required: the method describes a +// complete zone configuration, not a partial update. export function zoneSide({ effect, brightness, speed, magic, color }) { return { e: effect, @@ -96,12 +95,12 @@ export function rgbConfigParams({ ambient, keys }) { return { ambient: zoneSide(ambient), keys: zoneSide(keys) }; } -// Traduction des six emplacements de thread-status en entrées thstatus. -// Un emplacement libre est éteint (brightness 0) ; les autres portent la -// couleur d'état de la palette du dépôt en effet fixe. +// Translates the six thread-status slots into thstatus entries. A free slot is +// unlit (brightness 0); the others carry their state colour from the repository +// palette, as a solid effect. export function slotsToThreadEntries(rows, { brightness = 1 } = {}) { if (!Array.isArray(rows) || rows.length !== SLOT_CONTROLS.length) { - throw new Error(`${SLOT_CONTROLS.length} emplacements attendus.`); + throw new Error(`Expected ${SLOT_CONTROLS.length} slots.`); } return rows.map((row, index) => { const state = row?.state ?? STATES.free; @@ -111,7 +110,7 @@ export function slotsToThreadEntries(rows, { brightness = 1 } = {}) { }); } -// Éteint les six emplacements sans toucher aux autres paramètres. +// Turns the six slots off without touching the other parameters. export function allOffParams() { return SLOT_THREAD_IDS.map((id) => threadEntry({ id, brightness: 0 })); } diff --git a/scripts/lib/thread-slots.mjs b/scripts/lib/thread-slots.mjs index 80769fb..f2623cd 100644 --- a/scripts/lib/thread-slots.mjs +++ b/scripts/lib/thread-slots.mjs @@ -1,46 +1,45 @@ -// Réduction des sessions Claude Code vivantes à six emplacements physiques, un -// par touche Agent du Codex Micro. +// Reduces the live Claude Code sessions to six physical slots, one per Agent key +// on the Codex Micro. // -// Le module est volontairement pur : il ne lance aucun processus, n'écrit aucun -// fichier et ne lit aucun transcript. Toute la logique d'attribution et de -// transition est donc testable sans Claude en cours d'exécution. +// The module is deliberately pure: it spawns no process, writes no file and +// reads no transcript. All the assignment and transition logic is therefore +// testable without a running Claude. // -// Deux sources alimentent le réducteur, et elles ne sont pas interchangeables : +// Two sources feed the reducer, and they are not interchangeable: // -// - le roster (`claude agents --json`) est l'autorité sur l'appartenance : -// lui seul décide quelle session occupe un emplacement ; -// - les hooks sont l'autorité sur l'état : eux seuls savent si un tour est en -// cours, terminé ou bloqué sur une décision. +// - the roster (`claude agents --json`) is the authority on membership: it +// alone decides which session occupies a slot; +// - the hooks are the authority on state: they alone know whether a turn is +// running, finished, or blocked on a decision. // -// Cette séparation n'est pas esthétique. Un hook manqué (crash, `kill -9`) fige -// l'état à `running` pour toujours ; le roster le rattrape. Inversement le -// roster ne publie aucun état ; les hooks le fournissent instantanément. +// This separation is not cosmetic. A missed hook (crash, `kill -9`) would pin +// the state to `running` forever; the roster catches that. Conversely the roster +// publishes no state at all; the hooks deliver it instantly. export const SLOT_COUNT = 6; -// Les six touches Agent, dans l'ordre de lecture physique : rangée du haut -// (deux touches) puis rangée suivante (quatre touches). Les identifiants -// proviennent de KEY_CONTROL_LOCATIONS dans shared/input-profile.mjs. +// The six Agent keys, in physical reading order: top row (two keys) then the +// next row (four keys). The ids come from KEY_CONTROL_LOCATIONS in +// shared/input-profile.mjs. export const SLOT_CONTROLS = Object.freeze(["key-9", "key-10", "key-5", "key-6", "key-7", "key-8"]); -// Réexportées depuis shared/, où elles sont la source unique de vérité : le GUI -// affiche la même légende, et une palette dupliquée finirait par diverger. +// Re-exported from shared/, where they are the single source of truth: the GUI +// shows the same legend, and a duplicated palette would end up diverging. export { LEGEND_ORDER, STATE_COLORS, STATES } from "../../shared/thread-status-palette.mjs"; import { STATE_COLORS, STATES } from "../../shared/thread-status-palette.mjs"; -// Les types de notification qui exigent une action humaine. Tout autre type -// (`auth_success`, `elicitation_complete`…) ne change pas l'état : un témoin -// rouge doit signifier « on t'attend », rien d'autre. +// The notification types that require a human. Any other type +// (`auth_success`, `elicitation_complete`…) leaves the state alone: a red light +// has to mean "you are being waited for", and nothing else. const BLOCKING_NOTIFICATIONS = new Set([ "permission_prompt", "agent_needs_input", "elicitation_dialog", ]); -// Les événements de hook peuvent précéder l'apparition de la session dans le -// roster, et certaines sessions n'y apparaissent jamais (`claude -p`, sous-agents). -// On garde leur dernier état en attente, borné, plutôt que de leur ouvrir un -// emplacement. +// Hook events can arrive before the session shows up in the roster, and some +// sessions never show up at all (`claude -p`, subagents). Their last state is +// held in a bounded pending map rather than given a slot. const MAX_PENDING = 32; export function stateFromHookEvent(event) { @@ -72,7 +71,7 @@ export function emptySnapshot() { }; } -// Une copie défensive suffit : les entrées sont des objets plats. +// A defensive shallow copy is enough: the entries are flat objects. function cloneSnapshot(snapshot) { return { version: 1, @@ -102,9 +101,9 @@ function slotOf(snapshot, sessionId) { return snapshot.slots.findIndex((entry) => entry?.sessionId === sessionId); } -// Ordre d'éviction : un emplacement libre, puis la session terminée la plus -// ancienne, puis la session au repos la plus ancienne. Une session vivante qui -// travaille ou qui attend une décision n'est jamais évincée. +// Eviction order: a free slot, then the oldest closed session, then the oldest +// finished one. A live session that is working, or waiting on a decision, is +// never evicted. const EVICTABLE = [STATES.ended, STATES.done]; function claimSlot(snapshot) { @@ -135,9 +134,8 @@ function rememberPending(snapshot, sessionId, state) { } /** - * Applique un événement de hook. Une session absente du roster ne reçoit pas - * d'emplacement : son état est mis en attente et sera promu si le roster la - * confirme. + * Applies a hook event. A session missing from the roster gets no slot: its + * state is held pending, and gets promoted if the roster later confirms it. */ export function applyHookEvent(snapshot, event, now = 0) { const next = cloneSnapshot(snapshot); @@ -152,9 +150,9 @@ export function applyHookEvent(snapshot, event, now = 0) { } const entry = next.slots[index]; - // Un `Stop` qui suit un `blocked` est légitime : la décision a été prise et le - // tour s'est terminé. Aucune transition n'est donc interdite ici, on garde - // seulement la trace du moment du changement. + // A `Stop` following a `blocked` is legitimate: the decision was made and the + // turn ended. No transition is forbidden here, then — only the moment of the + // change is recorded. const changed = entry.state !== state; entry.state = state; entry.updatedAt = now; @@ -165,11 +163,11 @@ export function applyHookEvent(snapshot, event, now = 0) { } /** - * Réconcilie le roster officiel. Crée les entrées manquantes, rafraîchit les - * métadonnées, et marque `ended` toute session dont le processus a disparu. + * Reconciles the official roster. Creates the missing entries, refreshes the + * metadata, and marks `ended` any session whose process has disappeared. * - * @param roster tableau de `claude agents --json`, éventuellement enrichi d'un - * champ `tty` et `terminalApp` par l'appelant. + * @param roster array from `claude agents --json`, optionally enriched with + * `tty` and `terminalApp` fields by the caller. */ export function applyRoster(snapshot, roster, now = 0) { const next = cloneSnapshot(snapshot); @@ -184,7 +182,7 @@ export function applyRoster(snapshot, roster, now = 0) { index = claimSlot(next); if (index === -1) { next.overflow += 1; - notes.push(`Aucun emplacement libre pour la session ${row.sessionId}.`); + notes.push(`No free slot for session ${row.sessionId}.`); continue; } const promoted = next.pending[row.sessionId]; @@ -204,8 +202,8 @@ export function applyRoster(snapshot, roster, now = 0) { changed = true; } } - // Le processus est revenu dans le roster alors qu'on l'avait déclaré mort : - // le cas n'est pas censé arriver, mais le roster reste l'autorité. + // The process came back into the roster after we had declared it dead: this + // is not supposed to happen, but the roster remains the authority. if (entry.state === STATES.ended) { entry.state = STATES.idle; entry.updatedAt = now; @@ -234,14 +232,14 @@ export function slotView(snapshot) { } /** - * Normalise le terminal rapporté par `ps -o tty=` en chemin de périphérique. + * Normalises the terminal reported by `ps -o tty=` into a device path. * - * macOS renvoie déjà la forme préfixée (`ttys001`), là où d'autres BSD renvoient - * la forme courte (`s001`). Préfixer `/dev/tty` sans distinguer les deux - * fabriquait `/dev/ttyttys001` : un chemin inexistant, que l'AppleScript de - * focus comparait au `tty` réel de chaque fenêtre sans jamais pouvoir - * correspondre. Les seules sessions pourtant navigables échouaient donc toutes - * en « fenêtre introuvable ». + * macOS already returns the prefixed form (`ttys001`), where other BSDs return + * the short form (`s001`). Prefixing `/dev/tty` without telling the two apart + * produced `/dev/ttyttys001`: a path that does not exist, which the focus + * AppleScript compared against each window's real `tty` and could never match. + * The only navigable sessions were therefore all failing with "window not + * found". */ export function ttyDevice(tty) { if (!tty || tty === "??" || tty === "-") return null; @@ -250,20 +248,20 @@ export function ttyDevice(tty) { } /** - * Le `session` de `claude://resume` est validé par une regex UUID stricte avant - * d'être repris. Un identifiant d'une autre forme est refusé par l'application : - * on ne fabrique donc l'URL que pour ce que le handler acceptera. + * The `session` of `claude://resume` is validated against a strict UUID regex + * before being resumed. An id of any other shape is rejected by the + * application, so the URL is only built for what the handler will accept. */ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** - * Traduit un emplacement en action de navigation. Ne décide rien d'irréversible - * et n'invente aucune route : une session que l'on ne sait pas atteindre renvoie - * `unsupported` plutôt qu'une URL supposée. + * Turns a slot into a navigation action. Decides nothing irreversible and + * invents no route: a session we do not know how to reach returns `unsupported` + * rather than a guessed URL. * - * Une session sans `tty` est hébergée par Claude Desktop. `claude://resume` la - * désigne par son `sessionId` — celui du roster, pas le `hostSessionId`, qui - * regroupe plusieurs sessions et n'en adresse aucune. + * A session with no `tty` is hosted by Claude Desktop. `claude://resume` + * addresses it by its `sessionId` — the roster's, not the `hostSessionId`, + * which groups several sessions and addresses none of them. */ export function resolveNavigation(entry) { if (!entry) return { kind: "empty" }; @@ -287,6 +285,6 @@ export function resolveNavigation(entry) { sessionId: entry.sessionId, entrypoint: entry.entrypoint ?? null, hostSessionId: entry.hostSessionId ?? null, - reason: "Session sans terminal et sans identifiant UUID : `claude://resume` refuserait cette cible.", + reason: "Session with no terminal and no UUID id: `claude://resume` would reject this target.", }; } diff --git a/scripts/lighting-probe.mjs b/scripts/lighting-probe.mjs index bc483f0..45f4f62 100644 --- a/scripts/lighting-probe.mjs +++ b/scripts/lighting-probe.mjs @@ -1,48 +1,48 @@ #!/usr/bin/env node -// SPIKE — sonde de cadrage HID, côté Node. +// SPIKE — HID framing probe, Node side. // -// PÉRIMÉ POUR L'ÉCRITURE. Mesuré depuis : `node-hid` ne peut pas ouvrir ce -// périphérique sur macOS. Il embarque hidapi 0.15.0, qui ouvre en mode *seize* -// et n'expose pas `hid_darwin_set_open_exclusive` ; macOS refuse la saisie même -// avec « Surveillance des saisies » accordée. `--send` échouera donc toujours. -// Le cadre a été confirmé autrement, par scripts/lib/hid-probe.swift, en -// ouverture non exclusive via IOKit. +// OBSOLETE FOR WRITING. Measured since: `node-hid` cannot open this device on +// macOS. It ships hidapi 0.15.0, which opens in *seize* mode and does not expose +// `hid_darwin_set_open_exclusive`; macOS refuses the grab even with "Input +// Monitoring" granted. `--send` will therefore always fail. The frame was +// confirmed another way, by scripts/lib/hid-probe.swift, opening +// non-exclusively through IOKit. // -// Ce fichier reste utile pour une seule chose : l'énumération en lecture seule, -// qui montre les quatre collections là où IOKit n'expose qu'un périphérique. +// This file stays useful for one thing only: the read-only enumeration, which +// shows the four collections where IOKit exposes a single device. // -// Ce fichier existe pour lever une seule incertitude, pas pour devenir le -// DeviceAdapter : il est destiné à être jeté une fois le vrai transport écrit. +// It exists to settle one uncertainty, not to become the DeviceAdapter: it is +// meant to be thrown away once the real transport is written. // -// L'incertitude : le cadre rapporté — rapports de 64 octets, octet 0 = report ID -// `0x06`, octet 1 = canal `2`, octet 2 = longueur, charge utile UTF-8 ensuite — -// n'a jamais été vérifié à l'exécution. Tant qu'il ne l'est pas, écrire les -// bibliothèques d'encodage et leurs tests reviendrait à fabriquer de la -// confiance : les tests passeraient au vert en encodant du néant. +// The uncertainty: the reported frame — 64-byte reports, byte 0 = report ID +// `0x06`, byte 1 = channel `2`, byte 2 = length, UTF-8 payload after that — had +// never been verified at runtime. Until it is, writing the encoding libraries +// and their tests would be manufacturing confidence: the tests would go green +// while encoding nothing. // -// Deux précautions dictent la forme de la sonde : +// Two precautions dictate the shape of the probe: // -// 1. Elle ne fait rien par défaut. Sans `--send`, elle énumère et affiche la -// trame qu'elle enverrait. Écrire sur un périphérique est un effet de bord, -// il demande un geste explicite. -// 2. Sa charge utile est un no-op. Le SDK observé documente « Only the thread -// id is required on each entry » et « omit optional fields to leave those -// parameters unchanged on the device » : une entrée réduite à `{"id":0}` -// prouve donc le cadre sans changer une seule couleur. +// 1. It does nothing by default. Without `--send`, it enumerates and prints +// the frame it would send. Writing to a device is a side effect; it takes +// an explicit gesture. +// 2. Its payload is a no-op. The observed SDK documents "Only the thread id is +// required on each entry" and "omit optional fields to leave those +// parameters unchanged on the device": an entry reduced to `{"id":0}` +// therefore proves the frame without changing a single colour. // -// Elle n'écrit aucun fichier, ne touche ni au stockage Input, ni au firmware, -// ni à l'app ChatGPT. Voir docs/research/thread-status-feasibility.md. +// It writes no file, and touches neither Input's storage, nor the firmware, nor +// the ChatGPT app. See docs/research/thread-status-feasibility.md. const VENDOR_ID = 0x303a; const PRODUCT_ID = 0x8360; -// Le cadre à éprouver, surchargeable pour l'itération bornée de l'étape 2. +// The frame under test, overridable for the bounded iteration of step 2. const DEFAULT_FRAME = { reportId: 0x06, channel: 0x02, size: 64, headerLength: 3 }; -// Variantes plausibles si le cadre par défaut échoue. Bornées volontairement : -// une sonde qui balaie l'espace complet des octets écrit n'importe quoi sur un -// périphérique qui est aussi un clavier. +// Plausible variants should the default frame fail. Deliberately bounded: a +// probe sweeping the full byte space writes anything at all to a device that is +// also a keyboard. const FRAME_VARIANTS = [ { reportId: 0x06, channel: 0x02, size: 64, headerLength: 3 }, { reportId: 0x06, channel: 0x02, size: 65, headerLength: 3 }, @@ -90,11 +90,11 @@ function encode(payload, frame) { const bytes = Buffer.from(payload, "utf8"); const capacity = frame.size - frame.headerLength; if (bytes.length > capacity) { - // La continuation multi-rapports est hors périmètre de la sonde : toutes ses - // charges utiles tiennent dans un rapport, par construction. + // Multi-report continuation is out of scope for the probe: all of its + // payloads fit in one report, by construction. throw new Error( - `Charge utile de ${bytes.length} octets pour une capacité de ${capacity}. ` + - "La sonde n'implémente pas la continuation.", + `Payload of ${bytes.length} bytes for a capacity of ${capacity}. ` + + "The probe does not implement continuation.", ); } const report = Buffer.alloc(frame.size, 0); @@ -105,13 +105,13 @@ function encode(payload, frame) { return report; } -// La lecture est volontairement tolérante : savoir *quelle* interprétation -// fonctionne est précisément ce que la sonde doit rapporter. +// Reading is deliberately tolerant: knowing *which* interpretation works is +// precisely what the probe has to report. function decode(buffer) { const bytes = Buffer.from(buffer); const attempts = [ - { label: "en-tête à 3 octets, longueur en [2]", start: 3, length: bytes[2] }, - { label: "report ID retiré par hidapi, longueur en [1]", start: 2, length: bytes[1] }, + { label: "3-byte header, length in [2]", start: 3, length: bytes[2] }, + { label: "report ID stripped by hidapi, length in [1]", start: 2, length: bytes[1] }, ]; for (const attempt of attempts) { if (!Number.isInteger(attempt.length) || attempt.length <= 0) continue; @@ -119,7 +119,7 @@ function decode(buffer) { try { return { json: JSON.parse(slice), interpretation: attempt.label, raw: slice }; } catch { - // Interprétation suivante. + // Next interpretation. } } const text = bytes.toString("utf8"); @@ -129,7 +129,7 @@ function decode(buffer) { try { return { json: JSON.parse(text.slice(first, last + 1)), - interpretation: "JSON retrouvé par balayage, cadre non conforme", + interpretation: "JSON recovered by scanning, frame does not conform", raw: text.slice(first, last + 1), }; } catch { @@ -146,9 +146,9 @@ async function loadHid() { return (await import("node-hid")).default ?? (await import("node-hid")); } catch (error) { throw new Error( - "node-hid est absent. Il est en optionalDependencies :\n" + + "node-hid is missing. It sits in optionalDependencies:\n" + " npm install node-hid\n" + - `Cause : ${error.message}`, + `Cause: ${error.message}`, ); } } @@ -162,9 +162,9 @@ function describe(entry) { ].join(" "); } -// La collection vendor est préférée si elle existe : sur macOS, l'accès à une -// collection d'usage clavier est soumis à l'autorisation « Surveillance des -// saisies », pas celui d'une collection vendor. +// The vendor collection is preferred when it exists: on macOS, access to a +// keyboard-usage collection is gated by the "Input Monitoring" permission, where +// access to a vendor collection is not. function chooseInterface(entries) { const vendor = entries.find((entry) => (entry.usagePage ?? 0) >= 0xff00); return { entry: vendor ?? entries[0], isVendor: Boolean(vendor) }; @@ -177,18 +177,18 @@ function openDevice(HID, entry) { const message = String(error?.message ?? error); if (/permission|not permitted|cannot open|privile/i.test(message)) { throw new Error( - "Ouverture refusée par macOS.\n" + - " Mesuré : le refus vise aussi la collection vendor. Le verrou porte donc\n" + - " sur le périphérique — qui expose un usage clavier — et non sur la\n" + - " collection visée. L'autorisation « Surveillance des saisies » est requise.\n" + + "macOS refused to open the device.\n" + + " Measured: the refusal covers the vendor collection too. The lock is on\n" + + " the device — which exposes a keyboard usage — not on the targeted\n" + + " collection. The \"Input Monitoring\" permission is required.\n" + "\n" + - " Attention au processus responsable : macOS attribue l'autorisation à\n" + - " l'application parente, pas à l'exécutable `node`. Lancée depuis un agent\n" + - " ou un IDE, la sonde demande l'autorisation pour cette application-là.\n" + - " Lancer depuis Terminal.app, et autoriser Terminal :\n" + - " Réglages Système > Confidentialité et sécurité > Surveillance des saisies\n" + + " Mind the responsible process: macOS grants the permission to the parent\n" + + " application, not to the `node` executable. Started from an agent or an\n" + + " IDE, the probe asks for the permission on behalf of that application.\n" + + " Run it from Terminal.app, and authorise Terminal:\n" + + " System Settings > Privacy & Security > Input Monitoring\n" + "\n" + - ` Message d'origine : ${message}`, + ` Original message: ${message}`, ); } throw error; @@ -209,7 +209,7 @@ function exchange(device, payload, frame) { const decoded = decode(data); if (decoded.json) return { ok: true, ...decoded }; } - return { ok: false, error: "aucune réponse exploitable" }; + return { ok: false, error: "no usable answer" }; } // --- sonde ------------------------------------------------------------------- @@ -223,12 +223,12 @@ const NO_OP_REQUEST = JSON.stringify({ function reportFrame(frame, payload) { const report = encode(payload, frame); process.stdout.write( - ` cadre report ID ${hex(frame.reportId)}, canal ${hex(frame.channel)}, ` + - `${frame.size} octets, en-tête ${frame.headerLength}\n` + - ` charge ${payload}\n` + - ` ${Buffer.byteLength(payload, "utf8")} octets sur ` + - `${frame.size - frame.headerLength} disponibles\n` + - ` trame ${report.subarray(0, 16).toString("hex")}…\n`, + ` frame report ID ${hex(frame.reportId)}, channel ${hex(frame.channel)}, ` + + `${frame.size} bytes, header ${frame.headerLength}\n` + + ` payload ${payload}\n` + + ` ${Buffer.byteLength(payload, "utf8")} bytes of ` + + `${frame.size - frame.headerLength} available\n` + + ` report ${report.subarray(0, 16).toString("hex")}…\n`, ); } @@ -238,24 +238,24 @@ async function probe(options) { (entry) => entry.vendorId === VENDOR_ID && entry.productId === PRODUCT_ID, ); - process.stdout.write(`Périphérique ${hex(VENDOR_ID, 4)}:${hex(PRODUCT_ID, 4)}\n`); + process.stdout.write(`Device ${hex(VENDOR_ID, 4)}:${hex(PRODUCT_ID, 4)}\n`); if (entries.length === 0) { - return fail(" Introuvable. Le Codex Micro est-il connecté, en USB ou en Bluetooth ?"); + return fail(" Not found. Is the Codex Micro connected, over USB or Bluetooth?"); } for (const entry of entries) process.stdout.write(`${describe(entry)}\n`); const { entry, isVendor } = chooseInterface(entries); process.stdout.write( - ` choisie interface ${entry.interface ?? "?"}, ` + - `${isVendor ? "collection vendor" : "collection clavier — autorisation macOS probable"}\n\n`, + ` chosen interface ${entry.interface ?? "?"}, ` + + `${isVendor ? "vendor collection" : "keyboard collection — macOS permission likely needed"}\n\n`, ); reportFrame(options.frame, NO_OP_REQUEST); if (!options.send) { process.stdout.write( - "\n Rien n'a été écrit. Relancer avec --send pour éprouver le cadre.\n" + - " La charge est un no-op : elle ne change aucune couleur.\n", + "\n Nothing was written. Run again with --send to exercise the frame.\n" + + " The payload is a no-op: it changes no colour.\n", ); return undefined; } @@ -264,13 +264,13 @@ async function probe(options) { try { const candidates = options.variants ? FRAME_VARIANTS : [options.frame]; for (const [index, frame] of candidates.entries()) { - if (index > 0) process.stdout.write(`\n variante ${index} :\n`), reportFrame(frame, NO_OP_REQUEST); + if (index > 0) process.stdout.write(`\n variant ${index}:\n`), reportFrame(frame, NO_OP_REQUEST); const result = exchange(device, NO_OP_REQUEST, frame); if (result.ok) { process.stdout.write( - `\n ✔ CADRE CONFIRMÉ\n` + - ` interprétation : ${result.interpretation}\n` + - ` réponse : ${result.raw}\n`, + `\n ✔ FRAME CONFIRMED\n` + + ` interpretation: ${result.interpretation}\n` + + ` answer: ${result.raw}\n`, ); if (options.map) await mapSlots(device, frame); return undefined; @@ -278,20 +278,20 @@ async function probe(options) { process.stdout.write(` ✘ ${result.error}\n`); } return fail( - "\n Aucun cadre n'a répondu. Le format d'en-tête est à revoir :\n" + - " relire localement le bundle ChatGPT.app pour fixer l'en-tête et la\n" + - " continuation, puis élargir FRAME_VARIANTS de façon bornée.", + "\n No frame answered. The header format needs revisiting:\n" + + " re-read the ChatGPT.app bundle locally to pin down the header and the\n" + + " continuation, then widen FRAME_VARIANTS in a bounded way.", ); } finally { device.close(); } } -// Modifie l'éclairage : réservé à `--map`, jamais fait implicitement. +// Changes the lighting: reserved for `--map`, never done implicitly. async function mapSlots(device, frame) { process.stdout.write( - "\n Balayage des six emplacements. L'éclairage est modifié ;\n" + - " l'app ChatGPT le rétablira à sa prochaine poussée, sous 35 à 40 s.\n", + "\n Sweeping the six slots. The lighting is modified;\n" + + " the ChatGPT app will restore it on its next push, within 35 to 40s.\n", ); const restore = () => { try { @@ -302,7 +302,7 @@ async function mapSlots(device, frame) { }); device.write([...encode(off, frame)]); } catch { - // Le périphérique est peut-être déjà fermé : rien de mieux à tenter. + // The device may already be closed: nothing better to try. } }; process.on("SIGINT", () => (restore(), process.exit(130))); @@ -315,19 +315,19 @@ async function mapSlots(device, frame) { }); const result = exchange(device, request, frame); process.stdout.write( - ` id ${id} → ${result.ok ? "accusé reçu" : `échec : ${result.error}`}` + - " — quelle touche s'est allumée ?\n", + ` id ${id} → ${result.ok ? "acknowledged" : `failed: ${result.error}`}` + + " — which key lit up?\n", ); await new Promise((resolve) => setTimeout(resolve, 1200)); } restore(); process.stdout.write( - "\n Reporter la correspondance id → touche dans SLOT_THREAD_IDS,\n" + - " puis figer les lignes « rapporté, non revérifié » de la note de recherche.\n", + "\n Record the id → key mapping in SLOT_THREAD_IDS,\n" + + " then freeze the \"reported, not re-verified\" rows of the research note.\n", ); } -// --- entrée ------------------------------------------------------------------ +// --- entry point -------------------------------------------------------------- function parseArguments(argv) { const numeric = (flag, fallback) => { diff --git a/scripts/lighting.mjs b/scripts/lighting.mjs index 0411b91..859e19b 100644 --- a/scripts/lighting.mjs +++ b/scripts/lighting.mjs @@ -1,17 +1,17 @@ #!/usr/bin/env node -// Pilotage local de l'éclairage du Codex Micro : couleur et effet des six -// touches Agent, zones globales, écoute des événements touches et joystick. +// Local control of the Codex Micro lighting: colour and effect of the six Agent +// keys, global zones, and listening to key and joystick events. // -// Réimplémentation originale du format observé, documentée dans -// docs/research/hid-lighting-protocol.md. Aucune écriture persistante : seuls -// des rapports HID volatils sont émis ; rien n'est flashé, rien n'est écrit -// dans le stockage d'Input. +// Original reimplementation of the observed format, documented in +// docs/research/hid-lighting-protocol.md. Nothing persistent is written: only +// volatile HID reports are emitted; nothing is flashed, nothing goes into +// Input's storage. // -// Contention : l'app ChatGPT pousse sa propre configuration toutes les 35 à -// 40 s et la dernière écriture gagne. Sans --hold, un état posé ici peut être -// recouvert ; avec --hold, toute poussée étrangère détectée déclenche une -// réapplication immédiate (plus un filet de sécurité périodique). +// Contention: the ChatGPT app pushes its own configuration every 35 to 40s and +// the last write wins. Without --hold, a state set here can be overwritten; +// with --hold, any detected foreign push triggers an immediate reapply (plus a +// periodic safety net). import { spawn } from "node:child_process"; import { promises as fs, watch } from "node:fs"; @@ -35,35 +35,35 @@ const stateDir = process.env.CLAUDE_THREAD_STATUS_DIR || path.join(os.homedir(), ".claude", "thread-status"); const slotsPath = path.join(stateDir, "slots.json"); -// Garde-temps contre les répétitions de touche du firmware. +// Guard time against the firmware's key repeats. const FOCUS_DEBOUNCE_MS = 300; const HOLD_SAFETY_MS = 10000; function usage() { - return `Usage: node scripts/lighting.mjs [options] - -Commandes - list [--json] énumère les interfaces HID vendeur du Codex Micro - probe [--delay=ms] vérifie le canal (sys.version) puis allume les six - touches une par une pour confirmer le mapping - set slot <1-${SLOT_COUNT}> <#RRGGBB> [opts] couleur/effet d'une touche Agent - set all <#RRGGBB> [opts] même réglage sur les six touches - set zones --keys=#RRGGBB --ambient=#RRGGBB [opts] les deux zones globales - watch [--hold] [--focus] pousse les couleurs d'état de slots.json en continu - --focus : un appui sur une touche Agent va à sa session - listen journalise touches et joystick (v.oai.hid / v.oai.rad) - off éteint les six touches Agent + return `Usage: node scripts/lighting.mjs [options] + +Commands + list [--json] list the Codex Micro vendor HID interfaces + probe [--delay=ms] check the channel (sys.version), then light the six + keys one by one to confirm the mapping + set slot <1-${SLOT_COUNT}> <#RRGGBB> [opts] colour/effect of one Agent key + set all <#RRGGBB> [opts] same setting on all six keys + set zones --keys=#RRGGBB --ambient=#RRGGBB [opts] the two global zones + watch [--hold] [--focus] push slots.json state colours continuously + --focus: pressing an Agent key goes to its session + listen log key and joystick events (v.oai.hid / v.oai.rad) + off turn the six Agent keys off Options - --effect=nom ${Object.keys(EFFECTS).join(", ")} - --brightness=0..1 intensité (0 = éteint, 1 = plein) - --speed=0..1 vitesse d'effet - --magic=n paramètre magic de zone (défaut 1) - --hold réapplique dès qu'une écriture étrangère est détectée - --path=chemin interface HID précise (défaut : première trouvée) - -Contention : l'app ChatGPT repousse sa configuration toutes les 35 à 40 s et la -dernière écriture gagne. Sans --hold, l'état posé ici peut être recouvert. + --effect=name ${Object.keys(EFFECTS).join(", ")} + --brightness=0..1 intensity (0 = off, 1 = full) + --speed=0..1 effect speed + --magic=n zone magic parameter (default 1) + --hold reapply as soon as a foreign write is detected + --path=path specific HID interface (default: first one found) + +Contention: the ChatGPT app pushes its own configuration every 35 to 40s and the +last write wins. Without --hold, the state set here can be overwritten. `; } @@ -87,7 +87,7 @@ function parseUnit(name, value, fallback) { if (value === undefined) return fallback; const parsed = Number(value); if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) { - throw new Error(`--${name} attendu entre 0 et 1 : ${value}`); + throw new Error(`--${name} expected between 0 and 1: ${value}`); } return parsed; } @@ -96,7 +96,7 @@ function parseEffect(value) { if (value === undefined) return EFFECTS.solid; const effect = EFFECTS[value]; if (effect === undefined) { - throw new Error(`--effect inconnu : ${value} (${Object.keys(EFFECTS).join(", ")})`); + throw new Error(`unknown --effect: ${value} (${Object.keys(EFFECTS).join(", ")})`); } return effect; } @@ -119,7 +119,7 @@ async function commandList(flags) { return; } if (interfaces.length === 0) { - return fail("Aucune interface vendeur (VID 0x303a, usage 0xFF00). Clavier connecté ?"); + return fail("No vendor interface (VID 0x303a, usage 0xFF00). Is the keyboard plugged in?"); } for (const device of interfaces) { process.stdout.write( @@ -140,7 +140,7 @@ async function commandProbe(flags) { if (!session) return; session.onNotification(METHODS.notifyHid, (params) => { - process.stdout.write(` [touche] ${JSON.stringify(params)}\n`); + process.stdout.write(` [key] ${JSON.stringify(params)}\n`); }); session.onNotification(METHODS.notifyJoystick, (params) => { process.stdout.write(` [joystick] ${JSON.stringify(params)}\n`); @@ -148,15 +148,15 @@ async function commandProbe(flags) { try { const version = await session.call("sys.version"); - process.stdout.write(`✔ canal RPC fonctionnel — sys.version : ${JSON.stringify(version.result ?? version)}\n`); + process.stdout.write(`✔ RPC channel works — sys.version: ${JSON.stringify(version.result ?? version)}\n`); } catch (error) { await session.close(); - return fail(`✘ pas de réponse à sys.version : ${error.message}`); + return fail(`✘ no answer to sys.version: ${error.message}`); } process.stdout.write( - `\nAllumage des six emplacements, un par un (thread id → touche attendue).\n` + - `Noter toute divergence : la table SLOT_THREAD_IDS de scripts/lib/hid-lighting.mjs sera figée d'après cette observation.\n`, + `\nLighting the six slots one by one (thread id → expected key).\n` + + `Note any divergence: the SLOT_THREAD_IDS table in scripts/lib/hid-lighting.mjs is frozen from this observation.\n`, ); for (let index = 0; index < SLOT_COUNT; index += 1) { const entries = SLOT_THREAD_IDS.map((id, other) => @@ -170,15 +170,15 @@ async function commandProbe(flags) { await session.call(METHODS.threadsLighting, entries); } catch (error) { await session.close(); - return fail(`✘ échec d'écriture thstatus : ${error.message}`); + return fail(`✘ thstatus write failed: ${error.message}`); } process.stdout.write( - ` thread ${SLOT_THREAD_IDS[index]} allumé → emplacement ${index + 1} attendu (${SLOT_CONTROLS[index]})\n`, + ` thread ${SLOT_THREAD_IDS[index]} lit → slot ${index + 1} expected (${SLOT_CONTROLS[index]})\n`, ); await wait(Number.isNaN(delay) ? 2000 : delay); } await session.call(METHODS.threadsLighting, allOffParams()); - process.stdout.write(`✔ sonde terminée, touches éteintes.\n`); + process.stdout.write(`✔ probe finished, keys turned off.\n`); await session.close(); } @@ -186,7 +186,7 @@ async function commandProbe(flags) { function holdNote(flags) { if (!flags.hold) { - process.stdout.write(` (sans --hold, l'app ChatGPT peut recouvrir cet état toutes les 35 à 40 s)\n`); + process.stdout.write(` (without --hold, the ChatGPT app can overwrite this state every 35 to 40s)\n`); } } @@ -196,7 +196,7 @@ async function commandSet(flags, positional) { const keys = flags.keys; const ambient = flags.ambient; if (keys === undefined || ambient === undefined || keys === true || ambient === true) { - return fail("set zones exige --keys=#RRGGBB et --ambient=#RRGGBB (rgbcfg décrit les deux zones d'un coup)."); + return fail("set zones requires --keys=#RRGGBB and --ambient=#RRGGBB (rgbcfg describes both zones at once)."); } const side = { effect: parseEffect(flags.effect), @@ -219,7 +219,7 @@ async function commandSet(flags, positional) { let entries; try { if (target === "all") { - if (!color) return fail("set all exige une couleur #RRGGBB."); + if (!color) return fail("set all requires a #RRGGBB colour."); const base = { color, brightness: parseUnit("brightness", flags.brightness, 1), @@ -230,9 +230,9 @@ async function commandSet(flags, positional) { } else if (target === "slot") { const slot = Number.parseInt(positional[1] ?? "", 10); if (!Number.isInteger(slot) || slot < 1 || slot > SLOT_COUNT) { - return fail(`Emplacement attendu entre 1 et ${SLOT_COUNT}.`); + return fail(`Expected a slot between 1 and ${SLOT_COUNT}.`); } - if (!positional[2]) return fail("set slot exige une couleur #RRGGBB."); + if (!positional[2]) return fail("set slot requires a #RRGGBB colour."); const base = { id: SLOT_THREAD_IDS[slot - 1], color: positional[2], @@ -242,9 +242,9 @@ async function commandSet(flags, positional) { if (flags.speed !== undefined) base.speed = parseUnit("speed", flags.speed, 0.5); entries = [threadEntry(base)]; } else { - return fail(`Cible inconnue : ${target ?? "(absente)"}\n\n${usage()}`); + return fail(`Unknown target: ${target ?? "(missing)"}\n\n${usage()}`); } - colorToInt(entries[0].c); // valide avant d'ouvrir le périphérique + colorToInt(entries[0].c); // validate before opening the device } catch (error) { return fail(error.message); } @@ -256,7 +256,7 @@ async function applyOnce(flags, method, params, label) { const session = await openSession(flags, { onForeignWrite: flags.hold ? async (foreignMethod) => { - process.stdout.write(` ! poussée étrangère (${foreignMethod}) — réapplication\n`); + process.stdout.write(` ! foreign write (${foreignMethod}) — reapplying\n`); await session.call(method, current).catch(() => {}); } : undefined, @@ -265,18 +265,18 @@ async function applyOnce(flags, method, params, label) { try { await session.call(method, current); - process.stdout.write(`✔ ${label} appliqué.\n`); + process.stdout.write(`✔ ${label} applied.\n`); holdNote(flags); } catch (error) { await session.close(); - return fail(`✘ échec d'écriture : ${error.message}`); + return fail(`✘ write failed: ${error.message}`); } if (!flags.hold) return session.close(); const timer = setInterval(() => { session.call(method, current).catch(() => {}); }, HOLD_SAFETY_MS); - process.stdout.write(` maintien actif (--hold), filet de sécurité ${HOLD_SAFETY_MS / 1000} s. Ctrl-C pour quitter.\n`); + process.stdout.write(` hold active (--hold), safety net every ${HOLD_SAFETY_MS / 1000}s. Ctrl-C to quit.\n`); process.on("SIGINT", async () => { clearInterval(timer); await session.close(); @@ -288,7 +288,7 @@ async function applyOnce(flags, method, params, label) { async function readSlots() { const raw = JSON.parse(await fs.readFile(slotsPath, "utf8")); - if (!Array.isArray(raw.slots)) throw new Error("slots.json sans tableau slots"); + if (!Array.isArray(raw.slots)) throw new Error("slots.json has no slots array"); return raw.slots; } @@ -298,7 +298,7 @@ async function commandWatch(flags) { rows = await readSlots(); } catch { return fail( - `${slotsPath} illisible. Lancer d'abord \`npm run thread-status -- watch\` pour produire l'état.`, + `${slotsPath} is unreadable. Run \`npm run thread-status -- watch\` first to produce the state.`, ); } @@ -306,24 +306,24 @@ async function commandWatch(flags) { const apply = async (reason) => { if (!lastApplied) return; await session.call(METHODS.threadsLighting, lastApplied).catch((error) => { - process.stderr.write(` ! écriture impossible (${reason}) : ${error.message}\n`); + process.stderr.write(` ! write failed (${reason}): ${error.message}\n`); }); }; const session = await openSession(flags, { onForeignWrite: flags.hold ? (foreignMethod) => { - process.stdout.write(` ! poussée étrangère (${foreignMethod}) — réapplication\n`); + process.stdout.write(` ! foreign write (${foreignMethod}) — reapplying\n`); void apply("hold"); } : undefined, }); if (!session) return; - // Les six touches Agent émettent `v.oai.hid` avec `k` valant `AG00` à `AG05` : - // le clavier désigne lui-même l'emplacement pressé, sur le canal HID déjà - // ouvert. Aucun raccourci global, aucune API native, aucune autorisation macOS. - // `act` vaut 1 à l'appui et 0 au relâchement. + // The six Agent keys emit `v.oai.hid` with `k` set to `AG00` through `AG05`: + // the keyboard itself names the slot that was pressed, over the HID channel + // already open. No global shortcut, no native API, no macOS permission. + // `act` is 1 on press and 0 on release. if (flags.focus) { let lastPress = 0; const focusScript = fileURLToPath(new URL("./thread-status.mjs", import.meta.url)); @@ -341,19 +341,19 @@ async function commandWatch(flags) { child.stdout.on("data", (chunk) => (output += chunk)); child.stderr.on("data", (chunk) => (output += chunk)); child.on("close", () => { - // Toutes les lignes, pas seulement la première : pour une session fermée, - // `focus` répond sur deux lignes et c'est la seconde qui porte la commande - // de reprise. N'en afficher qu'une revenait à masquer l'essentiel. + // Every line, not only the first: for a closed session, `focus` answers + // over two lines and the second one carries the resume command. Showing + // only one hid the part that mattered. const lines = output.trim().split("\n").filter((line) => line.trim()); - const stamp = `${new Date().toISOString()} [touche ${slot}] `; + const stamp = `${new Date().toISOString()} [key ${slot}] `; process.stdout.write( lines.length ? `${stamp}${lines[0]}\n${lines.slice(1).map((line) => `${" ".repeat(stamp.length)}${line.trim()}\n`).join("")}` - : `${stamp}sans retour\n`, + : `${stamp}no output\n`, ); }); }); - process.stdout.write("Appui sur une touche Agent → navigation vers sa session.\n"); + process.stdout.write("Pressing an Agent key navigates to its session.\n"); } const push = async (nextRows, reason) => { @@ -378,15 +378,15 @@ async function commandWatch(flags) { try { await push(await readSlots(), "slots.json"); } catch (error) { - process.stderr.write(` ! relecture : ${error.message}\n`); + process.stderr.write(` ! re-read: ${error.message}\n`); } }, 100); }); - // Filet de sécurité : en --hold, réapplication périodique quoi qu'il arrive. - const timer = flags.hold ? setInterval(() => void apply("filet"), HOLD_SAFETY_MS) : null; + // Safety net: under --hold, reapply periodically whatever happens. + const timer = flags.hold ? setInterval(() => void apply("safety-net"), HOLD_SAFETY_MS) : null; - process.stdout.write(`Surveillance de ${slotsPath}. Ctrl-C pour quitter.\n`); + process.stdout.write(`Watching ${slotsPath}. Ctrl-C to quit.\n`); process.on("SIGINT", async () => { watcher.close(); if (timer) clearInterval(timer); @@ -401,12 +401,12 @@ async function commandListen(flags) { const session = await openSession(flags); if (!session) return; session.onNotification(METHODS.notifyHid, (params) => { - process.stdout.write(`${new Date().toISOString()} touche ${JSON.stringify(params)}\n`); + process.stdout.write(`${new Date().toISOString()} key ${JSON.stringify(params)}\n`); }); session.onNotification(METHODS.notifyJoystick, (params) => { process.stdout.write(`${new Date().toISOString()} joystick ${JSON.stringify(params)}\n`); }); - process.stdout.write(`Écoute des notifications du périphérique. Ctrl-C pour quitter.\n`); + process.stdout.write(`Listening for device notifications. Ctrl-C to quit.\n`); process.on("SIGINT", async () => { await session.close(); process.exit(0); @@ -420,15 +420,15 @@ async function commandOff(flags) { if (!session) return; try { await session.call(METHODS.threadsLighting, allOffParams()); - process.stdout.write(`✔ six emplacements éteints.\n`); + process.stdout.write(`✔ six slots turned off.\n`); } catch (error) { await session.close(); - return fail(`✘ échec d'écriture : ${error.message}`); + return fail(`✘ write failed: ${error.message}`); } return session.close(); } -// --- entrée ------------------------------------------------------------------ +// --- entry point -------------------------------------------------------------- async function main(argv) { const [command, ...rest] = argv; @@ -445,7 +445,7 @@ async function main(argv) { if (command === "watch") return await commandWatch(flags); if (command === "listen") return await commandListen(flags); if (command === "off") return await commandOff(flags); - return fail(`Commande inconnue : ${command}\n\n${usage()}`); + return fail(`Unknown command: ${command}\n\n${usage()}`); } catch (error) { if (error instanceof DeviceError) return fail(error.message); throw error; diff --git a/scripts/prepare-gui.mjs b/scripts/prepare-gui.mjs index 13ee590..259c04e 100644 --- a/scripts/prepare-gui.mjs +++ b/scripts/prepare-gui.mjs @@ -9,7 +9,7 @@ const guiDirectory = resolve(repositoryRoot, "prototype"); try { const result = ensureGuiDependencies(guiDirectory); - console.log(result.installed ? "OK: dépendances GUI installées" : "OK: dépendances GUI déjà disponibles"); + console.log(result.installed ? "OK: GUI dependencies installed" : "OK: GUI dependencies already available"); } catch (error) { console.error(error.message); process.exitCode = error.exitCode ?? 1; diff --git a/scripts/thread-status.mjs b/scripts/thread-status.mjs index 4e95054..886e362 100644 --- a/scripts/thread-status.mjs +++ b/scripts/thread-status.mjs @@ -1,16 +1,16 @@ #!/usr/bin/env node -// Compagnon local des six touches Agent. Réconcilie deux sources officielles : +// Local companion for the six Agent keys. Reconciles two official sources: // -// - `claude agents --json` pour le roster des sessions vivantes ; -// - le journal NDJSON écrit par le plugin thread-status pour leurs états. +// - `claude agents --json` for the roster of live sessions; +// - the NDJSON journal written by the thread-status plugin for their states. // -// Il n'écrit rien sur le périphérique. Sa sortie est `slots.json`, qui sert de -// couture pour un futur DeviceAdapter : tant que le protocole RGB du Codex Micro -// n'est pas mesuré, le compagnon s'arrête à ce fichier et à l'affichage. +// It writes nothing to the device. Its output is `slots.json`, the seam the +// DeviceAdapter consumes: this companion stops at that file and at the display, +// and scripts/lighting.mjs is what pushes colours to the keyboard. // -// Voir docs/research/thread-status-feasibility.md pour ce qui est mesuré et ce -// qui reste ouvert. +// See docs/research/thread-status-feasibility.md for what is measured and what +// is still open. import { spawnSync } from "node:child_process"; import { existsSync, promises as fs, readdirSync, watch } from "node:fs"; @@ -29,8 +29,8 @@ import { ttyDevice, } from "./lib/thread-slots.mjs"; -// Contrat de chemin partagé avec thread-status/bin/emit.mjs, qui reste -// volontairement sans dépendance. +// Path contract shared with thread-status/bin/emit.mjs, which deliberately +// stays dependency-free. const stateDir = process.env.CLAUDE_THREAD_STATUS_DIR || path.join(os.homedir(), ".claude", "thread-status"); const journalPath = path.join(stateDir, "events.ndjson"); @@ -38,9 +38,9 @@ const snapshotPath = path.join(stateDir, "slots.json"); const DEFAULT_INTERVAL_MS = 2000; -// Détection du terminal hôte par le nom de l'exécutable remonté par `ps`. Seuls -// iTerm2 et Terminal sont pilotables ici ; les autres sont nommés pour que le -// message d'erreur soit exploitable au lieu d'être générique. +// Host terminal detected from the executable name reported by `ps`. Only iTerm2 +// and Terminal are scriptable here; the others are named so the error message is +// actionable instead of generic. const TERMINAL_APPS = [ { basename: "iTerm2", name: "iTerm2", driver: "iterm" }, { basename: "Terminal", name: "Terminal", driver: "terminal" }, @@ -54,16 +54,16 @@ const TERMINAL_APPS = [ ]; function usage() { - return `Usage: node scripts/thread-status.mjs + return `Usage: node scripts/thread-status.mjs -Commandes - watch [--interval=ms] réconcilie en continu et écrit slots.json - status [--json] affiche l'état courant des six emplacements - focus <1-${SLOT_COUNT}> va à la session de cet emplacement - doctor vérifie les prérequis et les sources +Commands + watch [--interval=ms] reconcile continuously and write slots.json + status [--json] show the current state of the six slots + focus <1-${SLOT_COUNT}> go to the session on that slot + doctor check prerequisites and sources -Environnement - CLAUDE_THREAD_STATUS_DIR répertoire d'état (défaut ~/.claude/thread-status) +Environment + CLAUDE_THREAD_STATUS_DIR state directory (default ~/.claude/thread-status) `; } @@ -90,7 +90,7 @@ function resolveClaudeBinary() { candidates.push(path.join(bundled, version, "claude.app", "Contents", "MacOS", "claude")); } } catch { - // Installation non groupée avec Claude Desktop : les autres candidats suffisent. + // Not bundled with Claude Desktop: the other candidates are enough. } return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null; @@ -99,15 +99,15 @@ function resolveClaudeBinary() { function readRoster(binary) { const result = spawnSync(binary, ["agents", "--json"], { encoding: "utf8", timeout: 15000 }); if (result.status !== 0) { - throw new Error(`\`claude agents --json\` a échoué : ${(result.stderr || result.stdout || "").trim()}`); + throw new Error(`\`claude agents --json\` failed: ${(result.stderr || result.stdout || "").trim()}`); } const parsed = JSON.parse(result.stdout); - if (!Array.isArray(parsed)) throw new Error("`claude agents --json` n'a pas renvoyé un tableau."); + if (!Array.isArray(parsed)) throw new Error("`claude agents --json` did not return an array."); return parsed; } -// Une seule lecture de la table des processus : le tty de la session et, en -// remontant les parents, le terminal qui l'héberge. +// One single read of the process table: the session's tty and, by walking up +// the parents, the terminal hosting it. function processTable() { const result = spawnSync("ps", ["-Ao", "pid=,ppid=,tty=,comm="], { encoding: "utf8" }); const table = new Map(); @@ -144,18 +144,18 @@ function enrichRoster(roster) { }); } -// --- état persistant --------------------------------------------------------- +// --- persistent state -------------------------------------------------------- -// Empreinte de ce qu'un consommateur observe. `slots.json` est la couture du -// futur DeviceAdapter : s'il est réécrit à chaque tick, un `watch` qui le suit -// pousserait des rapports HID en continu alors que rien n'a bougé. On compare -// donc les six entrées avant d'écrire. +// Fingerprint of what a consumer actually observes. `slots.json` is the +// DeviceAdapter's seam: rewritten on every tick, a `watch` following it would +// push HID reports continuously while nothing moved. So the six entries are +// compared before writing. // -// `pending`, `dropped`, `overflow`, `journalOffset` et l'horodatage sont exclus -// de l'empreinte : ils bougent sans que l'affichage change. Le curseur de -// journal n'est donc persisté qu'à l'occasion d'un vrai changement, et un -// redémarrage rejoue au pire la queue d'événements depuis ce point — des -// événements qui, par construction, n'avaient rien changé. +// `pending`, `dropped`, `overflow`, `journalOffset` and the timestamp are left +// out of the fingerprint: they move without the display changing. The journal +// cursor is therefore only persisted on a real change, and a restart replays at +// worst the tail of events from that point — events which, by construction, had +// changed nothing. function publishedFingerprint(snapshot) { return JSON.stringify(snapshot.slots); } @@ -200,9 +200,9 @@ async function saveState(snapshot, journalOffset) { return true; } -// Lit les lignes ajoutées depuis le dernier passage. Une taille inférieure à -// l'offset signale une rotation du journal : on repart de zéro plutôt que de -// lire au milieu d'un enregistrement. +// Reads the lines appended since the last pass. A size below the offset signals +// a journal rotation: start over from zero rather than read from the middle of +// a record. async function readJournalSince(offset) { let size = 0; try { @@ -219,7 +219,7 @@ async function readJournalSince(offset) { const buffer = Buffer.alloc(length); await handle.read(buffer, 0, length, offset); const text = buffer.toString("utf8"); - // Une dernière ligne incomplète est laissée pour le prochain passage. + // A trailing incomplete line is left for the next pass. const complete = text.endsWith("\n") ? text : text.slice(0, text.lastIndexOf("\n") + 1); const events = []; for (const line of complete.split("\n")) { @@ -227,7 +227,7 @@ async function readJournalSince(offset) { try { events.push(JSON.parse(line)); } catch { - // Ligne tronquée par une rotation concurrente : ignorée. + // Line truncated by a concurrent rotation: ignored. } } return { events, offset: offset + Buffer.byteLength(complete, "utf8") }; @@ -245,8 +245,8 @@ async function reconcile(binary, state) { snapshot = roster.snapshot; let changed = roster.changed; - // Les hooks après le roster : le roster ouvre l'emplacement, les hooks y - // posent l'état, y compris celui mis en attente avant l'apparition. + // Hooks after the roster: the roster opens the slot, the hooks set the state + // on it, including the one held pending before the session appeared. for (const event of journal.events) { const applied = applyHookEvent(snapshot, event, event.ts ?? now); snapshot = applied.snapshot; @@ -256,15 +256,15 @@ async function reconcile(binary, state) { return { snapshot, journalOffset: journal.offset, changed, notes: roster.notes }; } -// --- affichage --------------------------------------------------------------- +// --- display ----------------------------------------------------------------- const STATE_LABELS = { - [STATES.free]: "libre", - [STATES.idle]: "au repos", - [STATES.running]: "en cours", - [STATES.blocked]: "intervention", - [STATES.done]: "terminé", - [STATES.ended]: "fermé", + [STATES.free]: "free", + [STATES.idle]: "idle", + [STATES.running]: "running", + [STATES.blocked]: "needs you", + [STATES.done]: "done", + [STATES.ended]: "closed", }; function dot(color) { @@ -299,37 +299,37 @@ function renderTable(snapshot) { function runAppleScript(script) { const result = spawnSync("osascript", ["-e", script], { encoding: "utf8", timeout: 15000 }); const output = (result.stdout || result.stderr || "").trim(); - // Un AppleEvent qui expire — `-1712`, ou `osascript` tué par le timeout — - // signifie presque toujours que le consentement Automation n'a pas été accordé - // au shell appelant : macOS n'affiche pas toujours l'invite et laisse - // simplement l'événement expirer. Le confondre avec « fenêtre introuvable » - // envoie chercher le problème du côté du terminal, où il n'est pas. + // An AppleEvent that times out — `-1712`, or `osascript` killed by the + // timeout — almost always means Automation consent was not granted to the + // calling shell: macOS does not always show the prompt and simply lets the + // event expire. Confusing it with "window not found" sends you looking at the + // terminal, which is not where the problem is. if (result.error?.code === "ETIMEDOUT" || result.signal || output.includes("-1712")) { return { ok: false, reason: "automation-consent", output }; } return { ok: result.status === 0, output }; } -// Le tty vient de `ps` : on le revalide avant interpolation plutôt que de faire -// confiance à sa provenance. +// The tty comes from `ps`: revalidate it before interpolation rather than trust +// where it came from. function focusTerminal(target) { if (!/^\/dev\/tty[a-z0-9.]+$/i.test(target.tty)) { - return { ok: false, output: `Chemin de terminal inattendu : ${target.tty}` }; + return { ok: false, output: `unexpected terminal path: ${target.tty}` }; } const app = TERMINAL_APPS.find((candidate) => candidate.name === target.app); if (!app?.driver) { return { ok: false, - output: `Terminal non piloté${target.app ? ` : ${target.app}` : ""}. Emplacement sur ${target.tty}.`, + output: `terminal not scriptable${target.app ? `: ${target.app}` : ""}. Slot is on ${target.tty}.`, }; } - // L'ordre des opérations décide du résultat quand les fenêtres se recouvrent. - // `activate` d'abord remonte la fenêtre déjà frontale, et réordonner ensuite ne - // tient pas : la cible reste sous la pile. On sélectionne donc l'onglet, on - // remonte sa fenêtre en tête de l'ordre de profondeur, et on n'active l'app - // qu'en dernier. Bénéfice secondaire : un `tty` introuvable ne vole plus le - // focus pour rien, puisqu'on sort sans jamais activer. + // The order of operations decides the outcome when windows overlap. + // `activate` first raises the window that is already frontmost, and + // reordering afterwards does not hold: the target stays under the stack. So + // select the tab, bring its window to the front of the z-order, and only + // activate the app last. Side benefit: a `tty` that cannot be found no longer + // steals focus for nothing, since we return without ever activating. if (app.driver === "iterm") { return runAppleScript(`tell application "iTerm2" repeat with w in windows @@ -372,79 +372,79 @@ async function focus(slotNumber) { switch (target.kind) { case "empty": - return fail(`Emplacement ${slotNumber} : libre.`); + return fail(`Slot ${slotNumber}: free.`); case "terminal": { const result = focusTerminal(target); if (result.reason === "automation-consent") { return fail( - `Emplacement ${slotNumber} : ${target.app} n'a pas répondu à l'AppleEvent.\n` + - " C'est le consentement Automation, pas le terminal. Accorder\n" + - ` Réglages Système > Confidentialité et sécurité > Automatisation > ${target.app},\n` + - " pour le terminal depuis lequel cette commande est lancée.", + `Slot ${slotNumber}: ${target.app} did not answer the AppleEvent.\n` + + " This is Automation consent, not the terminal. Grant\n" + + ` System Settings > Privacy & Security > Automation > ${target.app},\n` + + " for the terminal this command runs from.", ); } if (!result.ok || result.output === "not-found") { - return fail(`Emplacement ${slotNumber} : ${result.output || "fenêtre introuvable"}.`); + return fail(`Slot ${slotNumber}: ${result.output || "window not found"}.`); } - process.stdout.write(`Emplacement ${slotNumber} : ${target.app} activé sur ${target.tty}.\n`); + process.stdout.write(`Slot ${slotNumber}: ${target.app} focused on ${target.tty}.\n`); return undefined; } case "resume": { - // La session est fermée : on n'en lance pas la reprise à la place de - // l'utilisateur, on lui donne la commande exacte. Reprendre une session - // vivante depuis un second terminal entrelacerait les deux transcripts. + // The session is closed: we do not resume it on the user's behalf, we hand + // them the exact command. Resuming a live session from a second terminal + // would interleave the two transcripts. const where = target.cwd ? redactHome(target.cwd) : "."; process.stdout.write( - `Emplacement ${slotNumber} : session fermée. Reprise :\n cd ${where} && claude --resume ${target.sessionId}\n`, + `Slot ${slotNumber}: session closed. Resume it with:\n cd ${where} && claude --resume ${target.sessionId}\n`, ); return undefined; } case "desktop": { - // `claude://resume?session=` ouvre la session par son identifiant. - // Comme `open -b`, le passage par le handler d'URL évite AppleScript et - // n'exige donc aucun consentement Automation. + // `claude://resume?session=` opens the session by its id. Like + // `open -b`, going through the URL handler avoids AppleScript and + // therefore needs no Automation consent. // - // Le handler ne rend pas compte de l'issue : `open` sort en 0 dès que - // l'URL est remise. Une session dont le transcript a disparu du disque - // échoue côté application, sans que rien ne remonte ici. + // The handler reports nothing back: `open` exits 0 as soon as the URL is + // delivered. A session whose transcript has left the disk fails on the + // application side, with nothing surfacing here. const opened = spawnSync("open", [target.url], { encoding: "utf8", timeout: 10000 }); if (opened.status !== 0) { return fail( - `Emplacement ${slotNumber} : ${target.url} n'a pas pu être ouvert : ${(opened.stderr || "").trim()}`, + `Slot ${slotNumber}: could not open ${target.url}: ${(opened.stderr || "").trim()}`, ); } process.stdout.write( - `Emplacement ${slotNumber} : session ${target.sessionId.slice(0, 8)} demandée à Claude Desktop.\n`, + `Slot ${slotNumber}: session ${target.sessionId.slice(0, 8)} requested from Claude Desktop.\n`, ); return undefined; } default: { - // Session hébergée dont l'identifiant n'est pas un UUID : `claude://resume` - // le refuserait. Faute de pouvoir sélectionner la bonne session, on met au - // moins l'application au premier plan. + // Hosted session whose id is not a UUID: `claude://resume` would reject + // it. Unable to select the right session, at least bring the application + // to the front. // - // `open -b` évite AppleScript, donc n'exige aucun consentement Automation. + // `open -b` avoids AppleScript, so it needs no Automation consent. const activated = spawnSync("open", ["-b", "com.anthropic.claudefordesktop"], { encoding: "utf8", timeout: 10000, }); if (activated.status !== 0) { return fail( - `Emplacement ${slotNumber} : ${target.reason}\n` + + `Slot ${slotNumber}: ${target.reason}\n` + ` session ${target.sessionId}\n` + - ` Claude Desktop n'a pas pu être activé : ${(activated.stderr || "").trim()}`, + ` Claude Desktop could not be activated: ${(activated.stderr || "").trim()}`, ); } process.stdout.write( - `Emplacement ${slotNumber} : Claude Desktop activé. La session ${target.sessionId.slice(0, 8)} ` + - "ne peut pas être sélectionnée : aucune route ne l'adresse.\n", + `Slot ${slotNumber}: Claude Desktop activated. Session ${target.sessionId.slice(0, 8)} ` + + "cannot be selected: no route addresses it.\n", ); return undefined; } } } -// --- commandes --------------------------------------------------------------- +// --- commands ---------------------------------------------------------------- async function commandStatus(binary, asJson) { const state = await loadState(); @@ -459,7 +459,7 @@ async function commandStatus(binary, asJson) { for (const note of result.notes) process.stdout.write(` ! ${note}\n`); if (result.snapshot.overflow > 0) { process.stdout.write( - ` ! ${result.snapshot.overflow} session(s) sans emplacement depuis le démarrage : ${SLOT_COUNT} touches Agent, pas plus.\n`, + ` ! ${result.snapshot.overflow} session(s) left without a slot since startup: ${SLOT_COUNT} Agent keys, no more.\n`, ); } } @@ -474,8 +474,8 @@ async function commandWatch(binary, intervalMs) { try { const result = await reconcile(binary, state); state = { snapshot: result.snapshot, journalOffset: result.journalOffset }; - // Le rendu suit l'écriture, pas `result.changed` : seul un changement - // réellement publié mérite une ligne à l'écran comme une poussée HID. + // The render follows the write, not `result.changed`: only a change that + // was actually published deserves a line on screen, or an HID push. if (await saveState(result.snapshot, result.journalOffset)) { process.stdout.write(`\n${new Date().toISOString()}\n${renderTable(result.snapshot)}\n`); for (const note of result.notes) process.stdout.write(` ! ${note}\n`); @@ -489,7 +489,7 @@ async function commandWatch(binary, intervalMs) { await fs.mkdir(stateDir, { recursive: true }); await fs.appendFile(journalPath, ""); - // Le poll rattrape les hooks manqués, le watch donne la latence. + // The poll catches missed hooks, the watch gives the latency. const timer = setInterval(tick, intervalMs); const watcher = watch(journalPath, () => void tick()); process.on("SIGINT", () => { @@ -498,30 +498,30 @@ async function commandWatch(binary, intervalMs) { process.exit(0); }); - process.stdout.write(`Journal : ${redactHome(journalPath)}\nSortie : ${redactHome(snapshotPath)}\n`); + process.stdout.write(`Journal: ${redactHome(journalPath)}\nOutput: ${redactHome(snapshotPath)}\n`); await tick(); } async function commandDoctor(binary) { const checks = []; - checks.push([Boolean(binary), `binaire claude : ${binary ? redactHome(binary) : "introuvable"}`]); + checks.push([Boolean(binary), `claude binary: ${binary ? redactHome(binary) : "not found"}`]); let roster = []; if (binary) { try { roster = readRoster(binary); - checks.push([true, `claude agents --json : ${roster.length} session(s) vivante(s)`]); + checks.push([true, `claude agents --json: ${roster.length} live session(s)`]); } catch (error) { - checks.push([false, `claude agents --json : ${error.message}`]); + checks.push([false, `claude agents --json: ${error.message}`]); } } try { await fs.mkdir(stateDir, { recursive: true }); await fs.access(stateDir); - checks.push([true, `répertoire d'état accessible : ${redactHome(stateDir)}`]); + checks.push([true, `state directory reachable: ${redactHome(stateDir)}`]); } catch (error) { - checks.push([false, `répertoire d'état : ${error.message}`]); + checks.push([false, `state directory: ${error.message}`]); } const journal = await readJournalSince(0); @@ -529,16 +529,16 @@ async function commandDoctor(binary) { checks.push([ journal.events.length > 0, journal.events.length > 0 - ? `journal : ${journal.events.length} événement(s), dernier ${last.event} il y a ${Math.round((Date.now() - last.ts) / 1000)} s` - : "journal vide : le plugin n'a jamais émis. Charger thread-status/ puis lancer un tour.", + ? `journal: ${journal.events.length} event(s), last ${last.event} ${Math.round((Date.now() - last.ts) / 1000)}s ago` + : "journal empty: the plugin has never emitted. Load thread-status/ then run a turn.", ]); const withTty = enrichRoster(roster).filter((row) => row.tty); checks.push([ true, - `navigation : ${withTty.length}/${roster.length} session(s) dans un terminal identifiable` + + `navigation: ${withTty.length}/${roster.length} session(s) in an identifiable terminal` + (withTty.length < roster.length - ? " — les autres sont hébergées par Claude Desktop ou un IDE, sans route documentée" + ? " — the others are hosted by Claude Desktop or an IDE, reached through claude://resume" : ""), ]); @@ -546,7 +546,7 @@ async function commandDoctor(binary) { if (checks.some(([ok]) => !ok)) process.exitCode = 1; } -// --- entrée ------------------------------------------------------------------ +// --- entry point -------------------------------------------------------------- async function main(argv) { const [command, ...rest] = argv; @@ -558,14 +558,14 @@ async function main(argv) { if (command === "focus") { const slotNumber = Number.parseInt(rest[0] ?? "", 10); if (!Number.isInteger(slotNumber) || slotNumber < 1 || slotNumber > SLOT_COUNT) { - return fail(`Emplacement attendu entre 1 et ${SLOT_COUNT}.`); + return fail(`Expected a slot between 1 and ${SLOT_COUNT}.`); } return focus(slotNumber); } const binary = resolveClaudeBinary(); if (command === "doctor") return commandDoctor(binary); - if (!binary) return fail("Binaire `claude` introuvable. Voir `doctor`."); + if (!binary) return fail("`claude` binary not found. See `doctor`."); if (command === "status") return commandStatus(binary, rest.includes("--json")); if (command === "watch") { @@ -574,7 +574,7 @@ async function main(argv) { return commandWatch(binary, Number.isInteger(parsed) && parsed >= 250 ? parsed : DEFAULT_INTERVAL_MS); } - return fail(`Commande inconnue : ${command}\n\n${usage()}`); + return fail(`Unknown command: ${command}\n\n${usage()}`); } main(process.argv.slice(2)).catch((error) => { diff --git a/shared/input-profile.mjs b/shared/input-profile.mjs index e9fa3de..2fa6fbf 100644 --- a/shared/input-profile.mjs +++ b/shared/input-profile.mjs @@ -8,9 +8,9 @@ const MODIFIER_KEYCODES = { Control: "KC_LCTL", }; -// Touches finales autorisées pour un raccourci. Retour/Entrée, Suppression et -// Retour arrière sont volontairement absents : un appui accidentel ne doit -// jamais envoyer, approuver ou détruire quoi que ce soit. +// Final keys allowed in a shortcut. Return/Enter, Delete and Backspace are +// deliberately absent: an accidental press must never send, approve or destroy +// anything. const FINAL_KEYCODES = { ...Object.fromEntries( Array.from({ length: 26 }, (_, index) => { @@ -52,8 +52,8 @@ const MODIFIER_KEY_BY_KEYCODE = Object.fromEntries( const FORBIDDEN_KEYS = Object.freeze(["Enter", "Return", "Delete", "Backspace"]); -// Une touche imprimable seule taperait du texte dans la conversation : elle -// n'est acceptée qu'accompagnée d'un modificateur. +// A printable key on its own would type text into the conversation: it is only +// accepted together with a modifier. const PRINTABLE_KEYS = new Set([ ...Array.from({ length: 26 }, (_, index) => String.fromCharCode(65 + index)), ...Array.from({ length: 10 }, (_, digit) => String(digit)), @@ -65,9 +65,9 @@ const PRINTABLE_KEYS = new Set([ "Minus", ]); -// Les douze keycaps programmables sont répartis sur quatre rangées. La -// première cellule de la dernière rangée est le capteur de changement de -// layer : elle est volontairement absente de cette table et reste intacte. +// The twelve programmable keycaps are spread over four rows. The first cell of +// the last row is the layer-change sensor: it is deliberately absent from this +// table and stays untouched. const KEY_CONTROL_LOCATIONS = Object.freeze({ "key-9": { row: 0, column: 0 }, "key-10": { row: 0, column: 1 }, @@ -89,10 +89,10 @@ const ENCODER_PRESS_CONTROL = "key-13"; const DEFAULT_MAPPING = { joystick: "navigation", - // La molette est en mode Effort par défaut : c'est le geste distinctif de cette - // carte pour Claude, et il est calibré et documenté (voir - // docs/research/effort-wheel-calibration.md). Les autres modes restent - // disponibles dans le GUI, le défilement compris. + // The wheel is in Effort mode by default: that is this board's distinctive + // gesture for Claude, and it is calibrated and documented (see + // docs/research/effort-wheel-calibration.md). The other modes stay available + // in the GUI, scrolling included. wheel: "effort", "key-9": "none", "key-10": "none", @@ -148,10 +148,10 @@ const ACTION_DEFINITIONS = { type: "direct", key: "Escape", }, - // Cycle entre les sessions du Code tab de Claude Desktop. La documentation - // précise que ce raccourci utilise Control sur toutes les plateformes, - // contrairement aux autres. Il n'existe aucun raccourci pour choisir une - // session par son rang : seul le cycle est adressable. + // Cycles through the sessions of Claude Desktop's Code tab. The documentation + // states that this shortcut uses Control on every platform, unlike the + // others. There is no shortcut to pick a session by its rank: only cycling is + // addressable. nextSession: { name: "Claude Next Session", type: "shortcut", @@ -162,7 +162,7 @@ const ACTION_DEFINITIONS = { type: "shortcut", keys: ["Control", "Shift", "Tab"], }, - // Ouvre le menu d'effort, où les chiffres 1 à 9 sélectionnent une entrée. + // Opens the effort menu, where the digits 1 to 9 select an entry. effortMenu: { name: "Claude Effort Menu", type: "shortcut", @@ -229,45 +229,44 @@ const ACTION_DEFINITIONS = { }, }; -// Attente laissée au sélecteur d'effort pour apparaître, en millisecondes. +// Time given to the effort picker to appear, in milliseconds. // -// Input ne documente pas si `delay` s'applique avant ou après son étape, et -// aucune source ne permet de le trancher : le champ est transmis verbatim au -// firmware, qui seul l'interprète. La macro contourne la question par sa forme -// plutôt que par une mesure. Toute l'attente est posée sur la libération de ⌘ et -// la flèche reçoit 0, ce qui rend les deux lectures équivalentes : +// Input does not document whether `delay` applies before or after its step, and +// no source settles it: the field is passed verbatim to the firmware, which +// alone interprets it. The macro sidesteps the question by its shape rather +// than by a measurement. All of the wait is placed on the ⌘ release and the +// arrow gets 0, which makes both readings equivalent: // -// - lecture « après » : ⌘ relâché, attente, flèche -> sélecteur : ce délai -// - lecture « avant » : attente, ⌘ relâché, flèche -> sélecteur : ce délai +// - "after" reading: ⌘ released, wait, arrow -> picker gets this delay +// - "before" reading: wait, ⌘ released, arrow -> picker gets this delay // -// Même marge et même durée totale dans les deux cas. Ne pas répartir cette -// attente sur les deux étapes : cela double le délai d'ouverture sans rien -// garantir de plus. +// Same margin and same total duration either way. Do not split this wait across +// both steps: that doubles the opening delay and guarantees nothing more. // -// Calibrage matériel, échelle descendante testée sur Codex Micro : 40 ms tient, -// 20 ms échoue. La valeur retenue double ce plancher mesuré. Le délai -// d'ouverture est donc de 80 ms, auquel s'ajoutent les 10 ms de retour visuel -// ci-dessous, contre 900 ms pour la première version de la macro. +// Hardware calibration, descending scale tested on the Codex Micro: 40ms holds, +// 20ms fails. The chosen value doubles that measured floor. The opening delay is +// therefore 80ms, plus the 10ms of visual feedback below, against 900ms for the +// first version of the macro. // -// En descendant plus bas, l'échec n'est pas bruyant : la flèche part avant que -// le sélecteur ait le focus et le changement de niveau est perdu sans trace. -// Toute nouvelle baisse doit donc être validée par plusieurs répétitions ET par -// une première ouverture à froid, au retour d'une autre application. +// Going lower fails quietly: the arrow leaves before the picker has focus and +// the level change is lost without a trace. Any further reduction must be +// validated over several repetitions AND on a cold first open, coming back from +// another application. const EFFORT_PICKER_DELAY_MS = 80; -// Attente portée par l'étape Escape, en millisecondes. Elle sert au retour -// visuel, pas à la fiabilité, et ne doit pas être ramenée à 0. +// Wait carried by the Escape step, in milliseconds. It serves visual feedback, +// not reliability, and must not be dropped to 0. // -// Sans elle, la flèche et Escape sont émis sans écart et Claude les traite dans -// le même tour de boucle : le sélecteur s'ouvre et se referme sans jamais peindre -// une image montrant le slider à son nouveau niveau. On change donc l'effort à -// l'aveugle, et l'effet visible est un simple clignotement. 10 ms suffisent à -// laisser passer une image, et le niveau atteint devient lisible. +// Without it, the arrow and Escape are emitted with no gap and Claude handles +// them in the same loop turn: the picker opens and closes without ever painting +// a frame showing the slider at its new level. Effort then changes blind, and +// the visible effect is a mere flicker. 10ms is enough to let one frame through, +// and the level reached becomes readable. // -// Cette attente est payée APRÈS que le niveau a changé : elle allonge la macro -// sans retarder son effet. C'est aussi ce qui indique que `delay` s'applique -// avant son étape et non après — dans la lecture « après » ces 10 ms seraient du -// temps mort en fin de macro et ne changeraient rien à l'affichage. +// This wait is paid AFTER the level has changed: it lengthens the macro without +// delaying its effect. That is also what indicates `delay` applies before its +// step and not after — under the "after" reading these 10ms would be dead time +// at the end of the macro and would change nothing on screen. const EFFORT_FEEDBACK_DELAY_MS = 10; const WHEEL_MODES = { @@ -389,17 +388,17 @@ function buildKeyInputs(keys) { ]; } -// Claude Desktop ouvre le sélecteur d'effort avec ⌘⇧E. Son curseur ARIA -// accepte ensuite gauche/droite pour passer au niveau disponible précédent ou -// suivant. ⌘⇧E est une bascule vérifiée sur Claude Desktop : chaque cran doit -// donc refermer le sélecteur avec Escape, sinon le cran suivant le referme au -// lieu de l'ouvrir et le niveau est sauté. +// Claude Desktop opens the effort picker with ⌘⇧E. Its ARIA slider then takes +// left/right to move to the previous or next available level. ⌘⇧E is a toggle, +// verified on Claude Desktop: every notch must therefore close the picker with +// Escape, otherwise the next notch closes it instead of opening it and the level +// is skipped. // -// Le sélecteur est rendu de façon asynchrone, il faut donc l'attendre avant -// d'envoyer la flèche : EFFORT_PICKER_DELAY_MS, porté par la seule libération de -// ⌘. Puis il faut le laisser peindre le niveau atteint avant de le refermer : -// EFFORT_FEEDBACK_DELAY_MS, porté par Escape. L'étape de la flèche reste à 0, -// c'est elle qui rend les deux lectures possibles de `delay` équivalentes. +// The picker renders asynchronously, so it has to be waited for before sending +// the arrow: EFFORT_PICKER_DELAY_MS, carried by the ⌘ release alone. Then it has +// to be allowed to paint the level reached before closing: +// EFFORT_FEEDBACK_DELAY_MS, carried by Escape. The arrow step stays at 0, and +// that is what makes both possible readings of `delay` equivalent. function buildEffortWheelKeyInputs(directionKeycode) { return [ { keycode: "KC_LGUI", delay: 0, actionType: 1 }, @@ -435,6 +434,29 @@ function decodeKeyInputs(keyInputs) { return keys; } +let actionDecodingIndexes; + +function getActionDecodingIndexes() { + if (actionDecodingIndexes) return actionDecodingIndexes; + + const directActionByKeycode = new Map(); + const sequencedActionByInputs = new Map(); + for (const [id, definition] of Object.entries(ACTION_DEFINITIONS)) { + if (definition.type === "direct") { + directActionByKeycode.set(FINAL_KEYCODES[definition.key], id); + } else if (definition.type === "directKeycode") { + directActionByKeycode.set(definition.keycode, id); + } else if (definition.type === "shortcut") { + sequencedActionByInputs.set(JSON.stringify(buildKeyInputs(definition.keys)), id); + } else if (definition.type === "sequence") { + sequencedActionByInputs.set(JSON.stringify(definition.keyInputs), id); + } + } + + actionDecodingIndexes = { directActionByKeycode, sequencedActionByInputs }; + return actionDecodingIndexes; +} + function nextId(items) { return items.reduce((highest, item) => Math.max(highest, Number(item.id) || 0), -1) + 1; } @@ -444,12 +466,7 @@ function findOrCreateAction(profile, name, keyInputs, createdActionIds) { if (existing) return existing.id; const id = nextId(profile.actions); - profile.actions.push({ - id, - name, - color: null, - keyInputs, - }); + profile.actions.push({ id, name, color: null, keyInputs }); createdActionIds.push(id); return id; } @@ -479,12 +496,7 @@ function resolveKeyAssignment(profile, assignment, createdActionIds) { } if (definition.type === "sequence") { const keyInputs = clone(definition.keyInputs); - const id = findOrCreateAction( - profile, - definition.name, - keyInputs, - createdActionIds, - ); + const id = findOrCreateAction(profile, definition.name, keyInputs, createdActionIds); return `KA_${id}`; } if (definition.type === "direct") { @@ -533,21 +545,22 @@ function addActionsToGroup(profile, actionIds) { group.actionIds = [...new Set([...group.actionIds, ...actionIds])]; } -// Le joystick accepte, en plus des deux préréglages `navigation` et `none`, une -// affectation par direction : +// On top of the two `navigation` and `none` presets, the joystick accepts a +// per-direction assignment: // // { directions: 4, sectors: ["newSession", "voice", "diff", "stop"] } // -// Chaque secteur prend la même valeur qu'une touche — identifiant du catalogue, -// raccourci personnalisé, ou `none`. La sérialisation d'Input convertit bien les -// références `KA_` dans les secteurs, donc une macro complète y est possible et -// pas seulement un keycode nu. +// Each sector takes the same value as a key — catalogue id, custom shortcut, or +// `none`. Input's serialisation does convert `KA_` references inside sectors, so +// a full macro is possible there and not only a bare keycode. // -// 45° restent réservés à la zone de fermeture `KI_X` en haut, exactement comme -// le gabarit par défaut d'Input. Les 315° restants se partagent, soit 78,75° à -// quatre directions et 39,4° à huit. Au-delà de huit, viser au pouce devient -// hasardeux : la borne est ergonomique, le format n'en impose aucune. +// 45° stay reserved for the `KI_X` close zone at the top, exactly like Input's +// default template. The remaining 315° are shared out, so 78.75° at four +// directions and 39.4° at eight. Beyond eight, aiming with a thumb gets +// unreliable: the bound is ergonomic, the format imposes none. const JOYSTICK_DIRECTION_COUNTS = Object.freeze([4, 8]); +const JOYSTICK_CLOSE_ANGLE = 45 / 360; +const JOYSTICK_START_ANGLE = (90 - 45 / 2) / 360; function isCustomJoystick(value) { return Boolean(value) && typeof value === "object" && Array.isArray(value.sectors); @@ -566,23 +579,35 @@ function validateCustomJoystick(joystick) { ); } -function radialSectors(keycodes) { - const closeAngle = 45 / 360; - const start = (90 - 45 / 2) / 360; - const remainingAngle = 1 - closeAngle; - const sectorAngle = remainingAngle / keycodes.length; - const sectors = [{ k: "KI_X", a1: start, a2: (start + closeAngle) % 1 }]; - - keycodes.forEach((keycode, index) => { - const a1 = start + closeAngle + sectorAngle * index; - sectors.push({ - k: keycode, - a1: a1 % 1, - a2: (a1 + sectorAngle) % 1, - }); - }); +function radialSectorGeometry(directionCount) { + assert( + Number.isInteger(directionCount) && directionCount > 0, + `Le joystick doit contenir au moins une direction, reçu : ${JSON.stringify(directionCount)}`, + "JOYSTICK_SECTOR_COUNT", + ); + const sectorAngle = (1 - JOYSTICK_CLOSE_ANGLE) / directionCount; + return { + close: { + a1: JOYSTICK_START_ANGLE, + a2: (JOYSTICK_START_ANGLE + JOYSTICK_CLOSE_ANGLE) % 1, + }, + sectors: Array.from({ length: directionCount }, (_, index) => { + const a1 = JOYSTICK_START_ANGLE + JOYSTICK_CLOSE_ANGLE + sectorAngle * index; + return { index, a1: a1 % 1, a2: (a1 + sectorAngle) % 1 }; + }), + }; +} - return sectors; +function radialSectors(keycodes) { + const geometry = radialSectorGeometry(keycodes.length); + return [ + { k: "KI_X", ...geometry.close }, + ...geometry.sectors.map(({ a1, a2 }, index) => ({ + k: keycodes[index], + a1, + a2, + })), + ]; } export function inspectInputProfile(source, { requireAppSense = true } = {}) { @@ -688,10 +713,10 @@ function hasClaudeLayout(layer) { ); } -// Crée le layer « Claude » à partir d'un export qui n'en contient pas, en -// clonant la structure d'un layer existant. Le lien AppSense (linkedAppId) -// référence le registre local d'Input et ne peut pas être inventé ici : le -// layer créé doit être lié via « Auto detect » après import. +// Creates the "Claude" layer from an export that has none, by cloning the +// structure of an existing layer. The AppSense link (linkedAppId) references +// Input's local registry and cannot be invented here: the created layer has to +// be linked through "Auto detect" after import. export function addClaudeLayer(source) { assert(source && typeof source === "object", "Le fichier JSON est vide.", "EMPTY_FILE"); assert( @@ -737,9 +762,9 @@ export function addClaudeLayer(source) { layer.id = nextId(output.profile.layers); layer.name = TARGET_LAYER_NAME; delete layer.linkedAppId; - // Sécurité par défaut : les contrôles assignables hérités du modèle sont - // neutralisés. Le capteur base[3][0] conserve sa fonction de changement de - // layer et ne sera jamais exposé dans le configurateur. + // Safe by default: the assignable controls inherited from the template are + // cleared. The base[3][0] sensor keeps its layer-change function and is never + // exposed in the configurator. for (const [rowIndex, row] of layer.layout.base.entries()) { if (!Array.isArray(row)) continue; for (const [columnIndex, cell] of row.entries()) { @@ -767,33 +792,20 @@ export function deriveMappingFromProfile(source) { const inspection = inspectInputProfile(source, { requireAppSense: false }); const layer = source.profile.layers[inspection.layerIndex]; const actionsById = new Map(source.actions.map((action) => [String(action.id), action])); + const { directActionByKeycode, sequencedActionByInputs } = getActionDecodingIndexes(); const decodeCell = (cell) => { const keycode = cell?.keycode; if (!keycode || keycode === "KC_NONE") return "none"; - for (const [id, definition] of Object.entries(ACTION_DEFINITIONS)) { - if ( - (definition.type === "direct" && FINAL_KEYCODES[definition.key] === keycode) || - (definition.type === "directKeycode" && definition.keycode === keycode) - ) { - return id; - } - } + const directAction = directActionByKeycode.get(keycode); + if (directAction) return directAction; const reference = /^KA_(\d+)$/.exec(keycode); if (reference) { const action = actionsById.get(reference[1]); if (!action) return "none"; - for (const [id, definition] of Object.entries(ACTION_DEFINITIONS)) { - if ( - (definition.type === "shortcut" && - sameJson(action.keyInputs, buildKeyInputs(definition.keys))) || - (definition.type === "sequence" && - sameJson(action.keyInputs, definition.keyInputs)) - ) { - return id; - } - } + const sequencedAction = sequencedActionByInputs.get(JSON.stringify(action.keyInputs)); + if (sequencedAction) return sequencedAction; const keys = decodeKeyInputs(action.keyInputs); return keys ? { type: "custom", keys } : "none"; } @@ -839,8 +851,8 @@ export function deriveMappingFromProfile(source) { encoder[PHYSICAL_ENCODER_SLOTS.press], ); - // Le premier secteur est toujours la zone de fermeture `KI_X` : les - // directions utiles sont les suivants, dans l'ordre où radialSectors les pose. + // The first sector is always the `KI_X` close zone: the useful directions are + // the ones after it, in the order radialSectors lays them down. const sectors = (layer.layout.joystick?.sectors ?? []).filter( (sector) => sector.k !== "KI_X", ); @@ -861,12 +873,11 @@ export function deriveMappingFromProfile(source) { return { mapping, assigned }; } -// Un fichier `*-profile.json` exporté par Input ne transporte PAS la table -// `linkedApps`, seulement les références `linkedAppId` posées sur les layers. -// Aucune des deux options ci-dessous ne peut donc créer une entrée : elles -// écrivent une référence vers une entrée qui doit déjà exister sur la carte, -// créée une fois dans l'UI d'Input. Une référence vers une entrée absente -// s'importe sans erreur et laisse AppSense mort sans le dire. +// A `*-profile.json` exported by Input does NOT carry the `linkedApps` table, +// only the `linkedAppId` references set on the layers. Neither option below can +// therefore create an entry: they write a reference to an entry that must +// already exist on the board, created once in Input's UI. A reference to a +// missing entry imports without error and leaves AppSense dead without saying so. function validateAppSenseId(value, label) { assert( Number.isInteger(value) && value >= 0, @@ -886,8 +897,8 @@ export function buildInputProfile( if (forcesClaudeLink) validateAppSenseId(appSenseId, "appSenseId"); if (linksBaseLayer) validateAppSenseId(baseLayerAppSenseId, "baseLayerAppSenseId"); - // Deux layers liés à la même entrée rendent la bascule ambiguë : le firmware - // ne documente pas dans quel ordre il parcourt sa table. + // Two layers linked to the same entry make the switch ambiguous: the firmware + // does not document the order in which it walks its table. if (forcesClaudeLink && linksBaseLayer) { assert( appSenseId !== baseLayerAppSenseId, @@ -896,8 +907,8 @@ export function buildInputProfile( ); } - // Forcer le lien du layer Claude rend son absence dans la source acceptable : - // c'est précisément le cas d'usage, réparer un lien perdu. + // Forcing the Claude layer's link makes its absence in the source acceptable: + // that is precisely the use case, repairing a lost link. const inspection = inspectInputProfile(source, { requireAppSense: requireAppSense && !forcesClaudeLink, }); @@ -921,9 +932,10 @@ export function buildInputProfile( ); } - const wheelMode = WHEEL_MODES[mapping.wheel]; + const hasWheelMode = Object.hasOwn(WHEEL_MODES, mapping.wheel); + const wheelMode = hasWheelMode ? WHEEL_MODES[mapping.wheel] : null; assert( - mapping.wheel in WHEEL_MODES, + hasWheelMode, `Action inconnue pour la molette : ${mapping.wheel}`, "UNKNOWN_ASSIGNMENT", ); @@ -982,9 +994,9 @@ export function buildInputProfile( addActionsToGroup(output, createdActionIds); if (forcesClaudeLink) targetLayer.linkedAppId = appSenseId; - // AppSense n'a pas de retour : chaque règle est une transition aller. Lier le - // layer natif à une seconde application est le seul moyen de quitter le layer - // Claude automatiquement, en entrant dans ce layer-là. Voir + // AppSense has no return path: every rule is a one-way transition. Linking the + // native layer to a second application is the only way to leave the Claude + // layer automatically, by entering that one. See // docs/research/appsense-behavior.md. if (linksBaseLayer) output.profile.layers[0].linkedAppId = baseLayerAppSenseId; @@ -1004,8 +1016,8 @@ export function buildInputProfile( "Le lien AppSense du layer Claude n’a pas été préservé.", ); if (linksBaseLayer) { - // Le lien change, jamais le keymap : les touches natives OpenAI doivent - // rester intactes au keycode près. + // The link changes, never the keymap: the native OpenAI keys must stay + // intact down to the keycode. assert( sameJson(source.profile.layers[0].layout, output.profile.layers[0].layout), "Le keymap du layer natif Work Louder a été modifié.", @@ -1033,12 +1045,12 @@ export function buildInputProfile( preservedLayers: output.profile.layers.length - 1, nativeLayerPreserved: true, appSensePreserved: inspection.appSenseLinked, - // Référence effectivement écrite sur le layer Claude, forcée ou héritée. + // Reference actually written on the Claude layer, forced or inherited. appSenseId: forcesClaudeLink ? appSenseId : output.profile.layers[inspection.layerIndex].linkedAppId, appSenseForced: forcesClaudeLink, - // Lien du layer natif, qui fournit la transition de sortie du layer Claude. + // Native layer link, which provides the exit transition out of the Claude layer. baseLayerAppSenseId: linksBaseLayer ? baseLayerAppSenseId : null, assignedSwitches: [...KEY_CONTROL_ORDER, ENCODER_PRESS_CONTROL].filter( (controlId) => mapping[controlId] !== "none", @@ -1061,7 +1073,9 @@ export { FORBIDDEN_KEYS, KEY_CONTROL_LOCATIONS, KEY_CONTROL_ORDER, + JOYSTICK_DIRECTION_COUNTS, MODIFIER_KEYCODES, PRINTABLE_KEYS, WHEEL_MODES, + radialSectorGeometry, }; diff --git a/shared/thread-status-palette.mjs b/shared/thread-status-palette.mjs index 86c1720..fdc45bd 100644 --- a/shared/thread-status-palette.mjs +++ b/shared/thread-status-palette.mjs @@ -1,11 +1,11 @@ -// Palette des états de session Claude Code, partagée entre l'outillage Node et le -// GUI. Elle vit dans `shared/` parce que c'est la seule chose que les deux côtés -// ont besoin de connaître en commun : le réducteur reste dans `scripts/lib/`, où -// le GUI n'a rien à aller chercher. +// Claude Code session state palette, shared between the Node tooling and the +// GUI. It lives in `shared/` because it is the only thing both sides need to +// know in common: the reducer stays in `scripts/lib/`, where the GUI has nothing +// to look for. // -// Source unique de vérité. `scripts/lib/thread-slots.mjs` la réexporte pour ne -// pas casser ses importateurs, et le GUI l'importe pour afficher sa légende — les -// deux ne peuvent donc pas diverger. +// Single source of truth. `scripts/lib/thread-slots.mjs` re-exports it so its +// importers keep working, and the GUI imports it to draw its legend — so the two +// cannot diverge. export const STATES = Object.freeze({ free: "free", @@ -16,9 +16,9 @@ export const STATES = Object.freeze({ ended: "ended", }); -// Teintes reprises de la palette du dépôt. `blocked` est la seule ajoutée : -// aucune couleur existante ne signifiait « une décision est attendue ». -// `free` vaut `null` : un emplacement libre est éteint, pas coloré. +// Hues taken from the repository palette. `blocked` is the only one added: no +// existing colour meant "a decision is waiting". +// `free` is `null`: a free slot is unlit, not coloured. export const STATE_COLORS = Object.freeze({ free: null, idle: "#6D5A7D", @@ -28,8 +28,8 @@ export const STATE_COLORS = Object.freeze({ ended: "#2F2927", }); -// Ordre de lecture pour une légende : du plus urgent au plus inerte. Ce n'est pas -// l'ordre de `STATES`, qui suit le cycle de vie d'une session. +// Reading order for a legend: from the most urgent to the most inert. This is +// not the order of `STATES`, which follows a session's life cycle. export const LEGEND_ORDER = Object.freeze([ STATES.blocked, STATES.running, diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs new file mode 100644 index 0000000..cb0bb0c --- /dev/null +++ b/tests/cli.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { sourceProfile } from "./helpers/input-profile-fixture.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function runScript(relativePath, args = [], options = {}) { + return spawnSync(process.execPath, [path.join(repositoryRoot, relativePath), ...args], { + cwd: repositoryRoot, + encoding: "utf8", + timeout: 5000, + ...options, + }); +} + +async function temporaryDirectory(t) { + const directory = await mkdtemp(path.join(os.tmpdir(), "codex-micro-test-")); + t.after(() => rm(directory, { recursive: true, force: true })); + return directory; +} + +test("every command exposes a safe help path without requiring hardware", async (t) => { + const cases = [ + ["scripts/input-layer.mjs", ["help"]], + ["scripts/lighting.mjs", ["--help"]], + ["scripts/lighting-probe.mjs", ["--help"]], + ["scripts/thread-status.mjs", ["--help"]], + ["scripts/enable-agent-keys.mjs", []], + ["scripts/configure.mjs", ["--help"]], + ]; + + for (const [script, args] of cases) { + await t.test(script, () => { + const result = runScript(script, args); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage:/); + assert.equal(result.stderr, ""); + }); + } + + const missingArguments = runScript("scripts/build-input-profile.mjs"); + assert.equal(missingArguments.status, 1); + assert.match(missingArguments.stderr, /Usage:/); +}); + +test("repository validation commands run successfully from the checkout", async (t) => { + for (const script of [ + "scripts/validate-profile.mjs", + "scripts/validate-presets.mjs", + "scripts/check-doc-links.mjs", + "scripts/prepare-gui.mjs", + ]) { + await t.test(script, () => { + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /OK:/); + }); + } +}); + +test("build-input-profile writes a validated profile once and refuses overwrite", async (t) => { + const directory = await temporaryDirectory(t); + const inputPath = path.join(directory, "source-profile.json"); + const outputPath = path.join(directory, "built-profile.json"); + await writeFile(inputPath, JSON.stringify(sourceProfile())); + + const result = runScript("scripts/build-input-profile.mjs", [ + inputPath, + outputPath, + "--app-sense-id=12", + "--base-layer-app-sense-id=13", + ]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Input profile written/); + + const output = JSON.parse(await readFile(outputPath, "utf8")); + assert.equal(output.profile.name, "Claude macOS"); + assert.equal(output.profile.layers[0].linkedAppId, 13); + assert.equal(output.profile.layers[1].linkedAppId, 12); + + const before = await readFile(outputPath, "utf8"); + const overwrite = runScript("scripts/build-input-profile.mjs", [inputPath, outputPath]); + assert.notEqual(overwrite.status, 0); + assert.equal(await readFile(outputPath, "utf8"), before); +}); + +test("input-layer inspects, inventories, and plans from an isolated official export", async (t) => { + const directory = await temporaryDirectory(t); + const profilePath = path.join(directory, "source-profile.json"); + const inventoryPath = path.join(directory, "inventory.json"); + await writeFile(profilePath, JSON.stringify(sourceProfile())); + + const inspection = runScript("scripts/input-layer.mjs", [ + "inspect-export", + "--input", + profilePath, + "--json", + ]); + assert.equal(inspection.status, 0, inspection.stderr); + const inspectionResult = JSON.parse(inspection.stdout); + assert.equal(inspectionResult.kind, "profile"); + assert.equal("payload" in inspectionResult, false, "the CLI summary must not echo the profile"); + + const inventory = runScript("scripts/input-layer.mjs", [ + "inventory", + "--profile-export", + profilePath, + "--output", + inventoryPath, + "--json", + ]); + assert.equal(inventory.status, 0, inventory.stderr); + const inventoryResult = JSON.parse(inventory.stdout); + assert.equal(inventoryResult.inventory.layers[1].name, "Claude"); + assert.equal(inventoryResult.inventory.layers[1].appSenseLinked, true); + + const plan = runScript("scripts/input-layer.mjs", [ + "install", + "--inventory", + inventoryPath, + "--json", + ]); + assert.equal(plan.status, 0, plan.stderr); + const planResult = JSON.parse(plan.stdout); + assert.equal(planResult.dryRun, true); + assert.equal(planResult.plan.targetLayerIndex, 1); + assert.equal(planResult.plan.canGenerateProfile, true); + assert.deepEqual(planResult.plan.blockers, []); +}); + +test("enable-agent-keys changes only the six Claude cells and can revert them", async (t) => { + const directory = await temporaryDirectory(t); + const sourcePath = path.join(directory, "source-profile.json"); + const enabledPath = path.join(directory, "enabled-profile.json"); + const revertedPath = path.join(directory, "reverted-profile.json"); + const source = sourceProfile(); + const nativeLayer = structuredClone(source.profile.layers[0]); + await writeFile(sourcePath, JSON.stringify(source)); + + const enabled = runScript("scripts/enable-agent-keys.mjs", [sourcePath, enabledPath]); + assert.equal(enabled.status, 0, enabled.stderr); + const enabledProfile = JSON.parse(await readFile(enabledPath, "utf8")); + assert.deepEqual(enabledProfile.profile.layers[0], nativeLayer); + assert.deepEqual( + enabledProfile.profile.layers[1].layout.base.slice(0, 2).map((row) => + row.map((cell) => cell.keycode), + ), + [ + ["KV_OAI_AG00", "KV_OAI_AG01"], + ["KV_OAI_AG02", "KV_OAI_AG03", "KV_OAI_AG04", "KV_OAI_AG05"], + ], + ); + + const reverted = runScript("scripts/enable-agent-keys.mjs", [ + enabledPath, + revertedPath, + "--revert", + ]); + assert.equal(reverted.status, 0, reverted.stderr); + const revertedProfile = JSON.parse(await readFile(revertedPath, "utf8")); + assert.equal( + revertedProfile.profile.layers[1].layout.base + .slice(0, 2) + .flat() + .every((cell) => cell.keycode === "KC_NONE"), + true, + ); +}); + +test("the thread-status hook records only transition metadata and stays silent", async (t) => { + const directory = await temporaryDirectory(t); + const env = { + ...process.env, + CLAUDE_THREAD_STATUS_DIR: directory, + CLAUDE_CODE_SESSION_ID: "session-from-env", + CLAUDE_PID: "1234", + CLAUDE_CODE_ENTRYPOINT: "cli", + }; + const payload = { + hook_event_name: "Notification", + notification_type: "permission_prompt", + cwd: "/tmp/project", + message: "must never be persisted", + }; + + const emitted = runScript("thread-status/bin/emit.mjs", [], { + env, + input: JSON.stringify(payload), + }); + assert.equal(emitted.status, 0); + assert.equal(emitted.stdout, ""); + assert.equal(emitted.stderr, ""); + + const journalPath = path.join(directory, "events.ndjson"); + const record = JSON.parse((await readFile(journalPath, "utf8")).trim()); + assert.equal(record.event, "Notification"); + assert.equal(record.sessionId, "session-from-env"); + assert.equal(record.notificationType, "permission_prompt"); + assert.equal(record.pid, 1234); + assert.equal("message" in record, false); + + const before = await readFile(journalPath, "utf8"); + const invalid = runScript("thread-status/bin/emit.mjs", [], { + env, + input: "not-json", + }); + assert.equal(invalid.status, 0); + assert.equal(await readFile(journalPath, "utf8"), before); +}); diff --git a/tests/gui-dependencies.test.mjs b/tests/gui-dependencies.test.mjs new file mode 100644 index 0000000..8e853b2 --- /dev/null +++ b/tests/gui-dependencies.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { ensureGuiDependencies } from "../scripts/lib/gui-dependencies.mjs"; + +const guiDirectory = path.resolve("/tmp", "codex-micro-prototype"); + +function dependencyPaths(platform = "linux") { + return { + packageJson: path.join(guiDirectory, "package.json"), + vite: path.join( + guiDirectory, + "node_modules", + ".bin", + platform === "win32" ? "vite.cmd" : "vite", + ), + }; +} + +test("skips installation when the locked GUI dependencies are already present", () => { + const { packageJson, vite } = dependencyPaths(); + let spawned = false; + const result = ensureGuiDependencies(guiDirectory, { + exists: (candidate) => candidate === packageJson || candidate === vite, + spawn: () => { + spawned = true; + }, + log: () => assert.fail("an existing install should not log preparation"), + platform: "linux", + }); + + assert.deepEqual(result, { installed: false }); + assert.equal(spawned, false); +}); + +test("installs with the platform-specific npm command and locked options", () => { + const { packageJson } = dependencyPaths("win32"); + const calls = []; + const logs = []; + const result = ensureGuiDependencies(guiDirectory, { + exists: (candidate) => candidate === packageJson, + spawn: (...args) => { + calls.push(args); + return { status: 0 }; + }, + log: (message) => logs.push(message), + platform: "win32", + }); + + assert.deepEqual(result, { installed: true }); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], "npm.cmd"); + assert.deepEqual(calls[0][1], ["ci", "--ignore-scripts", "--no-audit", "--no-fund"]); + assert.deepEqual(calls[0][2], { cwd: guiDirectory, stdio: "inherit" }); + assert.equal(logs.length, 1); +}); + +test("reports missing files, spawn failures, and non-zero install exits", () => { + assert.throws( + () => ensureGuiDependencies(guiDirectory, { exists: () => false }), + /prototype\/package\.json is missing/, + ); + + const { packageJson } = dependencyPaths(); + const exists = (candidate) => candidate === packageJson; + assert.throws( + () => + ensureGuiDependencies(guiDirectory, { + exists, + spawn: () => ({ error: new Error("spawn failed") }), + log: () => {}, + }), + /spawn failed/, + ); + + assert.throws( + () => + ensureGuiDependencies(guiDirectory, { + exists, + spawn: () => ({ status: 17 }), + log: () => {}, + }), + (error) => error.exitCode === 17 && /code 17/.test(error.message), + ); + + assert.throws( + () => + ensureGuiDependencies(guiDirectory, { + exists, + spawn: () => ({ status: null }), + log: () => {}, + }), + (error) => error.exitCode === 1 && /code 1/.test(error.message), + ); +}); diff --git a/tests/helpers/input-profile-fixture.mjs b/tests/helpers/input-profile-fixture.mjs new file mode 100644 index 0000000..5e6a47c --- /dev/null +++ b/tests/helpers/input-profile-fixture.mjs @@ -0,0 +1,81 @@ +export function sourceProfile() { + return { + keyboard: "codex_micro", + language: "us", + profile: { + id: 0, + name: "Default", + layers: [ + { + id: 0, + name: "Layer 1", + layout: { + encoders: [[ + { keycode: "KV_OAI_ENC_CC" }, + { keycode: "KV_OAI_ENC_CW" }, + { keycode: "KV_OAI_ENC_CLK" }, + ]], + joystick: { type: "VENDOR", sectors: [] }, + base: [[{ keycode: "KV_0" }]], + }, + }, + { + id: 1, + name: "Claude", + linkedAppId: 7, + layout: { + encoders: [[ + { keycode: "KC_NONE" }, + { keycode: "KC_NONE" }, + { keycode: "KC_NONE" }, + ]], + joystick: { + type: "RADIAL", + sectors: [ + { k: "KI_X", a1: 0.1875, a2: 0.3125 }, + { k: "KC_NONE", a1: 0.3125, a2: 0.1875 }, + ], + }, + base: [ + [{ keycode: "KC_NONE" }, { keycode: "KC_NONE" }], + [ + { keycode: "KC_NONE" }, + { keycode: "KC_NONE" }, + { keycode: "KC_NONE" }, + { keycode: "KC_NONE" }, + ], + [ + { keycode: "KA_0" }, + { keycode: "KA_1" }, + { keycode: "KA_2" }, + { keycode: "KC_ESC" }, + ], + [ + { keycode: "KC_NONE" }, + { keycode: "KC_NONE" }, + { keycode: "KC_NONE" }, + ], + ], + }, + }, + ], + }, + actions: [ + { + id: 0, + name: "Claude New", + color: null, + keyInputs: [ + { keycode: "KC_LGUI", delay: 0, actionType: 1 }, + { keycode: "KC_N", delay: 0, actionType: 2 }, + { keycode: "KC_LGUI", delay: 0, actionType: 0 }, + ], + }, + ], + multiactions: [], + smartActions: [], + actionGroups: [{ id: 0, name: "Default", actionIds: [0] }], + multiactionGroups: [], + smartActionGroups: [], + }; +} diff --git a/tests/hid-device.test.mjs b/tests/hid-device.test.mjs new file mode 100644 index 0000000..6a627d3 --- /dev/null +++ b/tests/hid-device.test.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import { + DeviceSession, + PRODUCT_ID, + VENDOR_ID, + VENDOR_USAGE_PAGE, + isCodexVendorInterface, +} from "../scripts/lib/hid-device.mjs"; +import { + CHANNEL_DEBUG, + REPORT_ID, + REPORT_SIZE, + decodeReport, + encodeFrames, +} from "../scripts/lib/hid-frame.mjs"; +import { METHODS } from "../scripts/lib/hid-lighting.mjs"; + +class FakeHandle extends EventEmitter { + writes = []; + writeError = null; + closed = false; + + async write(frame) { + if (this.writeError) throw this.writeError; + this.writes.push(frame); + } + + async close() { + this.closed = true; + this.emit("close"); + } +} + +function emitRpc(handle, payload) { + for (const frame of encodeFrames(`${JSON.stringify(payload)}\n`)) { + handle.emit("data", frame); + } +} + +function emitDebug(handle, line) { + const bytes = Buffer.from(`${line}\n`, "utf8"); + const frame = Buffer.alloc(REPORT_SIZE); + frame[0] = REPORT_ID; + frame[1] = CHANNEL_DEBUG; + frame[2] = bytes.length; + bytes.copy(frame, 3); + handle.emit("data", frame); +} + +async function waitFor(predicate, message = "condition") { + const deadline = Date.now() + 500; + while (!predicate()) { + if (Date.now() >= deadline) assert.fail(`Timed out waiting for ${message}`); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} + +test("filters the vendor RPC collection without touching unrelated HID interfaces", () => { + const match = { + vendorId: VENDOR_ID, + productId: PRODUCT_ID, + usagePage: VENDOR_USAGE_PAGE, + }; + assert.equal(isCodexVendorInterface(match), true); + assert.equal(isCodexVendorInterface({ ...match, usagePage: 1 }), false); + assert.equal(isCodexVendorInterface({ ...match, productId: 1 }), false); + assert.equal(isCodexVendorInterface(null), false); +}); + +test("writes an RPC request and resolves the matching response", async () => { + const handle = new FakeHandle(); + const session = new DeviceSession(handle); + const response = session.call("sys.version", null, 41); + + await waitFor(() => handle.writes.length === 1, "the first HID write"); + const request = JSON.parse(decodeReport(handle.writes[0]).payload); + assert.deepEqual(request, { method: "sys.version", params: null, id: 41 }); + + emitRpc(handle, { result: { version: "0.4.1" }, id: 41 }); + assert.deepEqual(await response, { result: { version: "0.4.1" }, id: 41 }); + await session.close(); + assert.equal(handle.closed, true); +}); + +test("keeps exactly one request in flight and starts the next after its response", async () => { + const handle = new FakeHandle(); + const session = new DeviceSession(handle); + const first = session.call("first", null, 1); + const second = session.call("second", null, 2); + + await waitFor(() => handle.writes.length === 1, "the first queued request"); + await new Promise((resolve) => setTimeout(resolve, 65)); + assert.equal(handle.writes.length, 1, "the second request must wait for the first response"); + + emitRpc(handle, { result: "one", id: 1 }); + assert.equal((await first).result, "one"); + await waitFor(() => handle.writes.length === 2, "the second queued request"); + emitRpc(handle, { result: "two", id: 2 }); + assert.equal((await second).result, "two"); + await session.close(); +}); + +test("dispatches debug lines, notifications, and foreign lighting writes", async () => { + const handle = new FakeHandle(); + const debugLines = []; + const foreignWrites = []; + const notifications = []; + const session = new DeviceSession(handle, { + onDebugLine: (line) => debugLines.push(line), + onForeignWrite: (method, message) => foreignWrites.push({ method, message }), + }); + + const oldHandler = () => notifications.push("old"); + const removeOld = session.onNotification(METHODS.notifyHid, oldHandler); + const removeCurrent = session.onNotification(METHODS.notifyHid, (params) => { + notifications.push(params); + }); + removeOld(); + + handle.emit("data", Buffer.alloc(2)); + emitDebug(handle, "ready"); + emitRpc(handle, { method: METHODS.notifyHid, params: { key: 3 } }); + emitRpc(handle, { method: METHODS.threadsLighting, result: { ok: true }, id: 999 }); + + assert.deepEqual(debugLines, ["ready"]); + assert.deepEqual(notifications, [{ key: 3 }]); + assert.equal(foreignWrites.length, 1); + assert.equal(foreignWrites[0].method, METHODS.threadsLighting); + + removeCurrent(); + emitRpc(handle, { method: METHODS.notifyHid, params: { key: 4 } }); + assert.deepEqual(notifications, [{ key: 3 }]); + await session.close(); +}); + +test("turns RPC, write, and device failures into stable DeviceError codes", async () => { + const rpcHandle = new FakeHandle(); + const rpcSession = new DeviceSession(rpcHandle); + const rpcFailure = assert.rejects(rpcSession.call("broken", null, 5), { + name: "DeviceError", + code: "RPC_ERROR", + }); + await waitFor(() => rpcHandle.writes.length === 1, "the failing RPC request"); + emitRpc(rpcHandle, { error: { message: "bad request" }, id: 5 }); + await rpcFailure; + await rpcSession.close(); + + const writeHandle = new FakeHandle(); + writeHandle.writeError = new Error("write refused"); + const writeSession = new DeviceSession(writeHandle); + await assert.rejects(writeSession.call("write", null, 6), { + name: "DeviceError", + code: "WRITE_FAILED", + }); + await writeSession.close(); + + const disconnectedHandle = new FakeHandle(); + const disconnectedSession = new DeviceSession(disconnectedHandle); + const current = assert.rejects(disconnectedSession.call("current", null, 7), { + name: "DeviceError", + code: "DEVICE_ERROR", + }); + const queued = assert.rejects(disconnectedSession.call("queued", null, 8), { + name: "DeviceError", + code: "DEVICE_ERROR", + }); + await waitFor(() => disconnectedHandle.writes.length === 1, "the active request"); + disconnectedHandle.emit("error", new Error("transport failed")); + await Promise.all([current, queued]); + await disconnectedSession.close(); +}); diff --git a/tests/hid-frame.test.mjs b/tests/hid-frame.test.mjs index 37bc6ea..8b99cb4 100644 --- a/tests/hid-frame.test.mjs +++ b/tests/hid-frame.test.mjs @@ -15,7 +15,7 @@ import { escapeUnicode, } from "../scripts/lib/hid-frame.mjs"; -test("l'identifiant RPC reste dans la borne firmware [0, 999)", () => { +test("the RPC id stays within the firmware bound [0, 999)", () => { for (let index = 0; index < 200; index += 1) { const id = createRpcId(); assert.ok(Number.isInteger(id)); @@ -23,7 +23,7 @@ test("l'identifiant RPC reste dans la borne firmware [0, 999)", () => { } }); -test("l'enveloppe de requête est {method, params, id}, params null par défaut", () => { +test("the request envelope is {method, params, id}, params null by default", () => { assert.equal(buildRequest({ method: "sys.version", id: 42 }), '{"method":"sys.version","params":null,"id":42}'); assert.equal( buildRequest({ method: "v.oai.thstatus", params: [{ id: 0, c: 1 }], id: 7 }), @@ -31,19 +31,19 @@ test("l'enveloppe de requête est {method, params, id}, params null par défaut" ); }); -test("l'enveloppe valide la méthode et la borne de l'identifiant", () => { - assert.throws(() => buildRequest({ method: "", id: 1 }), /méthode/); - assert.throws(() => buildRequest({ method: "x", id: -1 }), /entre 0/); - assert.throws(() => buildRequest({ method: "x", id: RPC_ID_LIMIT }), /entre 0/); +test("the envelope validates the method and the id bound", () => { + assert.throws(() => buildRequest({ method: "", id: 1 }), /RPC method name/); + assert.throws(() => buildRequest({ method: "x", id: -1 }), /between 0/); + assert.throws(() => buildRequest({ method: "x", id: RPC_ID_LIMIT }), /between 0/); }); -test("les caractères non ASCII sont échappés, y compris hors BMP", () => { +test("non-ASCII characters are escaped, including beyond the BMP", () => { assert.equal(escapeUnicode("aéb"), "a\\u00e9b"); assert.equal(escapeUnicode("🎹"), "\\ud83c\\udfb9"); assert.equal(escapeUnicode("ascii"), "ascii"); }); -test("un message court tient en un rapport de 64 octets avec en-tête 06/02/longueur", () => { +test("a short message fits one 64-byte report with a 06/02/length header", () => { const frames = encodeFrames('{"a":1}'); assert.equal(frames.length, 1); const frame = frames[0]; @@ -55,31 +55,31 @@ test("un message court tient en un rapport de 64 octets avec en-tête 06/02/long assert.equal(frame.subarray(10).every((byte) => byte === 0), true); }); -test("la frontière de 61 octets découpe exactement", () => { +test("the 61-byte boundary splits exactly", () => { assert.equal(encodeFrames("x".repeat(CHUNK_PAYLOAD)).length, 1); const frames = encodeFrames("x".repeat(CHUNK_PAYLOAD + 1)); assert.equal(frames.length, 2); assert.equal(frames[0][2], CHUNK_PAYLOAD); assert.equal(frames[1][2], 1); - // Même en-tête sur les rapports de continuation, seule la longueur varie. + // Same header on the continuation reports, only the length varies. assert.equal(frames[1][0], REPORT_ID); assert.equal(frames[1][1], CHANNEL_RPC); }); -test("un message vide n'émet aucun rapport", () => { +test("an empty message emits no report", () => { assert.equal(encodeFrames("").length, 0); }); -test("un rapport se décode : canal, longueur, charge utile", () => { +test("a report decodes into channel, length and payload", () => { const [frame] = encodeFrames("bonjour"); const decoded = decodeReport(frame); assert.equal(decoded.channel, CHANNEL_RPC); assert.equal(decoded.length, 7); assert.equal(decoded.payload, "bonjour"); - assert.throws(() => decodeReport(Buffer.alloc(2)), /trop court/); + assert.throws(() => decodeReport(Buffer.alloc(2)), /too short/); }); -test("l'assembleur réunit les fragments et découpe aux sauts de ligne", () => { +test("the assembler joins fragments and splits on newlines", () => { const assemble = createLineAssembler(); const frames = encodeFrames('{"a":"' + "x".repeat(80) + '"}\n'); assert.ok(frames.length > 1); @@ -90,7 +90,7 @@ test("l'assembleur réunit les fragments et découpe aux sauts de ligne", () => assert.equal(JSON.parse(lines[0].line).a, "x".repeat(80)); }); -test("l'assembleur gère \\r\\n, plusieurs lignes et la séparation des canaux", () => { +test("the assembler handles \\r\\n, several lines and channel separation", () => { const assemble = createLineAssembler(); const rpcFrame = Buffer.alloc(REPORT_SIZE); rpcFrame[0] = REPORT_ID; @@ -115,7 +115,7 @@ test("l'assembleur gère \\r\\n, plusieurs lignes et la séparation des canaux", ); }); -test("l'accumulateur classe réponses, notifications et messages invalides", () => { +test("the accumulator sorts responses, notifications and invalid messages", () => { const accumulate = createRpcAccumulator(); const response = accumulate('{"result":{"ok":1},"id":475,"method":"v.oai.thstatus"}'); assert.equal(response.kind, "response"); @@ -131,7 +131,7 @@ test("l'accumulateur classe réponses, notifications et messages invalides", () assert.equal(invalid.kind, "invalid"); }); -test("l'accumulateur attend la fin d'un JSON fragmenté et saute les préfixes parasites", () => { +test("the accumulator waits for the end of a fragmented JSON and skips stray prefixes", () => { const accumulate = createRpcAccumulator(); assert.equal(accumulate('bruit sans json'), null); assert.equal(accumulate('{"id":12,"res'), null); @@ -139,7 +139,7 @@ test("l'accumulateur attend la fin d'un JSON fragmenté et saute les préfixes p assert.equal(message.kind, "response"); assert.equal(message.id, "12"); - // Après un message complet, l'accumulateur repart à zéro. + // After a complete message, the accumulator starts over. const next = accumulate('{"i":3,"result":2}'); assert.equal(next.kind, "response"); assert.equal(next.id, "3"); diff --git a/tests/hid-lighting.test.mjs b/tests/hid-lighting.test.mjs index 2babbd8..b40a996 100644 --- a/tests/hid-lighting.test.mjs +++ b/tests/hid-lighting.test.mjs @@ -13,12 +13,12 @@ import { } from "../scripts/lib/hid-lighting.mjs"; import { SLOT_CONTROLS, STATE_COLORS, STATES } from "../scripts/lib/thread-slots.mjs"; -test("la table thread provisoire suit l'ordre physique des six touches Agent", () => { +test("the thread id table follows the physical order of the six Agent keys", () => { assert.deepEqual([...SLOT_THREAD_IDS], [0, 1, 2, 3, 4, 5]); assert.equal(SLOT_THREAD_IDS.length, SLOT_CONTROLS.length); }); -test("les couleurs #RRGGBB deviennent des entiers RGB compactés", () => { +test("#RRGGBB colours become packed RGB integers", () => { assert.equal(colorToInt("#D97757"), 0xd97757); assert.equal(colorToInt("d97757"), 0xd97757); assert.equal(colorToInt(0x123456), 0x123456); @@ -27,7 +27,7 @@ test("les couleurs #RRGGBB deviennent des entiers RGB compactés", () => { assert.throws(() => colorToInt(0x1000000), /#RRGGBB/); }); -test("une entrée thread est minimisée et n'exige que l'identifiant", () => { +test("a thread entry is minified and requires only the id", () => { assert.deepEqual(threadEntry({ id: 3 }), { id: 3 }); assert.deepEqual( threadEntry({ id: 3, color: "#C2483D", brightness: 0.5, effect: EFFECTS.breath, speed: 1 }), @@ -39,15 +39,15 @@ test("une entrée thread est minimisée et n'exige que l'identifiant", () => { ); }); -test("les entrées thread valident leurs bornes", () => { - assert.throws(() => threadEntry({}), /Identifiant/); - assert.throws(() => threadEntry({ id: -1 }), /Identifiant/); +test("thread entries validate their bounds", () => { + assert.throws(() => threadEntry({}), /thread id/); + assert.throws(() => threadEntry({ id: -1 }), /thread id/); assert.throws(() => threadEntry({ id: 0, brightness: 1.5 }), /brightness/); assert.throws(() => threadEntry({ id: 0, speed: -0.1 }), /speed/); - assert.throws(() => threadEntry({ id: 0, effect: 99 }), /Effet/); + assert.throws(() => threadEntry({ id: 0, effect: 99 }), /Unknown effect/); }); -test("threadsLightingParams compose un tableau d'entrées", () => { +test("threadsLightingParams composes an array of entries", () => { const params = threadsLightingParams([ { id: 0, color: "#D97757" }, { id: 1, brightness: 0 }, @@ -55,7 +55,7 @@ test("threadsLightingParams compose un tableau d'entrées", () => { assert.deepEqual(params, [{ id: 0, c: 0xd97757 }, { id: 1, b: 0 }]); }); -test("une zone rgbcfg porte les cinq champs minimisés", () => { +test("an rgbcfg zone carries all five minified fields", () => { assert.deepEqual( zoneSide({ effect: EFFECTS.solid, brightness: 1, speed: 0.5, magic: 1, color: "#D97757" }), { e: 1, b: 1, s: 0.5, m: 1, c: 0xd97757 }, @@ -68,7 +68,7 @@ test("une zone rgbcfg porte les cinq champs minimisés", () => { assert.equal(config.keys.c, 0xd97757); }); -test("les six emplacements deviennent des entrées d'éclairage d'état", () => { +test("the six slots become state lighting entries", () => { const rows = [ { state: STATES.running }, { state: STATES.blocked }, @@ -82,17 +82,17 @@ test("les six emplacements deviennent des entrées d'éclairage d'état", () => assert.deepEqual(entries[0], { id: 0, c: 0xd97757, b: 1, e: EFFECTS.solid }); assert.deepEqual(entries[1], { id: 1, c: 0xc2483d, b: 1, e: EFFECTS.solid }); assert.deepEqual(entries[4], { id: 4, c: 0x2f2927, b: 1, e: EFFECTS.solid }); - // Un emplacement libre est éteint, sans toucher à la couleur. + // A free slot is unlit, without touching the colour. assert.deepEqual(entries[5], { id: 5, b: 0 }); - // La couleur d'état utilisée est bien celle de la palette thread-status. + // The state colour used is the one from the thread-status palette. assert.equal(entries[2].c, colorToInt(STATE_COLORS.idle)); }); test("slotsToThreadEntries exige exactement six lignes", () => { - assert.throws(() => slotsToThreadEntries([{ state: STATES.free }]), /6 emplacements/); - assert.throws(() => slotsToThreadEntries(null), /6 emplacements/); + assert.throws(() => slotsToThreadEntries([{ state: STATES.free }]), /Expected 6 slots/); + assert.throws(() => slotsToThreadEntries(null), /Expected 6 slots/); }); -test("allOffParams éteint les six emplacements sans autre champ", () => { +test("allOffParams turns the six slots off with no other field", () => { assert.deepEqual(allOffParams(), [0, 1, 2, 3, 4, 5].map((id) => ({ id, b: 0 }))); }); diff --git a/tests/input-layer.test.mjs b/tests/input-layer.test.mjs index d1e724f..537d3db 100644 --- a/tests/input-layer.test.mjs +++ b/tests/input-layer.test.mjs @@ -166,6 +166,147 @@ test("validators reject a sensitive key and an unprotected native layer", async assert.match(staleResult.errors.join(" "), /first-free layer selection is forbidden/); }); +test("preset diagnostics cover every safety contract with actionable messages", async (t) => { + const original = await loadPreset(path.join(projectRoot, "profiles", "claude-shortcuts")); + const cases = [ + { + name: "manifest identity and compatibility", + mutate({ manifest }) { + manifest.formatVersion = "2.0.0"; + manifest.status = "unknown"; + manifest.target.platform = "Linux"; + manifest.target.application.bundleId = "wrong.app"; + manifest.compatibility.input.bundleId = "wrong.input"; + manifest.compatibility.input.supportedVersions = []; + }, + expected: [ + /formatVersion must be 1\.0\.0/, + /Unsupported preset status/, + /target\.platform must be macOS/, + /Claude Desktop bundle identifier/, + /Input bundle identifier/, + /supportedVersions must include/, + ], + }, + { + name: "preservation and installation", + mutate({ manifest }) { + manifest.preservation.protectedLayerIndexes = []; + manifest.preservation.replaceExisting = true; + manifest.preservation.targetLayerPolicy = "first-free"; + manifest.preservation.targetLayerName = "Other"; + manifest.preservation.targetAppSenseLink = "replace"; + manifest.preservation.maxLayers = 7; + manifest.preservation.otherProfiles = "changed"; + manifest.preservation.otherLayers = "changed"; + manifest.preservation.otherAppSenseLinks = "changed"; + manifest.installation.mechanism = "direct-write"; + manifest.installation.applyMode = "automatic"; + manifest.installation.sourceArtifact = "private-storage"; + manifest.installation.outputArtifact = "storage-patch"; + manifest.validation.required = []; + }, + expected: [ + /index 0 must be protected/, + /replaceExisting must be false/, + /targetLayerPolicy/, + /targetLayerName must be Claude/, + /AppSense link must be preserved/, + /maxLayers must be 6/, + /otherProfiles must be unchanged/, + /otherLayers must be unchanged/, + /otherAppSenseLinks must be unchanged/, + /local profile transform/, + /applyMode must be guided-ui/, + /sourceArtifact/, + /outputArtifact/, + /existing Claude layer/, + ], + }, + { + name: "artifact evidence", + mutate({ manifest }) { + manifest.installation.layerArtifact = "artifacts/Claude-layer.json"; + manifest.installation.layerArtifactSha256 = "INVALID"; + manifest.installation.layerArtifactStatus = "roundtrip-verified"; + manifest.validation.completed = []; + }, + expected: [ + /exact lowercase SHA-256/, + /official-layer-export-roundtrip evidence/, + ], + warning: /layer artifact is declared/i, + }, + { + name: "mapping identity and controls", + mutate({ manifest, mapping }) { + mapping.formatVersion = "2.0.0"; + mapping.presetId = `${manifest.id}-other`; + mapping.layer.rgb.hex = "orange"; + mapping.layer.name = "Other"; + mapping.controls[1].id = mapping.controls[0].id; + mapping.controls = mapping.controls.filter((control) => control.id !== "command-row-right"); + mapping.controls.find((control) => control.id === "command-row-center-right").action = { + type: "shortcut", + keys: ["Meta", "X"], + }; + mapping.controls.find((control) => control.id === "encoder-rotate").action = {}; + mapping.controls.find((control) => control.id === "joystick").action = {}; + mapping.unusedControls = []; + }, + expected: [ + /mapping\.formatVersion/, + /mapping\.presetId/, + /layer RGB/, + /layer name/, + /duplicate control id/, + /missing required control/, + /command-row-center-right must exactly match/, + /encoder rotation/, + /joystick action/, + /unused controls/, + ], + }, + { + name: "activation and exclusions", + mutate({ mapping }) { + mapping.activation = { + type: "manual", + application: { bundleId: "wrong.app" }, + detection: "none", + linkPolicy: "replace", + duplicatePolicy: "allow", + }; + mapping.excludedByDefault = []; + }, + expected: [ + /activation must use AppSense/, + /AppSense bundle identifier/, + /existing link/, + /linkPolicy/, + /duplicate AppSense links/, + /excludedByDefault must include send-message/, + /excludedByDefault must include destructive-command/, + ], + }, + ]; + + for (const scenario of cases) { + await t.test(scenario.name, () => { + const value = { + manifest: structuredClone(original.manifest), + mapping: structuredClone(original.mapping), + }; + scenario.mutate(value); + const result = validatePreset(value.manifest, value.mapping); + const messages = result.errors.join("\n"); + assert.equal(result.ok, false); + for (const expected of scenario.expected) assert.match(messages, expected); + if (scenario.warning) assert.match(result.warnings.join("\n"), scenario.warning); + }); + } +}); + test("official profile export inventory protects index 0 and finds one existing Claude layer", async (t) => { const temp = await fs.mkdtemp(path.join(os.tmpdir(), "codex-inventory-")); t.after(() => fs.rm(temp, { recursive: true, force: true })); diff --git a/tests/input-profile.test.mjs b/tests/input-profile.test.mjs index 1105e89..2b29820 100644 --- a/tests/input-profile.test.mjs +++ b/tests/input-profile.test.mjs @@ -9,89 +9,10 @@ import { deriveMappingFromProfile, FORBIDDEN_KEYS, inspectInputProfile, + JOYSTICK_DIRECTION_COUNTS, + radialSectorGeometry, } from "../shared/input-profile.mjs"; - -function sourceProfile() { - return { - keyboard: "codex_micro", - language: "us", - profile: { - id: 0, - name: "Default", - layers: [ - { - id: 0, - name: "Layer 1", - layout: { - encoders: [[ - { keycode: "KV_OAI_ENC_CC" }, - { keycode: "KV_OAI_ENC_CW" }, - { keycode: "KV_OAI_ENC_CLK" }, - ]], - joystick: { type: "VENDOR", sectors: [] }, - base: [[{ keycode: "KV_0" }]], - }, - }, - { - id: 1, - name: "Claude", - linkedAppId: 7, - layout: { - encoders: [[ - { keycode: "KC_NONE" }, - { keycode: "KC_NONE" }, - { keycode: "KC_NONE" }, - ]], - joystick: { - type: "RADIAL", - sectors: [ - { k: "KI_X", a1: 0.1875, a2: 0.3125 }, - { k: "KC_NONE", a1: 0.3125, a2: 0.1875 }, - ], - }, - base: [ - [{ keycode: "KC_NONE" }, { keycode: "KC_NONE" }], - [ - { keycode: "KC_NONE" }, - { keycode: "KC_NONE" }, - { keycode: "KC_NONE" }, - { keycode: "KC_NONE" }, - ], - [ - { keycode: "KA_0" }, - { keycode: "KA_1" }, - { keycode: "KA_2" }, - { keycode: "KC_ESC" }, - ], - [ - { keycode: "KC_NONE" }, - { keycode: "KC_NONE" }, - { keycode: "KC_NONE" }, - ], - ], - }, - }, - ], - }, - actions: [ - { - id: 0, - name: "Claude New", - color: null, - keyInputs: [ - { keycode: "KC_LGUI", delay: 0, actionType: 1 }, - { keycode: "KC_N", delay: 0, actionType: 2 }, - { keycode: "KC_LGUI", delay: 0, actionType: 0 }, - ], - }, - ], - multiactions: [], - smartActions: [], - actionGroups: [{ id: 0, name: "Default", actionIds: [0] }], - multiactionGroups: [], - smartActionGroups: [], - }; -} +import { sourceProfile } from "./helpers/input-profile-fixture.mjs"; test("recognizes exactly one non-native Claude layer with AppSense", () => { const report = inspectInputProfile(sourceProfile()); @@ -114,8 +35,8 @@ test("builds the canonical mapping without touching the source or native layer", claude.layout.base[2].map((entry) => entry.keycode), ["KA_0", "KA_1", "KA_2", "KC_ESC"], ); - // La molette est en mode Effort par défaut : les deux sens référencent donc - // une action générée, pas un keycode direct. + // The wheel is in Effort mode by default, so both directions reference a + // generated action rather than a direct keycode. const defaultEncoder = claude.layout.encoders[0].map((entry) => entry.keycode); assert.match(defaultEncoder[0], /^KA_\d+$/); assert.match(defaultEncoder[1], /^KA_\d+$/); @@ -172,8 +93,8 @@ test("forces the Claude AppSense link even when the source has lost it", () => { assert.equal(report.appSenseId, 4); assert.equal(report.appSenseForced, true); assert.equal(report.baseLayerAppSenseId, null); - // Forcer le lien dispense de l'exiger dans la source, sans avoir à passer - // requireAppSense: false. + // Forcing the link removes the need to require it in the source, without + // having to pass requireAppSense: false. assert.equal(report.appSenseLinked, false); }); @@ -209,7 +130,7 @@ test("rejects invalid or colliding AppSense ids", () => { () => buildInputProfile(sourceProfile(), DEFAULT_MAPPING, { baseLayerAppSenseId: "1" }), /baseLayerAppSenseId/, ); - // Deux layers liés à la même application rendraient la bascule ambiguë. + // Two layers linked to the same application would make the switch ambiguous. assert.throws( () => buildInputProfile(sourceProfile(), DEFAULT_MAPPING, { @@ -388,6 +309,11 @@ test("supports the additional wheel modes", () => { ); assert.equal(effortUp.keyInputs[5].keycode, "KC_RGHT"); assert.equal(deriveMappingFromProfile(effort.profile).mapping.wheel, "effort"); + + assert.throws( + () => buildInputProfile(sourceProfile(), { ...DEFAULT_MAPPING, wheel: "toString" }), + { code: "UNKNOWN_ASSIGNMENT" }, + ); }); test("maps all 13 switches while preserving the layer sensor", () => { @@ -587,8 +513,8 @@ test("assigns Claude actions to four joystick directions", () => { }); const { sectors } = profile.profile.layers[1].layout.joystick; - // Le premier secteur reste la zone de fermeture, les quatre suivants portent - // des références d'action et non des keycodes nus. + // The first sector stays the close zone; the next four carry action + // references rather than bare keycodes. assert.equal(sectors.length, 5); assert.equal(sectors[0].k, "KI_X"); for (const sector of sectors.slice(1)) { @@ -607,6 +533,16 @@ test("assigns Claude actions to four joystick directions", () => { ]); }); +test("shares the exact joystick geometry with every consumer", () => { + assert.deepEqual([...JOYSTICK_DIRECTION_COUNTS], [4, 8]); + const geometry = radialSectorGeometry(4); + assert.deepEqual(geometry.close, { a1: 0.1875, a2: 0.3125 }); + assert.equal(geometry.sectors.length, 4); + assert.equal(geometry.sectors[0].a1, geometry.close.a2); + assert.equal(geometry.sectors.at(-1).a2, geometry.close.a1); + assert.throws(() => radialSectorGeometry(0), { code: "JOYSTICK_SECTOR_COUNT" }); +}); + test("supports eight joystick directions and round-trips them", () => { const sectorsIn = [ "newSession", diff --git a/tests/thread-slots.test.mjs b/tests/thread-slots.test.mjs index f3d5b76..15d4336 100644 --- a/tests/thread-slots.test.mjs +++ b/tests/thread-slots.test.mjs @@ -29,14 +29,27 @@ function withRoster(rows, snapshot = emptySnapshot(), now = 1) { return applyRoster(snapshot, rows, now); } -test("les événements de hook se traduisent en états", () => { +function fullRunningRoster() { + const rows = Array.from({ length: SLOT_COUNT }, (_, index) => rosterRow(index + 1)); + let snapshot = withRoster(rows).snapshot; + for (const row of rows) { + snapshot = applyHookEvent( + snapshot, + { event: "UserPromptSubmit", sessionId: row.sessionId }, + 2, + ).snapshot; + } + return { rows, snapshot }; +} + +test("hook events translate into states", () => { assert.equal(stateFromHookEvent({ event: "SessionStart" }), STATES.idle); assert.equal(stateFromHookEvent({ event: "UserPromptSubmit" }), STATES.running); assert.equal(stateFromHookEvent({ event: "Stop" }), STATES.done); assert.equal(stateFromHookEvent({ event: "SessionEnd" }), STATES.ended); }); -test("seules les notifications qui attendent une personne bloquent", () => { +test("only notifications that wait on a person block", () => { for (const notificationType of ["permission_prompt", "agent_needs_input", "elicitation_dialog"]) { assert.equal(stateFromHookEvent({ event: "Notification", notificationType }), STATES.blocked); } @@ -44,13 +57,13 @@ test("seules les notifications qui attendent une personne bloquent", () => { stateFromHookEvent({ event: "Notification", notificationType: "idle_prompt" }), STATES.idle, ); - // Un témoin rouge doit signifier « on t'attend » : ces types ne changent rien. + // A red light must mean "you are being waited for": these types change nothing. for (const notificationType of ["auth_success", "elicitation_complete", "agent_completed"]) { assert.equal(stateFromHookEvent({ event: "Notification", notificationType }), null); } }); -test("le roster ouvre les emplacements dans l'ordre et rafraîchit les métadonnées", () => { +test("the roster opens slots in order and refreshes the metadata", () => { const first = withRoster([rosterRow(1), rosterRow(2)]); const view = slotView(first.snapshot); @@ -65,7 +78,7 @@ test("le roster ouvre les emplacements dans l'ordre et rafraîchit les métadonn assert.equal(renamed.snapshot.slots[0].sessionId, "session-1", "l'emplacement reste collant"); }); -test("un état reçu avant le roster est mis en attente puis promu", () => { +test("a state received before the roster is held pending, then promoted", () => { const early = applyHookEvent(emptySnapshot(), { event: "UserPromptSubmit", sessionId: "session-1" }, 1); assert.equal(early.changed, false, "aucun emplacement n'est ouvert par un hook seul"); assert.equal(early.snapshot.pending["session-1"], STATES.running); @@ -76,9 +89,9 @@ test("un état reçu avant le roster est mis en attente puis promu", () => { assert.equal("session-1" in confirmed.snapshot.pending, false); }); -test("une session absente du roster ne prend jamais d'emplacement", () => { - // Mesuré : les sessions `claude -p` et les sous-agents émettent des hooks sans - // apparaître dans `claude agents --json`. +test("a session missing from the roster never takes a slot", () => { + // Measured: `claude -p` sessions and subagents emit hooks without ever showing + // up in `claude agents --json`. let snapshot = emptySnapshot(); for (let index = 0; index < 50; index += 1) { snapshot = applyHookEvent(snapshot, { event: "Stop", sessionId: `fantome-${index}` }, index).snapshot; @@ -88,29 +101,24 @@ test("une session absente du roster ne prend jamais d'emplacement", () => { assert.ok(snapshot.dropped > 0, "les abandons sont comptés, pas silencieux"); }); -test("un hook manqué est rattrapé par la disparition du roster", () => { +test("a missed hook is caught by the session leaving the roster", () => { const live = withRoster([rosterRow(1)]); const busy = applyHookEvent(live.snapshot, { event: "UserPromptSubmit", sessionId: "session-1" }, 2); assert.equal(busy.snapshot.slots[0].state, STATES.running); - // Le processus meurt sans émettre Stop ni SessionEnd. + // The process dies without emitting Stop or SessionEnd. const gone = withRoster([], busy.snapshot, 3); assert.equal(gone.changed, true); assert.equal(gone.snapshot.slots[0].state, STATES.ended); assert.equal(gone.snapshot.slots[0].sessionId, "session-1", "l'emplacement reste consultable"); }); -test("une session fermée est évincée avant toute session vivante", () => { - let snapshot = emptySnapshot(); - const rows = Array.from({ length: SLOT_COUNT }, (_, index) => rosterRow(index + 1)); - snapshot = withRoster(rows, snapshot).snapshot; - for (const row of rows) { - snapshot = applyHookEvent(snapshot, { event: "UserPromptSubmit", sessionId: row.sessionId }, 2).snapshot; - } +test("a closed session is evicted before any live one", () => { + const { rows, snapshot: fullSnapshot } = fullRunningRoster(); - // La troisième meurt, une septième arrive : elle doit prendre cet emplacement. + // The third one dies and a seventh arrives: it must take that slot. const survivors = rows.filter((row) => row.sessionId !== "session-3"); - snapshot = withRoster(survivors, snapshot, 3).snapshot; + let snapshot = withRoster(survivors, fullSnapshot, 3).snapshot; assert.equal(snapshot.slots[2].state, STATES.ended); const crowded = withRoster([...survivors, rosterRow(7)], snapshot, 4); @@ -123,13 +131,8 @@ test("une session fermée est évincée avant toute session vivante", () => { ); }); -test("le débordement est signalé au lieu d'être tronqué en silence", () => { - let snapshot = emptySnapshot(); - const rows = Array.from({ length: SLOT_COUNT }, (_, index) => rosterRow(index + 1)); - snapshot = withRoster(rows, snapshot).snapshot; - for (const row of rows) { - snapshot = applyHookEvent(snapshot, { event: "UserPromptSubmit", sessionId: row.sessionId }, 2).snapshot; - } +test("overflow is reported instead of being silently truncated", () => { + const { rows, snapshot } = fullRunningRoster(); const overflow = withRoster([...rows, rosterRow(7)], snapshot, 3); assert.equal(overflow.snapshot.overflow, 1); @@ -137,7 +140,7 @@ test("le débordement est signalé au lieu d'être tronqué en silence", () => { assert.equal(overflow.snapshot.slots.some((entry) => entry?.sessionId === "session-7"), false); }); -test("la navigation ne suppose aucune route", () => { +test("navigation assumes no route", () => { assert.equal(resolveNavigation(null).kind, "empty"); const terminal = resolveNavigation({ @@ -152,9 +155,9 @@ test("la navigation ne suppose aucune route", () => { assert.equal(closed.kind, "resume"); assert.equal(closed.sessionId, "session-1"); - // Session hébergée par Claude Desktop : pas de tty. `claude://resume` la - // désigne par le `sessionId` du roster — jamais par le `hostSessionId`, qui - // regroupe plusieurs sessions. + // Session hosted by Claude Desktop: no tty. `claude://resume` addresses it by + // the roster's `sessionId` — never by the `hostSessionId`, which groups + // several sessions together. const hosted = resolveNavigation({ sessionId: "6f2d3f4a-8c11-4b2e-9a77-0d5e1c8b4a30", state: STATES.running, @@ -165,8 +168,8 @@ test("la navigation ne suppose aucune route", () => { assert.equal(hosted.url, "claude://resume?session=6f2d3f4a-8c11-4b2e-9a77-0d5e1c8b4a30"); assert.equal(hosted.hostSessionId, "local_f92b6e6a"); - // L'application valide la cible par une regex UUID stricte. Un identifiant - // d'une autre forme ne donne pas lieu à une URL que le handler refuserait. + // The application validates the target against a strict UUID regex. An id of + // any other shape yields no URL that the handler would reject. const opaque = resolveNavigation({ sessionId: "session-1", state: STATES.running, @@ -176,21 +179,21 @@ test("la navigation ne suppose aucune route", () => { assert.equal(opaque.url, undefined); }); -test("le tty de `ps` devient un chemin de périphérique qui existe", () => { - // La forme que renvoie macOS. Un préfixe `/dev/tty` inconditionnel donnait - // `/dev/ttyttys001`, et le focus AppleScript ne trouvait jamais la fenêtre. +test("the tty from `ps` becomes a device path that exists", () => { + // The form macOS returns. An unconditional `/dev/tty` prefix produced + // `/dev/ttyttys001`, and the AppleScript focus never found the window. assert.equal(ttyDevice("ttys001"), "/dev/ttys001"); - // La forme courte des autres BSD, qui exige bien le préfixe complet. + // The short form of other BSDs, which does need the full prefix. assert.equal(ttyDevice("s001"), "/dev/ttys001"); - // Déjà absolu : conservé tel quel, sans double préfixe. + // Already absolute: kept as is, with no double prefix. assert.equal(ttyDevice("/dev/ttys006"), "/dev/ttys006"); - // Sessions sans terminal : Claude Desktop, un IDE, un `claude -p`. + // Sessions with no terminal: Claude Desktop, an IDE, a `claude -p`. for (const absent of ["??", "-", "", null, undefined]) { assert.equal(ttyDevice(absent), null); } }); -test("un instantané corrompu retombe sur six emplacements libres", () => { +test("a corrupted snapshot falls back to six free slots", () => { assert.equal(normalizeSnapshot(null).slots.length, SLOT_COUNT); assert.equal(normalizeSnapshot({ slots: "nope" }).slots.length, SLOT_COUNT); const partial = normalizeSnapshot({ slots: [{ sessionId: "session-1", state: STATES.done }, 42] }); diff --git a/thread-status/bin/emit.mjs b/thread-status/bin/emit.mjs index f485ec0..bdf1789 100755 --- a/thread-status/bin/emit.mjs +++ b/thread-status/bin/emit.mjs @@ -1,39 +1,39 @@ #!/usr/bin/env node -// Émetteur de hook. Lit l'événement sur stdin, y joint l'identité du processus -// prise dans l'environnement, et ajoute une ligne NDJSON au journal. +// Hook emitter. Reads the event on stdin, attaches the process identity taken +// from the environment, and appends one NDJSON line to the journal. // -// Trois contraintes dictent la forme de ce fichier : +// Three constraints dictate the shape of this file: // -// 1. Il ne doit rien écrire sur stdout. La sortie d'un hook UserPromptSubmit -// est injectée dans le contexte de la conversation : un journal bavard -// finirait dans le prompt de l'utilisateur. -// 2. Il doit toujours sortir avec le code 0. Un hook en échec remonte une -// erreur dans la session, pour un témoin lumineux qui n'a rien d'essentiel. -// 3. Il ne doit dépendre de rien. Le plugin est distribuable seul, sans le -// dépôt : la résolution du chemin du journal est donc dupliquée ici et dans -// scripts/thread-status.mjs, qui en est le seul autre lecteur. +// 1. It must write nothing to stdout. The output of a UserPromptSubmit hook is +// injected into the conversation context: a chatty journal would end up in +// the user's prompt. +// 2. It must always exit with code 0. A failing hook raises an error in the +// session, over a status light that is not essential to anything. +// 3. It must depend on nothing. The plugin is distributable on its own, +// without the repository: journal path resolution is therefore duplicated +// here and in scripts/thread-status.mjs, its only other reader. import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -// Le contrat de chemin, dupliqué : voir contrainte 3. +// The path contract, duplicated: see constraint 3. const STATE_DIR = process.env.CLAUDE_THREAD_STATUS_DIR || path.join(os.homedir(), ".claude", "thread-status"); const JOURNAL = path.join(STATE_DIR, "events.ndjson"); const JOURNAL_MAX_BYTES = 4 * 1024 * 1024; -// CLAUDE_PLUGIN_DATA n'est pas utilisé comme racine d'état : sa valeur diffère -// selon le mode de chargement du plugin (`…/data/-inline` avec -// --plugin-dir, `…/data/` après installation), ce qui perdrait les -// emplacements entre le développement et l'usage réel. +// CLAUDE_PLUGIN_DATA is not used as the state root: its value differs with how +// the plugin is loaded (`…/data/-inline` under --plugin-dir, +// `…/data/` once installed), which would lose the slots between +// development and real use. function rotate() { try { if (statSync(JOURNAL).size > JOURNAL_MAX_BYTES) renameSync(JOURNAL, `${JOURNAL}.1`); } catch { - // Journal absent ou rotation impossible : l'append qui suit le recréera. + // Journal missing, or rotation impossible: the append below recreates it. } } @@ -55,9 +55,8 @@ function main() { hostSessionId: process.env.CLAUDE_CODE_HOST_SESSION_ID ?? null, }; - // Seuls les champs qui portent une transition sont conservés. Aucun contenu de - // message, aucun chemin de transcript, aucune entrée de tool : le journal doit - // rester publiable tel quel. + // Only the fields that carry a transition are kept. No message content, no + // transcript path, no tool input: the journal has to stay publishable as is. if (payload.notification_type) record.notificationType = payload.notification_type; if (payload.stop_reason) record.stopReason = payload.stop_reason; if (payload.reason) record.reason = payload.reason; @@ -73,6 +72,6 @@ function main() { try { main(); } catch { - // Contrainte 2 : aucune erreur ne remonte dans la session. + // Constraint 2: no error ever surfaces in the session. } process.exit(0); From 58315318de8639950503e5f8ba7d4650189ec1be Mon Sep 17 00:00:00 2001 From: Thanh Chau <1320427+thannous@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:16:15 +0200 Subject: [PATCH 2/4] Allow clean GUI setup in CLI tests --- tests/cli.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index cb0bb0c..1938eff 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -53,7 +53,6 @@ test("repository validation commands run successfully from the checkout", async "scripts/validate-profile.mjs", "scripts/validate-presets.mjs", "scripts/check-doc-links.mjs", - "scripts/prepare-gui.mjs", ]) { await t.test(script, () => { const result = runScript(script); @@ -61,6 +60,12 @@ test("repository validation commands run successfully from the checkout", async assert.match(result.stdout, /OK:/); }); } + + await t.test("scripts/prepare-gui.mjs", () => { + const result = runScript("scripts/prepare-gui.mjs", [], { timeout: 120_000 }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /OK:/); + }); }); test("build-input-profile writes a validated profile once and refuses overwrite", async (t) => { From 41e2dded14072dd7f75a3686ab90d49484ee96dd Mon Sep 17 00:00:00 2001 From: Thanh Chau <1320427+thannous@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:31:35 +0200 Subject: [PATCH 3/4] fix: address architecture review findings --- prototype/src/App.jsx | 17 ++-- .../src/components/ConfiguratorWorkspace.jsx | 10 ++- prototype/src/components/JoystickDial.jsx | 5 +- .../src/components/KeyAssignmentEditor.jsx | 9 +- prototype/src/components/MappingDialog.jsx | 83 +++++++++++++++---- prototype/src/components/ProfileReview.jsx | 7 +- prototype/src/configurator-presenter.js | 13 ++- prototype/src/configurator-state.js | 4 +- prototype/src/hooks/use-theme.js | 19 +++-- prototype/src/i18n/de.js | 3 + prototype/src/i18n/en.js | 3 + prototype/src/i18n/es.js | 3 + prototype/src/i18n/fr.js | 3 + prototype/src/profile-panel-loader.js | 9 +- prototype/src/profile-workflow.js | 2 +- prototype/src/retryable-loader.js | 15 ++++ prototype/src/styles.css | 24 ++++-- prototype/tests/app-render.test.mjs | 9 +- prototype/tests/configurator-state.test.mjs | 10 ++- prototype/tests/presentation.test.mjs | 24 +++++- prototype/tests/retryable-loader.test.mjs | 40 +++++++++ scripts/configure.mjs | 2 +- scripts/lib/hid-device.mjs | 2 +- tests/cli.test.mjs | 6 -- tests/hid-device.test.mjs | 3 +- tests/hid-lighting.test.mjs | 2 +- tests/input-layer.test.mjs | 19 ++++- tests/thread-slots.test.mjs | 2 +- 28 files changed, 269 insertions(+), 79 deletions(-) create mode 100644 prototype/src/retryable-loader.js create mode 100644 prototype/tests/retryable-loader.test.mjs diff --git a/prototype/src/App.jsx b/prototype/src/App.jsx index 8b75dae..9a91d98 100644 --- a/prototype/src/App.jsx +++ b/prototype/src/App.jsx @@ -41,15 +41,18 @@ import { profileSessionReducer, } from "./profile-session.js"; import { loadProfileExportPanel } from "./profile-panel-loader.js"; +import { createRetryableLoader } from "./retryable-loader.js"; import { useTheme } from "./hooks/use-theme.js"; const DEFAULT_CONTROL_ID = "key-1"; const TOAST_DURATION_MS = 6000; -let profileWorkflowPromise; +const loadProfileWorkflow = createRetryableLoader( + () => import("./profile-workflow.js"), +); -function loadProfileWorkflow() { - profileWorkflowPromise ??= import("./profile-workflow.js"); - return profileWorkflowPromise; +function prefetchProfileModules() { + loadProfileWorkflow().catch(() => {}); + loadProfileExportPanel().catch(() => {}); } export function App() { @@ -281,15 +284,13 @@ export function App() { }, [mapping, profile.appSenseIds, profile.source, scrollToLoader, scrollToReview, t]); const switchToExport = useCallback(() => { - void loadProfileWorkflow(); - void loadProfileExportPanel(); + prefetchProfileModules(); setPanelMode("export"); if (profile.source) void runReview(); }, [profile.source, runReview]); const openReviewFromHero = useCallback(() => { - void loadProfileWorkflow(); - void loadProfileExportPanel(); + prefetchProfileModules(); returnFocusRef.current = document.activeElement; setPanelOpen(true); setPanelMode("export"); diff --git a/prototype/src/components/ConfiguratorWorkspace.jsx b/prototype/src/components/ConfiguratorWorkspace.jsx index fd3ac8a..b9f79f2 100644 --- a/prototype/src/components/ConfiguratorWorkspace.jsx +++ b/prototype/src/components/ConfiguratorWorkspace.jsx @@ -145,6 +145,7 @@ export const ConfiguratorWorkspace = memo(function ConfiguratorWorkspace({ return (
- @@ -221,6 +226,7 @@ export const ConfiguratorWorkspace = memo(function ConfiguratorWorkspace({ return ( + + ); + + return ( + + {t("loader.hint")}

}> + +
+
+ ); +} export function MappingDialog({ t, @@ -74,6 +117,7 @@ export function MappingDialog({ )} ) : ( <> - @@ -109,6 +113,7 @@ export function ProfileReview({

{profile.source ? t("review.ready") : t("review.needProfile")}