diff --git a/package-lock.json b/package-lock.json index 3cc33a5..b89f08d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7933,7 +7933,7 @@ }, "packages/server": { "name": "@johanleroy/git-arboretum", - "version": "3.4.0", + "version": "3.5.0", "license": "MIT", "dependencies": { "@fastify/cookie": "^11.0.0", diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 9ee024c..089a72d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -4,6 +4,20 @@ Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon an extension keep their own changelogs in `packages/server/CHANGELOG.md` and `packages/vscode/CHANGELOG.md`. +## 0.2.1 + +Ships the daemon 3.5.0, which fixes the black window seen after updating the app. + +- **Black window after an update, fixed.** The window loaded an `index.html` kept from the previous + version (revalidated as `304` because the tarball mtime is constant, so the etag did not change) whose + `/assets/` files no longer existed. Nothing painted. If you hit it before updating, the app + repairs itself now; clearing `~/.config/Arboretum/Partitions/arboretum/Cache` was the manual fix. +- **Copy & paste in session terminals.** `Ctrl+Shift+C` / `Ctrl+Shift+V` (`Cmd+C` / `Cmd+V` on macOS); + the Edit menu's Copy also works on a terminal selection now. `Ctrl+C` still interrupts. +- Browse the files of a group's worktrees straight from the Groups panel. + +The Electron shell itself is unchanged. + ## 0.2.0 Distribution release: the Linux launcher icon finally shows up, Windows becomes a first-class target, diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index cb2361c..a2b5f46 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@arboretum/desktop", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@arboretum/desktop", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9a00064..95e84bb 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@arboretum/desktop", "private": true, - "version": "0.2.0", + "version": "0.2.1", "description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions", "homepage": "https://git-arboretum.com", "repository": { diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 616e05f..425f568 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -3,6 +3,33 @@ Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code extension keeps its own changelog in `packages/vscode/CHANGELOG.md`. +## 3.5.0 + +Fixes a black screen after every update, gives the web terminal a working copy & paste, and lets you +browse a group's files without leaving the Groups panel. Fully additive, no protocol version bump. + +- **No more black screen after an update.** The embedded SPA is served by `@fastify/static`, whose weak + etag derives from size + mtime, and `npm pack` pins the mtime of every file in the tarball to a + constant (1985-10-26). Two different `index.html` of equal size therefore shared an etag: clients got a + `304 Not Modified` and kept an index referencing `/assets/` files that no longer existed. The + fallback route then answered those module requests with `index.html` as `text/html`, the browser + refused the script, and nothing painted. `index.html` and every other unhashed file are now served + `no-store` with conditional validation disabled, so a client holding a stale copy repairs itself; + hashed `/assets/` are served `immutable` for a year. +- **Copy & paste in the terminal.** xterm's selection is not a DOM selection, so the native Copy (the + Electron Edit menu, the browser context menu) had nothing to copy and terminal output could not be + retrieved at all. `Ctrl+Shift+C` / `Ctrl+Shift+V` (`Cmd+C` / `Cmd+V` on macOS, plus + `Ctrl+Insert` / `Shift+Insert`) now copy the selection and paste the clipboard, and the DOM `copy` + event is intercepted so the native Copy works too. `Ctrl+C` is deliberately untouched: it stays SIGINT. +- **Theme applied before the first paint again.** The anti-FOUC script was inline in `index.html`, which + the daemon's own CSP (`script-src 'self'`) refused to execute; it moved to `/theme-boot.js`. +- **Browse files from the Groups panel.** A group's worktrees expand into their file tree, the same + component and the same expansion state as the Explorer, and those worktrees are now watched for + real-time changes too. +- **Sources are text again.** Three files embedded a literal NUL byte in a string separator, which made + git and grep treat them as binary: their diffs were unreviewable and the `lint-dashes` CI guard + (`git grep -I`) silently skipped them. Escaped as `\0`, same runtime value. + ## 3.4.0 Visibility release: the real-time machinery is now actually armed, worktrees show what they are worth, diff --git a/packages/server/package.json b/packages/server/package.json index f2ffad2..a8d6a0a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@johanleroy/git-arboretum", - "version": "3.4.0", + "version": "3.5.0", "description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them", "license": "MIT", "type": "module", diff --git a/packages/server/scripts/verify-clipboard.mjs b/packages/server/scripts/verify-clipboard.mjs new file mode 100644 index 0000000..dc13f6f --- /dev/null +++ b/packages/server/scripts/verify-clipboard.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +// Vérification E2E du copier / coller dans le terminal web (régression : la sélection d'xterm n'est +// pas une sélection DOM, le « Copier » natif ne voyait donc rien). Daemon temporaire isolé + session +// `bash` (pas `claude` : pas de quota consommé) + Chromium piloté en CDP : on tape un marqueur, on le +// sélectionne à la souris, Ctrl+Shift+C, et on relit le presse-papier réel du navigateur. Puis +// l'inverse : on remplit le presse-papier, Ctrl+Shift+V, et on vérifie que le PTY l'a reçu. +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { WebSocket } from 'ws'; + +const PORT = 7411; +const CDP_PORT = 9334; +const ORIGIN = `http://127.0.0.1:${PORT}`; +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +let failures = 0; +function check(label, ok, detail = '') { + console.log(`${ok ? '✅' : '❌'} ${label}${detail ? ` : ${detail}` : ''}`); + if (!ok) failures++; +} + +function findChromium() { + for (const bin of ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome']) { + if (existsSync(bin)) return bin; + } + return null; +} + +/** Client CDP minimal : un socket, corrélation par id. */ +function cdp(url) { + const ws = new WebSocket(url, { perMessageDeflate: false }); + const pending = new Map(); + let seq = 0; + const ready = new Promise((resolve, reject) => { + ws.once('open', resolve); + ws.once('error', reject); + }); + ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + const entry = pending.get(msg.id); + if (!entry) return; + pending.delete(msg.id); + msg.error ? entry.reject(new Error(msg.error.message)) : entry.resolve(msg.result); + }); + return { + ready, + close: () => ws.close(), + send(method, params = {}, sessionId) { + const id = ++seq; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + setTimeout(() => pending.has(id) && (pending.delete(id), reject(new Error(`CDP timeout: ${method}`))), 30_000); + }); + }, + }; +} + +let srv, browser, tmp; +try { + tmp = mkdtempSync(join(tmpdir(), 'arb-clip-')); + + srv = spawn( + 'node', + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 't.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], + { env: { ...process.env, XDG_DATA_HOME: join(tmp, 'xdg'), ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let srvOut = ''; + srv.stdout.on('data', (d) => (srvOut += d)); + srv.stderr.on('data', (d) => (srvOut += d)); + for (let i = 0; i < 60 && !/arb_[0-9a-f]{16,}/.test(srvOut); i++) await sleep(150); + const token = /arb_[0-9a-f]{16,}/.exec(srvOut)?.[0]; + check('daemon temporaire démarré', !!token); + + const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: JSON.stringify({ token }), + }); + const cookie = (login.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session=')); + const cookieValue = cookie?.slice('arb_session='.length) ?? ''; + check('login → cookie de session', !!cookie); + + const sess = await ( + await fetch(`${ORIGIN}/api/v1/sessions`, { + method: 'POST', + headers: { Origin: ORIGIN, Cookie: cookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ cwd: tmp, command: 'bash' }), + }) + ).json(); + const sessionId = sess.session?.id; + check('session bash lancée', !!sessionId); + + const chromeBin = findChromium(); + check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable'); + if (!chromeBin || !sessionId) throw new Error('prérequis manquants'); + + browser = spawn( + chromeBin, + [ + '--headless=new', + `--remote-debugging-port=${CDP_PORT}`, + `--user-data-dir=${join(tmp, 'chrome')}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-gpu', + '--hide-scrollbars', + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let wsUrl = null; + for (let i = 0; i < 80 && !wsUrl; i++) { + await sleep(200); + try { + wsUrl = (await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json()).webSocketDebuggerUrl; + } catch { + /* pas encore prêt */ + } + } + check('Chromium en écoute CDP', !!wsUrl); + + const client = cdp(wsUrl); + await client.ready; + // Presse-papier lisible/écrivable sans geste utilisateur (sinon readText() rejette en headless). + await client.send('Browser.grantPermissions', { + origin: ORIGIN, + permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'], + }); + + const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' }); + const { sessionId: sid } = await client.send('Target.attachToTarget', { targetId, flatten: true }); + await client.send('Page.enable', {}, sid); + await client.send('Runtime.enable', {}, sid); + await client.send('Network.enable', {}, sid); + await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sid); + await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, sid); + + const evaluate = async (expression, awaitPromise = false) => + (await client.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }, sid)).result?.value; + + await client.send('Page.navigate', { url: `${ORIGIN}/sessions/${sessionId}` }, sid); + // attend que xterm soit monté ET que bash ait rendu son invite + let screen = null; + for (let i = 0; i < 80 && !screen; i++) { + await sleep(250); + screen = await evaluate(`(() => { const el = document.querySelector('.xterm-screen'); if (!el) return null; const r = el.getBoundingClientRect(); return r.width > 50 ? JSON.stringify(r) : null; })()`); + } + check('terminal xterm monté', !!screen); + const rect = screen ? JSON.parse(screen) : null; + + // Le renderer WebGL peint dans un canvas : `.xterm-rows` est vide, on ne peut RIEN vérifier via le + // DOM. Les preuves passent donc par le système de fichiers (le cwd de la session est `tmp`) et par + // le presse-papier réel du navigateur. + const focusTerm = () => client.send('Runtime.evaluate', { expression: `document.querySelector('.xterm-helper-textarea')?.focus()` }, sid); + const pressEnter = async () => { + await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: '\r' }, sid); + await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sid); + }; + const waitForFile = async (name, tries = 40) => { + for (let i = 0; i < tries; i++) { + if (existsSync(join(tmp, name))) return true; + await sleep(200); + } + return false; + }; + + // --- Frappe dans le PTY (Input.insertText → textarea xterm → stdin) --- + await focusTerm(); + await client.send('Input.insertText', { text: 'touch typed-ok' }, sid); + await pressEnter(); + check('le PTY exécute une commande tapée au clavier', await waitForFile('typed-ok')); + + // Marqueur affiché à l'écran, cible de la copie + const MARKER = 'COPIE_MOI_4242'; + await client.send('Input.insertText', { text: `echo ${MARKER}` }, sid); + await pressEnter(); + await sleep(600); + + // --- Sélection à la souris sur la zone du terminal, puis Ctrl+Shift+C --- + if (rect) { + const y = rect.y + 8; + await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x + 2, y, button: 'left', clickCount: 1, buttons: 1 }, sid); + await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + rect.width - 4, y: y + 40, button: 'left', buttons: 1 }, sid); + await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + rect.width - 4, y: y + 40, button: 'left', clickCount: 1, buttons: 0 }, sid); + } + await sleep(300); + const selection = await evaluate(`(() => { const s = document.querySelector('.xterm')?.classList; return document.getSelection()?.toString() ?? ''; })()`); + // ctrl(2) + shift(8) = 10 + const keyOpts = { modifiers: 10, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'C', code: 'KeyC' }; + await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...keyOpts }, sid); + await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...keyOpts }, sid); + await sleep(500); + const copied = (await evaluate('navigator.clipboard.readText()', true)) ?? ''; + check('Ctrl+Shift+C copie la sélection du terminal', copied.includes(MARKER), JSON.stringify(copied.slice(0, 60))); + + // --- Collage : presse-papier → Ctrl+Shift+V → la commande collée doit atteindre le PTY --- + await evaluate(`navigator.clipboard.writeText('touch paste-ok')`, true); + await focusTerm(); + const vOpts = { modifiers: 10, windowsVirtualKeyCode: 86, nativeVirtualKeyCode: 86, key: 'V', code: 'KeyV' }; + await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...vOpts }, sid); + await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...vOpts }, sid); + await sleep(400); + await pressEnter(); + check('Ctrl+Shift+V colle le presse-papier dans le terminal', await waitForFile('paste-ok')); + + // --- Ctrl+C ne doit PAS être détourné : il reste SIGINT --- + // `sleep 25` bloque le shell ; si le ^C passe, le shell reprend et exécute la commande suivante. + await focusTerm(); + await client.send('Input.insertText', { text: 'sleep 25' }, sid); + await pressEnter(); + await sleep(700); + const cOpts = { modifiers: 2, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'c', code: 'KeyC' }; + await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...cOpts }, sid); + await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...cOpts }, sid); + await sleep(500); + await client.send('Input.insertText', { text: 'touch interrupt-ok' }, sid); + await pressEnter(); + check('Ctrl+C reste transmis au PTY (SIGINT, pas une copie)', await waitForFile('interrupt-ok', 30)); + + client.close(); +} catch (err) { + check('exécution du scénario', false, err?.message ?? String(err)); +} finally { + browser?.kill('SIGKILL'); + srv?.kill('SIGKILL'); + await sleep(300); + if (tmp) rmSync(tmp, { recursive: true, force: true }); +} + +console.log(failures === 0 ? '\nVERIFY CLIPBOARD: ALL GREEN' : `\nVERIFY CLIPBOARD: ${failures} ÉCHEC(S)`); +process.exit(failures === 0 ? 0 : 1); diff --git a/packages/server/scripts/verify-ui.mjs b/packages/server/scripts/verify-ui.mjs index 518c564..87cc015 100644 --- a/packages/server/scripts/verify-ui.mjs +++ b/packages/server/scripts/verify-ui.mjs @@ -168,12 +168,19 @@ try { const context = JSON.stringify(JSON.stringify({ repoId, wtPath: repo })); const seedExplorer = `localStorage.setItem('arb.ide.expandedRepos', ${expanded});localStorage.setItem('arb.ide.context', ${context});`; const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');localStorage.setItem('arb.history.open', 'true');`; + // Panneau Groupes avec le groupe ET le worktree dépliés : c'est la vue qui porte l'arborescence de + // fichiers des membres du groupe, sinon jamais capturée. + const seedGroups = + `${seedExplorer}localStorage.setItem('arb.ide.activity', '"groups"');` + + `localStorage.setItem('arb.ide.expandedGroups', ${JSON.stringify(JSON.stringify([groupRes.group?.id]))});` + + `localStorage.setItem('arb.ide.expandedWts', ${JSON.stringify(JSON.stringify([repo]))});`; const shots = [ { name: 'ide-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedExplorer }, { name: 'ide-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedExplorer }, { name: 'git-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGit }, { name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit }, + { name: 'groups-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGroups }, { name: 'ide-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedExplorer }, { name: 'ide-light-mobile', theme: 'light', width: 390, height: 844, seed: seedExplorer }, { name: 'dashboard-dark-mobile', theme: 'dark', width: 390, height: 844, path: '/dashboard' }, diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 0280013..418485a 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -64,6 +64,22 @@ const SECURITY_HEADERS: Record = { ].join('; '), }; +// Politique de cache du statique. Piège à connaître : `npm pack` normalise le mtime de TOUS les +// fichiers du tarball à une date constante (1985-10-26). L'etag faible de @fastify/static étant +// dérivé de taille+mtime, deux versions différentes d'un même fichier non haché produisent le +// MÊME etag dès que leur taille coïncide : le client reçoit un 304 et garde indéfiniment +// l'ancienne copie. Vécu en production sur index.html à la mise à jour de l'app desktop : l'index +// obsolète référençait des `/assets/.js` disparus, le fallback SPA répondait du text/html +// pour ces modules, et la page restait noire. +// Conséquence : seuls les fichiers dont le NOM porte un hash de contenu (/assets/) sont +// cachables ; tout le reste (index.html, sw.js, theme-boot.js, manifest, icônes) part en +// no-store, la revalidation par etag n'étant pas fiable ici. +export function cacheControlFor(pathname: string): string { + return pathname.startsWith('/assets/') + ? 'public, max-age=31536000, immutable' + : 'no-store'; +} + declare module 'fastify' { interface FastifyRequest { authContext: AuthContext | null; @@ -220,7 +236,25 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund // SPA buildée embarquée dans le paquet npm (public/) : absente en dev (vite dev sert le front) const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public'); if (existsSync(publicDir)) { - void app.register(fastifyStatic, { root: publicDir, wildcard: false }); + void app.register(fastifyStatic, { + root: publicDir, + wildcard: false, + // Validation conditionnelle désactivée : l'etag faible et le Last-Modified dérivent du mtime, + // que `npm pack` fige (cf. cacheControlFor). Les laisser actifs ferait répondre 304 aux + // clients qui détiennent encore un index.html périmé d'une version antérieure : ils y + // resteraient bloqués. Sans etag, ils reçoivent un 200 et se réparent d'eux-mêmes. Le coût + // est nul pour /assets (noms hachés, servis immutable) et négligeable ailleurs. + etag: false, + lastModified: false, + // Indispensable : sinon le plugin écrit son propre `cache-control: public, max-age=0` + // APRÈS setHeaders et écrase le no-store ci-dessous. + cacheControl: false, + // `setHeaders` s'applique aussi aux `reply.sendFile` du fallback SPA ci-dessous. + setHeaders(res, path) { + const rel = path.slice(publicDir.length).replace(/\\/g, '/'); + res.setHeader('Cache-Control', cacheControlFor(rel)); + }, + }); app.setNotFoundHandler((req, reply) => { if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } }); diff --git a/packages/server/test/static-cache.test.ts b/packages/server/test/static-cache.test.ts new file mode 100644 index 0000000..6b8d674 --- /dev/null +++ b/packages/server/test/static-cache.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { cacheControlFor } from '../src/app.js'; + +// Régression : à la mise à jour de l'app desktop, l'index.html mis en cache par le client était +// revalidé en 304 (etag = taille+mtime, mtime figé à 1985 par npm pack dans le tarball) et +// continuait donc de référencer des /assets/ disparus → modules servis en text/html par le +// fallback SPA → page noire. Seuls les noms hachés sont cachables. +describe('cacheControlFor', () => { + it('rend les assets hachés cachables indéfiniment', () => { + expect(cacheControlFor('/assets/index-94trXkeo.js')).toBe('public, max-age=31536000, immutable'); + expect(cacheControlFor('/assets/inter-latin-wght-normal-Dx4kXJAl.woff2')).toBe( + 'public, max-age=31536000, immutable', + ); + }); + + it("interdit la mise en cache de l'index.html", () => { + expect(cacheControlFor('/index.html')).toBe('no-store'); + }); + + it('interdit la mise en cache des fichiers racine non hachés', () => { + for (const p of ['/sw.js', '/theme-boot.js', '/manifest.webmanifest', '/favicon.ico', '/icon-512.png']) { + expect(cacheControlFor(p)).toBe('no-store'); + } + }); +}); diff --git a/packages/vscode/src/views/repos-tree.ts b/packages/vscode/src/views/repos-tree.ts index 1ba53f4..ab9b875 100644 Binary files a/packages/vscode/src/views/repos-tree.ts and b/packages/vscode/src/views/repos-tree.ts differ diff --git a/packages/web/index.html b/packages/web/index.html index b14ee5c..9874f07 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -5,27 +5,10 @@ - - + + diff --git a/packages/web/public/theme-boot.js b/packages/web/public/theme-boot.js new file mode 100644 index 0000000..f799408 --- /dev/null +++ b/packages/web/public/theme-boot.js @@ -0,0 +1,26 @@ +// Anti-FOUC : applique la préférence de thème (arb.theme) avant le premier paint, sinon un +// utilisateur en thème clair verrait un flash sombre au rechargement. +// +// Pourquoi un fichier séparé plutôt qu'un