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

Configure Feed

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

feat: mobile responsive polish with E2E tests (#271)

Add 22 mobile E2E tests across 3 device viewports (Pixel 7, iPhone 14,
iPad gen 7) covering landing page, docs editor, and sheets. Fix CSS
responsive issues: sidebar overlays, link tooltip viewport constraint,
full-width toolbar dropdowns on phones, stacked find/replace bar.

+414 -1
+14
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.16.2] — 2026-03-31 9 + 10 + ### Added 11 + - Mobile E2E tests: 22 tests across landing, docs, and sheets for 3 device viewports (Pixel 7, iPhone 14, iPad gen 7) (#271) 12 + - Playwright mobile device projects (mobile-chrome, mobile-safari, tablet) 13 + - Unit tests for mobile CSS media query rules (sidebar overlays, link tooltip, toolbar dropdowns) 14 + 15 + ### Fixed 16 + - Mobile: sidebars overlay content instead of pushing layout on tablet/phone (#271) 17 + - Mobile: link preview tooltip constrained to viewport width 18 + - Mobile: toolbar dropdowns use full-width fixed overlay on phones 19 + - Mobile: find/replace bar stacks vertically on phones 20 + - Mobile: command palette height increased on phones for easier reach 21 + 8 22 ## [0.16.1] — 2026-03-31 9 23 10 24 ### Added
+114
e2e/mobile-docs.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewDoc } from './helpers'; 3 + 4 + test.describe('Mobile — Docs Editor', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewDoc(page); 7 + }); 8 + 9 + test('editor is visible and fills viewport width', async ({ page }) => { 10 + const editor = page.locator('.tiptap'); 11 + await expect(editor).toBeVisible(); 12 + 13 + const viewport = page.viewportSize()!; 14 + const box = await editor.boundingBox(); 15 + // Editor should use most of the viewport width 16 + expect(box!.width).toBeGreaterThan(viewport.width * 0.8); 17 + }); 18 + 19 + test('toolbar buttons meet minimum touch target size', async ({ page }) => { 20 + const viewport = page.viewportSize()!; 21 + if (viewport.width > 768) test.skip(); 22 + 23 + const buttons = page.locator('.tb-btn:visible'); 24 + const count = await buttons.count(); 25 + expect(count).toBeGreaterThan(0); 26 + 27 + for (let i = 0; i < Math.min(count, 5); i++) { 28 + const box = await buttons.nth(i).boundingBox(); 29 + if (box) { 30 + expect(box.width).toBeGreaterThanOrEqual(42); // 44 target, 2px tolerance 31 + expect(box.height).toBeGreaterThanOrEqual(42); 32 + } 33 + } 34 + }); 35 + 36 + test('non-essential toolbar items hidden on mobile', async ({ page }) => { 37 + const viewport = page.viewportSize()!; 38 + if (viewport.width > 768) test.skip(); 39 + 40 + const hiddenItems = page.locator('.toolbar-mobile-hide'); 41 + const count = await hiddenItems.count(); 42 + for (let i = 0; i < count; i++) { 43 + await expect(hiddenItems.nth(i)).not.toBeVisible(); 44 + } 45 + }); 46 + 47 + test('can type and see text in editor', async ({ page }) => { 48 + const editor = page.locator('.tiptap'); 49 + await editor.click(); 50 + await page.keyboard.type('Mobile typing test'); 51 + await expect(editor).toContainText('Mobile typing test'); 52 + }); 53 + 54 + test('topbar does not overflow horizontally', async ({ page }) => { 55 + const viewport = page.viewportSize()!; 56 + const topbar = page.locator('.app-topbar'); 57 + const box = await topbar.boundingBox(); 58 + if (box) { 59 + expect(box.width).toBeLessThanOrEqual(viewport.width + 1); 60 + } 61 + }); 62 + 63 + test('document title input is accessible', async ({ page }) => { 64 + const titleInput = page.locator('.doc-title-input'); 65 + await expect(titleInput).toBeVisible(); 66 + await titleInput.fill('My Mobile Doc'); 67 + await expect(titleInput).toHaveValue('My Mobile Doc'); 68 + }); 69 + 70 + test('word count visible in footer', async ({ page }) => { 71 + const editor = page.locator('.tiptap'); 72 + await editor.click(); 73 + await page.keyboard.type('one two three'); 74 + 75 + await expect(page.locator('#word-count')).toContainText('3 words', { timeout: 5000 }); 76 + }); 77 + 78 + test('command palette opens and is usable', async ({ page }) => { 79 + const viewport = page.viewportSize()!; 80 + 81 + await page.keyboard.press('Meta+k'); 82 + const palette = page.locator('.cmd-palette'); 83 + await expect(palette).toBeVisible({ timeout: 3000 }); 84 + 85 + // Palette should fit within viewport 86 + const box = await palette.boundingBox(); 87 + if (box) { 88 + expect(box.right).toBeLessThanOrEqual(viewport.width + 5); 89 + expect(box.bottom).toBeLessThanOrEqual(viewport.height + 5); 90 + } 91 + 92 + // Dismiss 93 + await page.keyboard.press('Escape'); 94 + await expect(palette).not.toBeVisible(); 95 + }); 96 + 97 + test('modals are full-screen on phones', async ({ page }) => { 98 + const viewport = page.viewportSize()!; 99 + if (viewport.width > 480) test.skip(); 100 + 101 + // Open share dialog as a test modal 102 + await page.click('#btn-share'); 103 + const dialog = page.locator('#share-dialog'); 104 + await expect(dialog).toBeVisible({ timeout: 3000 }); 105 + 106 + const box = await dialog.boundingBox(); 107 + if (box) { 108 + // Should be approximately full-width on phones 109 + expect(box.width).toBeGreaterThan(viewport.width * 0.9); 110 + } 111 + 112 + await page.click('#share-dialog-close'); 113 + }); 114 + });
+68
e2e/mobile-landing.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { dismissUsernamePrompt } from './helpers'; 3 + 4 + test.describe('Mobile — Landing Page', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await page.goto('/'); 7 + await dismissUsernamePrompt(page); 8 + await page.reload(); 9 + await page.waitForSelector('.landing-header'); 10 + }); 11 + 12 + test('landing page renders without horizontal overflow', async ({ page }) => { 13 + const viewport = page.viewportSize()!; 14 + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); 15 + expect(bodyWidth).toBeLessThanOrEqual(viewport.width + 1); // 1px tolerance 16 + }); 17 + 18 + test('create buttons are visible and tappable', async ({ page }) => { 19 + const newDoc = page.locator('#new-doc'); 20 + const newSheet = page.locator('#new-sheet'); 21 + await expect(newDoc).toBeVisible(); 22 + await expect(newSheet).toBeVisible(); 23 + 24 + // Verify minimum touch target size (44x44) 25 + const docBox = await newDoc.boundingBox(); 26 + expect(docBox!.width).toBeGreaterThanOrEqual(44); 27 + expect(docBox!.height).toBeGreaterThanOrEqual(44); 28 + }); 29 + 30 + test('search input is full width on mobile', async ({ page }) => { 31 + const viewport = page.viewportSize()!; 32 + if (viewport.width > 768) test.skip(); 33 + 34 + const searchInput = page.locator('.search-input'); 35 + if (await searchInput.isVisible()) { 36 + const box = await searchInput.boundingBox(); 37 + // Should be at least 80% of viewport width on mobile 38 + expect(box!.width).toBeGreaterThan(viewport.width * 0.7); 39 + } 40 + }); 41 + 42 + test('version badge hidden on small phones', async ({ page }) => { 43 + const viewport = page.viewportSize()!; 44 + if (viewport.width > 480) test.skip(); 45 + 46 + const badge = page.locator('.version-badge'); 47 + if (await badge.count() > 0) { 48 + await expect(badge).not.toBeVisible(); 49 + } 50 + }); 51 + 52 + test('no content clips outside viewport', async ({ page }) => { 53 + const viewport = page.viewportSize()!; 54 + // Check that no visible element extends beyond the right edge 55 + const overflowing = await page.evaluate((vw) => { 56 + const els = document.querySelectorAll('*'); 57 + const issues: string[] = []; 58 + for (const el of els) { 59 + const rect = el.getBoundingClientRect(); 60 + if (rect.right > vw + 5 && rect.width > 0 && getComputedStyle(el).display !== 'none') { 61 + issues.push(`${el.tagName}.${el.className.split(' ')[0]} right=${Math.round(rect.right)}`); 62 + } 63 + } 64 + return issues.slice(0, 5); 65 + }, viewport.width); 66 + expect(overflowing).toHaveLength(0); 67 + }); 68 + });
+105
e2e/mobile-sheets.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewSheet, typeInCell, getCellText } from './helpers'; 3 + 4 + test.describe('Mobile — Sheets', () => { 5 + test.beforeEach(async ({ page }) => { 6 + await createNewSheet(page); 7 + }); 8 + 9 + test('sheet grid renders and is scrollable', async ({ page }) => { 10 + const grid = page.locator('#sheet-grid'); 11 + await expect(grid).toBeVisible(); 12 + 13 + const container = page.locator('.sheet-container'); 14 + const box = await container.boundingBox(); 15 + expect(box).toBeTruthy(); 16 + }); 17 + 18 + test('can enter data in cells on mobile', async ({ page }) => { 19 + await typeInCell(page, 'A1', 'Hello'); 20 + await typeInCell(page, 'B1', 'World'); 21 + 22 + expect(await getCellText(page, 'A1')).toBe('Hello'); 23 + expect(await getCellText(page, 'B1')).toBe('World'); 24 + }); 25 + 26 + test('formula bar is full-width on mobile', async ({ page }) => { 27 + const viewport = page.viewportSize()!; 28 + if (viewport.width > 768) test.skip(); 29 + 30 + const formulaBar = page.locator('.formula-bar'); 31 + if (await formulaBar.isVisible()) { 32 + const formulaInput = page.locator('.formula-input, .formula-input-wrap'); 33 + const box = await formulaInput.first().boundingBox(); 34 + if (box) { 35 + // Formula input should span most of the width 36 + expect(box.width).toBeGreaterThan(viewport.width * 0.6); 37 + } 38 + } 39 + }); 40 + 41 + test('toolbar buttons meet touch target size', async ({ page }) => { 42 + const viewport = page.viewportSize()!; 43 + if (viewport.width > 768) test.skip(); 44 + 45 + const buttons = page.locator('.tb-btn:visible'); 46 + const count = await buttons.count(); 47 + expect(count).toBeGreaterThan(0); 48 + 49 + for (let i = 0; i < Math.min(count, 5); i++) { 50 + const box = await buttons.nth(i).boundingBox(); 51 + if (box) { 52 + expect(box.width).toBeGreaterThanOrEqual(42); 53 + expect(box.height).toBeGreaterThanOrEqual(42); 54 + } 55 + } 56 + }); 57 + 58 + test('column A stays sticky when scrolling right', async ({ page }) => { 59 + const viewport = page.viewportSize()!; 60 + if (viewport.width > 768) test.skip(); 61 + 62 + // Enter data in column A 63 + await typeInCell(page, 'A1', 'Sticky'); 64 + 65 + // Scroll the sheet container right 66 + const container = page.locator('.sheet-container'); 67 + await container.evaluate(el => { el.scrollLeft = 500; }); 68 + await page.waitForTimeout(200); 69 + 70 + // Column A cell should still be visible (sticky) 71 + const cellA1 = page.locator('td[data-id="A1"]'); 72 + const box = await cellA1.boundingBox(); 73 + if (box) { 74 + // Should still be within the left portion of the viewport 75 + expect(box.left).toBeLessThan(viewport.width * 0.5); 76 + } 77 + }); 78 + 79 + test('no horizontal overflow on page load', async ({ page }) => { 80 + const viewport = page.viewportSize()!; 81 + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); 82 + // Sheet grid can be wider than viewport (scrollable), but body should not overflow 83 + // Check the topbar specifically 84 + const topbar = page.locator('.app-topbar'); 85 + const topbarBox = await topbar.boundingBox(); 86 + if (topbarBox) { 87 + expect(topbarBox.width).toBeLessThanOrEqual(viewport.width + 1); 88 + } 89 + }); 90 + 91 + test('sheet tab bar is accessible', async ({ page }) => { 92 + const tabBar = page.locator('.sheet-tabs, .tab-bar'); 93 + if (await tabBar.count() > 0) { 94 + await expect(tabBar.first()).toBeVisible(); 95 + } 96 + }); 97 + 98 + test('cell selection works via tap', async ({ page }) => { 99 + const cellA1 = page.locator('td[data-id="A1"]'); 100 + await cellA1.click(); 101 + 102 + // Cell should be selected 103 + await expect(cellA1).toHaveClass(/selected/); 104 + }); 105 + });
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.16.1", 3 + "version": "0.16.2", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+15
playwright.config.ts
··· 22 22 name: 'chromium', 23 23 use: { ...devices['Desktop Chrome'] }, 24 24 }, 25 + { 26 + name: 'mobile-chrome', 27 + use: { ...devices['Pixel 7'] }, 28 + testMatch: /mobile.*\.spec\.ts/, 29 + }, 30 + { 31 + name: 'mobile-safari', 32 + use: { ...devices['iPhone 14'] }, 33 + testMatch: /mobile.*\.spec\.ts/, 34 + }, 35 + { 36 + name: 'tablet', 37 + use: { ...devices['iPad (gen 7)'] }, 38 + testMatch: /mobile.*\.spec\.ts/, 39 + }, 25 40 ], 26 41 webServer: { 27 42 command: 'npm run dev',
+68
src/css/app.css
··· 4812 4812 bottom: 0.25rem; 4813 4813 right: 0.5rem; 4814 4814 } 4815 + 4816 + /* Sidebars overlay on tablet/mobile */ 4817 + .outline-sidebar { 4818 + position: absolute; 4819 + left: 0; 4820 + top: 0; 4821 + bottom: 0; 4822 + z-index: 20; 4823 + width: 260px; 4824 + box-shadow: var(--shadow-lg); 4825 + } 4826 + 4827 + .version-sidebar { 4828 + position: absolute; 4829 + right: 0; 4830 + top: 0; 4831 + bottom: 0; 4832 + z-index: 20; 4833 + width: 300px; 4834 + max-width: 85vw; 4835 + box-shadow: var(--shadow-lg); 4836 + } 4837 + 4838 + /* Link preview tooltip constrained to viewport */ 4839 + .link-preview-tooltip { 4840 + max-width: calc(100vw - 2rem); 4841 + left: 1rem !important; 4842 + right: 1rem; 4843 + } 4815 4844 } 4816 4845 4817 4846 /* ======================================================== ··· 4863 4892 4864 4893 .version-badge { 4865 4894 display: none; 4895 + } 4896 + 4897 + /* Sidebars full-width on phones */ 4898 + .outline-sidebar, 4899 + .version-sidebar { 4900 + width: 100%; 4901 + max-width: 100%; 4902 + } 4903 + 4904 + /* Find/replace bar stacks vertically */ 4905 + .search-replace-bar { 4906 + flex-direction: column; 4907 + gap: var(--space-xs); 4908 + } 4909 + 4910 + .search-replace-bar input { 4911 + width: 100%; 4912 + } 4913 + 4914 + /* Compact save status */ 4915 + .save-status { 4916 + font-size: 0.65rem; 4917 + } 4918 + 4919 + /* Toolbar dropdowns: full-width overlay */ 4920 + .toolbar-dropdown-content { 4921 + position: fixed; 4922 + left: 0; 4923 + right: 0; 4924 + top: auto; 4925 + width: 100%; 4926 + max-height: 60vh; 4927 + overflow-y: auto; 4928 + border-radius: 0; 4929 + } 4930 + 4931 + /* Command palette: slightly taller on phones for easier reach */ 4932 + .cmd-palette { 4933 + max-height: 70vh; 4866 4934 } 4867 4935 } 4868 4936
+29
tests/mobile.test.ts
··· 118 118 expect(mobileSection).toBeTruthy(); 119 119 expect(mobileSection).toMatch(/\.sheet-container/); 120 120 }); 121 + 122 + it('overlays sidebars on tablet', () => { 123 + const tabletSection = extractMediaBlock(css, '768px'); 124 + expect(tabletSection).toBeTruthy(); 125 + expect(tabletSection).toMatch(/\.outline-sidebar/); 126 + expect(tabletSection).toMatch(/\.version-sidebar/); 127 + expect(tabletSection).toMatch(/position:\s*absolute/); 128 + }); 129 + 130 + it('constrains link preview tooltip to viewport', () => { 131 + const tabletSection = extractMediaBlock(css, '768px'); 132 + expect(tabletSection).toBeTruthy(); 133 + expect(tabletSection).toMatch(/\.link-preview-tooltip/); 134 + }); 135 + 136 + it('makes sidebars full-width on phones', () => { 137 + const phoneSection = extractMediaBlock(css, '480px'); 138 + expect(phoneSection).toBeTruthy(); 139 + expect(phoneSection).toMatch(/\.outline-sidebar/); 140 + expect(phoneSection).toMatch(/\.version-sidebar/); 141 + expect(phoneSection).toMatch(/width:\s*100%/); 142 + }); 143 + 144 + it('makes toolbar dropdowns full-width on phones', () => { 145 + const phoneSection = extractMediaBlock(css, '480px'); 146 + expect(phoneSection).toBeTruthy(); 147 + expect(phoneSection).toMatch(/\.toolbar-dropdown-content/); 148 + expect(phoneSection).toMatch(/position:\s*fixed/); 149 + }); 121 150 }); 122 151 123 152 describe('Mobile toolbar collapse logic', () => {