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

Configure Feed

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

Merge pull request 'feat(mobile): touch targets + toolbar wrap + topbar wrap (v0.61.0)' (#408) from feat/v0.61.0-mobile-viability into main

scott 63b4786d 3047341a

+124 -4
+1
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Added 11 + - Mobile/touch viability (v0.61.0, #688) — three CSS refinements that unblock phone use of every editor. (1) `.toolbar.gdocs-toolbar` now wraps at base instead of forcing `flex-wrap: nowrap` + overflow, so narrow desktop windows (~800px) no longer cut off trailing buttons; height became `min-height: 40px` so a wrapped row can grow vertically. (2) The `@media (max-width: 768px)` block now enlarges every `.btn-icon` (topbar outline/comments/history/share/AI-chat/shortcuts buttons across all editors) to `min-width: 44px; min-height: 44px` with `0.5rem` padding — meeting Apple HIG & WCAG 2.5.5 touch-target guidance. Previously only `.tb-btn` got this treatment, leaving topbar icon buttons at the desktop 28px. (3) The same block adds `flex-wrap: wrap` + `row-gap: var(--space-xs)` to `.app-topbar` so overflowing buttons drop to a second row instead of pushing the title or action group off-screen. Also removed a stray `.toolbar { flex-wrap: nowrap }` rule inside the legacy `@media (max-width: 640px)` block that contradicted the newer 768px wrap rule. 7 new regression tests (`tests/mobile-viability.test.ts`) pin the CSS invariants so future refactors can't silently reintroduce nowrap or shrink touch targets. No JS/HTML changes — all 6 editors already had the topbar buttons and the sidebars already overlay at <=768px / go full-width at <=480px. (#688) 11 12 - AI chat now identifies the signed-in user in every editor's system prompt (v0.60.0, #678). A shared `fetchUserIdentity()` helper (`src/lib/user-identity.ts`) resolves the current user by first probing `/api/me` (Tailscale identity injected by the serve layer) and falling back to the `tools-username` key in localStorage for anonymous/local access. A process-wide cache (`src/lib/user-identity-cache.ts`) memoizes the promise so all 6 chat panels (docs, sheets, slides, diagrams, forms, calendar) share a single network call. `buildSystemMessage` gained a `userIdentity` option that injects a single sentence (`The signed-in user is "Name" (login). Address them by first name when appropriate.`); when name equals login, the parenthetical is dropped; when identity is `null`/missing, the line is omitted entirely. 16 new tests cover fetch fallback chain, memoization, empty-name guards, and identity-line presence across editor types. (#678) 12 13 - AI chat endpoint URL validation (v0.59.0, #675) — the AI chat settings panel now validates the Endpoint field before persisting. Invalid inputs (empty strings, `ftp://…`, `javascript:`, `file://`, bare hostnames without a protocol, protocol-relative `//…`, malformed URLs) no longer overwrite a working endpoint in localStorage: the previous value is retained. Invalid inputs also set `aria-invalid="true"` on the input and surface an inline error message (`role="alert"`) explaining the accepted formats (`/api/ai` same-origin path or an absolute https URL). A permanent helper hint sits below the input so users understand the format up-front; both elements are linked via `aria-describedby`. Built on the existing `validateEndpoint()` helper, which was previously only consulted to toggle the onboarding banner. 17 new tests (7 unit tests for `validateEndpoint`, 10 jsdom tests for the persist flow + a11y attributes). 13 14 - Sheets fit-and-finish (v0.58.0) — four Excel/Sheets-parity wins for daily spreadsheet work:
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.60.0", 3 + "version": "0.61.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+15 -3
src/css/app.css
··· 1942 1942 } 1943 1943 1944 1944 .toolbar.gdocs-toolbar { 1945 - height: 40px; 1945 + min-height: 40px; 1946 1946 padding: 0 var(--space-sm); 1947 1947 gap: 2px; 1948 - flex-wrap: nowrap; 1948 + flex-wrap: wrap; 1949 1949 overflow: visible; 1950 1950 } 1951 1951 ··· 4063 4063 .landing-actions { flex-direction: column; align-items: stretch; } 4064 4064 .new-dropdown, .new-btn, .daily-note-btn { width: 100%; justify-content: center; } 4065 4065 .new-menu { left: 0; right: 0; } 4066 - .toolbar { flex-wrap: nowrap; } 4067 4066 .editor-container { padding: var(--space-md); } 4068 4067 .shortcuts-modal { max-width: 95vw; } 4069 4068 .docs-footer { gap: var(--space-md); } ··· 6005 6004 .toolbar-overflow-toggle { 6006 6005 min-width: 44px; 6007 6006 min-height: 44px; 6007 + } 6008 + 6009 + /* Larger touch targets for icon buttons in topbar/inline controls */ 6010 + .btn-icon { 6011 + min-width: 44px; 6012 + min-height: 44px; 6013 + padding: 0.5rem; 6014 + } 6015 + 6016 + /* Topbar wraps on narrow screens so buttons don't overflow horizontally */ 6017 + .app-topbar { 6018 + flex-wrap: wrap; 6019 + row-gap: var(--space-xs); 6008 6020 } 6009 6021 6010 6022 /* Toolbar allow wrapping with wider gap for touch */
+107
tests/mobile-viability.test.ts
··· 1 + /** 2 + * Mobile/touch viability regression tests (#688). 3 + * 4 + * These are source-text assertions on src/css/app.css — not true layout 5 + * tests (CSS computed-style testing in jsdom is unreliable for media 6 + * queries). They pin the invariants from the #688 spec so a future 7 + * refactor can't silently reintroduce a nowrap or shrink touch targets. 8 + */ 9 + 10 + import { describe, it, expect } from 'vitest'; 11 + import { readFileSync } from 'node:fs'; 12 + import { resolve } from 'node:path'; 13 + 14 + const CSS_PATH = resolve(__dirname, '../src/css/app.css'); 15 + const CSS = readFileSync(CSS_PATH, 'utf8'); 16 + 17 + /** Extract the CSS text inside a given @media block body. Returns the first match. */ 18 + function extractMediaBody(css: string, query: RegExp): string { 19 + const start = css.search(query); 20 + if (start < 0) return ''; 21 + // Find the opening brace of the media block 22 + const braceStart = css.indexOf('{', start); 23 + if (braceStart < 0) return ''; 24 + let depth = 1; 25 + let i = braceStart + 1; 26 + while (i < css.length && depth > 0) { 27 + const ch = css[i]; 28 + if (ch === '{') depth++; 29 + else if (ch === '}') depth--; 30 + i++; 31 + } 32 + return css.slice(braceStart + 1, i - 1); 33 + } 34 + 35 + /** Extract the CSS body of a top-level rule (outside any @media). */ 36 + function extractTopLevelRule(css: string, selector: string): string { 37 + // Find a rule whose selector starts at column 0 (not indented inside @media). 38 + // Escape regex metachars in selector. 39 + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 40 + const re = new RegExp(`^${escaped}\\s*\\{([^}]*)\\}`, 'm'); 41 + const m = re.exec(css); 42 + return m?.[1] ?? ''; 43 + } 44 + 45 + describe('#688 mobile viability — CSS invariants', () => { 46 + it('.toolbar.gdocs-toolbar does NOT set flex-wrap: nowrap at base', () => { 47 + const body = extractTopLevelRule(CSS, '.toolbar.gdocs-toolbar'); 48 + expect(body).not.toMatch(/flex-wrap\s*:\s*nowrap/); 49 + // positive: allows wrap (or is unset, which defaults to nowrap — we require explicit wrap) 50 + expect(body).toMatch(/flex-wrap\s*:\s*wrap/); 51 + }); 52 + 53 + it('no base-state toolbar rule forces flex-wrap: nowrap', () => { 54 + // Any top-level .toolbar rule (no pseudo, no prefix like @media) must not lock to nowrap. 55 + const matches = CSS.matchAll(/^(\.toolbar[^{\n]*)\s*\{([^}]*)\}/gm); 56 + for (const m of matches) { 57 + const selector = (m[1] ?? '').trim(); 58 + const body = m[2] ?? ''; 59 + if (/flex-wrap\s*:\s*nowrap/.test(body)) { 60 + throw new Error(`${selector} sets flex-wrap: nowrap at base: ${body.trim()}`); 61 + } 62 + } 63 + }); 64 + 65 + it('max-width: 768px block enlarges .btn-icon to 44×44', () => { 66 + const body = extractMediaBody(CSS, /@media\s*\(\s*max-width:\s*768px\s*\)/); 67 + expect(body).toContain('.btn-icon'); 68 + // Must set both min-width and min-height to at least 44px 69 + const btnIconRuleMatch = /\.btn-icon\s*\{([^}]*)\}/.exec(body); 70 + expect(btnIconRuleMatch).toBeTruthy(); 71 + const rule = btnIconRuleMatch![1]; 72 + expect(rule).toMatch(/min-width\s*:\s*44px/); 73 + expect(rule).toMatch(/min-height\s*:\s*44px/); 74 + }); 75 + 76 + it('max-width: 768px block enlarges .tb-btn to 44×44', () => { 77 + const body = extractMediaBody(CSS, /@media\s*\(\s*max-width:\s*768px\s*\)/); 78 + const tbBtnRuleMatch = /\.tb-btn\s*\{([^}]*)\}/.exec(body); 79 + expect(tbBtnRuleMatch).toBeTruthy(); 80 + const rule = tbBtnRuleMatch![1]; 81 + expect(rule).toMatch(/width\s*:\s*44px/); 82 + expect(rule).toMatch(/height\s*:\s*44px/); 83 + }); 84 + 85 + it('max-width: 768px block allows .app-topbar to wrap', () => { 86 + const body = extractMediaBody(CSS, /@media\s*\(\s*max-width:\s*768px\s*\)/); 87 + const topbarRuleMatch = /\.app-topbar\s*\{([^}]*)\}/.exec(body); 88 + expect(topbarRuleMatch).toBeTruthy(); 89 + expect(topbarRuleMatch![1]).toMatch(/flex-wrap\s*:\s*wrap/); 90 + }); 91 + 92 + it('max-width: 480px block makes sidebars full-width', () => { 93 + const body = extractMediaBody(CSS, /@media\s*\(\s*max-width:\s*480px\s*\)/); 94 + expect(body).toMatch(/\.outline-sidebar\s*,?/); 95 + expect(body).toMatch(/\.version-sidebar/); 96 + // One of these rules must set width: 100% 97 + const outlineBlock = /\.outline-sidebar[^{]*\{[^}]*width\s*:\s*100%/.test(body); 98 + expect(outlineBlock).toBe(true); 99 + }); 100 + 101 + it('slides panel remains toggleable on phone', () => { 102 + // Slides rules live in a separate 480px @media block later in the file. 103 + // Search the whole CSS for the invariants — both must exist somewhere. 104 + expect(CSS).toMatch(/\.slides-panel\s*\{[^}]*display\s*:\s*none/); 105 + expect(CSS).toMatch(/\.slides-panel\.slides-panel--mobile-open/); 106 + }); 107 + });