···88## [Unreleased]
991010### Fixed
1111+- CSP: externalized 3 inline scripts from every page template so the app's `script-src 'self'` CSP stops silently blocking them (v0.62.2, #694). Theme init (FOUC prevention, reads localStorage before paint), theme-toggle click handler, and service-worker update→reload handler now live in `public/theme-init.js`, `public/theme-toggle.js`, and `public/sw-reload.js` respectively, loaded via `<script src="...">` so they satisfy the strict CSP without needing nonces or `unsafe-inline`. Before: the theme toggle button did nothing, dark-mode users saw a flash of light theme on every page load, and users never auto-reloaded when a new version deployed. All 7 HTML templates (landing + 6 editors) had the inline blocks; all 7 are now externalized. Added `tests/csp-no-inline-scripts.test.ts` which scans every template for inline `<script>` blocks and fails if any are reintroduced. Caught live by driving the deployed v0.62.1 app via Playwright MCP. (#694)
1112- Sheets: first printable keystroke against an empty cell no longer duplicates the character (v0.62.1, #693). The grid's keydown handler for printable chars in `src/sheets/keyboard-handler.ts` entered edit mode and set `editor.value = key` but never called `e.preventDefault()`, so the browser's native keypress/input pipeline on the now-focused cell-editor input also inserted the same character — pressing `5` produced `55`, pressing `1`→`0`→Enter produced `110`. Added the missing `preventDefault()` plus a targeted regression test in `tests/sheets-keyboard-handler.test.ts` (3 new tests: preventDefault invariant, single-char-not-doubled invariant, and the pre-existing Cmd+key-skip invariant). Caught live by driving the deployed app in a real browser via Playwright MCP during TipTap v3 post-ship smoke testing. (#693)
12131314### Changed
···11+// Service worker registration + auto-reload on controllerchange.
22+// When a new SW activates (a new deploy), this triggers a single page reload
33+// so users get the new version without a manual refresh.
44+(function () {
55+ if (!('serviceWorker' in navigator)) return;
66+77+ var reloading = false;
88+ navigator.serviceWorker.addEventListener('controllerchange', function () {
99+ if (!reloading) {
1010+ reloading = true;
1111+ location.reload();
1212+ }
1313+ });
1414+1515+ navigator.serviceWorker
1616+ .register('/sw.js', { updateViaCache: 'none' })
1717+ .then(function (reg) {
1818+ // Force an immediate update check on every page load.
1919+ reg.update().catch(function () {});
2020+ })
2121+ .catch(function () {});
2222+})();
+19
public/theme-init.js
···11+// Theme init — runs before paint to avoid FOUC.
22+// Must be loaded from <head> as a blocking <script src="/theme-init.js">
33+// so that data-theme is set before the body renders.
44+// Served with long cache headers since the content is stable; bump the URL
55+// query string (e.g. /theme-init.js?v=2) when editing to bust caches.
66+(function () {
77+ var saved = localStorage.getItem('tools-theme');
88+ if (saved === 'dark' || saved === 'light') {
99+ document.documentElement.setAttribute('data-theme', saved);
1010+ }
1111+ if (window.electronAPI) document.documentElement.classList.add('is-electron');
1212+ // Enable focus rings only when keyboard navigation is detected
1313+ document.addEventListener('keydown', function (e) {
1414+ if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', '');
1515+ });
1616+ document.addEventListener('mousedown', function () {
1717+ document.documentElement.removeAttribute('data-a11y-focus');
1818+ });
1919+})();
+34
public/theme-toggle.js
···11+// Theme toggle button wiring. Requires a #theme-toggle element in the page.
22+// Loaded as <script src="/theme-toggle.js"> at the end of <body> (defer-equivalent
33+// since the DOM is parsed by then). Only attached when the element exists, so
44+// pages without a toggle (e.g. calendar) don't crash.
55+(function () {
66+ var toggle = document.getElementById('theme-toggle');
77+ if (!toggle) return;
88+99+ function getEffectiveTheme() {
1010+ var saved = localStorage.getItem('tools-theme');
1111+ if (saved === 'dark' || saved === 'light') return saved;
1212+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1313+ }
1414+1515+ function updateIcon() {
1616+ var theme = getEffectiveTheme();
1717+ toggle.textContent = theme === 'dark' ? '\u263E' : '\u2600';
1818+ toggle.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
1919+ }
2020+2121+ toggle.addEventListener('click', function () {
2222+ var current = getEffectiveTheme();
2323+ var next = current === 'dark' ? 'light' : 'dark';
2424+ document.documentElement.setAttribute('data-theme', next);
2525+ localStorage.setItem('tools-theme', next);
2626+ updateIcon();
2727+ });
2828+2929+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
3030+ if (!localStorage.getItem('tools-theme')) updateIcon();
3131+ });
3232+3333+ updateIcon();
3434+})();
···11+/**
22+ * Regression test for #694: CSP blocks inline scripts on every page.
33+ *
44+ * The server sets `script-src 'self'` (see server/index.ts), which silently
55+ * blocks any inline <script> in the HTML templates. This test pins the
66+ * invariant that every page template only references external scripts via
77+ * <script src="...">, never inline <script>...</script> blocks.
88+ *
99+ * If this test fails, either:
1010+ * (a) you added an inline script to a page template — externalize it to a
1111+ * .js file under public/ and reference it with <script src="/name.js">, or
1212+ * (b) you weakened the CSP to allow 'unsafe-inline' — don't. The whole point
1313+ * of a strict CSP is that a single XSS can't inject executable <script>.
1414+ */
1515+1616+import { describe, it, expect } from 'vitest';
1717+import { readFileSync } from 'fs';
1818+import { resolve } from 'path';
1919+2020+const HTML_TEMPLATES = [
2121+ 'src/index.html',
2222+ 'src/docs/index.html',
2323+ 'src/sheets/index.html',
2424+ 'src/slides/index.html',
2525+ 'src/forms/index.html',
2626+ 'src/diagrams/index.html',
2727+ 'src/calendar/index.html',
2828+];
2929+3030+// Matches <script ...>body</script> where body is non-empty (ignoring pure
3131+// whitespace). Does NOT match self-closing <script src="..."></script>.
3232+// Using a tolerant regex so any accidental inline script content is caught,
3333+// including minified or oddly-formatted ones.
3434+const INLINE_SCRIPT_RE = /<script\b(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
3535+3636+describe('#694 — CSP: no inline scripts in HTML templates', () => {
3737+ for (const template of HTML_TEMPLATES) {
3838+ it(`${template} has no inline <script> blocks`, () => {
3939+ const html = readFileSync(resolve(process.cwd(), template), 'utf-8');
4040+ const matches: string[] = [];
4141+ let m: RegExpExecArray | null;
4242+ while ((m = INLINE_SCRIPT_RE.exec(html)) !== null) {
4343+ const body = (m[1] ?? '').trim();
4444+ if (body.length > 0) {
4545+ // Capture first 80 chars of each offender for a useful failure message
4646+ matches.push(body.slice(0, 80).replace(/\s+/g, ' '));
4747+ }
4848+ }
4949+ expect(
5050+ matches,
5151+ `Inline <script> blocks found in ${template} — these will be blocked ` +
5252+ `by the CSP 'script-src self' directive. Move them to public/*.js and ` +
5353+ `reference via <script src="/name.js">. Offenders:\n - ${matches.join('\n - ')}`,
5454+ ).toEqual([]);
5555+ });
5656+ }
5757+5858+ it('server CSP header still uses strict script-src self (no unsafe-inline)', () => {
5959+ const serverSrc = readFileSync(
6060+ resolve(process.cwd(), 'server/index.ts'),
6161+ 'utf-8',
6262+ );
6363+ // CSP block lives in the response-header middleware; look for the directive.
6464+ expect(serverSrc).toContain(`"script-src 'self'"`);
6565+ // The whole point of this issue is NOT to weaken the CSP. Guard against
6666+ // anyone reintroducing unsafe-inline for script-src.
6767+ const scriptSrcLine = serverSrc.match(/"script-src[^"]*"/g) ?? [];
6868+ for (const line of scriptSrcLine) {
6969+ expect(line, `script-src directive must not include 'unsafe-inline': ${line}`)
7070+ .not.toMatch(/unsafe-inline/);
7171+ }
7272+ });
7373+});