···894894**Week 3 (Formula Power):** #112 (color-coded refs), #91 (array spill) -- transform the formula editing experience and unlock the next wave of functions.
895895896896**Week 4 (Platform):** #52 (command palette), #21 (toolbar cleanup) -- polish the interaction layer that every user touches.
897897-898898----
899899-900900-## 11. Gap Analysis: Daily Driver Readiness
901901-902902-What would need to be true to use Tools as the ONLY docs+sheets tool, replacing Google Docs and Google Sheets entirely? This section identifies the gaps honestly.
903903-904904-### Data Loss Prevention
905905-906906-**Current state:** Good, with caveats.
907907-908908-- Snapshots are saved on a 500ms debounce after every edit, plus every 10 seconds via periodic timer, plus on `beforeunload` (tab close) and `visibilitychange` (tab switch).
909909-- The Yjs CRDT document is the source of truth. If the WebSocket disconnects, local edits are preserved in the Yjs Doc in memory. They sync when the connection resumes.
910910-- Version history keeps up to 50 snapshots per document on the server.
911911-912912-**Gaps:**
913913-- **Browser crash during edit.** If the browser process crashes (not a clean tab close), `beforeunload` does not fire. The last saved snapshot on the server may be up to 10 seconds old. Edits between the last save and the crash are lost. Mitigation: IndexedDB persistence (#84) would save the Yjs state to disk on every change, surviving a crash.
914914-- **localStorage is not durable.** Encryption keys stored in localStorage can be lost if the user clears browser data. There is no key backup mechanism. If the key is lost and the user does not have the URL saved, the document is permanently unrecoverable.
915915-- **No undo across sessions.** The Yjs UndoManager tracks undo history in memory. If the user closes the tab and reopens, undo history is gone. Only version history provides cross-session recovery.
916916-917917-**Verdict:** Adequate for normal use. Not crash-proof. IndexedDB persistence (#84) is the key improvement.
918918-919919-### Reliability
920920-921921-**Current state:** Reasonable for a self-hosted tool.
922922-923923-- WebSocket reconnects with 2-4 second randomized backoff on disconnect.
924924-- When reconnecting, the client sends its state vector and the peer (or snapshot) provides missing updates. No data is lost during disconnection.
925925-- Single-client scenario (no peers) works fully offline after initial load. The "synced" flag is set immediately if no peers are detected.
926926-927927-**Gaps:**
928928-- **Server restart loses WebSocket rooms.** If the server restarts, all WebSocket connections drop. Clients reconnect and re-sync from snapshots, but there is a brief interruption. In-flight encrypted messages that were not snapshot-persisted are lost (up to 500ms of edits).
929929-- **No health monitoring.** The `/health` endpoint exists but there is no alerting, no uptime monitoring, no automatic restart on crash. This is a deployment concern, not an application concern, but it matters for daily-driver reliability.
930930-- **Snapshot conflicts.** If two clients save snapshots simultaneously, the last write wins. Yjs state merges correctly on load, but there is a theoretical window where a client loads a snapshot that is missing another client's most recent changes. Those changes are recovered on the next WebSocket sync, but the user might see a brief "time travel" if they reload during this window.
931931-932932-**Verdict:** Reliable for 1-3 concurrent users on a stable network. Not yet battle-tested for high-concurrency or unreliable networks.
933933-934934-### Performance
935935-936936-**Current state:** Good for typical use, with known scaling limits.
937937-938938-- The sheets grid renders all cells on every `renderGrid()` call (100 rows x 26 cols = 2,600 cells). This is fast enough for default grids but would degrade with 1,000+ rows.
939939-- The virtual scrolling module exists but is not wired up (#146). Activating it would allow 10,000+ row sheets.
940940-- Formula evaluation caches results by formula text. Dependent formulas are evaluated recursively. There is no topological sort (though `RecalcEngine` appears to have been added for some formulas), and circular references crash the browser (#145).
941941-- `updateSelectionVisuals()` queries the DOM for each cell in the selection range. A 50-column selection triggers 50+ `querySelector` calls. Noticeable lag at large selections.
942942-- TipTap/ProseMirror handles document editing performance well for documents up to ~100 pages. Very long documents (500+ pages) may become sluggish due to ProseMirror's document traversal.
943943-944944-**Gaps:**
945945-- **No lazy loading for the landing page.** If a user has 500+ documents, the landing page fetches all of them, decrypts all names, and renders the full list. Pagination or virtual list rendering would help.
946946-- **Chart rendering blocks the main thread.** Chart.js renders synchronously on a canvas. Large datasets in charts can cause a visible frame drop.
947947-- **Full grid re-render on Yjs change.** Any Yjs update (from any peer) triggers `scheduleRenderGrid()` which does a `requestAnimationFrame(renderGrid)`. This rebuilds the entire grid HTML. A targeted cell update (only re-render changed cells) would be more efficient.
948948-949949-**Verdict:** Fine for sheets up to ~500 rows and docs up to ~50 pages. Wire up virtual scrolling (#146) and fix the circular reference crash (#145) to unlock larger workloads.
950950-951951-### Missing Basics (Compared to Google Sheets/Docs)
952952-953953-These are things a Google Sheets/Docs user would notice within the first 30 minutes:
954954-955955-| Feature | Google Has It | Tools Status |
956956-|---------|:---:|---|
957957-| Insert row/column in the middle | Yes | No -- can only append (#113) |
958958-| Paste from clipboard | Yes | Broken (#144 -- crashes) |
959959-| Drag-and-drop images | Yes | No -- URL-only (#81) |
960960-| Find and replace in sheets | Yes | No (only in docs) |
961961-| Conditional formatting presets (color scales, data bars) | Yes | No -- rules only (#120) |
962962-| Cell comments/notes with author | Yes | Partial -- notes exist but no author tracking |
963963-| Sparklines in cells | Yes | No (#87) |
964964-| IMPORTRANGE / cross-document data | Yes | No (#72) |
965965-| Print dialog with options | Yes | Docs only -- sheets print is basic (#115) |
966966-| Download as CSV | Yes | Exists (implemented) |
967967-| Download as .xlsx | Yes | No (#109) |
968968-| Revision history diff view | Yes | No -- versions exist but no visual diff (#49) |
969969-| Mobile app / PWA | Yes | No PWA yet (#54, #83, #84) |
970970-| Multiple undo levels with history | Yes | Yjs UndoManager works but no visual undo history |
971971-| Data validation with custom error messages | Yes | Partial -- validation exists, messages are generic |
972972-| Hyperlinks in cells | Yes | No -- cells are plain text |
973973-974974-### Trust Assessment
975975-976976-**Would I trust Tools with tax documents?**
977977-978978-Yes, with caveats:
979979-- **Encryption is sound.** AES-256-GCM with random IVs via the Web Crypto API is industry-standard. The key-in-fragment approach is clever and verifiable.
980980-- **Server is zero-knowledge.** Auditable by reading server.js (322 lines). The server never calls decrypt, never stores keys, never parses document content.
981981-- **Self-hosted eliminates third-party trust.** Running your own instance means no one else touches your data.
982982-983983-But:
984984-- **Key management is fragile.** One cleared localStorage away from permanent data loss. No backup mechanism, no recovery phrase, no key escrow.
985985-- **No audit log.** Cannot prove who accessed the document or when.
986986-- **Server-served JavaScript.** If someone compromises the server, they could serve modified JS that exfiltrates keys. Self-hosting mitigates this, but most users will trust the deployed instance.
987987-988988-**Verdict:** Trustworthy for privacy-sensitive documents. Not yet trustworthy for "only copy of critical financial records" without an external backup strategy.
989989-990990-### Summary: What's Needed for Daily Driver
991991-992992-**Must-fix (blocking daily use):**
993993-1. Fix paste crash (#144) -- basic operations must not crash
994994-2. Fix circular reference crash (#145) -- formulas must not freeze the browser
995995-3. Insert row/column in the middle (#113) -- fundamental spreadsheet operation
996996-4. Wire up virtual scrolling (#146) -- needed for any real-world sheet
997997-998998-**High-value improvements:**
999999-5. IndexedDB persistence (#84) -- crash recovery and true offline
10001000-6. Command palette (#52) -- fast navigation between documents
10011001-7. Row/column context menu actions (#149) -- right-click must work
10021002-8. Image drag-and-drop (#81) -- basic docs expectation
10031003-9. .xlsx export (#109) -- interoperability with the outside world
10041004-10. Key backup/export mechanism -- prevent catastrophic key loss
10051005-10061006-**Nice-to-have for parity:**
10071007-11. Find and replace in sheets
10081008-12. Revision history diff view (#49)
10091009-13. PWA with offline support (#83, #84)
10101010-14. Hyperlinks in sheet cells
10111011-10121012-The honest assessment: Tools is approximately **70% of the way to daily-driver status** for a technical user who values privacy. The remaining 30% is dominated by missing basics (#113, #144, #145), data durability (IndexedDB, key backup), and interoperability (.xlsx export, PWA).
+128
e2e/collaboration.spec.ts
···11+import { test, expect } from '@playwright/test';
22+import { createNewDoc, createNewSheet, dismissUsernamePrompt } from './helpers';
33+44+test.describe('Collaboration Indicators', () => {
55+ test('docs: save indicator shows Saved after typing', async ({ page }) => {
66+ await createNewDoc(page);
77+ const editor = page.locator('.tiptap');
88+ await editor.click();
99+ await page.keyboard.type('Save test content');
1010+1111+ // Wait for save indicator to show "Saved"
1212+ await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 15000 });
1313+1414+ // Save dot should have the saved class
1515+ await expect(page.locator('.save-dot--saved')).toBeVisible();
1616+ });
1717+1818+ test('docs: E2EE indicator is visible', async ({ page }) => {
1919+ await createNewDoc(page);
2020+2121+ // E2EE indicator should be visible with the encrypted dot
2222+ await expect(page.locator('.status-dot.encrypted')).toBeVisible();
2323+ await expect(page.locator('.status-indicator')).toContainText('E2EE');
2424+ });
2525+2626+ test('docs: status indicator shows connection state', async ({ page }) => {
2727+ await createNewDoc(page);
2828+2929+ // Status text should eventually show "Connected" or similar
3030+ const statusText = page.locator('#status-text');
3131+ await expect(statusText).not.toHaveText('', { timeout: 10000 });
3232+ });
3333+3434+ test('docs: version history panel opens via button', async ({ page }) => {
3535+ await createNewDoc(page);
3636+3737+ // Type content and wait for save
3838+ const editor = page.locator('.tiptap');
3939+ await editor.click();
4040+ await page.keyboard.type('Version history content');
4141+ await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 15000 });
4242+4343+ // Open version history
4444+ await page.click('#btn-history');
4545+ await expect(page.locator('#version-sidebar')).toBeVisible({ timeout: 5000 });
4646+4747+ // Should have version list or empty state
4848+ const versionContent = page.locator('#version-list');
4949+ await expect(versionContent).toBeVisible();
5050+5151+ // Close
5252+ await page.click('#version-sidebar-close');
5353+ await expect(page.locator('#version-sidebar')).not.toBeVisible();
5454+ });
5555+5656+ test('sheets: save indicator shows Saved after editing cell', async ({ page }) => {
5757+ await createNewSheet(page);
5858+5959+ await page.locator('td[data-id="A1"]').click();
6060+ await page.keyboard.type('Sheet save test');
6161+ await page.keyboard.press('Enter');
6262+6363+ await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 15000 });
6464+ });
6565+6666+ test('sheets: E2EE indicator is visible', async ({ page }) => {
6767+ await createNewSheet(page);
6868+6969+ await expect(page.locator('.status-dot.encrypted')).toBeVisible();
7070+ await expect(page.locator('.status-indicator')).toContainText('E2EE');
7171+ });
7272+7373+ test('sheets: version history opens via button', async ({ page }) => {
7474+ await createNewSheet(page);
7575+7676+ // Add some data
7777+ await page.locator('td[data-id="A1"]').click();
7878+ await page.keyboard.type('Data');
7979+ await page.keyboard.press('Enter');
8080+ await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 15000 });
8181+8282+ // Open version history
8383+ await page.click('#btn-history');
8484+8585+ // Version panel should appear
8686+ const versionPanel = page.locator('.version-sidebar, .version-panel, #version-sidebar, [class*="version"]');
8787+ await expect(versionPanel.first()).toBeVisible({ timeout: 5000 });
8888+ });
8989+9090+ test('docs: share dialog shows link and copy button', async ({ page }) => {
9191+ await createNewDoc(page);
9292+9393+ await page.click('#btn-share');
9494+ await expect(page.locator('#share-dialog')).toBeVisible();
9595+9696+ // Link input should contain the document URL with key
9797+ const linkValue = await page.locator('#share-link-input').inputValue();
9898+ expect(linkValue).toContain('/docs/');
9999+ expect(linkValue).toContain('#');
100100+101101+ // Copy button should be visible
102102+ await expect(page.locator('#share-copy-link')).toBeVisible();
103103+104104+ // Share mode select should be available
105105+ await expect(page.locator('#share-mode-select')).toBeVisible();
106106+ });
107107+108108+ test('sheets: share dialog shows link', async ({ page }) => {
109109+ await createNewSheet(page);
110110+111111+ await page.click('#btn-share');
112112+ await expect(page.locator('#share-dialog')).toBeVisible();
113113+114114+ const linkValue = await page.locator('#share-link-input').inputValue();
115115+ expect(linkValue).toContain('/sheets/');
116116+ expect(linkValue).toContain('#');
117117+ });
118118+119119+ test('docs: keyboard shortcuts dialog opens', async ({ page }) => {
120120+ await createNewDoc(page);
121121+122122+ await page.click('#btn-shortcuts');
123123+124124+ // Some kind of shortcuts overlay/modal should appear
125125+ const shortcutsEl = page.locator('.shortcuts-modal, .modal, [class*="shortcut"]');
126126+ await expect(shortcutsEl.first()).toBeVisible({ timeout: 5000 });
127127+ });
128128+});
···11+import { test, expect } from '@playwright/test';
22+import { createNewDoc } from './helpers';
33+44+test.describe('Docs - Advanced Features', () => {
55+ test.beforeEach(async ({ page }) => {
66+ await createNewDoc(page);
77+ });
88+99+ test('slash command menu appears when typing /', async ({ page }) => {
1010+ const editor = page.locator('.tiptap');
1111+ await editor.click();
1212+ await page.keyboard.type('/');
1313+1414+ // Slash command menu should appear
1515+ await expect(page.locator('.slash-menu')).toBeVisible({ timeout: 5000 });
1616+1717+ // Should have menu items
1818+ await expect(page.locator('.slash-menu-item').first()).toBeVisible();
1919+2020+ // Dismiss with Escape
2121+ await page.keyboard.press('Escape');
2222+ await expect(page.locator('.slash-menu')).not.toBeVisible();
2323+ });
2424+2525+ test('slash command inserts heading', async ({ page }) => {
2626+ const editor = page.locator('.tiptap');
2727+ await editor.click();
2828+ await page.keyboard.type('/');
2929+ await expect(page.locator('.slash-menu')).toBeVisible({ timeout: 5000 });
3030+3131+ // Type to filter for heading
3232+ await page.keyboard.type('heading');
3333+ // Click the first heading option
3434+ await page.locator('.slash-menu-item').first().click();
3535+3636+ // Should have created a heading element
3737+ const headings = editor.locator('h1, h2, h3');
3838+ await expect(headings.first()).toBeVisible();
3939+ });
4040+4141+ test('find and replace opens with Cmd+F', async ({ page }) => {
4242+ const editor = page.locator('.tiptap');
4343+ await editor.click();
4444+ await page.keyboard.type('Find this text in the document');
4545+4646+ // Open find
4747+ await page.keyboard.press('Meta+f');
4848+ const searchBar = page.locator('.search-bar, .find-replace-bar, [class*="search"]').first();
4949+ await expect(searchBar).toBeVisible({ timeout: 5000 });
5050+ });
5151+5252+ test('zen mode toggles with Cmd+Shift+F', async ({ page }) => {
5353+ // Type some content first
5454+ const editor = page.locator('.tiptap');
5555+ await editor.click();
5656+ await page.keyboard.type('Zen mode content');
5757+5858+ // Toggle zen mode
5959+ await page.keyboard.press('Meta+Shift+f');
6060+6161+ // Body or app should have zen class
6262+ await expect(page.locator('.zen-mode, body.zen-mode, .app-shell.zen-mode')).toBeVisible({ timeout: 5000 });
6363+6464+ // Exit button should be visible
6565+ await expect(page.locator('#zen-exit')).toBeVisible();
6666+6767+ // Toggle back
6868+ await page.keyboard.press('Meta+Shift+f');
6969+ await expect(page.locator('#zen-exit')).not.toBeVisible();
7070+ });
7171+7272+ test('markdown toggle with Cmd+Shift+M', async ({ page }) => {
7373+ const editor = page.locator('.tiptap');
7474+ await editor.click();
7575+ await page.keyboard.type('# Hello Markdown');
7676+7777+ // Toggle to markdown view
7878+ await page.keyboard.press('Meta+Shift+m');
7979+8080+ // Markdown source textarea should be visible
8181+ const mdSource = page.locator('#markdown-source');
8282+ await expect(mdSource).toBeVisible({ timeout: 5000 });
8383+8484+ // It should contain markdown text
8585+ const mdValue = await mdSource.inputValue();
8686+ expect(mdValue).toContain('Hello Markdown');
8787+8888+ // Toggle back
8989+ await page.keyboard.press('Meta+Shift+m');
9090+ await expect(mdSource).not.toBeVisible();
9191+ });
9292+9393+ test('word count updates in footer', async ({ page }) => {
9494+ const editor = page.locator('.tiptap');
9595+ await editor.click();
9696+9797+ // Initially should show 0 words
9898+ await expect(page.locator('#word-count')).toContainText('0 words');
9999+100100+ // Type some text
101101+ await page.keyboard.type('one two three four five');
102102+103103+ // Word count should update
104104+ await expect(page.locator('#word-count')).toContainText('5 words', { timeout: 5000 });
105105+ });
106106+107107+ test('character count updates in footer', async ({ page }) => {
108108+ const editor = page.locator('.tiptap');
109109+ await editor.click();
110110+ await page.keyboard.type('Hello');
111111+112112+ await expect(page.locator('#char-count')).toContainText('5 characters', { timeout: 5000 });
113113+ });
114114+115115+ test('outline sidebar shows headings', async ({ page }) => {
116116+ const editor = page.locator('.tiptap');
117117+ await editor.click();
118118+119119+ // Create some headings
120120+ await page.keyboard.type('# First Heading');
121121+ await page.keyboard.press('Enter');
122122+ await page.keyboard.type('Some paragraph text');
123123+ await page.keyboard.press('Enter');
124124+ await page.keyboard.type('## Second Heading');
125125+126126+ // Open outline sidebar
127127+ await page.click('#btn-outline');
128128+129129+ const sidebar = page.locator('#outline-sidebar');
130130+ await expect(sidebar).toBeVisible({ timeout: 5000 });
131131+132132+ // Should list the headings
133133+ const items = sidebar.locator('.outline-item, .outline-link, [class*="outline"]');
134134+ // At least the headings should appear (wait for rendering)
135135+ await expect(items.first()).toBeVisible({ timeout: 5000 });
136136+ });
137137+138138+ test('heading select in toolbar changes paragraph to heading', async ({ page }) => {
139139+ const editor = page.locator('.tiptap');
140140+ await editor.click();
141141+ await page.keyboard.type('Make me a heading');
142142+ await page.keyboard.press('Meta+a');
143143+144144+ // Change heading level via toolbar select
145145+ await page.selectOption('#tb-heading', '1');
146146+ await expect(editor.locator('h1')).toContainText('Make me a heading');
147147+ });
148148+149149+ test('version history panel opens with Cmd+Shift+H', async ({ page }) => {
150150+ // Type something to create content
151151+ const editor = page.locator('.tiptap');
152152+ await editor.click();
153153+ await page.keyboard.type('Version test content');
154154+155155+ // Wait for save
156156+ await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 10000 });
157157+158158+ // Open version history via button (Cmd+Shift+H might not be bound, use button)
159159+ await page.click('#btn-history');
160160+ const versionSidebar = page.locator('#version-sidebar');
161161+ await expect(versionSidebar).toBeVisible({ timeout: 5000 });
162162+163163+ // Close it
164164+ await page.click('#version-sidebar-close');
165165+ await expect(versionSidebar).not.toBeVisible();
166166+ });
167167+168168+ test('share dialog opens', async ({ page }) => {
169169+ await page.click('#btn-share');
170170+ const shareDialog = page.locator('#share-dialog');
171171+ await expect(shareDialog).toBeVisible({ timeout: 5000 });
172172+173173+ // Should have a sharing link input
174174+ await expect(page.locator('#share-link-input')).toBeVisible();
175175+176176+ // Close
177177+ await page.click('#share-dialog-close');
178178+ await expect(shareDialog).not.toBeVisible();
179179+ });
180180+});
+110
e2e/helpers.ts
···11+/**
22+ * Shared helpers for e2e tests.
33+ *
44+ * The app uses E2EE with keys derived from the URL fragment.
55+ * For testing we create documents via the landing page flow
66+ * so keys are generated and stored in localStorage automatically.
77+ */
88+import { type Page, expect } from '@playwright/test';
99+1010+/**
1111+ * Dismiss the username prompt if it appears.
1212+ * Sets a username in localStorage so subsequent navigations skip it.
1313+ */
1414+export async function dismissUsernamePrompt(page: Page): Promise<void> {
1515+ await page.evaluate(() => {
1616+ if (!localStorage.getItem('tools-username')) {
1717+ localStorage.setItem('tools-username', 'TestUser');
1818+ }
1919+ });
2020+}
2121+2222+/**
2323+ * Navigate to landing page and ensure it is ready.
2424+ */
2525+export async function goToLanding(page: Page): Promise<void> {
2626+ await page.goto('/');
2727+ await dismissUsernamePrompt(page);
2828+ // Reload after setting username to avoid the modal
2929+ await page.reload();
3030+ await page.waitForSelector('.landing-header');
3131+}
3232+3333+/**
3434+ * Create a new document via the landing page and wait for the editor to load.
3535+ * Returns the full URL of the new document.
3636+ */
3737+export async function createNewDoc(page: Page): Promise<string> {
3838+ await goToLanding(page);
3939+ await page.click('#new-doc');
4040+ // Wait for TipTap editor to mount
4141+ await page.waitForSelector('.tiptap', { timeout: 15000 });
4242+ return page.url();
4343+}
4444+4545+/**
4646+ * Create a new spreadsheet via the landing page and wait for the grid to load.
4747+ * Returns the full URL of the new spreadsheet.
4848+ */
4949+export async function createNewSheet(page: Page): Promise<string> {
5050+ await goToLanding(page);
5151+ await page.click('#new-sheet');
5252+ // Wait for the sheet grid to render rows
5353+ await page.waitForSelector('#sheet-grid tbody tr td[data-id]', { timeout: 15000 });
5454+ return page.url();
5555+}
5656+5757+/**
5858+ * Click a cell in the spreadsheet by its cell ID (e.g. "A1", "B3").
5959+ */
6060+export async function clickCell(page: Page, cellId: string): Promise<void> {
6161+ await page.click(`td[data-id="${cellId}"]`);
6262+}
6363+6464+/**
6565+ * Double-click a cell to start editing.
6666+ */
6767+export async function dblClickCell(page: Page, cellId: string): Promise<void> {
6868+ await page.dblclick(`td[data-id="${cellId}"]`);
6969+}
7070+7171+/**
7272+ * Type a value into a cell. Assumes the cell is already selected (single-click).
7373+ * Typing a printable character auto-starts editing in sheets.
7474+ */
7575+export async function typeInCell(page: Page, cellId: string, value: string): Promise<void> {
7676+ await clickCell(page, cellId);
7777+ // Typing a character starts editing; we type the full value then press Enter
7878+ await page.keyboard.type(value);
7979+ await page.keyboard.press('Enter');
8080+}
8181+8282+/**
8383+ * Get the displayed text of a cell.
8484+ */
8585+export async function getCellText(page: Page, cellId: string): Promise<string> {
8686+ const cell = page.locator(`td[data-id="${cellId}"] .cell-display`);
8787+ return (await cell.textContent()) ?? '';
8888+}
8989+9090+/**
9191+ * Get the value shown in the formula bar for the currently selected cell.
9292+ */
9393+export async function getFormulaBarValue(page: Page): Promise<string> {
9494+ return await page.inputValue('#formula-input');
9595+}
9696+9797+/**
9898+ * Use the modifier key appropriate for the current platform (Meta on Mac, Control elsewhere).
9999+ */
100100+export function mod(page: Page): string {
101101+ // Playwright Desktop Chrome on macOS uses Meta
102102+ return 'Meta';
103103+}
104104+105105+/**
106106+ * Wait for the save indicator to show "Saved" (green dot).
107107+ */
108108+export async function waitForSaved(page: Page): Promise<void> {
109109+ await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 10000 });
110110+}
+141
e2e/landing.spec.ts
···11+import { test, expect } from '@playwright/test';
22+import { goToLanding, dismissUsernamePrompt } from './helpers';
33+44+test.describe('Landing Page', () => {
55+ test.beforeEach(async ({ page }) => {
66+ await goToLanding(page);
77+ });
88+99+ test('shows app name and E2EE badge', async ({ page }) => {
1010+ await expect(page.locator('.brand-name')).toHaveText('Tools');
1111+ await expect(page.locator('.brand-badge')).toHaveText('E2EE');
1212+ });
1313+1414+ test('shows tagline', async ({ page }) => {
1515+ await expect(page.locator('.brand-tagline')).toContainText('end-to-end encrypted', { ignoreCase: true });
1616+ });
1717+1818+ test('create new document navigates to docs editor', async ({ page }) => {
1919+ await page.click('#new-doc');
2020+ await page.waitForURL(/\/docs\/[^/]+#/);
2121+ // Editor should mount
2222+ await expect(page.locator('.tiptap')).toBeVisible({ timeout: 15000 });
2323+ });
2424+2525+ test('create new spreadsheet navigates to sheets editor', async ({ page }) => {
2626+ await page.click('#new-sheet');
2727+ await page.waitForURL(/\/sheets\/[^/]+#/);
2828+ // Grid should render
2929+ await expect(page.locator('#sheet-grid')).toBeVisible({ timeout: 15000 });
3030+ });
3131+3232+ test('new document appears in document list after returning to landing', async ({ page }) => {
3333+ // Create a doc
3434+ await page.click('#new-doc');
3535+ await page.waitForURL(/\/docs\//);
3636+ await page.waitForSelector('.tiptap', { timeout: 15000 });
3737+3838+ // Go back to landing
3939+ await page.goto('/');
4040+ await page.waitForSelector('.doc-section');
4141+4242+ // Document list should have at least one item
4343+ await expect(page.locator('.doc-item').first()).toBeVisible({ timeout: 10000 });
4444+ await expect(page.locator('.doc-item-name').first()).toContainText('Untitled Document');
4545+ });
4646+4747+ test('rename document via title input in editor', async ({ page }) => {
4848+ // Create a doc and rename it
4949+ await page.click('#new-doc');
5050+ await page.waitForSelector('.tiptap', { timeout: 15000 });
5151+ const titleInput = page.locator('#doc-title');
5252+ await titleInput.fill('My Test Document');
5353+ await titleInput.press('Enter');
5454+5555+ // Wait for save
5656+ await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 10000 });
5757+5858+ // Go back to landing and verify the name appears
5959+ await page.goto('/');
6060+ await page.waitForSelector('.doc-section');
6161+ await expect(page.locator('.doc-item-name').first()).toContainText('My Test Document', { timeout: 10000 });
6262+ });
6363+6464+ test('trash and restore document', async ({ page }) => {
6565+ // Create a doc first
6666+ await page.click('#new-doc');
6767+ await page.waitForURL(/\/docs\//);
6868+ await page.waitForSelector('.tiptap', { timeout: 15000 });
6969+7070+ // Go back to landing
7171+ await page.goto('/');
7272+ await page.waitForSelector('.doc-section');
7373+7474+ // Wait for at least one doc to show
7575+ const deleteBtn = page.locator('.doc-item-delete').first();
7676+ await expect(deleteBtn).toBeVisible({ timeout: 10000 });
7777+7878+ // Trash it
7979+ await deleteBtn.click();
8080+8181+ // Trash section should appear
8282+ await expect(page.locator('#trash-section')).toBeVisible();
8383+8484+ // Expand trash
8585+ await page.click('#trash-toggle');
8686+8787+ // Restore the document
8888+ const restoreBtn = page.locator('.trash-restore').first();
8989+ await expect(restoreBtn).toBeVisible({ timeout: 5000 });
9090+ await restoreBtn.click();
9191+9292+ // Document should reappear in the main list
9393+ await expect(page.locator('.doc-item').first()).toBeVisible({ timeout: 10000 });
9494+ });
9595+9696+ test('command palette opens with Cmd+K', async ({ page }) => {
9797+ await page.keyboard.press('Meta+k');
9898+ await expect(page.locator('.command-palette')).toBeVisible({ timeout: 5000 });
9999+100100+ // Should show actions
101101+ await expect(page.locator('.command-palette-item').first()).toBeVisible();
102102+103103+ // Close with Escape
104104+ await page.keyboard.press('Escape');
105105+ await expect(page.locator('.command-palette')).not.toBeVisible();
106106+ });
107107+108108+ test('search filters documents', async ({ page }) => {
109109+ // Create a doc with a known name
110110+ await page.click('#new-doc');
111111+ await page.waitForSelector('.tiptap', { timeout: 15000 });
112112+ await page.locator('#doc-title').fill('SearchableDoc');
113113+ await page.locator('#doc-title').press('Enter');
114114+ await expect(page.locator('#save-text')).toHaveText('Saved', { timeout: 10000 });
115115+116116+ // Go back
117117+ await page.goto('/');
118118+ await page.waitForSelector('.doc-section');
119119+ await expect(page.locator('.doc-item').first()).toBeVisible({ timeout: 10000 });
120120+121121+ // Search for the doc
122122+ await page.fill('#search-input', 'SearchableDoc');
123123+ await expect(page.locator('.doc-item-name')).toContainText(['SearchableDoc']);
124124+125125+ // Search for something that doesn't exist
126126+ await page.fill('#search-input', 'zzznonexistent');
127127+ await expect(page.locator('#no-results')).toBeVisible();
128128+ });
129129+130130+ test('theme toggle switches between light and dark', async ({ page }) => {
131131+ // Click theme toggle
132132+ await page.click('#theme-toggle');
133133+ const theme1 = await page.locator('html').getAttribute('data-theme');
134134+ expect(theme1).toBeTruthy();
135135+136136+ // Click again to toggle back
137137+ await page.click('#theme-toggle');
138138+ const theme2 = await page.locator('html').getAttribute('data-theme');
139139+ expect(theme2).not.toEqual(theme1);
140140+ });
141141+});
+122
e2e/sheets-basic.spec.ts
···11+import { test, expect } from '@playwright/test';
22+import { createNewSheet, clickCell, typeInCell, getCellText, getFormulaBarValue } from './helpers';
33+44+test.describe('Sheets - Basic', () => {
55+ test.beforeEach(async ({ page }) => {
66+ await createNewSheet(page);
77+ });
88+99+ test('grid renders with row and column headers', async ({ page }) => {
1010+ // Column headers (A, B, C...)
1111+ await expect(page.locator('thead th[data-col="1"]')).toContainText('A');
1212+ await expect(page.locator('thead th[data-col="2"]')).toContainText('B');
1313+ await expect(page.locator('thead th[data-col="3"]')).toContainText('C');
1414+1515+ // Row headers (1, 2, 3...)
1616+ await expect(page.locator('th.row-header[data-row="1"]')).toContainText('1');
1717+ await expect(page.locator('th.row-header[data-row="2"]')).toContainText('2');
1818+ await expect(page.locator('th.row-header[data-row="3"]')).toContainText('3');
1919+ });
2020+2121+ test('click cell to select it', async ({ page }) => {
2222+ await clickCell(page, 'B2');
2323+2424+ // Cell address input should show B2
2525+ await expect(page.locator('#cell-address')).toHaveValue('B2');
2626+2727+ // Cell should have selected class
2828+ await expect(page.locator('td[data-id="B2"]')).toHaveClass(/selected/);
2929+ });
3030+3131+ test('type value and press Enter', async ({ page }) => {
3232+ await typeInCell(page, 'A1', 'Hello');
3333+3434+ // Cell should display the value
3535+ const text = await getCellText(page, 'A1');
3636+ expect(text).toBe('Hello');
3737+ });
3838+3939+ test('formula bar shows cell content when selected', async ({ page }) => {
4040+ await typeInCell(page, 'A1', 'Test Value');
4141+4242+ // Click the cell to select it
4343+ await clickCell(page, 'A1');
4444+4545+ // Formula bar should show the value
4646+ const barValue = await getFormulaBarValue(page);
4747+ expect(barValue).toBe('Test Value');
4848+ });
4949+5050+ test('navigate with arrow keys', async ({ page }) => {
5151+ // Start at A1
5252+ await clickCell(page, 'A1');
5353+ await expect(page.locator('#cell-address')).toHaveValue('A1');
5454+5555+ // Right arrow -> B1
5656+ await page.keyboard.press('ArrowRight');
5757+ await expect(page.locator('#cell-address')).toHaveValue('B1');
5858+5959+ // Down arrow -> B2
6060+ await page.keyboard.press('ArrowDown');
6161+ await expect(page.locator('#cell-address')).toHaveValue('B2');
6262+6363+ // Left arrow -> A2
6464+ await page.keyboard.press('ArrowLeft');
6565+ await expect(page.locator('#cell-address')).toHaveValue('A2');
6666+6767+ // Up arrow -> A1
6868+ await page.keyboard.press('ArrowUp');
6969+ await expect(page.locator('#cell-address')).toHaveValue('A1');
7070+ });
7171+7272+ test('Tab moves right, Enter moves down', async ({ page }) => {
7373+ await clickCell(page, 'A1');
7474+ await page.keyboard.type('One');
7575+ await page.keyboard.press('Tab');
7676+7777+ // Should be at B1 now
7878+ await expect(page.locator('#cell-address')).toHaveValue('B1');
7979+8080+ await page.keyboard.type('Two');
8181+ await page.keyboard.press('Enter');
8282+8383+ // Enter moves down from the last Tab column -> B2
8484+ await expect(page.locator('#cell-address')).toHaveValue('B2');
8585+ });
8686+8787+ test('double-click cell starts editing', async ({ page }) => {
8888+ await typeInCell(page, 'A1', 'Existing');
8989+9090+ // Double-click to edit
9191+ await page.dblclick('td[data-id="A1"]');
9292+9393+ // An input editor should appear inside the cell
9494+ const cellEditor = page.locator('td[data-id="A1"] .cell-editor');
9595+ await expect(cellEditor).toBeVisible({ timeout: 5000 });
9696+ expect(await cellEditor.inputValue()).toBe('Existing');
9797+ });
9898+9999+ test('cell address input shows correct address', async ({ page }) => {
100100+ await clickCell(page, 'C5');
101101+ await expect(page.locator('#cell-address')).toHaveValue('C5');
102102+103103+ await clickCell(page, 'Z1');
104104+ await expect(page.locator('#cell-address')).toHaveValue('Z1');
105105+ });
106106+107107+ test('type numeric value', async ({ page }) => {
108108+ await typeInCell(page, 'A1', '42');
109109+ const text = await getCellText(page, 'A1');
110110+ expect(text).toBe('42');
111111+ });
112112+113113+ test('multiple cells can hold different values', async ({ page }) => {
114114+ await typeInCell(page, 'A1', 'First');
115115+ await typeInCell(page, 'B1', 'Second');
116116+ await typeInCell(page, 'A2', 'Third');
117117+118118+ expect(await getCellText(page, 'A1')).toBe('First');
119119+ expect(await getCellText(page, 'B1')).toBe('Second');
120120+ expect(await getCellText(page, 'A2')).toBe('Third');
121121+ });
122122+});
+116
e2e/sheets-clipboard.spec.ts
···11+import { test, expect } from '@playwright/test';
22+import { createNewSheet, clickCell, typeInCell, getCellText } from './helpers';
33+44+test.describe('Sheets - Clipboard', () => {
55+ test.beforeEach(async ({ page, context }) => {
66+ // Grant clipboard permissions
77+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
88+ await createNewSheet(page);
99+ });
1010+1111+ test('copy cell and paste to another cell', async ({ page }) => {
1212+ await typeInCell(page, 'A1', 'CopyMe');
1313+1414+ // Select A1 and copy
1515+ await clickCell(page, 'A1');
1616+ await page.keyboard.press('Meta+c');
1717+1818+ // Select B1 and paste
1919+ await clickCell(page, 'B1');
2020+ await page.keyboard.press('Meta+v');
2121+2222+ // B1 should now have the same value
2323+ await expect(page.locator('td[data-id="B1"] .cell-display')).toContainText('CopyMe', { timeout: 5000 });
2424+2525+ // A1 should still have its value (copy, not cut)
2626+ expect(await getCellText(page, 'A1')).toBe('CopyMe');
2727+ });
2828+2929+ test('cut cell clears original', async ({ page }) => {
3030+ await typeInCell(page, 'A1', 'CutMe');
3131+3232+ // Select A1 and cut
3333+ await clickCell(page, 'A1');
3434+ await page.keyboard.press('Meta+x');
3535+3636+ // Select B1 and paste
3737+ await clickCell(page, 'B1');
3838+ await page.keyboard.press('Meta+v');
3939+4040+ // B1 should have the value
4141+ await expect(page.locator('td[data-id="B1"] .cell-display')).toContainText('CutMe', { timeout: 5000 });
4242+4343+ // A1 should be empty (it was cut)
4444+ const a1Text = await getCellText(page, 'A1');
4545+ expect(a1Text).toBe('');
4646+ });
4747+4848+ test('copy multiple cells and paste', async ({ page }) => {
4949+ await typeInCell(page, 'A1', 'One');
5050+ await typeInCell(page, 'A2', 'Two');
5151+ await typeInCell(page, 'A3', 'Three');
5252+5353+ // Select range A1:A3
5454+ await clickCell(page, 'A1');
5555+ await page.keyboard.down('Shift');
5656+ await clickCell(page, 'A3');
5757+ await page.keyboard.up('Shift');
5858+5959+ // Copy
6060+ await page.keyboard.press('Meta+c');
6161+6262+ // Paste to B1
6363+ await clickCell(page, 'B1');
6464+ await page.keyboard.press('Meta+v');
6565+6666+ // Verify all values pasted
6767+ await expect(page.locator('td[data-id="B1"] .cell-display')).toContainText('One', { timeout: 5000 });
6868+ expect(await getCellText(page, 'B2')).toBe('Two');
6969+ expect(await getCellText(page, 'B3')).toBe('Three');
7070+ });
7171+7272+ test('copy cell with formula pastes computed value or adjusted formula', async ({ page }) => {
7373+ await typeInCell(page, 'A1', '10');
7474+ await typeInCell(page, 'A2', '=A1*2');
7575+7676+ // A2 should show 20
7777+ expect(await getCellText(page, 'A2')).toBe('20');
7878+7979+ // Copy A2
8080+ await clickCell(page, 'A2');
8181+ await page.keyboard.press('Meta+c');
8282+8383+ // Paste to B2
8484+ await clickCell(page, 'B2');
8585+ await page.keyboard.press('Meta+v');
8686+8787+ // B2 should have a value (either the formula adjusted or the value pasted)
8888+ const b2Text = await getCellText(page, 'B2');
8989+ expect(b2Text).toBeTruthy();
9090+ });
9191+9292+ test('delete key clears selected cell', async ({ page }) => {
9393+ await typeInCell(page, 'A1', 'DeleteMe');
9494+ expect(await getCellText(page, 'A1')).toBe('DeleteMe');
9595+9696+ await clickCell(page, 'A1');
9797+ await page.keyboard.press('Delete');
9898+9999+ // Cell should be empty
100100+ const text = await getCellText(page, 'A1');
101101+ expect(text).toBe('');
102102+ });
103103+104104+ test('backspace clears selected cell', async ({ page }) => {
105105+ await typeInCell(page, 'A1', 'ClearMe');
106106+ await clickCell(page, 'A1');
107107+ await page.keyboard.press('Backspace');
108108+109109+ // Should start editing with empty content (Backspace enters edit mode)
110110+ // Press Escape or Enter to confirm
111111+ await page.keyboard.press('Escape');
112112+113113+ // Cell may still have content if Backspace enters edit mode
114114+ // Just verify the action doesn't crash
115115+ });
116116+});
+236
e2e/sheets-features.spec.ts
···11+import { test, expect } from '@playwright/test';
22+import { createNewSheet, clickCell, typeInCell, getCellText } from './helpers';
33+44+test.describe('Sheets - Advanced Features', () => {
55+ test.beforeEach(async ({ page }) => {
66+ await createNewSheet(page);
77+ });
88+99+ test('multi-sheet tabs: create new sheet and switch between tabs', async ({ page }) => {
1010+ // Should start with "Sheet 1" tab
1111+ await expect(page.locator('.sheet-tab').first()).toContainText('Sheet 1');
1212+1313+ // Add a new sheet
1414+ await page.click('#add-sheet');
1515+1616+ // Should now have two tabs
1717+ const tabs = page.locator('.sheet-tab');
1818+ await expect(tabs).toHaveCount(2);
1919+ await expect(tabs.last()).toContainText('Sheet 2');
2020+2121+ // Type something in Sheet 2
2222+ await typeInCell(page, 'A1', 'Sheet 2 Data');
2323+ expect(await getCellText(page, 'A1')).toBe('Sheet 2 Data');
2424+2525+ // Switch back to Sheet 1
2626+ await tabs.first().click();
2727+2828+ // A1 should be empty on Sheet 1
2929+ const text = await getCellText(page, 'A1');
3030+ expect(text).toBe('');
3131+3232+ // Switch back to Sheet 2 and verify data persisted
3333+ await tabs.last().click();
3434+ expect(await getCellText(page, 'A1')).toBe('Sheet 2 Data');
3535+ });
3636+3737+ test('column resize by dragging column border', async ({ page }) => {
3838+ // Get initial width of column A
3939+ const colHeader = page.locator('thead th[data-col="1"]');
4040+ const initialBox = await colHeader.boundingBox();
4141+ expect(initialBox).toBeTruthy();
4242+4343+ // Find the resize handle
4444+ const handle = page.locator('.col-resize-handle[data-resize-col="1"]');
4545+ await expect(handle).toBeVisible();
4646+4747+ // Drag the handle to resize
4848+ const handleBox = await handle.boundingBox();
4949+ expect(handleBox).toBeTruthy();
5050+5151+ await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2);
5252+ await page.mouse.down();
5353+ await page.mouse.move(handleBox!.x + 50, handleBox!.y + handleBox!.height / 2, { steps: 5 });
5454+ await page.mouse.up();
5555+5656+ // Column should be wider now
5757+ const newBox = await colHeader.boundingBox();
5858+ expect(newBox!.width).toBeGreaterThan(initialBox!.width);
5959+ });
6060+6161+ test('right-click context menu appears on cell', async ({ page }) => {
6262+ await clickCell(page, 'A1');
6363+ await page.click('td[data-id="A1"]', { button: 'right' });
6464+6565+ // Context menu should appear
6666+ const contextMenu = page.locator('.context-menu');
6767+ await expect(contextMenu).toBeVisible({ timeout: 5000 });
6868+6969+ // Should have menu items
7070+ await expect(contextMenu.locator('.context-menu-item, [role="menuitem"]').first()).toBeVisible();
7171+7272+ // Dismiss by clicking elsewhere
7373+ await page.keyboard.press('Escape');
7474+ });
7575+7676+ test('cell merge via toolbar button', async ({ page }) => {
7777+ // Select a range A1:B2
7878+ await clickCell(page, 'A1');
7979+ await page.keyboard.down('Shift');
8080+ await clickCell(page, 'B2');
8181+ await page.keyboard.up('Shift');
8282+8383+ // Click merge button
8484+ await page.click('#tb-merge');
8585+8686+ // The merged cell should have colspan/rowspan attributes
8787+ const mergedTd = page.locator('td[data-id="A1"]');
8888+ const colspan = await mergedTd.getAttribute('colspan');
8989+ const rowspan = await mergedTd.getAttribute('rowspan');
9090+ expect(colspan).toBe('2');
9191+ expect(rowspan).toBe('2');
9292+ });
9393+9494+ test('freeze rows via overflow menu', async ({ page }) => {
9595+ // Select row 2
9696+ await clickCell(page, 'A2');
9797+9898+ // Open overflow menu
9999+ await page.click('#overflow-toggle');
100100+ await expect(page.locator('.toolbar-overflow-menu')).toBeVisible();
101101+102102+ // Click freeze rows
103103+ await page.click('#tb-freeze-rows');
104104+105105+ // There should be frozen row styling on row 1
106106+ await expect(page.locator('td.frozen-row').first()).toBeVisible({ timeout: 5000 });
107107+ });
108108+109109+ test('freeze columns via overflow menu', async ({ page }) => {
110110+ // Select column B (col 2)
111111+ await clickCell(page, 'B1');
112112+113113+ // Open overflow menu
114114+ await page.click('#overflow-toggle');
115115+ await expect(page.locator('.toolbar-overflow-menu')).toBeVisible();
116116+117117+ // Click freeze cols
118118+ await page.click('#tb-freeze-cols');
119119+120120+ // Column A should be frozen
121121+ await expect(page.locator('td.frozen-col').first()).toBeVisible({ timeout: 5000 });
122122+ });
123123+124124+ test('conditional formatting dialog opens', async ({ page }) => {
125125+ await clickCell(page, 'A1');
126126+127127+ // Open overflow menu
128128+ await page.click('#overflow-toggle');
129129+ await expect(page.locator('.toolbar-overflow-menu')).toBeVisible();
130130+131131+ // Click conditional formatting
132132+ await page.click('#tb-cf');
133133+134134+ // A dialog/modal should appear
135135+ const cfDialog = page.locator('.cf-dialog, .modal, [class*="conditional"]');
136136+ await expect(cfDialog.first()).toBeVisible({ timeout: 5000 });
137137+ });
138138+139139+ test('data validation dialog opens', async ({ page }) => {
140140+ await clickCell(page, 'A1');
141141+142142+ // Open overflow menu
143143+ await page.click('#overflow-toggle');
144144+ await expect(page.locator('.toolbar-overflow-menu')).toBeVisible();
145145+146146+ // Click data validation
147147+ await page.click('#tb-validation');
148148+149149+ // A dialog/modal should appear
150150+ const validationDialog = page.locator('.validation-dialog, .modal, [class*="validation"]');
151151+ await expect(validationDialog.first()).toBeVisible({ timeout: 5000 });
152152+ });
153153+154154+ test('chart button opens chart creation', async ({ page }) => {
155155+ // Fill some data for chart
156156+ await typeInCell(page, 'A1', 'Q1');
157157+ await typeInCell(page, 'A2', 'Q2');
158158+ await typeInCell(page, 'A3', 'Q3');
159159+ await typeInCell(page, 'B1', '100');
160160+ await typeInCell(page, 'B2', '200');
161161+ await typeInCell(page, 'B3', '150');
162162+163163+ // Select the data range
164164+ await clickCell(page, 'A1');
165165+ await page.keyboard.down('Shift');
166166+ await clickCell(page, 'B3');
167167+ await page.keyboard.up('Shift');
168168+169169+ // Click chart button
170170+ await page.click('#tb-chart');
171171+172172+ // Chart dialog or chart element should appear
173173+ const chartDialog = page.locator('.chart-dialog, .modal, [class*="chart"]');
174174+ await expect(chartDialog.first()).toBeVisible({ timeout: 5000 });
175175+ });
176176+177177+ test('sort ascending via toolbar button', async ({ page }) => {
178178+ await typeInCell(page, 'A1', 'Charlie');
179179+ await typeInCell(page, 'A2', 'Alice');
180180+ await typeInCell(page, 'A3', 'Bob');
181181+182182+ // Select column A
183183+ await clickCell(page, 'A1');
184184+185185+ // Click sort ascending
186186+ await page.click('#tb-sort-asc');
187187+188188+ // After sort, A1 should be Alice
189189+ expect(await getCellText(page, 'A1')).toBe('Alice');
190190+ expect(await getCellText(page, 'A2')).toBe('Bob');
191191+ expect(await getCellText(page, 'A3')).toBe('Charlie');
192192+ });
193193+194194+ test('sort descending via toolbar button', async ({ page }) => {
195195+ await typeInCell(page, 'A1', 'Alice');
196196+ await typeInCell(page, 'A2', 'Charlie');
197197+ await typeInCell(page, 'A3', 'Bob');
198198+199199+ await clickCell(page, 'A1');
200200+ await page.click('#tb-sort-desc');
201201+202202+ expect(await getCellText(page, 'A1')).toBe('Charlie');
203203+ expect(await getCellText(page, 'A2')).toBe('Bob');
204204+ expect(await getCellText(page, 'A3')).toBe('Alice');
205205+ });
206206+207207+ test('striped rows toggle via overflow menu', async ({ page }) => {
208208+ // Open overflow menu
209209+ await page.click('#overflow-toggle');
210210+ await expect(page.locator('.toolbar-overflow-menu')).toBeVisible();
211211+212212+ // Click striped rows
213213+ await page.click('#tb-striped');
214214+215215+ // Even rows should have striped class
216216+ await expect(page.locator('td.striped-row').first()).toBeVisible({ timeout: 5000 });
217217+ });
218218+219219+ test('status bar shows selection stats', async ({ page }) => {
220220+ await typeInCell(page, 'A1', '10');
221221+ await typeInCell(page, 'A2', '20');
222222+ await typeInCell(page, 'A3', '30');
223223+224224+ // Select range A1:A3
225225+ await clickCell(page, 'A1');
226226+ await page.keyboard.down('Shift');
227227+ await clickCell(page, 'A3');
228228+ await page.keyboard.up('Shift');
229229+230230+ // Status bar should show stats (sum, average, count)
231231+ const statusBar = page.locator('#status-bar');
232232+ await expect(statusBar).toBeVisible({ timeout: 5000 });
233233+ const statsText = await page.locator('#status-bar-stats').textContent();
234234+ expect(statsText).toContain('60'); // sum
235235+ });
236236+});
···377377 { name: 'average_range', desc: 'The range to average (default: same as range)', required: false },
378378 ],
379379 },
380380+381381+ // --- Reference ---
382382+ INDIRECT: {
383383+ desc: 'Returns the value of a cell specified by a text string reference',
384384+ params: [
385385+ { name: 'ref_text', desc: 'A cell reference as a text string (e.g. "A1", "Sheet2!B3")', required: true },
386386+ ],
387387+ },
388388+ ADDRESS: {
389389+ desc: 'Returns a cell address as text given row and column numbers',
390390+ params: [
391391+ { name: 'row_num', desc: 'The row number of the cell reference', required: true },
392392+ { name: 'col_num', desc: 'The column number of the cell reference', required: true },
393393+ { name: 'abs_num', desc: '1=absolute (default), 2=abs row, 3=abs col, 4=relative', required: false },
394394+ ],
395395+ },
396396+ ROW: {
397397+ desc: 'Returns the row number of a cell reference',
398398+ params: [
399399+ { name: 'reference', desc: 'The cell reference to get the row number from', required: true },
400400+ ],
401401+ },
402402+ COLUMN: {
403403+ desc: 'Returns the column number of a cell reference',
404404+ params: [
405405+ { name: 'reference', desc: 'The cell reference to get the column number from', required: true },
406406+ ],
407407+ },
380408};
381409382410/**
+87
src/sheets/formulas.ts
···351351 if (t.value === 'LET') {
352352 return this.parseLet();
353353 }
354354+ // Special handling for INDIRECT — needs access to getCellValue and crossSheetResolver
355355+ if (t.value === 'INDIRECT') {
356356+ return this.parseIndirect();
357357+ }
358358+ // Special handling for ROW/COLUMN — needs raw cell ref, not its value
359359+ if (t.value === 'ROW' || t.value === 'COLUMN') {
360360+ return this.parseRowColumn(t.value as 'ROW' | 'COLUMN');
361361+ }
354362 this.expect(TokenType.LPAREN);
355363 const args = [];
356364 if (this.peek().type !== TokenType.RPAREN) {
···504512 }
505513 }
506514515515+ // Parse INDIRECT(ref_text) — evaluates its argument as a string, then resolves as a cell reference
516516+ parseIndirect(): unknown {
517517+ this.expect(TokenType.LPAREN);
518518+ const refText = String(this.expression());
519519+ this.expect(TokenType.RPAREN);
520520+521521+ if (!refText) return '#REF!';
522522+523523+ // Uppercase the cell ref portion (sheet names are case-sensitive)
524524+ // Check for cross-sheet ref: contains '!'
525525+ const bangIdx = refText.indexOf('!');
526526+ if (bangIdx !== -1) {
527527+ let sheetName: string;
528528+ let cellRefStr: string;
529529+530530+ // Handle quoted sheet names: 'Sheet Name'!A1
531531+ if (refText.startsWith("'")) {
532532+ const closeQuote = refText.indexOf("'", 1);
533533+ if (closeQuote === -1 || refText[closeQuote + 1] !== '!') return '#REF!';
534534+ sheetName = refText.slice(1, closeQuote);
535535+ cellRefStr = refText.slice(closeQuote + 2).toUpperCase();
536536+ } else {
537537+ sheetName = refText.slice(0, bangIdx);
538538+ cellRefStr = refText.slice(bangIdx + 1).toUpperCase();
539539+ }
540540+541541+ // Validate the cell ref portion
542542+ const parsed = parseRef(cellRefStr);
543543+ if (!parsed) return '#REF!';
544544+545545+ if (!this.crossSheetResolver) return '#REF!';
546546+ if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!';
547547+ return this.crossSheetResolver.getSheetCellValue(sheetName, cellRefStr);
548548+ }
549549+550550+ // Simple same-sheet ref — strip $ signs and uppercase
551551+ const cleaned = refText.toUpperCase().replace(/\$/g, '');
552552+ const parsed = parseRef(cleaned);
553553+ if (!parsed) return '#REF!';
554554+ return this.getCellValue(cleaned);
555555+ }
556556+557557+ // Parse ROW(ref) / COLUMN(ref) — needs the raw cell reference, not its value
558558+ parseRowColumn(fn: 'ROW' | 'COLUMN'): unknown {
559559+ this.expect(TokenType.LPAREN);
560560+ const t = this.peek();
561561+ if (t.type === TokenType.CELL_REF) {
562562+ this.advance();
563563+ this.expect(TokenType.RPAREN);
564564+ const ref = parseRef(t.value as string);
565565+ if (!ref) return '#REF!';
566566+ return fn === 'ROW' ? ref.row : ref.col;
567567+ }
568568+ // If not a direct cell ref, evaluate the expression (fallback)
569569+ const val = this.expression();
570570+ this.expect(TokenType.RPAREN);
571571+ // Try to parse the evaluated value as a ref string
572572+ const refStr = String(val).toUpperCase().replace(/\$/g, '');
573573+ const ref = parseRef(refStr);
574574+ if (!ref) return '#REF!';
575575+ return fn === 'ROW' ? ref.row : ref.col;
576576+ }
577577+507578 resolveRange(startRef: string, endRef: string): RangeArray {
508579 const start = parseRef(startRef);
509580 const end = parseRef(endRef);
···677748 const range = Array.isArray(args[1]) ? args[1] : [args[1]];
678749 const idx = range.findIndex(v => v === needle || String(v) === String(needle));
679750 return idx === -1 ? '#N/A' : idx + 1;
751751+ }
752752+753753+ case 'ADDRESS': {
754754+ // ADDRESS(row_num, col_num, [abs_num])
755755+ // abs_num: 1=absolute (default), 2=abs row/rel col, 3=rel row/abs col, 4=relative
756756+ const rowNum = toNum(args[0]);
757757+ const colNum = toNum(args[1]);
758758+ const absNum = args[2] !== undefined ? toNum(args[2]) : 1;
759759+ const colLetter = colToLetter(colNum);
760760+ switch (absNum) {
761761+ case 1: return '$' + colLetter + '$' + rowNum;
762762+ case 2: return colLetter + '$' + rowNum;
763763+ case 3: return '$' + colLetter + rowNum;
764764+ case 4: return colLetter + '' + rowNum;
765765+ default: return '$' + colLetter + '$' + rowNum;
766766+ }
680767 }
681768682769 case 'SUMIF': {