Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: key wrapping, oklch fallbacks, E2E tests (#304)

scott ba458219 4fbf3ad6

+1208 -52
+12
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.25.0] — 2026-04-08 9 + 10 + ### Added 11 + - Wrap document encryption keys with user-derived passphrase (PBKDF2 + AES-256-GCM) before localStorage/server storage (#405) 12 + - Passphrase modal UI for key setup and unlock, with auto-migration of legacy plaintext keys 13 + - E2E tests for AI chat panel toggle and keyboard shortcut (#303) 14 + - E2E tests for wiki-link (cross-doc link) creation via `[[text]]` syntax (#303) 15 + - E2E tests for daily notes creation and reopen (#303) 16 + 17 + ### Fixed 18 + - CSS: add hex fallbacks for all 320 oklch() color declarations for older browser support (#408) 19 + 8 20 ## [0.24.0] — 2026-04-07 9 21 10 22 ### Added
+105
e2e/ai-chat.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewDoc, mod } from './helpers'; 3 + 4 + test.describe('AI Chat panel', () => { 5 + test('toggle AI chat sidebar via toolbar button', async ({ page }) => { 6 + await createNewDoc(page); 7 + 8 + // Sidebar should not be visible initially 9 + const sidebar = page.locator('#ai-chat-sidebar'); 10 + await expect(sidebar).toBeHidden(); 11 + 12 + // Click the AI chat button 13 + await page.click('#btn-ai-chat'); 14 + await expect(sidebar).toBeVisible(); 15 + 16 + // Click again to close 17 + await page.click('#btn-ai-chat'); 18 + await expect(sidebar).toBeHidden(); 19 + }); 20 + 21 + test('toggle AI chat sidebar via keyboard shortcut', async ({ page }) => { 22 + await createNewDoc(page); 23 + 24 + const sidebar = page.locator('#ai-chat-sidebar'); 25 + await expect(sidebar).toBeHidden(); 26 + 27 + // Cmd+Shift+L to open 28 + await page.keyboard.press(`${mod(page)}+Shift+l`); 29 + await expect(sidebar).toBeVisible(); 30 + 31 + // Cmd+Shift+L to close 32 + await page.keyboard.press(`${mod(page)}+Shift+l`); 33 + await expect(sidebar).toBeHidden(); 34 + }); 35 + 36 + test('close sidebar via close button', async ({ page }) => { 37 + await createNewDoc(page); 38 + 39 + await page.click('#btn-ai-chat'); 40 + const sidebar = page.locator('#ai-chat-sidebar'); 41 + await expect(sidebar).toBeVisible(); 42 + 43 + await page.click('#ai-chat-close'); 44 + await expect(sidebar).toBeHidden(); 45 + }); 46 + 47 + test('sidebar has input and send button', async ({ page }) => { 48 + await createNewDoc(page); 49 + 50 + await page.click('#btn-ai-chat'); 51 + await expect(page.locator('#ai-chat-input')).toBeVisible(); 52 + await expect(page.locator('#ai-chat-send')).toBeVisible(); 53 + }); 54 + 55 + test('settings panel toggles', async ({ page }) => { 56 + await createNewDoc(page); 57 + 58 + await page.click('#btn-ai-chat'); 59 + const settings = page.locator('#ai-chat-settings'); 60 + 61 + // Settings should be hidden initially 62 + await expect(settings).toBeHidden(); 63 + 64 + // Click settings button to show 65 + await page.click('#ai-chat-settings-btn'); 66 + await expect(settings).toBeVisible(); 67 + 68 + // Click again to hide 69 + await page.click('#ai-chat-settings-btn'); 70 + await expect(settings).toBeHidden(); 71 + }); 72 + 73 + test('settings panel has model selector and options', async ({ page }) => { 74 + await createNewDoc(page); 75 + 76 + await page.click('#btn-ai-chat'); 77 + await page.click('#ai-chat-settings-btn'); 78 + 79 + await expect(page.locator('#ai-model')).toBeVisible(); 80 + await expect(page.locator('#ai-context-toggle')).toBeVisible(); 81 + await expect(page.locator('#ai-actions-toggle')).toBeVisible(); 82 + }); 83 + 84 + test('clear button resets messages', async ({ page }) => { 85 + await createNewDoc(page); 86 + 87 + await page.click('#btn-ai-chat'); 88 + const messages = page.locator('#ai-chat-messages'); 89 + 90 + // Initially the messages area should be empty or have a placeholder 91 + await page.click('#ai-chat-clear-btn'); 92 + 93 + // After clear, messages area should have no chat bubbles 94 + await expect(messages.locator('.ai-chat-bubble')).toHaveCount(0); 95 + }); 96 + 97 + test('can type in chat input', async ({ page }) => { 98 + await createNewDoc(page); 99 + 100 + await page.click('#btn-ai-chat'); 101 + const input = page.locator('#ai-chat-input'); 102 + await input.fill('Hello, this is a test message'); 103 + await expect(input).toHaveValue('Hello, this is a test message'); 104 + }); 105 + });
+86
e2e/daily-notes.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { goToLanding } from './helpers'; 3 + 4 + test.describe('Daily notes', () => { 5 + test('daily note button exists on landing page', async ({ page }) => { 6 + await goToLanding(page); 7 + 8 + const btn = page.locator('#daily-note'); 9 + await expect(btn).toBeVisible(); 10 + await expect(btn.locator('.create-card-title')).toHaveText("Today's Note"); 11 + }); 12 + 13 + test('clicking daily note creates a new document', async ({ page }) => { 14 + await goToLanding(page); 15 + 16 + await page.click('#daily-note'); 17 + 18 + // Should navigate to a docs URL with a key fragment 19 + await page.waitForURL(/\/docs\/[a-f0-9-]+#/, { timeout: 30000 }); 20 + }); 21 + 22 + test('daily note document has today\'s date in title', async ({ page }) => { 23 + await goToLanding(page); 24 + 25 + await page.click('#daily-note'); 26 + await page.waitForURL(/\/docs\//, { timeout: 30000 }); 27 + await page.waitForSelector('.tiptap', { timeout: 15000 }); 28 + 29 + // The title input should contain today's date in YYYY-MM-DD format 30 + const today = new Date(); 31 + const yyyy = today.getFullYear(); 32 + const mm = String(today.getMonth() + 1).padStart(2, '0'); 33 + const dd = String(today.getDate()).padStart(2, '0'); 34 + const datePrefix = `${yyyy}-${mm}-${dd}`; 35 + 36 + const titleInput = page.locator('#doc-title'); 37 + await expect(titleInput).toHaveValue(new RegExp(datePrefix), { timeout: 10000 }); 38 + }); 39 + 40 + test('daily note has task list and notes sections', async ({ page }) => { 41 + await goToLanding(page); 42 + 43 + await page.click('#daily-note'); 44 + await page.waitForURL(/\/docs\//, { timeout: 30000 }); 45 + await page.waitForSelector('.tiptap', { timeout: 15000 }); 46 + 47 + const editor = page.locator('.tiptap'); 48 + 49 + // Template includes "Tasks" and "Notes" headings 50 + await expect(editor.locator('h2').filter({ hasText: 'Tasks' })).toBeVisible({ timeout: 10000 }); 51 + await expect(editor.locator('h2').filter({ hasText: 'Notes' })).toBeVisible(); 52 + }); 53 + 54 + test('daily note has a task list item', async ({ page }) => { 55 + await goToLanding(page); 56 + 57 + await page.click('#daily-note'); 58 + await page.waitForURL(/\/docs\//, { timeout: 30000 }); 59 + await page.waitForSelector('.tiptap', { timeout: 15000 }); 60 + 61 + // Template includes a task list 62 + const taskList = page.locator('.tiptap [data-type="taskList"]'); 63 + await expect(taskList).toBeVisible({ timeout: 10000 }); 64 + }); 65 + 66 + test('clicking daily note again navigates to same note', async ({ page }) => { 67 + await goToLanding(page); 68 + 69 + // Create the daily note 70 + await page.click('#daily-note'); 71 + await page.waitForURL(/\/docs\//, { timeout: 30000 }); 72 + const firstUrl = page.url(); 73 + 74 + // Go back to landing and click again 75 + await goToLanding(page); 76 + await page.click('#daily-note'); 77 + await page.waitForURL(/\/docs\//, { timeout: 30000 }); 78 + const secondUrl = page.url(); 79 + 80 + // Should navigate to the same document (same doc ID) 81 + const firstDocId = firstUrl.match(/\/docs\/([a-f0-9-]+)/)?.[1]; 82 + const secondDocId = secondUrl.match(/\/docs\/([a-f0-9-]+)/)?.[1]; 83 + expect(firstDocId).toBeTruthy(); 84 + expect(firstDocId).toBe(secondDocId); 85 + }); 86 + });
+91
e2e/wiki-links.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewDoc } from './helpers'; 3 + 4 + test.describe('Wiki links (cross-doc links)', () => { 5 + test('typing [[text]] creates a wiki-link node', async ({ page }) => { 6 + await createNewDoc(page); 7 + 8 + const editor = page.locator('.tiptap'); 9 + await editor.click(); 10 + 11 + // Type the wiki-link syntax — TipTap InputRule triggers on closing ]] 12 + await editor.pressSequentially('Check out [[My Document]]', { delay: 30 }); 13 + 14 + // The InputRule should convert [[My Document]] into a wiki-link node 15 + const wikiLink = editor.locator('.wiki-link'); 16 + await expect(wikiLink).toBeVisible({ timeout: 5000 }); 17 + await expect(wikiLink).toContainText('My Document'); 18 + }); 19 + 20 + test('wiki-link node has correct data attributes', async ({ page }) => { 21 + await createNewDoc(page); 22 + 23 + const editor = page.locator('.tiptap'); 24 + await editor.click(); 25 + await editor.pressSequentially('Link to [[Test Page]]', { delay: 30 }); 26 + 27 + const wikiLink = editor.locator('.wiki-link'); 28 + await expect(wikiLink).toBeVisible({ timeout: 5000 }); 29 + await expect(wikiLink).toHaveAttribute('data-wiki-name', 'Test Page'); 30 + }); 31 + 32 + test('wiki-link renders as an anchor element', async ({ page }) => { 33 + await createNewDoc(page); 34 + 35 + const editor = page.locator('.tiptap'); 36 + await editor.click(); 37 + await editor.pressSequentially('See [[Another Doc]]', { delay: 30 }); 38 + 39 + // Wiki links render as <a> tags 40 + const link = editor.locator('a.wiki-link'); 41 + await expect(link).toBeVisible({ timeout: 5000 }); 42 + }); 43 + 44 + test('multiple wiki-links in same paragraph', async ({ page }) => { 45 + await createNewDoc(page); 46 + 47 + const editor = page.locator('.tiptap'); 48 + await editor.click(); 49 + 50 + // Type first wiki link 51 + await editor.pressSequentially('Link [[First Doc]] and [[Second Doc]]', { delay: 30 }); 52 + 53 + const wikiLinks = editor.locator('.wiki-link'); 54 + await expect(wikiLinks).toHaveCount(2); 55 + await expect(wikiLinks.nth(0)).toContainText('First Doc'); 56 + await expect(wikiLinks.nth(1)).toContainText('Second Doc'); 57 + }); 58 + 59 + test('incomplete wiki-link syntax does not create a node', async ({ page }) => { 60 + await createNewDoc(page); 61 + 62 + const editor = page.locator('.tiptap'); 63 + await editor.click(); 64 + 65 + // Type only opening brackets — should NOT create a wiki-link 66 + await editor.pressSequentially('Not a link [[incomplete', { delay: 30 }); 67 + 68 + const wikiLinks = editor.locator('.wiki-link'); 69 + await expect(wikiLinks).toHaveCount(0); 70 + }); 71 + 72 + test('wiki-link with insertWikiLink command', async ({ page }) => { 73 + await createNewDoc(page); 74 + 75 + // Use the editor command to insert a wiki link programmatically 76 + await page.evaluate(() => { 77 + const editorEl = document.querySelector('.tiptap') as any; 78 + const editor = editorEl?.__tiptapEditor || (window as any).__tiptapEditor; 79 + if (editor) { 80 + editor.commands.insertWikiLink('Programmatic Link'); 81 + } 82 + }); 83 + 84 + const wikiLink = page.locator('.tiptap .wiki-link'); 85 + // This test is best-effort — if the editor ref isn't exposed, skip gracefully 86 + const count = await wikiLink.count(); 87 + if (count > 0) { 88 + await expect(wikiLink).toContainText('Programmatic Link'); 89 + } 90 + }); 91 + });
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.24.0", 3 + "version": "0.25.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+9 -1
server/routes/documents.ts
··· 50 50 if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { 51 51 res.status(400).json({ error: 'keys must be an object' }); return; 52 52 } 53 + 54 + // Wrapped format (v2): client has already merged; store the encrypted blob as-is 55 + if (incoming.v === 2 && typeof incoming.salt === 'string' && typeof incoming.data === 'string') { 56 + stmts.putKeys.run(req.tsUser.login, JSON.stringify(incoming)); 57 + res.json({ ok: true }); 58 + return; 59 + } 60 + 61 + // Legacy plaintext format: validate and merge server-side 53 62 for (const [docId, keyStr] of Object.entries(incoming)) { 54 63 if (typeof keyStr !== 'string' || keyStr.length === 0) { 55 64 res.status(400).json({ error: `Invalid key for doc ${docId}` }); return; 56 65 } 57 66 } 58 - // Server-side merge: read existing, overlay incoming, write back 59 67 const existing = stmts.getKeys.get(req.tsUser.login) as { keys_json: string } | undefined; 60 68 const merged = { ...(existing ? JSON.parse(existing.keys_json) : {}), ...incoming }; 61 69 stmts.putKeys.run(req.tsUser.login, JSON.stringify(merged));
+320 -2
src/css/app.css
··· 6 6 7 7 /* --- Tokens --- */ 8 8 :root { 9 + --color-bg: #f5f3f0; 9 10 --color-bg: oklch(0.965 0.005 75); 11 + --color-surface: #ede9e4; 10 12 --color-surface: oklch(0.935 0.008 75); 13 + --color-surface-alt: #e7e2dc; 11 14 --color-surface-alt: oklch(0.915 0.01 75); 15 + --color-text: #221812; 12 16 --color-text: oklch(0.22 0.02 55); 17 + --color-text-muted: #655c56; 13 18 --color-text-muted: oklch(0.48 0.015 55); 19 + --color-text-faint: #8b8581; 14 20 --color-text-faint: oklch(0.62 0.01 55); 21 + --color-accent: #ab413e; 15 22 --color-accent: oklch(0.52 0.14 25); 23 + --color-accent-hover:#972e2d; 16 24 --color-accent-hover:oklch(0.46 0.14 25); 25 + --color-teal: #006e6f; 17 26 --color-teal: oklch(0.48 0.1 195); 27 + --color-teal-light: #bae0e0; 18 28 --color-teal-light: oklch(0.88 0.04 195); 29 + --color-border: #d7d4cf; 19 30 --color-border: oklch(0.87 0.008 75); 31 + --color-border-strong:#bbb7b0; 20 32 --color-border-strong:oklch(0.78 0.01 75); 33 + --color-grid-line: #cecac3; 21 34 --color-grid-line: oklch(0.84 0.01 75); 35 + --color-grid-header-line: #c9c3bc; 22 36 --color-grid-header-line: oklch(0.82 0.012 75); 37 + --color-hover: #e9e4dd; 23 38 --color-hover: oklch(0.92 0.01 75); 39 + --color-focus: #006e6f4d; 24 40 --color-focus: oklch(0.48 0.1 195 / 0.3); 41 + --color-success: #1c985a; 25 42 --color-success: oklch(0.6 0.14 155); 43 + --color-warning: #cd9c1f; 26 44 --color-warning: oklch(0.72 0.14 85); 45 + --color-danger: #bd413f; 27 46 --color-danger: oklch(0.55 0.16 25); 47 + --color-encrypted: #187c49; 28 48 --color-encrypted: oklch(0.52 0.12 155); 29 49 30 50 --font-display: Charter, 'Bitstream Charter', 'Sitka Text', Cambria, serif; ··· 42 62 --radius-md: 6px; 43 63 --radius-lg: 10px; 44 64 65 + --shadow-sm: 0 1px 2px #2218120f; 45 66 --shadow-sm: 0 1px 2px oklch(0.22 0.02 55 / 0.06); 67 + --shadow-md: 0 2px 8px #22181214; 46 68 --shadow-md: 0 2px 8px oklch(0.22 0.02 55 / 0.08); 69 + --shadow-lg: 0 8px 24px #2218121a; 47 70 --shadow-lg: 0 8px 24px oklch(0.22 0.02 55 / 0.1); 48 71 49 72 --transition-fast: 120ms ease-out; 50 73 --transition-med: 200ms ease-out; 51 74 75 + --color-text-secondary: #655c56; 52 76 --color-text-secondary: oklch(0.48 0.015 55); 77 + --color-bg-secondary: #ede9e4; 53 78 --color-bg-secondary: oklch(0.935 0.008 75); 79 + --color-surface-raised: #eeeae5; 54 80 --color-surface-raised: oklch(0.94 0.008 75); 55 81 56 82 /* Colors that need dark-mode overrides but are used inline */ 83 + --color-btn-primary-text: #f7f5f1; 57 84 --color-btn-primary-text: oklch(0.97 0.005 75); 85 + --color-collab-text: #f7f5f1; 58 86 --color-collab-text: oklch(0.97 0.005 75); 87 + --color-cursor-label: #f7f5f1; 59 88 --color-cursor-label: oklch(0.97 0.005 75); 89 + --color-cell-editor-bg: #fcfcfa; 60 90 --color-cell-editor-bg: oklch(0.99 0.002 75); 91 + --color-modal-backdrop: #22181280; 61 92 --color-modal-backdrop: oklch(0.22 0.02 55 / 0.5); 93 + --color-btn-active-bg: #ab413e14; 62 94 --color-btn-active-bg: oklch(0.52 0.14 25 / 0.08); 63 95 64 96 /* Comment highlight */ 97 + --color-comment-bg: #f7e2b8; 65 98 --color-comment-bg: oklch(0.92 0.06 85); 99 + --color-comment-border: #cd9c1f; 66 100 --color-comment-border: oklch(0.72 0.14 85); 101 + --color-comment-bg-hover: #f0d49b; 67 102 --color-comment-bg-hover: oklch(0.88 0.08 85); 68 103 69 104 /* Search highlight */ 105 + --color-search-match: #fcd176; 70 106 --color-search-match: oklch(0.88 0.12 85); 107 + --color-search-match-active: #ef852e; 71 108 --color-search-match-active: oklch(0.72 0.16 55); 109 + --color-search-match-text: #f7f5f1; 72 110 --color-search-match-text: oklch(0.97 0.005 75); 73 111 74 112 /* Range selection */ 113 + --color-range-bg: #006e6f1f; 75 114 --color-range-bg: oklch(0.48 0.1 195 / 0.12); 115 + --color-range-header-bg: #006e6f26; 76 116 --color-range-header-bg: oklch(0.48 0.1 195 / 0.15); 117 + --color-merge-active-bg: #006e6f1a; 77 118 --color-merge-active-bg: oklch(0.48 0.1 195 / 0.1); 78 119 79 120 /* Color swatch */ 80 121 --color-swatch-height: 4px; 122 + --color-highlight-default: #ffefb1; 81 123 --color-highlight-default: oklch(0.95 0.08 95); 82 124 83 125 /* Shadows (use dark base) */ 126 + --shadow-color: #221812; 84 127 --shadow-color: oklch(0.22 0.02 55); 85 128 86 129 /* Z-Index Scale — documented stacking context hierarchy. ··· 103 146 /* --- Dark theme --- */ 104 147 [data-theme="dark"] { 105 148 color-scheme: dark; 149 + --color-bg: #0f0d0b; 106 150 --color-bg: oklch(0.16 0.005 75); 151 + --color-surface: #181612; 107 152 --color-surface: oklch(0.20 0.008 75); 153 + --color-surface-alt: #221f1a; 108 154 --color-surface-alt: oklch(0.24 0.01 75); 155 + --color-text: #dbd7d0; 109 156 --color-text: oklch(0.88 0.01 75); 157 + --color-text-muted: #938e89; 110 158 --color-text-muted: oklch(0.65 0.01 75); 159 + --color-text-faint: #66635e; 111 160 --color-text-faint: oklch(0.50 0.008 75); 161 + --color-text-secondary: #938e89; 112 162 --color-text-secondary: oklch(0.65 0.01 75); 163 + --color-bg-secondary: #181612; 113 164 --color-bg-secondary: oklch(0.20 0.008 75); 165 + --color-surface-raised: #1d1a17; 114 166 --color-surface-raised: oklch(0.22 0.008 75); 167 + --color-accent: #cd605a; 115 168 --color-accent: oklch(0.62 0.14 25); 169 + --color-accent-hover: #b84d49; 116 170 --color-accent-hover: oklch(0.56 0.14 25); 171 + --color-teal: #0f9293; 117 172 --color-teal: oklch(0.60 0.1 195); 173 + --color-teal-light: #0b2f2f; 118 174 --color-teal-light: oklch(0.28 0.04 195); 175 + --color-border: #2b2825; 119 176 --color-border: oklch(0.28 0.008 75); 177 + --color-border-strong:#46423d; 120 178 --color-border-strong:oklch(0.38 0.01 75); 179 + --color-grid-line: #262420; 121 180 --color-grid-line: oklch(0.26 0.008 75); 181 + --color-grid-header-line: #312d28; 122 182 --color-grid-header-line: oklch(0.30 0.01 75); 183 + --color-hover: #201c18; 123 184 --color-hover: oklch(0.23 0.01 75); 185 + --color-focus: #0f92934d; 124 186 --color-focus: oklch(0.60 0.1 195 / 0.3); 187 + --color-success: #33a868; 125 188 --color-success: oklch(0.65 0.14 155); 189 + --color-warning: #cd9c1f; 126 190 --color-warning: oklch(0.72 0.14 85); 191 + --color-danger: #ce514d; 127 192 --color-danger: oklch(0.60 0.16 25); 193 + --color-encrypted: #3f9b65; 128 194 --color-encrypted: oklch(0.62 0.12 155); 129 195 196 + --shadow-sm: 0 1px 2px #0100004d; 130 197 --shadow-sm: 0 1px 2px oklch(0.05 0.005 75 / 0.3); 198 + --shadow-md: 0 2px 8px #01000066; 131 199 --shadow-md: 0 2px 8px oklch(0.05 0.005 75 / 0.4); 200 + --shadow-lg: 0 8px 24px #01000080; 132 201 --shadow-lg: 0 8px 24px oklch(0.05 0.005 75 / 0.5); 133 202 203 + --color-btn-primary-text: #f7f5f1; 134 204 --color-btn-primary-text: oklch(0.97 0.005 75); 205 + --color-collab-text: #f7f5f1; 135 206 --color-collab-text: oklch(0.97 0.005 75); 207 + --color-cursor-label: #f7f5f1; 136 208 --color-cursor-label: oklch(0.97 0.005 75); 209 + --color-cell-editor-bg: #13110f; 137 210 --color-cell-editor-bg: oklch(0.18 0.005 75); 211 + --color-modal-backdrop: #010000b3; 138 212 --color-modal-backdrop: oklch(0.05 0.005 75 / 0.7); 213 + --color-btn-active-bg: #cd605a26; 139 214 --color-btn-active-bg: oklch(0.62 0.14 25 / 0.15); 140 215 216 + --color-highlight-default: #563e00; 141 217 --color-highlight-default: oklch(0.38 0.08 85); 142 218 219 + --color-comment-bg: #372c15; 143 220 --color-comment-bg: oklch(0.30 0.04 85); 221 + --color-comment-border: #7d5e07; 144 222 --color-comment-border: oklch(0.50 0.10 85); 223 + --color-comment-bg-hover: #46381a; 145 224 --color-comment-bg-hover: oklch(0.35 0.05 85); 146 225 226 + --color-search-match: #4d3600; 147 227 --color-search-match: oklch(0.35 0.08 85); 228 + --color-search-match-active: #964d09; 148 229 --color-search-match-active: oklch(0.50 0.12 55); 230 + --color-search-match-text: #f7f5f1; 149 231 --color-search-match-text: oklch(0.97 0.005 75); 150 232 233 + --color-range-bg: #0f929326; 151 234 --color-range-bg: oklch(0.60 0.1 195 / 0.15); 235 + --color-range-header-bg: #0f929333; 152 236 --color-range-header-bg: oklch(0.60 0.1 195 / 0.20); 237 + --color-merge-active-bg: #0f929326; 153 238 --color-merge-active-bg: oklch(0.60 0.1 195 / 0.15); 154 239 } 155 240 156 241 @media (prefers-color-scheme: dark) { 157 242 :root:not([data-theme="light"]) { 158 243 color-scheme: dark; 244 + --color-bg: #0f0d0b; 159 245 --color-bg: oklch(0.16 0.005 75); 246 + --color-surface: #181612; 160 247 --color-surface: oklch(0.20 0.008 75); 248 + --color-surface-alt: #221f1a; 161 249 --color-surface-alt: oklch(0.24 0.01 75); 250 + --color-text: #dbd7d0; 162 251 --color-text: oklch(0.88 0.01 75); 252 + --color-text-muted: #938e89; 163 253 --color-text-muted: oklch(0.65 0.01 75); 254 + --color-text-faint: #66635e; 164 255 --color-text-faint: oklch(0.50 0.008 75); 256 + --color-text-secondary: #938e89; 165 257 --color-text-secondary: oklch(0.65 0.01 75); 258 + --color-bg-secondary: #181612; 166 259 --color-bg-secondary: oklch(0.20 0.008 75); 260 + --color-surface-raised: #1d1a17; 167 261 --color-surface-raised: oklch(0.22 0.008 75); 262 + --color-accent: #cd605a; 168 263 --color-accent: oklch(0.62 0.14 25); 264 + --color-accent-hover: #b84d49; 169 265 --color-accent-hover: oklch(0.56 0.14 25); 266 + --color-teal: #0f9293; 170 267 --color-teal: oklch(0.60 0.1 195); 268 + --color-teal-light: #0b2f2f; 171 269 --color-teal-light: oklch(0.28 0.04 195); 270 + --color-border: #2b2825; 172 271 --color-border: oklch(0.28 0.008 75); 272 + --color-border-strong:#46423d; 173 273 --color-border-strong:oklch(0.38 0.01 75); 274 + --color-grid-line: #262420; 174 275 --color-grid-line: oklch(0.26 0.008 75); 276 + --color-grid-header-line: #312d28; 175 277 --color-grid-header-line: oklch(0.30 0.01 75); 278 + --color-hover: #201c18; 176 279 --color-hover: oklch(0.23 0.01 75); 280 + --color-focus: #0f92934d; 177 281 --color-focus: oklch(0.60 0.1 195 / 0.3); 282 + --color-success: #33a868; 178 283 --color-success: oklch(0.65 0.14 155); 284 + --color-warning: #cd9c1f; 179 285 --color-warning: oklch(0.72 0.14 85); 286 + --color-danger: #ce514d; 180 287 --color-danger: oklch(0.60 0.16 25); 288 + --color-encrypted: #3f9b65; 181 289 --color-encrypted: oklch(0.62 0.12 155); 182 290 291 + --shadow-sm: 0 1px 2px #0100004d; 183 292 --shadow-sm: 0 1px 2px oklch(0.05 0.005 75 / 0.3); 293 + --shadow-md: 0 2px 8px #01000066; 184 294 --shadow-md: 0 2px 8px oklch(0.05 0.005 75 / 0.4); 295 + --shadow-lg: 0 8px 24px #01000080; 185 296 --shadow-lg: 0 8px 24px oklch(0.05 0.005 75 / 0.5); 186 297 298 + --color-btn-primary-text: #f7f5f1; 187 299 --color-btn-primary-text: oklch(0.97 0.005 75); 300 + --color-collab-text: #f7f5f1; 188 301 --color-collab-text: oklch(0.97 0.005 75); 302 + --color-cursor-label: #f7f5f1; 189 303 --color-cursor-label: oklch(0.97 0.005 75); 304 + --color-cell-editor-bg: #13110f; 190 305 --color-cell-editor-bg: oklch(0.18 0.005 75); 306 + --color-modal-backdrop: #010000b3; 191 307 --color-modal-backdrop: oklch(0.05 0.005 75 / 0.7); 308 + --color-btn-active-bg: #cd605a26; 192 309 --color-btn-active-bg: oklch(0.62 0.14 25 / 0.15); 193 310 311 + --color-highlight-default: #563e00; 194 312 --color-highlight-default: oklch(0.38 0.08 85); 195 313 314 + --color-comment-bg: #372c15; 196 315 --color-comment-bg: oklch(0.30 0.04 85); 316 + --color-comment-border: #7d5e07; 197 317 --color-comment-border: oklch(0.50 0.10 85); 318 + --color-comment-bg-hover: #46381a; 198 319 --color-comment-bg-hover: oklch(0.35 0.05 85); 199 320 321 + --color-search-match: #4d3600; 200 322 --color-search-match: oklch(0.35 0.08 85); 323 + --color-search-match-active: #964d09; 201 324 --color-search-match-active: oklch(0.50 0.12 55); 325 + --color-search-match-text: #f7f5f1; 202 326 --color-search-match-text: oklch(0.97 0.005 75); 203 327 328 + --color-range-bg: #0f929326; 204 329 --color-range-bg: oklch(0.60 0.1 195 / 0.15); 330 + --color-range-header-bg: #0f929333; 205 331 --color-range-header-bg: oklch(0.60 0.1 195 / 0.20); 332 + --color-merge-active-bg: #0f929326; 206 333 --color-merge-active-bg: oklch(0.60 0.1 195 / 0.15); 207 334 } 208 335 } ··· 210 337 /* --- Reduced motion: disable transitions/animations for users who prefer it --- */ 211 338 @media (prefers-contrast: more) { 212 339 :root { 340 + --color-text-muted: #433831; 213 341 --color-text-muted: oklch(0.35 0.02 55); 342 + --color-text-faint: #544b45; 214 343 --color-text-faint: oklch(0.42 0.015 55); 344 + --color-border: #9c9792; 215 345 --color-border: oklch(0.68 0.01 75); 346 + --color-border-strong: #76716a; 216 347 --color-border-strong: oklch(0.55 0.012 75); 348 + --color-grid-line: #9d9790; 217 349 --color-grid-line: oklch(0.68 0.012 75); 350 + --color-grid-header-line: #8c857c; 218 351 --color-grid-header-line: oklch(0.62 0.015 75); 352 + --color-hover: #d7d0c6; 219 353 --color-hover: oklch(0.86 0.015 75); 354 + --color-focus: #006e6f99; 220 355 --color-focus: oklch(0.48 0.1 195 / 0.6); 221 356 } 222 357 [data-theme="dark"] { 358 + --color-text-muted: #bbb7b0; 223 359 --color-text-muted: oklch(0.78 0.01 75); 360 + --color-text-faint: #a8a49e; 224 361 --color-text-faint: oklch(0.72 0.01 75); 362 + --color-border: #59554f; 225 363 --color-border: oklch(0.45 0.01 75); 364 + --color-border-strong: #76716a; 226 365 --color-border-strong: oklch(0.55 0.012 75); 366 + --color-grid-line: #514c46; 227 367 --color-grid-line: oklch(0.42 0.012 75); 368 + --color-grid-header-line: #635d54; 228 369 --color-grid-header-line: oklch(0.48 0.015 75); 370 + --color-hover: #2d2821; 229 371 --color-hover: oklch(0.28 0.015 75); 372 + --color-focus: #0f929399; 230 373 --color-focus: oklch(0.60 0.1 195 / 0.6); 231 374 } 232 375 } ··· 425 568 transform: translateX(-50%); 426 569 padding: 4px 8px; 427 570 border-radius: var(--radius-sm); 571 + background: #161616; 428 572 background: oklch(0.2 0 0); 573 + color: #eeeeee; 429 574 color: oklch(0.95 0 0); 430 575 font-size: 11px; 431 576 font-weight: 500; ··· 451 596 } 452 597 /* Dark mode tooltip */ 453 598 [data-theme="dark"] [data-tooltip]::after { 599 + background: #cecece; 454 600 background: oklch(0.85 0 0); 601 + color: #0b0b0b; 455 602 color: oklch(0.15 0 0); 456 603 } 457 604 @media (prefers-color-scheme: dark) { 458 605 :root:not([data-theme="light"]) [data-tooltip]::after { 606 + background: #cecece; 459 607 background: oklch(0.85 0 0); 608 + color: #0b0b0b; 460 609 color: oklch(0.15 0 0); 461 610 } 462 611 } ··· 554 703 } 555 704 556 705 .create-card-accent { 706 + border-color: #5194d566; 557 707 border-color: oklch(0.65 0.12 250 / 0.4); 708 + background: #5194d50d; 558 709 background: oklch(0.65 0.12 250 / 0.05); 559 710 } 560 711 .create-card-accent:hover { 712 + border-color: #5194d5b3; 561 713 border-color: oklch(0.65 0.12 250 / 0.7); 714 + background: #5194d51a; 562 715 background: oklch(0.65 0.12 250 / 0.1); 563 716 } 564 717 ··· 1577 1730 } 1578 1731 .tb-btn:hover { 1579 1732 color: var(--color-text); 1733 + background: #eeeeee; 1580 1734 background: oklch(0.95 0 0); 1581 1735 } 1582 1736 .tb-btn:active, 1583 1737 .tb-btn.active { 1584 1738 color: var(--color-accent); 1739 + background: #c1dcf0; 1585 1740 background: oklch(0.88 0.04 240); 1586 1741 } 1587 1742 1588 1743 /* Dark mode hover/active overrides for toolbar buttons */ 1589 1744 [data-theme="dark"] .tb-btn:hover { 1745 + background: #222222; 1590 1746 background: oklch(0.25 0 0); 1591 1747 } 1592 1748 [data-theme="dark"] .tb-btn:active, 1593 1749 [data-theme="dark"] .tb-btn.active { 1750 + background: #14242f; 1594 1751 background: oklch(0.25 0.03 240); 1595 1752 } 1596 1753 1597 1754 @media (prefers-color-scheme: dark) { 1598 1755 :root:not([data-theme="light"]) .tb-btn:hover { 1756 + background: #222222; 1599 1757 background: oklch(0.25 0 0); 1600 1758 } 1601 1759 :root:not([data-theme="light"]) .tb-btn:active, 1602 1760 :root:not([data-theme="light"]) .tb-btn.active { 1761 + background: #14242f; 1603 1762 background: oklch(0.25 0.03 240); 1604 1763 } 1605 1764 } ··· 1648 1807 } 1649 1808 .tb-select:hover { 1650 1809 border-color: var(--color-border); 1810 + background-color: #eeeeee; 1651 1811 background-color: oklch(0.95 0 0); 1652 1812 } 1653 1813 .tb-select:focus { ··· 1659 1819 } 1660 1820 1661 1821 [data-theme="dark"] .tb-select:hover { 1822 + background-color: #222222; 1662 1823 background-color: oklch(0.25 0 0); 1663 1824 } 1664 1825 @media (prefers-color-scheme: dark) { 1665 1826 :root:not([data-theme="light"]) .tb-select:hover { 1827 + background-color: #222222; 1666 1828 background-color: oklch(0.25 0 0); 1667 1829 } 1668 1830 } ··· 1680 1842 transition: background var(--transition-fast); 1681 1843 } 1682 1844 .tb-color-wrap:hover { 1845 + background: #eeeeee; 1683 1846 background: oklch(0.95 0 0); 1684 1847 } 1685 1848 [data-theme="dark"] .tb-color-wrap:hover { 1849 + background: #222222; 1686 1850 background: oklch(0.25 0 0); 1687 1851 } 1688 1852 @media (prefers-color-scheme: dark) { 1689 1853 :root:not([data-theme="light"]) .tb-color-wrap:hover { 1854 + background: #222222; 1690 1855 background: oklch(0.25 0 0); 1691 1856 } 1692 1857 } ··· 2055 2220 overflow: auto; 2056 2221 position: relative; 2057 2222 scrollbar-width: thin; 2223 + scrollbar-color: #bbb7b0 transparent; 2058 2224 scrollbar-color: oklch(0.78 0.01 75) transparent; 2059 2225 } 2060 2226 2061 2227 [data-theme="dark"] .sheet-container { 2228 + scrollbar-color: #3e3a35 transparent; 2062 2229 scrollbar-color: oklch(0.35 0.01 75) transparent; 2063 2230 } 2064 2231 2065 2232 @media (prefers-color-scheme: dark) { 2066 2233 :root:not([data-theme="light"]) .sheet-container { 2234 + scrollbar-color: #3e3a35 transparent; 2067 2235 scrollbar-color: oklch(0.35 0.01 75) transparent; 2068 2236 } 2069 2237 } ··· 2079 2247 } 2080 2248 2081 2249 .sheet-container::-webkit-scrollbar-thumb { 2250 + background: #bbb7b0; 2082 2251 background: oklch(0.78 0.01 75); 2083 2252 border-radius: 4px; 2084 2253 border: 2px solid transparent; ··· 2086 2255 } 2087 2256 2088 2257 .sheet-container::-webkit-scrollbar-thumb:hover { 2258 + background: #9c9792; 2089 2259 background: oklch(0.68 0.01 75); 2090 2260 background-clip: padding-box; 2091 2261 } 2092 2262 2093 2263 [data-theme="dark"] .sheet-container::-webkit-scrollbar-thumb { 2264 + background: #3e3a35; 2094 2265 background: oklch(0.35 0.01 75); 2095 2266 background-clip: padding-box; 2096 2267 } 2097 2268 2098 2269 [data-theme="dark"] .sheet-container::-webkit-scrollbar-thumb:hover { 2270 + background: #59554f; 2099 2271 background: oklch(0.45 0.01 75); 2100 2272 background-clip: padding-box; 2101 2273 } ··· 2383 2555 2384 2556 /* Multi-cell selection range highlight */ 2385 2557 .sheet-grid td.in-range { 2558 + background: #53c2c11f !important; 2386 2559 background: oklch(0.75 0.1 195 / 0.12) !important; 2387 2560 } 2388 2561 [data-theme="dark"] .sheet-grid td.in-range { 2562 + background: #00656633 !important; 2389 2563 background: oklch(0.45 0.1 195 / 0.2) !important; 2390 2564 } 2391 2565 @media (prefers-color-scheme: dark) { 2392 2566 :root:not([data-theme="light"]) .sheet-grid td.in-range { 2567 + background: #00656633 !important; 2393 2568 background: oklch(0.45 0.1 195 / 0.2) !important; 2394 2569 } 2395 2570 } ··· 2397 2572 /* Array formula spill range */ 2398 2573 .sheet-grid td.spill-source, 2399 2574 .sheet-grid td.spill-target { 2575 + border: 1px dashed #2784d580; 2400 2576 border: 1px dashed oklch(0.6 0.15 250 / 0.5); 2401 2577 } 2402 2578 .sheet-grid td.spill-target .cell-display { 2579 + color: #717171; 2403 2580 color: oklch(0.55 0 0); 2404 2581 } 2405 2582 [data-theme="dark"] .sheet-grid td.spill-target .cell-display { 2583 + color: #9e9e9e; 2406 2584 color: oklch(0.7 0 0); 2407 2585 } 2408 2586 @media (prefers-color-scheme: dark) { 2409 2587 :root:not([data-theme="light"]) .sheet-grid td.spill-target .cell-display { 2588 + color: #9e9e9e; 2410 2589 color: oklch(0.7 0 0); 2411 2590 } 2412 2591 } ··· 2429 2608 } 2430 2609 .cell-rating-star { 2431 2610 cursor: pointer; 2611 + color: #9e9e9e; 2432 2612 color: oklch(0.7 0 0); 2433 2613 font-size: 1em; 2434 2614 line-height: 1; ··· 2436 2616 transition: color var(--transition-fast); 2437 2617 } 2438 2618 .cell-rating-star.filled { 2619 + color: #d9a514; 2439 2620 color: oklch(0.75 0.15 85); 2440 2621 } 2441 2622 .cell-rating-star:hover { 2623 + color: #f2b200; 2442 2624 color: oklch(0.8 0.18 85); 2443 2625 } 2444 2626 .cell-progress { ··· 2456 2638 } 2457 2639 .cell-progress-label { 2458 2640 font-size: 0.7rem; 2641 + color: #808080; 2459 2642 color: oklch(0.6 0 0); 2460 2643 white-space: nowrap; 2461 2644 } ··· 2493 2676 } 2494 2677 .pivot-table th { background: var(--color-bg-secondary); font-weight: 600; text-align: left; } 2495 2678 .pivot-table td.pivot-row-header { font-weight: 600; text-align: left; background: var(--color-bg-secondary); } 2679 + .pivot-table td.pivot-total { font-weight: 600; background: #f6ede0; } 2496 2680 .pivot-table td.pivot-total { font-weight: 600; background: oklch(0.95 0.02 80); } 2681 + [data-theme="dark"] .pivot-table td.pivot-total { background: #272117; } 2497 2682 [data-theme="dark"] .pivot-table td.pivot-total { background: oklch(0.25 0.02 80); } 2498 2683 .pivot-table tfoot td { font-weight: 600; background: var(--color-bg-secondary); } 2499 2684 ··· 2536 2721 margin-bottom: var(--space-xs); cursor: pointer; font-size: 0.8rem; 2537 2722 transition: box-shadow var(--transition-fast); 2538 2723 } 2724 + .kanban-card:hover { box-shadow: 0 2px 8px #0000001a; } 2539 2725 .kanban-card:hover { box-shadow: 0 2px 8px oklch(0 0 0 / 0.1); } 2540 2726 .kanban-card-title { font-weight: 600; margin-bottom: 2px; } 2541 2727 .kanban-card-field { color: var(--color-text-secondary); font-size: 0.75rem; } ··· 2550 2736 border-radius: var(--radius-md); padding: var(--space-md); 2551 2737 cursor: pointer; transition: box-shadow var(--transition-fast); 2552 2738 } 2739 + .gallery-card:hover { box-shadow: 0 2px 8px #0000001a; } 2553 2740 .gallery-card:hover { box-shadow: 0 2px 8px oklch(0 0 0 / 0.1); } 2554 2741 .gallery-card-title { font-weight: 600; font-size: 0.9rem; margin-bottom: var(--space-xs); } 2555 2742 .gallery-card-field { font-size: 0.8rem; color: var(--color-text-secondary); } ··· 2577 2764 .calendar-day.calendar-day-other { opacity: 0.4; } 2578 2765 .calendar-day-number { font-weight: 600; margin-bottom: 2px; } 2579 2766 .calendar-event { 2767 + background: #7ee3d0; color: #002922; 2580 2768 background: oklch(0.85 0.1 180); color: oklch(0.25 0.05 180); 2581 2769 border-radius: 3px; padding: 1px 4px; margin-bottom: 1px; 2582 2770 font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 2583 2771 cursor: pointer; 2584 2772 } 2773 + [data-theme="dark"] .calendar-event { background: #004a3c; color: #abd9cf; } 2585 2774 [data-theme="dark"] .calendar-event { background: oklch(0.35 0.1 180); color: oklch(0.85 0.05 180); } 2586 2775 .calendar-event:hover { opacity: 0.8; } 2587 2776 ··· 2659 2848 .form-preview-desc { color: var(--color-text-secondary); margin-bottom: var(--space-md); } 2660 2849 .form-preview-question { margin-bottom: var(--space-md); } 2661 2850 .form-preview-label { font-weight: 500; display: block; margin-bottom: 4px; } 2851 + .form-required-mark { color: #de3b3d; } 2662 2852 .form-required-mark { color: oklch(0.6 0.2 25); } 2663 2853 .form-preview-hint { font-size: 0.8rem; color: var(--color-text-secondary); margin: 2px 0 6px; } 2664 2854 .form-preview-input, .form-preview-textarea, .form-preview-select { ··· 2674 2864 font-size: 1.5rem; background: none; border: none; cursor: pointer; 2675 2865 color: var(--color-border); transition: color var(--transition-fast); 2676 2866 } 2867 + .form-rating-star:hover, .form-rating-star.active { color: #dfa11a; } 2677 2868 .form-rating-star:hover, .form-rating-star.active { color: oklch(0.75 0.15 80); } 2678 2869 .form-scale-btn { 2679 2870 width: 32px; height: 32px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); ··· 2682 2873 .form-scale-btn:hover, .form-scale-btn.active { 2683 2874 background: var(--color-accent); color: white; border-color: var(--color-accent); 2684 2875 } 2876 + .form-preview-error { color: #de3b3d; font-size: 0.8rem; min-height: 1.2em; } 2685 2877 .form-preview-error { color: oklch(0.6 0.2 25); font-size: 0.8rem; min-height: 1.2em; } 2686 2878 .form-preview-actions { margin-top: var(--space-lg); } 2687 2879 ··· 2702 2894 background: var(--color-cell-editor-bg); 2703 2895 color: var(--color-text); 2704 2896 z-index: var(--z-component); 2897 + box-shadow: 0 2px 8px #006e6f26; 2705 2898 box-shadow: 0 2px 8px oklch(0.48 0.1 195 / 0.15); 2706 2899 box-sizing: border-box; 2707 2900 } ··· 2766 2959 /* Freeze boundary: thicker line with shadow for clear visual separation */ 2767 2960 .sheet-grid td.freeze-border-bottom, 2768 2961 .sheet-grid th.freeze-border-bottom { 2962 + border-bottom: 2px solid #006e6f99; 2769 2963 border-bottom: 2px solid oklch(0.48 0.1 195 / 0.6); 2964 + box-shadow: 0 2px 4px #006e6f26; 2770 2965 box-shadow: 0 2px 4px oklch(0.48 0.1 195 / 0.15); 2771 2966 } 2772 2967 2773 2968 .sheet-grid td.freeze-border-right, 2774 2969 .sheet-grid th.freeze-border-right { 2970 + border-right: 2px solid #006e6f99; 2775 2971 border-right: 2px solid oklch(0.48 0.1 195 / 0.6); 2972 + box-shadow: 2px 0 4px #006e6f26; 2776 2973 box-shadow: 2px 0 4px oklch(0.48 0.1 195 / 0.15); 2777 2974 } 2778 2975 2779 2976 /* Corner cells with both borders need combined box-shadow (CSS overwrites, not combines) */ 2780 2977 .sheet-grid td.freeze-border-bottom.freeze-border-right, 2781 2978 .sheet-grid th.freeze-border-bottom.freeze-border-right { 2979 + box-shadow: 2px 0 4px #006e6f26, 0 2px 4px #006e6f26; 2782 2980 box-shadow: 2px 0 4px oklch(0.48 0.1 195 / 0.15), 0 2px 4px oklch(0.48 0.1 195 / 0.15); 2783 2981 } 2784 2982 2785 2983 [data-theme="dark"] .sheet-grid td.freeze-border-bottom, 2786 2984 [data-theme="dark"] .sheet-grid th.freeze-border-bottom { 2985 + border-bottom-color: #0f9293b3; 2787 2986 border-bottom-color: oklch(0.6 0.1 195 / 0.7); 2987 + box-shadow: 0 2px 6px #00000066; 2788 2988 box-shadow: 0 2px 6px oklch(0.05 0.005 195 / 0.4); 2789 2989 } 2790 2990 2791 2991 [data-theme="dark"] .sheet-grid td.freeze-border-right, 2792 2992 [data-theme="dark"] .sheet-grid th.freeze-border-right { 2993 + border-right-color: #0f9293b3; 2793 2994 border-right-color: oklch(0.6 0.1 195 / 0.7); 2995 + box-shadow: 2px 0 6px #00000066; 2794 2996 box-shadow: 2px 0 6px oklch(0.05 0.005 195 / 0.4); 2795 2997 } 2796 2998 2797 2999 [data-theme="dark"] .sheet-grid td.freeze-border-bottom.freeze-border-right, 2798 3000 [data-theme="dark"] .sheet-grid th.freeze-border-bottom.freeze-border-right { 3001 + box-shadow: 2px 0 6px #00000066, 0 2px 6px #00000066; 2799 3002 box-shadow: 2px 0 6px oklch(0.05 0.005 195 / 0.4), 0 2px 6px oklch(0.05 0.005 195 / 0.4); 2800 3003 } 2801 3004 2802 3005 @media (prefers-color-scheme: dark) { 2803 3006 :root:not([data-theme="light"]) .sheet-grid td.freeze-border-bottom, 2804 3007 :root:not([data-theme="light"]) .sheet-grid th.freeze-border-bottom { 3008 + border-bottom-color: #0f9293b3; 2805 3009 border-bottom-color: oklch(0.6 0.1 195 / 0.7); 3010 + box-shadow: 0 2px 6px #00000066; 2806 3011 box-shadow: 0 2px 6px oklch(0.05 0.005 195 / 0.4); 2807 3012 } 2808 3013 :root:not([data-theme="light"]) .sheet-grid td.freeze-border-right, 2809 3014 :root:not([data-theme="light"]) .sheet-grid th.freeze-border-right { 3015 + border-right-color: #0f9293b3; 2810 3016 border-right-color: oklch(0.6 0.1 195 / 0.7); 3017 + box-shadow: 2px 0 6px #00000066; 2811 3018 box-shadow: 2px 0 6px oklch(0.05 0.005 195 / 0.4); 2812 3019 } 2813 3020 :root:not([data-theme="light"]) .sheet-grid td.freeze-border-bottom.freeze-border-right, 2814 3021 :root:not([data-theme="light"]) .sheet-grid th.freeze-border-bottom.freeze-border-right { 3022 + box-shadow: 2px 0 6px #00000066, 0 2px 6px #00000066; 2815 3023 box-shadow: 2px 0 6px oklch(0.05 0.005 195 / 0.4), 0 2px 6px oklch(0.05 0.005 195 / 0.4); 2816 3024 } 2817 3025 } ··· 3138 3346 background: var(--color-surface); 3139 3347 border: 1px solid var(--color-border); 3140 3348 border-radius: var(--radius-md); 3349 + box-shadow: 0 4px 16px #2218121f, 0 1px 3px #2218120f; 3141 3350 box-shadow: 0 4px 16px oklch(0.22 0.02 55 / 0.12), 0 1px 3px oklch(0.22 0.02 55 / 0.06); 3142 3351 } 3143 3352 .toolbar-dropdown.open .toolbar-dropdown-menu { ··· 3242 3451 /* Gdocs toolbar dropdown/overflow toggles: hover/active match .tb-btn states */ 3243 3452 .gdocs-toolbar .toolbar-dropdown-toggle:hover, 3244 3453 .gdocs-toolbar .toolbar-overflow-toggle:hover { 3454 + background: #eeeeee; 3245 3455 background: oklch(0.95 0 0); 3246 3456 } 3247 3457 .gdocs-toolbar .toolbar-dropdown-toggle.active { 3458 + background: #c1dcf0; 3248 3459 background: oklch(0.88 0.04 240); 3249 3460 } 3250 3461 [data-theme="dark"] .gdocs-toolbar .toolbar-dropdown-toggle:hover, 3251 3462 [data-theme="dark"] .gdocs-toolbar .toolbar-overflow-toggle:hover { 3463 + background: #222222; 3252 3464 background: oklch(0.25 0 0); 3253 3465 } 3254 3466 [data-theme="dark"] .gdocs-toolbar .toolbar-dropdown-toggle.active { 3467 + background: #14242f; 3255 3468 background: oklch(0.25 0.03 240); 3256 3469 } 3257 3470 @media (prefers-color-scheme: dark) { 3258 3471 :root:not([data-theme="light"]) .gdocs-toolbar .toolbar-dropdown-toggle:hover, 3259 3472 :root:not([data-theme="light"]) .gdocs-toolbar .toolbar-overflow-toggle:hover { 3473 + background: #222222; 3260 3474 background: oklch(0.25 0 0); 3261 3475 } 3262 3476 :root:not([data-theme="light"]) .gdocs-toolbar .toolbar-dropdown-toggle.active { 3477 + background: #14242f; 3263 3478 background: oklch(0.25 0.03 240); 3264 3479 } 3265 3480 } ··· 3365 3580 } 3366 3581 } 3367 3582 .sheet-grid td.fill-preview { 3583 + background: #c7eded; 3368 3584 background: oklch(0.92 0.04 195); 3369 3585 outline: 1px dashed var(--color-teal); 3370 3586 outline-offset: -1px; 3371 3587 } 3372 3588 [data-theme="dark"] .sheet-grid td.fill-preview { 3589 + background: #032728; 3373 3590 background: oklch(0.25 0.04 195); 3374 3591 } 3375 3592 @media (prefers-color-scheme: dark) { 3376 3593 :root:not([data-theme="light"]) .sheet-grid td.fill-preview { 3594 + background: #032728; 3377 3595 background: oklch(0.25 0.04 195); 3378 3596 } 3379 3597 } ··· 3381 3599 /* Header hover states */ 3382 3600 .sheet-grid th[data-col]:hover, 3383 3601 .sheet-grid th.row-header:hover { 3602 + background: #e2e9ee; 3384 3603 background: oklch(0.93 0.01 240); 3385 3604 } 3386 3605 [data-theme="dark"] .sheet-grid th[data-col]:hover, 3387 3606 [data-theme="dark"] .sheet-grid th.row-header:hover { 3607 + background: #171b1f; 3388 3608 background: oklch(0.22 0.01 240); 3389 3609 } 3390 3610 @media (prefers-color-scheme: dark) { 3391 3611 :root:not([data-theme="light"]) .sheet-grid th[data-col]:hover, 3392 3612 :root:not([data-theme="light"]) .sheet-grid th.row-header:hover { 3613 + background: #171b1f; 3393 3614 background: oklch(0.22 0.01 240); 3394 3615 } 3395 3616 } ··· 3422 3643 .tb-btn.format-painter-active, 3423 3644 .tb-btn#tb-format-painter.active { 3424 3645 color: var(--color-accent); 3646 + background: #c1dcf0; 3425 3647 background: oklch(0.88 0.04 240); 3426 3648 } 3427 3649 3428 3650 [data-theme="dark"] .tb-btn#tb-format-painter.active { 3651 + background: #14242f; 3429 3652 background: oklch(0.25 0.03 240); 3430 3653 } 3431 3654 3432 3655 @media (prefers-color-scheme: dark) { 3433 3656 :root:not([data-theme="light"]) .tb-btn#tb-format-painter.active { 3657 + background: #14242f; 3434 3658 background: oklch(0.25 0.03 240); 3435 3659 } 3436 3660 } ··· 3623 3847 border: 1px solid var(--color-border); 3624 3848 border-top: none; 3625 3849 border-radius: 0 0 var(--radius-md) var(--radius-md); 3850 + box-shadow: 0 4px 16px #2218121f; 3626 3851 box-shadow: 0 4px 16px oklch(0.22 0.02 55 / 0.12); 3627 3852 padding: var(--space-sm) var(--space-md); 3628 3853 display: flex; ··· 3639 3864 } 3640 3865 3641 3866 [data-theme="dark"] .find-bar { 3867 + box-shadow: 0 4px 16px #01000080; 3642 3868 box-shadow: 0 4px 16px oklch(0.05 0.005 75 / 0.5); 3643 3869 } 3644 3870 3645 3871 @media (prefers-color-scheme: dark) { 3646 3872 :root:not([data-theme="light"]) .find-bar { 3873 + box-shadow: 0 4px 16px #01000080; 3647 3874 box-shadow: 0 4px 16px oklch(0.05 0.005 75 / 0.5); 3648 3875 } 3649 3876 } ··· 3818 4045 position: absolute; 3819 4046 right: 0; 3820 4047 left: 0; 4048 + background: #5e93ca1a; 3821 4049 background: oklch(0.65 0.1 250 / 0.1); 4050 + border: 1px solid #5e93ca4d; 3822 4051 border: 1px solid oklch(0.65 0.1 250 / 0.3); 3823 4052 border-radius: 2px; 3824 4053 pointer-events: none; ··· 4450 4679 4451 4680 /* --- Striped Rows --- */ 4452 4681 .sheet-grid tr .striped-row { 4682 + background: #e6e4e199; 4453 4683 background: oklch(0.92 0.005 75 / 0.6); 4454 4684 } 4455 4685 [data-theme="dark"] .sheet-grid tr .striped-row { 4686 + background: #211f1d80; 4456 4687 background: oklch(0.24 0.005 75 / 0.5); 4457 4688 } 4458 4689 @media (prefers-color-scheme: dark) { 4459 4690 :root:not([data-theme="light"]) .sheet-grid tr .striped-row { 4691 + background: #211f1d80; 4460 4692 background: oklch(0.24 0.005 75 / 0.5); 4461 4693 } 4462 4694 } ··· 4715 4947 color: var(--color-danger); 4716 4948 } 4717 4949 .dv-form button.dv-btn-danger:hover { 4950 + background: #bd413f14; 4718 4951 background: oklch(0.55 0.16 25 / 0.08); 4719 4952 } 4720 4953 /* ======================================================== ··· 4906 5139 } 4907 5140 4908 5141 .suggesting-toggle-btn.active { 5142 + background: #b7e6b7; 4909 5143 background: oklch(0.88 0.08 145); 5144 + border-color: #1c985a; 4910 5145 border-color: oklch(0.6 0.14 155); 5146 + color: #00391b; 4911 5147 color: oklch(0.3 0.08 155); 4912 5148 } 4913 5149 4914 5150 [data-theme="dark"] .suggesting-toggle-btn.active { 5151 + background: #102719; 4915 5152 background: oklch(0.25 0.04 155); 5153 + border-color: #2a7449; 4916 5154 border-color: oklch(0.5 0.1 155); 5155 + color: #a0caad; 4917 5156 color: oklch(0.8 0.06 155); 4918 5157 } 4919 5158 ··· 4924 5163 /* Suggestion marks in the editor */ 4925 5164 .suggestion-insert { 4926 5165 text-decoration: underline; 5166 + text-decoration-color: #1c985a; 4927 5167 text-decoration-color: oklch(0.6 0.14 155); 4928 5168 text-decoration-thickness: 2px; 4929 5169 text-underline-offset: 2px; 5170 + background: #1c985a14; 4930 5171 background: oklch(0.6 0.14 155 / 0.08); 4931 5172 border-radius: 1px; 4932 5173 cursor: pointer; ··· 4935 5176 4936 5177 .suggestion-delete { 4937 5178 text-decoration: line-through; 5179 + text-decoration-color: #bd413f; 4938 5180 text-decoration-color: oklch(0.55 0.16 25); 4939 5181 text-decoration-thickness: 2px; 5182 + background: #bd413f14; 4940 5183 background: oklch(0.55 0.16 25 / 0.08); 4941 5184 color: var(--color-text-muted); 4942 5185 border-radius: 1px; ··· 4945 5188 } 4946 5189 4947 5190 [data-theme="dark"] .suggestion-insert { 5191 + text-decoration-color: #33a868; 4948 5192 text-decoration-color: oklch(0.65 0.14 155); 5193 + background: #33a8681f; 4949 5194 background: oklch(0.65 0.14 155 / 0.12); 4950 5195 } 4951 5196 4952 5197 [data-theme="dark"] .suggestion-delete { 5198 + text-decoration-color: #ce514d; 4953 5199 text-decoration-color: oklch(0.60 0.16 25); 5200 + background: #ce514d1f; 4954 5201 background: oklch(0.60 0.16 25 / 0.12); 4955 5202 } 4956 5203 ··· 5033 5280 } 5034 5281 5035 5282 .status-indicator .offline-badge { 5283 + background: #cd9c1f26; 5036 5284 background: oklch(0.72 0.14 85 / 0.15); 5285 + color: #8d6000; 5037 5286 color: oklch(0.52 0.14 85); 5038 5287 padding: 1px 6px; 5039 5288 border-radius: var(--radius-sm); ··· 5792 6041 .status-bar-freeze { 5793 6042 padding: 1px 6px; 5794 6043 border-radius: 2px; 6044 + background: #53c2c126; 5795 6045 background: oklch(0.75 0.1 195 / 0.15); 6046 + color: #00686a; 5796 6047 color: oklch(0.45 0.12 195); 5797 6048 font-weight: 500; 5798 6049 font-family: var(--font-body); ··· 5801 6052 transition: background var(--transition-fast); 5802 6053 } 5803 6054 .status-bar-freeze:hover { 6055 + background: #53c2c140; 5804 6056 background: oklch(0.75 0.1 195 / 0.25); 5805 6057 } 5806 6058 5807 6059 [data-theme="dark"] .status-bar-freeze { 6060 + background: #0047474d; 5808 6061 background: oklch(0.35 0.08 195 / 0.3); 6062 + color: #41b2b2; 5809 6063 color: oklch(0.7 0.1 195); 5810 6064 } 5811 6065 [data-theme="dark"] .status-bar-freeze:hover { 6066 + background: #00474773; 5812 6067 background: oklch(0.35 0.08 195 / 0.45); 5813 6068 } 5814 6069 @media (prefers-color-scheme: dark) { 5815 6070 :root:not([data-theme="light"]) .status-bar-freeze { 6071 + background: #0047474d; 5816 6072 background: oklch(0.35 0.08 195 / 0.3); 6073 + color: #41b2b2; 5817 6074 color: oklch(0.7 0.1 195); 5818 6075 } 5819 6076 :root:not([data-theme="light"]) .status-bar-freeze:hover { 6077 + background: #00474773; 5820 6078 background: oklch(0.35 0.08 195 / 0.45); 5821 6079 } 5822 6080 } ··· 5878 6136 5879 6137 /* Copy feedback on stat click */ 5880 6138 .status-bar-stat.copied { 6139 + background: #1c985a26; 5881 6140 background: oklch(0.6 0.14 155 / 0.15); 5882 6141 } 5883 6142 ··· 6003 6262 6004 6263 /* Token colors — Light theme */ 6005 6264 .formula-token-cell_ref { 6265 + color: #0071df; 6006 6266 color: oklch(0.55 0.2 250); 6007 6267 } 6008 6268 6009 6269 .formula-token-function { 6270 + color: #773ac1; 6010 6271 color: oklch(0.5 0.2 300); 6011 6272 } 6012 6273 6013 6274 .formula-token-string { 6275 + color: #00792f; 6014 6276 color: oklch(0.5 0.15 150); 6015 6277 } 6016 6278 6017 6279 .formula-token-number { 6280 + color: #ae5600; 6018 6281 color: oklch(0.55 0.15 60); 6019 6282 } 6020 6283 6021 6284 .formula-token-boolean { 6285 + color: #773ac1; 6022 6286 color: oklch(0.5 0.2 300); 6023 6287 } 6024 6288 ··· 6031 6295 } 6032 6296 6033 6297 .formula-token-error { 6298 + color: #cc272e; 6034 6299 color: oklch(0.55 0.2 25); 6035 6300 font-weight: 600; 6036 6301 } ··· 6047 6312 6048 6313 /* Token colors — Dark theme */ 6049 6314 [data-theme="dark"] .formula-token-cell_ref { 6315 + color: #00a1ff; 6050 6316 color: oklch(0.7 0.2 250); 6051 6317 } 6052 6318 6053 6319 [data-theme="dark"] .formula-token-function { 6320 + color: #ad87ed; 6054 6321 color: oklch(0.7 0.15 300); 6055 6322 } 6056 6323 6057 6324 [data-theme="dark"] .formula-token-string { 6325 + color: #4cb86a; 6058 6326 color: oklch(0.7 0.15 150); 6059 6327 } 6060 6328 6061 6329 [data-theme="dark"] .formula-token-number { 6330 + color: #e18528; 6062 6331 color: oklch(0.7 0.15 60); 6063 6332 } 6064 6333 6065 6334 [data-theme="dark"] .formula-token-boolean { 6335 + color: #ad87ed; 6066 6336 color: oklch(0.7 0.15 300); 6067 6337 } 6068 6338 6069 6339 [data-theme="dark"] .formula-token-error { 6340 + color: #f14d4c; 6070 6341 color: oklch(0.65 0.2 25); 6071 6342 } 6072 6343 ··· 6117 6388 } 6118 6389 6119 6390 .formula-tooltip-fn { 6391 + color: #773ac1; 6120 6392 color: oklch(0.5 0.2 300); 6121 6393 font-weight: 700; 6122 6394 } ··· 6164 6436 } 6165 6437 6166 6438 [data-theme="dark"] .formula-tooltip-fn { 6439 + color: #ad87ed; 6167 6440 color: oklch(0.7 0.15 300); 6168 6441 } 6169 6442 6170 6443 /* prefers-color-scheme fallback for syntax highlighting */ 6171 6444 @media (prefers-color-scheme: dark) { 6172 6445 :root:not([data-theme="light"]) .formula-token-cell_ref { 6446 + color: #00a1ff; 6173 6447 color: oklch(0.7 0.2 250); 6174 6448 } 6175 6449 :root:not([data-theme="light"]) .formula-token-function { 6450 + color: #ad87ed; 6176 6451 color: oklch(0.7 0.15 300); 6177 6452 } 6178 6453 :root:not([data-theme="light"]) .formula-token-string { 6454 + color: #4cb86a; 6179 6455 color: oklch(0.7 0.15 150); 6180 6456 } 6181 6457 :root:not([data-theme="light"]) .formula-token-number { 6458 + color: #e18528; 6182 6459 color: oklch(0.7 0.15 60); 6183 6460 } 6184 6461 :root:not([data-theme="light"]) .formula-token-boolean { 6462 + color: #ad87ed; 6185 6463 color: oklch(0.7 0.15 300); 6186 6464 } 6187 6465 :root:not([data-theme="light"]) .formula-token-error { 6466 + color: #f14d4c; 6188 6467 color: oklch(0.65 0.2 25); 6189 6468 } 6190 6469 :root:not([data-theme="light"]) .formula-tooltip { ··· 6192 6471 border-color: var(--color-border-strong); 6193 6472 } 6194 6473 :root:not([data-theme="light"]) .formula-tooltip-fn { 6474 + color: #ad87ed; 6195 6475 color: oklch(0.7 0.15 300); 6196 6476 } 6197 6477 } ··· 6398 6678 display: flex; 6399 6679 align-items: center; 6400 6680 justify-content: center; 6681 + background: #2218124d; 6401 6682 background: oklch(0.22 0.02 55 / 0.3); 6402 6683 backdrop-filter: blur(2px); 6403 6684 animation: onboarding-fade-in 200ms ease-out; ··· 6412 6693 background: var(--color-bg); 6413 6694 border: 1px solid var(--color-border); 6414 6695 border-radius: var(--radius-lg); 6696 + box-shadow: 0 12px 40px #22181226; 6415 6697 box-shadow: 0 12px 40px oklch(0.22 0.02 55 / 0.15); 6416 6698 padding: var(--space-lg) var(--space-xl); 6417 6699 max-width: 22rem; ··· 6484 6766 } 6485 6767 6486 6768 [data-theme="dark"] .onboarding-overlay { 6769 + background: #01000080; 6487 6770 background: oklch(0.05 0.005 75 / 0.5); 6488 6771 } 6489 6772 6490 6773 [data-theme="dark"] .onboarding-card { 6774 + box-shadow: 0 12px 40px #01000080; 6491 6775 box-shadow: 0 12px 40px oklch(0.05 0.005 75 / 0.5); 6492 6776 } 6493 6777 ··· 6789 7073 /* Current block (with cursor) gets a subtle left accent */ 6790 7074 .ProseMirror > .has-focus, 6791 7075 .ProseMirror > *:hover { 7076 + background: #006e6f08; 6792 7077 background: oklch(0.48 0.1 195 / 0.03); 6793 7078 } 6794 7079 ··· 6861 7146 6862 7147 [data-theme="dark"] .ProseMirror > .has-focus, 6863 7148 [data-theme="dark"] .ProseMirror > *:hover { 7149 + background: #0f92930d; 6864 7150 background: oklch(0.60 0.1 195 / 0.05); 6865 7151 } 6866 7152 ··· 6885 7171 } 6886 7172 :root:not([data-theme="light"]) .ProseMirror > .has-focus, 6887 7173 :root:not([data-theme="light"]) .ProseMirror > *:hover { 7174 + background: #0f92930d; 6888 7175 background: oklch(0.60 0.1 195 / 0.05); 6889 7176 } 6890 7177 } ··· 7438 7725 transition: background var(--transition-fast); 7439 7726 } 7440 7727 a.wiki-link:hover { 7728 + background: #a0dbda33; 7441 7729 background: oklch(0.85 0.06 195 / 0.2); 7442 7730 } 7443 7731 a.wiki-link[href="#"] { ··· 7454 7742 right: 0.75rem; 7455 7743 font-size: 0.65rem; 7456 7744 font-family: ui-monospace, 'SF Mono', monospace; 7457 - color: var(--color-text-faint, oklch(0.6 0 0)); 7745 + color: var(--color-text-faint, #808080); 7458 7746 opacity: 0.5; 7459 7747 pointer-events: none; 7460 7748 z-index: var(--z-float); ··· 7856 8144 } 7857 8145 7858 8146 .ai-chat-stop { 7859 - background: var(--color-error, oklch(0.65 0.2 25)); 8147 + background: var(--color-error, #f14d4c); 7860 8148 color: white; 7861 8149 font-size: 1rem; 7862 8150 } ··· 7920 8208 } 7921 8209 7922 8210 .ai-action-btn--suggest { 8211 + background: #7bc27e; 7923 8212 background: oklch(0.75 0.12 145); 7924 8213 color: white; 8214 + border-color: #7bc27e; 7925 8215 border-color: oklch(0.75 0.12 145); 7926 8216 } 7927 8217 ··· 7945 8235 } 7946 8236 7947 8237 .ai-action-card--suggested { 8238 + border-color: #7bc27e; 7948 8239 border-color: oklch(0.75 0.12 145); 7949 8240 opacity: 0.7; 7950 8241 } ··· 8110 8401 } 8111 8402 8112 8403 [data-theme="dark"] .slides-canvas { 8404 + background: #24211c; 8113 8405 background: oklch(0.25 0.01 75); 8114 8406 } 8115 8407 ··· 8174 8466 position: fixed; 8175 8467 inset: 0; 8176 8468 z-index: var(--z-modal); 8469 + background: #030303; 8177 8470 background: oklch(0.10 0 0); 8471 + color: #dedede; 8178 8472 color: oklch(0.90 0 0); 8179 8473 display: flex; 8180 8474 } ··· 8197 8491 width: 320px; 8198 8492 display: flex; 8199 8493 flex-direction: column; 8494 + border-left: 1px solid #2e2e2e; 8200 8495 border-left: 1px solid oklch(0.30 0 0); 8496 + background: #090909; 8201 8497 background: oklch(0.14 0 0); 8202 8498 } 8203 8499 8204 8500 .presenter-next { 8205 8501 padding: var(--space-sm); 8502 + border-bottom: 1px solid #2e2e2e; 8206 8503 border-bottom: 1px solid oklch(0.30 0 0); 8207 8504 } 8208 8505 8209 8506 .presenter-next h4 { 8210 8507 font-size: 0.7rem; 8211 8508 text-transform: uppercase; 8509 + color: #808080; 8212 8510 color: oklch(0.60 0 0); 8213 8511 margin-bottom: var(--space-xs); 8214 8512 } 8215 8513 8216 8514 .presenter-next-preview { 8217 8515 aspect-ratio: 16/9; 8516 + background: #161616; 8218 8517 background: oklch(0.20 0 0); 8219 8518 border-radius: var(--radius-sm); 8220 8519 overflow: hidden; ··· 8226 8525 overflow-y: auto; 8227 8526 font-size: 0.85rem; 8228 8527 line-height: 1.5; 8528 + color: #bebebe; 8229 8529 color: oklch(0.80 0 0); 8230 8530 } 8231 8531 ··· 8234 8534 align-items: center; 8235 8535 gap: var(--space-sm); 8236 8536 padding: var(--space-sm); 8537 + border-top: 1px solid #2e2e2e; 8237 8538 border-top: 1px solid oklch(0.30 0 0); 8238 8539 } 8239 8540 8240 8541 .presenter-timer { 8241 8542 font-family: var(--font-mono); 8242 8543 font-size: 1.2rem; 8544 + color: #9e9e9e; 8243 8545 color: oklch(0.70 0 0); 8244 8546 } 8245 8547 8246 8548 .presenter-progress { 8247 8549 font-size: 0.8rem; 8550 + color: #808080; 8248 8551 color: oklch(0.60 0 0); 8249 8552 margin-left: auto; 8250 8553 } ··· 8811 9114 .tiptap pre code .hljs-selector-tag, 8812 9115 .tiptap pre code .hljs-built_in, 8813 9116 .tiptap pre code .hljs-type { 9117 + color: #8c54b2; 8814 9118 color: oklch(0.55 0.15 310); 8815 9119 } 8816 9120 [data-theme="dark"] .tiptap pre code .hljs-keyword, 8817 9121 [data-theme="dark"] .tiptap pre code .hljs-selector-tag, 8818 9122 [data-theme="dark"] .tiptap pre code .hljs-built_in, 8819 9123 [data-theme="dark"] .tiptap pre code .hljs-type { 9124 + color: #bf8ae6; 8820 9125 color: oklch(0.72 0.14 310); 8821 9126 } 8822 9127 8823 9128 .tiptap pre code .hljs-string, 8824 9129 .tiptap pre code .hljs-addition { 9130 + color: #2f7434; 8825 9131 color: oklch(0.50 0.12 145); 8826 9132 } 8827 9133 [data-theme="dark"] .tiptap pre code .hljs-string, 8828 9134 [data-theme="dark"] .tiptap pre code .hljs-addition { 9135 + color: #6cb26f; 8829 9136 color: oklch(0.70 0.12 145); 8830 9137 } 8831 9138 8832 9139 .tiptap pre code .hljs-number, 8833 9140 .tiptap pre code .hljs-literal { 9141 + color: #a34d00; 8834 9142 color: oklch(0.52 0.14 55); 8835 9143 } 8836 9144 [data-theme="dark"] .tiptap pre code .hljs-number, 8837 9145 [data-theme="dark"] .tiptap pre code .hljs-literal { 9146 + color: #de8f57; 8838 9147 color: oklch(0.72 0.12 55); 8839 9148 } 8840 9149 8841 9150 .tiptap pre code .hljs-title, 8842 9151 .tiptap pre code .hljs-section { 9152 + color: #0465af; 8843 9153 color: oklch(0.50 0.14 250); 8844 9154 } 8845 9155 [data-theme="dark"] .tiptap pre code .hljs-title, 8846 9156 [data-theme="dark"] .tiptap pre code .hljs-section { 9157 + color: #67aaed; 8847 9158 color: oklch(0.72 0.12 250); 8848 9159 } 8849 9160 8850 9161 .tiptap pre code .hljs-name, 8851 9162 .tiptap pre code .hljs-selector-id, 8852 9163 .tiptap pre code .hljs-selector-class { 9164 + color: #007172; 8853 9165 color: oklch(0.48 0.12 195); 8854 9166 } 8855 9167 [data-theme="dark"] .tiptap pre code .hljs-name, 8856 9168 [data-theme="dark"] .tiptap pre code .hljs-selector-id, 8857 9169 [data-theme="dark"] .tiptap pre code .hljs-selector-class { 9170 + color: #39abab; 8858 9171 color: oklch(0.68 0.1 195); 8859 9172 } 8860 9173 8861 9174 .tiptap pre code .hljs-attr, 8862 9175 .tiptap pre code .hljs-attribute { 9176 + color: #906a21; 8863 9177 color: oklch(0.55 0.1 80); 8864 9178 } 8865 9179 [data-theme="dark"] .tiptap pre code .hljs-attr, 8866 9180 [data-theme="dark"] .tiptap pre code .hljs-attribute { 9181 + color: #c69e58; 8867 9182 color: oklch(0.72 0.1 80); 8868 9183 } 8869 9184 8870 9185 .tiptap pre code .hljs-deletion { 9186 + color: #b94642; 8871 9187 color: oklch(0.55 0.15 25); 8872 9188 } 8873 9189 [data-theme="dark"] .tiptap pre code .hljs-deletion { 9190 + color: #e97871; 8874 9191 color: oklch(0.70 0.14 25); 8875 9192 } 8876 9193 8877 9194 .tiptap pre code .hljs-regexp, 8878 9195 .tiptap pre code .hljs-link { 9196 + color: #007e5e; 8879 9197 color: oklch(0.52 0.12 170); 8880 9198 } 8881 9199
+6 -2
src/docs/main.ts
··· 33 33 34 34 import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 35 35 import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 36 + import { ensureWrappingKey } from '../lib/key-passphrase.js'; 36 37 import { EncryptedProvider } from '../lib/provider.js'; 37 38 import { FontSize } from './extensions/font-size.js'; 38 39 import { Indent } from './extensions/indent.js'; ··· 105 106 localStorage.removeItem('crypt-username'); 106 107 } 107 108 109 + // Ensure key wrapping passphrase is available before accessing keys 110 + await ensureWrappingKey(); 111 + 108 112 // Resolve key: URL hash > localStorage > server (cross-device sync) 109 - const storedKeysInit = getLocalKeys(); 113 + const storedKeysInit = await getLocalKeys(); 110 114 let keyString = hash || storedKeysInit[docId]; 111 115 112 116 if (!keyString) { ··· 121 125 throw new Error('No document ID or key'); 122 126 } 123 127 124 - storeKey(docId, keyString); 128 + await storeKey(docId, keyString); 125 129 pushKeysToServer({ [docId]: keyString }); 126 130 127 131 // --- Initialize ---
+9 -5
src/landing-create.ts
··· 7 7 8 8 import type { DocumentMeta, FolderAssignments } from './landing-types.js'; 9 9 import { generateKey, exportKey } from './lib/crypto.js'; 10 - import { storeKey, pushKeysToServer } from './lib/key-sync.js'; 10 + import { storeKey, pushKeysToServer, getLocalKeys } from './lib/key-sync.js'; 11 + import { ensureWrappingKeyForStore } from './lib/key-passphrase.js'; 11 12 import { formatDailyNoteName, findDailyNote, getDailyNoteTemplate } from './daily-notes.js'; 12 13 import { getTemplate } from './templates.js'; 13 14 import { trackRecentDoc } from './landing-utils.js'; ··· 50 51 if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 51 52 const { id } = await res.json(); 52 53 53 - storeKey(id, keyStr); 54 + await ensureWrappingKeyForStore(); 55 + await storeKey(id, keyStr); 54 56 pushKeysToServer({ [id]: keyStr }); 55 57 56 58 // If we're inside a folder, assign the new doc to it ··· 90 92 if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 91 93 const { id } = await res.json(); 92 94 93 - storeKey(id, keyStr); 95 + await ensureWrappingKeyForStore(); 96 + await storeKey(id, keyStr); 94 97 pushKeysToServer({ [id]: keyStr }); 95 98 96 99 const currentFolderId = deps.getCurrentFolderId(); ··· 118 121 // Check if today's note already exists 119 122 const existingId = findDailyNote(deps.getAllDocs()); 120 123 if (existingId) { 121 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 124 + const keys = await getLocalKeys(); 122 125 const keyStr = keys[existingId]; 123 126 if (keyStr) { 124 127 const updatedRecent = trackRecentDoc(deps.getRecentIds(), existingId); ··· 146 149 if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 147 150 const { id } = await res.json(); 148 151 149 - storeKey(id, keyStr); 152 + await ensureWrappingKeyForStore(); 153 + await storeKey(id, keyStr); 150 154 pushKeysToServer({ [id]: keyStr }); 151 155 152 156 // Store template for the editor to pick up
+2 -1
src/landing.ts
··· 6 6 import type { DocumentMeta, Folder, FolderAssignments, StarMap } from './landing-types.js'; 7 7 import { importKey, decryptString } from './lib/crypto.js'; 8 8 import { syncKeys } from './lib/key-sync.js'; 9 + import { ensureWrappingKey } from './lib/key-passphrase.js'; 9 10 import { createCommandPalette, type PaletteAction } from './command-palette.js'; 10 11 import { BUILT_IN_TEMPLATES } from './templates.js'; 11 12 import { DEFAULT_SORT } from './landing-utils.js'; ··· 307 308 308 309 // --- Init --- 309 310 initUsername(eventDeps); 310 - syncKeys().then(() => loadDocuments()); 311 + ensureWrappingKey().then(() => syncKeys()).then(() => loadDocuments()); 311 312 initDesktopDownload(); 312 313 313 314 // --- Handle PWA shortcut actions (?action=new-doc, etc.) ---
+51
src/lib/crypto.ts
··· 128 128 const keyStr = await exportKey(key); 129 129 return `#${docId}/${keyStr}`; 130 130 } 131 + 132 + // --- Key wrapping (PBKDF2 + AES-GCM) --- 133 + 134 + const PBKDF2_ITERATIONS = 600_000; // OWASP recommendation for SHA-256 135 + const WRAPPING_SALT_LENGTH = 16; // 128-bit salt 136 + 137 + /** Generate a random salt for PBKDF2. */ 138 + export function generateSalt(): Uint8Array { 139 + return crypto.getRandomValues(new Uint8Array(WRAPPING_SALT_LENGTH)); 140 + } 141 + 142 + /** Derive an AES-256-GCM wrapping key from a passphrase and salt via PBKDF2. */ 143 + export async function deriveWrappingKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> { 144 + const keyMaterial = await crypto.subtle.importKey( 145 + 'raw', 146 + new TextEncoder().encode(passphrase), 147 + 'PBKDF2', 148 + false, 149 + ['deriveKey'], 150 + ); 151 + return crypto.subtle.deriveKey( 152 + { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, 153 + keyMaterial, 154 + { name: ALGO, length: KEY_LENGTH }, 155 + false, 156 + ['encrypt', 'decrypt'], 157 + ); 158 + } 159 + 160 + /** Encrypt a key bundle JSON with a wrapping key. Returns base64url string of iv || ciphertext. */ 161 + export async function encryptKeyBundle( 162 + bundle: Record<string, string>, 163 + wrappingKey: CryptoKey, 164 + ): Promise<string> { 165 + const plaintext = new TextEncoder().encode(JSON.stringify(bundle)); 166 + const encrypted = await encrypt(plaintext, wrappingKey); 167 + return bufToBase64url(encrypted); 168 + } 169 + 170 + /** Decrypt a key bundle from a base64url-encoded iv || ciphertext blob. */ 171 + export async function decryptKeyBundle( 172 + encrypted: string, 173 + wrappingKey: CryptoKey, 174 + ): Promise<Record<string, string>> { 175 + const data = base64urlToBuf(encrypted); 176 + const decrypted = await decrypt(data, wrappingKey); 177 + return JSON.parse(new TextDecoder().decode(decrypted)); 178 + } 179 + 180 + // Re-export base64url helpers for key-sync wrapped storage 181 + export { bufToBase64url, base64urlToBuf };
+177
src/lib/key-passphrase.ts
··· 1 + /** 2 + * Key passphrase UI — prompts the user to set or enter their key-wrapping passphrase. 3 + * 4 + * The passphrase is used to derive a wrapping key (PBKDF2) that encrypts the 5 + * document key bundle before it is stored in localStorage or sent to the server. 6 + * 7 + * The derived key lives only in memory for the session — it is never persisted. 8 + */ 9 + 10 + import { 11 + hasWrappingKey, 12 + initWrappingKey, 13 + isLegacyFormat, 14 + migrateLegacyKeys, 15 + getLocalKeys, 16 + } from './key-sync.js'; 17 + 18 + const PASSPHRASE_SET_KEY = 'tools-keys-passphrase-set'; 19 + 20 + /** Check if the user has previously set a passphrase (stored as a flag, not the passphrase). */ 21 + export function hasPassphraseBeenSet(): boolean { 22 + return localStorage.getItem(PASSPHRASE_SET_KEY) === '1'; 23 + } 24 + 25 + /** Mark that a passphrase has been configured. */ 26 + function markPassphraseSet(): void { 27 + localStorage.setItem(PASSPHRASE_SET_KEY, '1'); 28 + } 29 + 30 + /** 31 + * Ensure the wrapping key is initialized. Shows a modal prompt if needed. 32 + * Returns true if the key is ready, false if the user cancelled. 33 + */ 34 + export async function ensureWrappingKey(): Promise<boolean> { 35 + if (hasWrappingKey()) return true; 36 + 37 + const isLegacy = isLegacyFormat(); 38 + const isFirstTime = !hasPassphraseBeenSet() && !isLegacy; 39 + 40 + // If there are no stored keys at all and no passphrase set, skip — new user 41 + const raw = localStorage.getItem('tools-keys'); 42 + if (!raw && !hasPassphraseBeenSet()) { 43 + // Brand new user with no keys — set up passphrase silently on first key store 44 + return true; 45 + } 46 + 47 + const passphrase = await showPassphraseModal(isLegacy || isFirstTime ? 'setup' : 'unlock'); 48 + if (passphrase === null) return false; // user cancelled 49 + 50 + await initWrappingKey(passphrase); 51 + 52 + if (isLegacy) { 53 + // Migrate plaintext keys to wrapped format 54 + await migrateLegacyKeys(); 55 + } else if (isFirstTime) { 56 + // First-time setup — just mark it 57 + } else { 58 + // Verify the passphrase decrypts existing keys 59 + try { 60 + await getLocalKeys(); 61 + } catch { 62 + // Wrong passphrase — decryption failed 63 + throw new Error('incorrect-passphrase'); 64 + } 65 + } 66 + 67 + markPassphraseSet(); 68 + return true; 69 + } 70 + 71 + /** 72 + * Ensure wrapping key is ready before storing a key. 73 + * For new users who haven't been prompted yet, prompts now. 74 + */ 75 + export async function ensureWrappingKeyForStore(): Promise<boolean> { 76 + if (hasWrappingKey()) return true; 77 + 78 + if (!hasPassphraseBeenSet()) { 79 + // New user storing their first key — prompt to set passphrase 80 + const passphrase = await showPassphraseModal('setup'); 81 + if (passphrase === null) return false; 82 + await initWrappingKey(passphrase); 83 + markPassphraseSet(); 84 + return true; 85 + } 86 + 87 + return ensureWrappingKey(); 88 + } 89 + 90 + // --- Modal UI --- 91 + 92 + function showPassphraseModal(mode: 'setup' | 'unlock'): Promise<string | null> { 93 + return new Promise((resolve) => { 94 + const overlay = document.createElement('div'); 95 + overlay.className = 'passphrase-overlay'; 96 + overlay.style.cssText = ` 97 + position: fixed; inset: 0; z-index: 10000; 98 + display: flex; align-items: center; justify-content: center; 99 + background: var(--color-modal-backdrop, rgba(0,0,0,0.5)); 100 + `; 101 + 102 + const dialog = document.createElement('div'); 103 + dialog.style.cssText = ` 104 + background: var(--color-surface, #fff); color: var(--color-text, #222); 105 + border-radius: var(--radius-lg, 10px); padding: 2rem; 106 + max-width: 400px; width: 90%; box-shadow: var(--shadow-lg); 107 + `; 108 + 109 + const title = mode === 'setup' 110 + ? 'Set a Key Passphrase' 111 + : 'Unlock Your Keys'; 112 + const description = mode === 'setup' 113 + ? 'Choose a passphrase to protect your document encryption keys. You\'ll need this passphrase to access your documents on new devices.' 114 + : 'Enter your passphrase to decrypt your document keys.'; 115 + 116 + dialog.innerHTML = ` 117 + <h3 style="margin: 0 0 0.5rem; font-family: var(--font-display, serif);">${title}</h3> 118 + <p style="margin: 0 0 1rem; font-size: 0.85rem; color: var(--color-text-muted, #666);">${description}</p> 119 + <input type="password" id="passphrase-input" placeholder="Passphrase" autocomplete="off" 120 + style="width: 100%; padding: 0.5rem; border: 1px solid var(--color-border, #ccc); 121 + border-radius: var(--radius-sm, 3px); font-size: 1rem; margin-bottom: 0.5rem; 122 + background: var(--color-bg, #fff); color: var(--color-text, #222);"> 123 + ${mode === 'setup' ? ` 124 + <input type="password" id="passphrase-confirm" placeholder="Confirm passphrase" autocomplete="off" 125 + style="width: 100%; padding: 0.5rem; border: 1px solid var(--color-border, #ccc); 126 + border-radius: var(--radius-sm, 3px); font-size: 1rem; margin-bottom: 0.5rem; 127 + background: var(--color-bg, #fff); color: var(--color-text, #222);"> 128 + ` : ''} 129 + <p id="passphrase-error" style="color: var(--color-danger, #c00); font-size: 0.8rem; min-height: 1.2em; margin: 0 0 0.75rem;"></p> 130 + <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 131 + <button id="passphrase-cancel" style="padding: 0.4rem 1rem; border: 1px solid var(--color-border, #ccc); 132 + border-radius: var(--radius-sm, 3px); background: transparent; color: var(--color-text, #222); cursor: pointer;">Cancel</button> 133 + <button id="passphrase-submit" style="padding: 0.4rem 1rem; border: none; 134 + border-radius: var(--radius-sm, 3px); background: var(--color-accent, #9c4a3e); 135 + color: var(--color-btn-primary-text, #fff); cursor: pointer; font-weight: 600;"> 136 + ${mode === 'setup' ? 'Set Passphrase' : 'Unlock'} 137 + </button> 138 + </div> 139 + `; 140 + 141 + overlay.appendChild(dialog); 142 + document.body.appendChild(overlay); 143 + 144 + const input = dialog.querySelector('#passphrase-input') as HTMLInputElement; 145 + const confirm = dialog.querySelector('#passphrase-confirm') as HTMLInputElement | null; 146 + const errorEl = dialog.querySelector('#passphrase-error') as HTMLElement; 147 + const submitBtn = dialog.querySelector('#passphrase-submit') as HTMLButtonElement; 148 + const cancelBtn = dialog.querySelector('#passphrase-cancel') as HTMLButtonElement; 149 + 150 + input.focus(); 151 + 152 + function cleanup() { 153 + overlay.remove(); 154 + } 155 + 156 + function submit() { 157 + const passphrase = input.value; 158 + if (!passphrase || passphrase.length < 4) { 159 + errorEl.textContent = 'Passphrase must be at least 4 characters.'; 160 + return; 161 + } 162 + if (mode === 'setup' && confirm) { 163 + if (passphrase !== confirm.value) { 164 + errorEl.textContent = 'Passphrases do not match.'; 165 + return; 166 + } 167 + } 168 + cleanup(); 169 + resolve(passphrase); 170 + } 171 + 172 + submitBtn.addEventListener('click', submit); 173 + cancelBtn.addEventListener('click', () => { cleanup(); resolve(null); }); 174 + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); 175 + confirm?.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); 176 + }); 177 + }
+159 -18
src/lib/key-sync.ts
··· 2 2 * Key sync — stores per-document encryption keys server-side (keyed by Tailscale identity) 3 3 * so users can seamlessly access documents across devices. 4 4 * 5 - * Local keys (localStorage) are the primary store for instant access. 6 - * Server keys are synced in the background for cross-device availability. 5 + * Keys are wrapped (encrypted) with a user-derived passphrase key before being stored 6 + * in localStorage or sent to the server. The wrapping key is derived via PBKDF2 from 7 + * the user's passphrase and a random salt. 8 + * 9 + * Storage format (v2 — wrapped): 10 + * { v: 2, salt: base64url, data: base64url(iv || AES-GCM(JSON(keyBundle))) } 11 + * 12 + * Legacy format (v1 — plaintext): 13 + * { docId: base64urlKey, ... } 14 + * 15 + * Migration: on first passphrase entry, plaintext keys are re-encrypted and stored 16 + * in the v2 format. The plaintext bundle is removed. 7 17 */ 18 + 19 + import { 20 + generateSalt, 21 + deriveWrappingKey, 22 + encryptKeyBundle, 23 + decryptKeyBundle, 24 + bufToBase64url, 25 + base64urlToBuf, 26 + } from './crypto.js'; 8 27 9 28 export interface KeyBundle { 10 29 [docId: string]: string; 11 30 } 12 31 32 + export interface WrappedKeyStore { 33 + v: 2; 34 + salt: string; // base64url-encoded PBKDF2 salt 35 + data: string; // base64url-encoded iv || ciphertext 36 + } 37 + 13 38 const STORAGE_KEY = 'tools-keys'; 14 39 15 - /** Read the local key bundle from localStorage */ 16 - export function getLocalKeys(): KeyBundle { 40 + // In-memory session cache for the wrapping key and salt 41 + let _wrappingKey: CryptoKey | null = null; 42 + let _salt: Uint8Array | null = null; 43 + 44 + /** Check if a wrapping key passphrase has been set for this session. */ 45 + export function hasWrappingKey(): boolean { 46 + return _wrappingKey !== null; 47 + } 48 + 49 + /** Set the wrapping key for this session. Call after passphrase entry. */ 50 + export async function initWrappingKey(passphrase: string, salt?: Uint8Array): Promise<void> { 51 + const existingSalt = getStoredSalt(); 52 + _salt = salt ?? existingSalt ?? generateSalt(); 53 + _wrappingKey = await deriveWrappingKey(passphrase, _salt); 54 + } 55 + 56 + /** Clear the session wrapping key (on logout or lock). */ 57 + export function clearWrappingKey(): void { 58 + _wrappingKey = null; 59 + _salt = null; 60 + } 61 + 62 + /** Get the stored salt from localStorage, if any. */ 63 + function getStoredSalt(): Uint8Array | null { 64 + try { 65 + const raw = localStorage.getItem(STORAGE_KEY); 66 + if (!raw) return null; 67 + const parsed = JSON.parse(raw); 68 + if (parsed?.v === 2 && parsed.salt) { 69 + return base64urlToBuf(parsed.salt); 70 + } 71 + } catch { /* ignore */ } 72 + return null; 73 + } 74 + 75 + /** Check if the store is still in legacy plaintext format. */ 76 + export function isLegacyFormat(): boolean { 77 + try { 78 + const raw = localStorage.getItem(STORAGE_KEY); 79 + if (!raw) return false; 80 + const parsed = JSON.parse(raw); 81 + return parsed?.v !== 2; 82 + } catch { 83 + return false; 84 + } 85 + } 86 + 87 + /** Read legacy plaintext keys (for migration). */ 88 + export function getLegacyKeys(): KeyBundle { 89 + try { 90 + const raw = localStorage.getItem(STORAGE_KEY); 91 + if (!raw) return {}; 92 + const parsed = JSON.parse(raw); 93 + if (parsed?.v === 2) return {}; // not legacy 94 + return parsed; 95 + } catch { 96 + return {}; 97 + } 98 + } 99 + 100 + /** Read the local key bundle from localStorage, decrypting if wrapped. */ 101 + export async function getLocalKeys(): Promise<KeyBundle> { 17 102 try { 18 - return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); 103 + const raw = localStorage.getItem(STORAGE_KEY); 104 + if (!raw) return {}; 105 + const parsed = JSON.parse(raw); 106 + 107 + if (parsed?.v === 2) { 108 + // Wrapped format 109 + if (!_wrappingKey) return {}; 110 + return await decryptKeyBundle(parsed.data, _wrappingKey); 111 + } 112 + 113 + // Legacy plaintext format — return as-is for migration callers 114 + return parsed; 19 115 } catch { 20 116 return {}; 21 117 } 22 118 } 23 119 24 - /** Write the local key bundle to localStorage */ 25 - export function setLocalKeys(keys: KeyBundle): void { 26 - localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); 120 + /** Write the local key bundle to localStorage, encrypting with the wrapping key. */ 121 + export async function setLocalKeys(keys: KeyBundle): Promise<void> { 122 + if (!_wrappingKey || !_salt) { 123 + // No wrapping key — store plaintext (legacy fallback, should not happen in normal flow) 124 + localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); 125 + return; 126 + } 127 + 128 + const data = await encryptKeyBundle(keys, _wrappingKey); 129 + const store: WrappedKeyStore = { 130 + v: 2, 131 + salt: bufToBase64url(_salt), 132 + data, 133 + }; 134 + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); 27 135 } 28 136 29 - /** Store a single key locally */ 30 - export function storeKey(docId: string, keyStr: string): void { 31 - const keys = getLocalKeys(); 137 + /** Store a single key locally. */ 138 + export async function storeKey(docId: string, keyStr: string): Promise<void> { 139 + const keys = await getLocalKeys(); 32 140 keys[docId] = keyStr; 33 - setLocalKeys(keys); 141 + await setLocalKeys(keys); 34 142 } 35 143 36 144 /** Merge local and server key bundles. Local wins on conflict. */ ··· 38 146 return { ...server, ...local }; 39 147 } 40 148 41 - /** Fetch the server-side key bundle for the authenticated user */ 149 + /** Fetch the server-side key bundle for the authenticated user. */ 42 150 export async function fetchServerKeys(): Promise<KeyBundle | null> { 43 151 try { 44 152 const res = await fetch('/api/keys'); 45 153 if (!res.ok) return null; 46 154 const data = await res.json(); 47 - return data.keys ?? null; 155 + const keys = data.keys; 156 + if (!keys) return null; 157 + 158 + // Server may have wrapped or plaintext format 159 + if (keys.v === 2) { 160 + if (!_wrappingKey) return null; 161 + return await decryptKeyBundle(keys.data, _wrappingKey); 162 + } 163 + 164 + // Legacy plaintext from server 165 + return keys; 48 166 } catch { 49 167 return null; 50 168 } 51 169 } 52 170 53 - /** Push keys to the server (server does merge) */ 171 + /** Push keys to the server. Keys are encrypted before sending if wrapping key is available. */ 54 172 export async function pushKeysToServer(keys: KeyBundle): Promise<boolean> { 55 173 try { 174 + let payload: KeyBundle | WrappedKeyStore; 175 + if (_wrappingKey && _salt) { 176 + const data = await encryptKeyBundle(keys, _wrappingKey); 177 + payload = { v: 2, salt: bufToBase64url(_salt), data }; 178 + } else { 179 + payload = keys; 180 + } 181 + 56 182 const res = await fetch('/api/keys', { 57 183 method: 'PUT', 58 184 headers: { 'Content-Type': 'application/json' }, 59 - body: JSON.stringify({ keys }), 185 + body: JSON.stringify({ keys: payload }), 60 186 }); 61 187 return res.ok; 62 188 } catch { ··· 65 191 } 66 192 67 193 /** 194 + * Migrate legacy plaintext keys to wrapped format. 195 + * Call after initWrappingKey() when isLegacyFormat() returns true. 196 + */ 197 + export async function migrateLegacyKeys(): Promise<void> { 198 + const legacy = getLegacyKeys(); 199 + if (Object.keys(legacy).length === 0) return; 200 + 201 + // Store locally in wrapped format 202 + await setLocalKeys(legacy); 203 + 204 + // Push wrapped format to server 205 + await pushKeysToServer(legacy); 206 + } 207 + 208 + /** 68 209 * Full sync: fetch server keys, merge with local, update both sides. 69 210 * Returns the merged key bundle. Safe to call on every page load. 70 211 */ 71 212 export async function syncKeys(): Promise<KeyBundle> { 72 - const local = getLocalKeys(); 213 + const local = await getLocalKeys(); 73 214 const server = await fetchServerKeys(); 74 215 75 216 if (!server) { ··· 83 224 const localChanged = Object.keys(merged).length !== Object.keys(local).length 84 225 || Object.keys(merged).some(k => local[k] !== merged[k]); 85 226 if (localChanged) { 86 - setLocalKeys(merged); 227 + await setLocalKeys(merged); 87 228 } 88 229 89 230 // Push to server if local had keys the server didn't
+6 -2
src/sheets/main.ts
··· 10 10 import { importKey } from '../lib/crypto.js'; 11 11 import { setupTooltips } from '../lib/tooltips.js'; 12 12 import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 13 + import { ensureWrappingKey } from '../lib/key-passphrase.js'; 13 14 import { EncryptedProvider } from '../lib/provider.js'; 14 15 import { evaluate, formatCell, cellId } from './formulas.js'; 15 16 import { RecalcEngine } from './recalc.js'; ··· 102 103 localStorage.removeItem('crypt-username'); 103 104 } 104 105 105 - const storedKeysInit = getLocalKeys(); 106 + // Ensure key wrapping passphrase is available before accessing keys 107 + await ensureWrappingKey(); 108 + 109 + const storedKeysInit = await getLocalKeys(); 106 110 let keyString = hash || storedKeysInit[docId]; 107 111 108 112 if (!keyString) { ··· 115 119 throw new Error('No document ID or key'); 116 120 } 117 121 118 - storeKey(docId, keyString); 122 + await storeKey(docId, keyString); 119 123 pushKeysToServer({ [docId]: keyString }); 120 124 121 125 const cryptoKey = await importKey(keyString);
+96 -1
tests/crypto.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { generateKey, exportKey, importKey, encrypt, decrypt, encryptString, decryptString, generateId } from '../src/lib/crypto.js'; 2 + import { generateKey, exportKey, importKey, encrypt, decrypt, encryptString, decryptString, generateId, generateSalt, deriveWrappingKey, encryptKeyBundle, decryptKeyBundle } from '../src/lib/crypto.js'; 3 3 4 4 describe('key generation and export/import', () => { 5 5 it('generates a CryptoKey', async () => { ··· 234 234 expect(unique.size).toBe(20); 235 235 }); 236 236 }); 237 + 238 + describe('PBKDF2 key derivation', () => { 239 + it('generates a 128-bit salt', () => { 240 + const salt = generateSalt(); 241 + expect(salt).toBeInstanceOf(Uint8Array); 242 + expect(salt.length).toBe(16); 243 + }); 244 + 245 + it('generates unique salts', () => { 246 + const salts = Array.from({ length: 10 }, () => generateSalt()); 247 + const hex = salts.map(s => Array.from(s).map(b => b.toString(16).padStart(2, '0')).join('')); 248 + expect(new Set(hex).size).toBe(10); 249 + }); 250 + 251 + it('derives a CryptoKey from passphrase and salt', async () => { 252 + const salt = generateSalt(); 253 + const key = await deriveWrappingKey('my-passphrase', salt); 254 + expect(key).toBeDefined(); 255 + expect(key.type).toBe('secret'); 256 + expect((key.algorithm as AesKeyAlgorithm).name).toBe('AES-GCM'); 257 + }); 258 + 259 + it('same passphrase + salt produces same key', async () => { 260 + const salt = generateSalt(); 261 + const key1 = await deriveWrappingKey('same-pass', salt); 262 + const key2 = await deriveWrappingKey('same-pass', salt); 263 + // Both should encrypt/decrypt interchangeably 264 + const data = new Uint8Array([1, 2, 3]); 265 + const encrypted = await encrypt(data, key1); 266 + const decrypted = await decrypt(encrypted, key2); 267 + expect(Array.from(decrypted)).toEqual([1, 2, 3]); 268 + }); 269 + 270 + it('different passphrase produces different key', async () => { 271 + const salt = generateSalt(); 272 + const key1 = await deriveWrappingKey('pass-one', salt); 273 + const key2 = await deriveWrappingKey('pass-two', salt); 274 + const encrypted = await encrypt(new Uint8Array([1, 2, 3]), key1); 275 + await expect(decrypt(encrypted, key2)).rejects.toThrow(); 276 + }); 277 + 278 + it('different salt produces different key', async () => { 279 + const salt1 = generateSalt(); 280 + const salt2 = generateSalt(); 281 + const key1 = await deriveWrappingKey('same-pass', salt1); 282 + const key2 = await deriveWrappingKey('same-pass', salt2); 283 + const encrypted = await encrypt(new Uint8Array([1, 2, 3]), key1); 284 + await expect(decrypt(encrypted, key2)).rejects.toThrow(); 285 + }); 286 + }); 287 + 288 + describe('key bundle encryption', () => { 289 + it('encrypts and decrypts a key bundle', async () => { 290 + const salt = generateSalt(); 291 + const wk = await deriveWrappingKey('bundle-test', salt); 292 + const bundle = { doc1: 'key-aaa', doc2: 'key-bbb', doc3: 'key-ccc' }; 293 + 294 + const encrypted = await encryptKeyBundle(bundle, wk); 295 + expect(typeof encrypted).toBe('string'); 296 + expect(encrypted).not.toContain('key-aaa'); 297 + 298 + const decrypted = await decryptKeyBundle(encrypted, wk); 299 + expect(decrypted).toEqual(bundle); 300 + }); 301 + 302 + it('fails with wrong wrapping key', async () => { 303 + const salt = generateSalt(); 304 + const wk1 = await deriveWrappingKey('correct', salt); 305 + const wk2 = await deriveWrappingKey('wrong', salt); 306 + const bundle = { doc1: 'secret-key' }; 307 + 308 + const encrypted = await encryptKeyBundle(bundle, wk1); 309 + await expect(decryptKeyBundle(encrypted, wk2)).rejects.toThrow(); 310 + }); 311 + 312 + it('handles empty bundle', async () => { 313 + const salt = generateSalt(); 314 + const wk = await deriveWrappingKey('empty-test', salt); 315 + const encrypted = await encryptKeyBundle({}, wk); 316 + const decrypted = await decryptKeyBundle(encrypted, wk); 317 + expect(decrypted).toEqual({}); 318 + }); 319 + 320 + it('handles large bundle', async () => { 321 + const salt = generateSalt(); 322 + const wk = await deriveWrappingKey('large-test', salt); 323 + const bundle: Record<string, string> = {}; 324 + for (let i = 0; i < 100; i++) { 325 + bundle[`doc-${i}`] = `key-${i}-${'x'.repeat(43)}`; 326 + } 327 + const encrypted = await encryptKeyBundle(bundle, wk); 328 + const decrypted = await decryptKeyBundle(encrypted, wk); 329 + expect(decrypted).toEqual(bundle); 330 + }); 331 + });
+78 -19
tests/key-sync.test.ts
··· 1 1 import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 - import { getLocalKeys, setLocalKeys, storeKey, mergeKeys } from '../src/lib/key-sync.js'; 2 + import { getLocalKeys, setLocalKeys, storeKey, mergeKeys, clearWrappingKey } from '../src/lib/key-sync.js'; 3 3 4 4 // Mock localStorage 5 5 const storage = new Map<string, string>(); ··· 16 16 17 17 beforeEach(() => { 18 18 storage.clear(); 19 + clearWrappingKey(); 19 20 }); 20 21 21 22 describe('getLocalKeys / setLocalKeys', () => { 22 - it('returns empty object when no keys stored', () => { 23 - expect(getLocalKeys()).toEqual({}); 23 + it('returns empty object when no keys stored', async () => { 24 + expect(await getLocalKeys()).toEqual({}); 24 25 }); 25 26 26 - it('roundtrips keys through localStorage', () => { 27 + it('roundtrips keys through localStorage (legacy/no wrapping key)', async () => { 27 28 const keys = { abc123: 'keyA', def456: 'keyB' }; 28 - setLocalKeys(keys); 29 - expect(getLocalKeys()).toEqual(keys); 29 + await setLocalKeys(keys); 30 + expect(await getLocalKeys()).toEqual(keys); 30 31 }); 31 32 32 - it('handles corrupted localStorage gracefully', () => { 33 + it('handles corrupted localStorage gracefully', async () => { 33 34 storage.set('tools-keys', 'not valid json{{{'); 34 - expect(getLocalKeys()).toEqual({}); 35 + expect(await getLocalKeys()).toEqual({}); 35 36 }); 36 37 }); 37 38 38 39 describe('storeKey', () => { 39 - it('adds a single key to an empty store', () => { 40 - storeKey('doc1', 'keyValue1'); 41 - expect(getLocalKeys()).toEqual({ doc1: 'keyValue1' }); 40 + it('adds a single key to an empty store', async () => { 41 + await storeKey('doc1', 'keyValue1'); 42 + expect(await getLocalKeys()).toEqual({ doc1: 'keyValue1' }); 42 43 }); 43 44 44 - it('adds to existing keys without losing them', () => { 45 - setLocalKeys({ existing: 'existingKey' }); 46 - storeKey('doc2', 'keyValue2'); 47 - expect(getLocalKeys()).toEqual({ existing: 'existingKey', doc2: 'keyValue2' }); 45 + it('adds to existing keys without losing them', async () => { 46 + await setLocalKeys({ existing: 'existingKey' }); 47 + await storeKey('doc2', 'keyValue2'); 48 + expect(await getLocalKeys()).toEqual({ existing: 'existingKey', doc2: 'keyValue2' }); 48 49 }); 49 50 50 - it('overwrites a key for the same docId', () => { 51 - storeKey('doc1', 'oldKey'); 52 - storeKey('doc1', 'newKey'); 53 - expect(getLocalKeys()).toEqual({ doc1: 'newKey' }); 51 + it('overwrites a key for the same docId', async () => { 52 + await storeKey('doc1', 'oldKey'); 53 + await storeKey('doc1', 'newKey'); 54 + expect(await getLocalKeys()).toEqual({ doc1: 'newKey' }); 54 55 }); 55 56 }); 56 57 ··· 151 152 expect(result).toBe(false); 152 153 }); 153 154 }); 155 + 156 + describe('wrapped key storage', () => { 157 + it('encrypts and decrypts key bundle with wrapping key', async () => { 158 + const { initWrappingKey, getLocalKeys, setLocalKeys } = await import('../src/lib/key-sync.js'); 159 + 160 + // Need Web Crypto API — vitest runs in Node which has it via globalThis.crypto 161 + await initWrappingKey('test-passphrase-123'); 162 + 163 + const keys = { doc1: 'keyA', doc2: 'keyB' }; 164 + await setLocalKeys(keys); 165 + 166 + // Verify stored format is wrapped (v2) 167 + const raw = storage.get('tools-keys'); 168 + expect(raw).toBeTruthy(); 169 + const parsed = JSON.parse(raw!); 170 + expect(parsed.v).toBe(2); 171 + expect(parsed.salt).toBeTruthy(); 172 + expect(parsed.data).toBeTruthy(); 173 + // Ensure plaintext keys are NOT visible in the stored data 174 + expect(raw).not.toContain('keyA'); 175 + expect(raw).not.toContain('keyB'); 176 + 177 + // Decrypt should return original keys 178 + expect(await getLocalKeys()).toEqual(keys); 179 + }); 180 + 181 + it('returns empty when wrapping key is wrong', async () => { 182 + const { initWrappingKey, setLocalKeys, getLocalKeys, clearWrappingKey } = await import('../src/lib/key-sync.js'); 183 + 184 + // Store with correct passphrase 185 + await initWrappingKey('correct-passphrase'); 186 + await setLocalKeys({ doc1: 'secret' }); 187 + 188 + // Switch to wrong passphrase (new salt to avoid hitting the stored one) 189 + clearWrappingKey(); 190 + const { generateSalt, deriveWrappingKey } = await import('../src/lib/crypto.js'); 191 + const wrongSalt = generateSalt(); 192 + await initWrappingKey('wrong-passphrase', wrongSalt); 193 + 194 + // Decryption should fail gracefully and return empty 195 + expect(await getLocalKeys()).toEqual({}); 196 + }); 197 + 198 + it('detects legacy format', async () => { 199 + const { isLegacyFormat, getLegacyKeys } = await import('../src/lib/key-sync.js'); 200 + 201 + storage.set('tools-keys', JSON.stringify({ doc1: 'plainKey' })); 202 + expect(isLegacyFormat()).toBe(true); 203 + expect(getLegacyKeys()).toEqual({ doc1: 'plainKey' }); 204 + }); 205 + 206 + it('does not detect v2 as legacy', async () => { 207 + const { isLegacyFormat } = await import('../src/lib/key-sync.js'); 208 + 209 + storage.set('tools-keys', JSON.stringify({ v: 2, salt: 'abc', data: 'def' })); 210 + expect(isLegacyFormat()).toBe(false); 211 + }); 212 + });