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

Configure Feed

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

fix: unified Synced + E2EE topbar chips across all editors (#695)

Closes #695

scott 64bf111f e14e769b

+194 -1
+3
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ### Fixed 11 + - Topbar status chips — unified Synced + E2EE indicators across slides / forms / diagrams / calendar (v0.62.7, #695). Docs and sheets already rendered three chips (Saved / Synced / E2EE) but slides, forms, diagrams, and calendar only showed the shared Saved indicator, leaving users unable to tell whether the document had synced to the server or whether it was encrypted. Extracted the Synced-chip wiring into `src/lib/status-chips.ts` (`wireStatusChips({ provider })`) that listens to the provider's `status` + `sync` events and flips `#status-dot` + `#status-text` — same pattern docs and sheets have used inline. Added the matching HTML (two `.status-indicator` spans) to all four editor templates that were missing them, and wired `wireStatusChips` into each editor's `main.ts` next to `wireSaveStatus`. 9 regression tests in `tests/status-chips.test.ts`: 3 for the helper behavior (status toggle, sync transition, no-op when host markup absent) + 6 that scan every editor template for both the Synced status-indicator and the E2EE chip markup so no editor can silently regress. (#695) 12 + 10 13 ### Changed 11 14 - Calendar: topbar settings button now renders as a proper 8-tooth gear glyph instead of a sunburst that was visually indistinguishable from the adjacent theme-toggle sun (v0.62.4, #697). The old `#btn-cal-settings` SVG was a circle-with-8-radiating-lines (`circle r=2.5` + eight `M..v.. / M..h..` rays) and the theme-toggle `#btn-theme-toggle` right next to it was the same circle-with-8-rays pattern at `r=3.5` — users clicked the wrong one constantly, with the settings panel hiding some destructive controls. Replaced the settings icon with a true 32-vertex 8-tooth gear polygon (outer tooth tip radius 6.8, valley radius 4.6, hub circle r=2.2) traced in `src/calendar/index.html`. Added an `icon-gear` marker class so future refactors can't silently regress to a sunburst, plus four Playwright regression tests in `e2e/calendar.spec.ts` that pin the contract: (1) the icon carries the `icon-gear` class; (2) the settings path `d` attribute differs from the theme-toggle path; (3) the path has either curves, a closed subpath, or >24 draw commands (sunburst had ~16 and no close); (4) both buttons remain visible and adjacent. Also added `aria-label` + `aria-hidden` attributes that the theme-toggle already had for a11y parity. (#697) 12 15
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.62.6", 3 + "version": "0.62.7", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+8
src/calendar/index.html
··· 28 28 <span class="save-dot save-dot--saved"></span> 29 29 <span id="save-text">Saved</span> 30 30 </div> 31 + <div class="status-indicator" id="status"> 32 + <span class="status-dot" id="status-dot"></span> 33 + <span id="status-text">Connecting&hellip;</span> 34 + </div> 35 + <div class="status-indicator"> 36 + <span class="status-dot encrypted"></span> 37 + <span>E2EE</span> 38 + </div> 31 39 <button class="btn-icon" id="btn-cal-settings" title="Calendar settings" aria-label="Calendar settings"> 32 40 <svg class="tb-icon icon-gear" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.57 6.24 L14.57 9.76 L12.44 9.19 L11.98 10.30 L13.89 11.40 L11.40 13.89 L10.30 11.98 L9.19 12.44 L9.76 14.57 L6.24 14.57 L6.81 12.44 L5.70 11.98 L4.60 13.89 L2.11 11.40 L4.02 10.30 L3.56 9.19 L1.43 9.76 L1.43 6.24 L3.56 6.81 L4.02 5.70 L2.11 4.60 L4.60 2.11 L5.70 4.02 L6.81 3.56 L6.24 1.43 L9.76 1.43 L9.19 3.56 L10.30 4.02 L11.40 2.11 L13.89 4.60 L11.98 5.70 L12.44 6.81 Z"/><circle cx="8" cy="8" r="2.2"/></svg> 33 41 </button>
+2
src/calendar/main.ts
··· 13 13 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 14 14 import { wireKeyWarningForSession } from '../lib/key-warning.js'; 15 15 import { wireSaveStatus } from '../lib/save-status-ui.js'; 16 + import { wireStatusChips } from '../lib/status-chips.js'; 16 17 import { setupTooltips } from '../lib/tooltips.js'; 17 18 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 18 19 import { createCommandPalette } from '../command-palette.js'; ··· 2356 2357 installDocGoneHandler(provider); 2357 2358 wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar')); 2358 2359 wireSaveStatus({ provider, ydoc }); 2360 + wireStatusChips({ provider }); 2359 2361 2360 2362 provider.on('sync', () => { 2361 2363 loadEventsFromYjs();
+8
src/diagrams/index.html
··· 39 39 <span class="save-dot save-dot--saved"></span> 40 40 <span id="save-text">Saved</span> 41 41 </div> 42 + <div class="status-indicator" id="status"> 43 + <span class="status-dot" id="status-dot"></span> 44 + <span id="status-text">Connecting&hellip;</span> 45 + </div> 46 + <div class="status-indicator"> 47 + <span class="status-dot encrypted"></span> 48 + <span>E2EE</span> 49 + </div> 42 50 </div> 43 51 44 52 <main class="diagrams-main" id="main-content">
+2
src/diagrams/main.ts
··· 10 10 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 11 11 import { wireKeyWarningForSession } from '../lib/key-warning.js'; 12 12 import { wireSaveStatus } from '../lib/save-status-ui.js'; 13 + import { wireStatusChips } from '../lib/status-chips.js'; 13 14 import { setupTooltips } from '../lib/tooltips.js'; 14 15 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 15 16 import { ··· 385 386 installDocGoneHandler(provider); 386 387 wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar')); 387 388 wireSaveStatus({ provider, ydoc }); 389 + wireStatusChips({ provider }); 388 390 provider.on('sync', () => { 389 391 loadFromYjs(); 390 392 pushHistory();
+8
src/forms/index.html
··· 29 29 <span class="save-dot save-dot--saved"></span> 30 30 <span id="save-text">Saved</span> 31 31 </div> 32 + <div class="status-indicator" id="status"> 33 + <span class="status-dot" id="status-dot"></span> 34 + <span id="status-text">Connecting&hellip;</span> 35 + </div> 36 + <div class="status-indicator"> 37 + <span class="status-dot encrypted"></span> 38 + <span>E2EE</span> 39 + </div> 32 40 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 33 41 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 34 42 </button>
+2
src/forms/main.ts
··· 11 11 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 12 12 import { wireKeyWarningForSession } from '../lib/key-warning.js'; 13 13 import { wireSaveStatus } from '../lib/save-status-ui.js'; 14 + import { wireStatusChips } from '../lib/status-chips.js'; 14 15 import { setupTooltips } from '../lib/tooltips.js'; 15 16 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 16 17 import { createForm, setTargetSheet, type FormSchema } from './form-builder.js'; ··· 223 224 installDocGoneHandler(provider); 224 225 wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar')); 225 226 wireSaveStatus({ provider, ydoc }); 227 + wireStatusChips({ provider }); 226 228 227 229 provider.on('sync', () => { 228 230 loadFormFromYjs();
+46
src/lib/status-chips.ts
··· 1 + /** 2 + * Status chips — "Synced" + "E2EE" indicators rendered next to the 3 + * shared "Saved" indicator in every editor's topbar. 4 + * 5 + * Regression: #695 — before this helper, only docs and sheets showed the 6 + * Synced/E2EE chips. Slides, forms, diagrams, and calendar only surfaced 7 + * the Saved indicator, leaving users guessing whether their data had 8 + * actually synced to the server and whether it was encrypted. 9 + * 10 + * Expected DOM in the host page: 11 + * <div class="status-indicator" id="status"> 12 + * <span class="status-dot" id="status-dot"></span> 13 + * <span id="status-text">Connecting…</span> 14 + * </div> 15 + * <div class="status-indicator"> 16 + * <span class="status-dot encrypted"></span> 17 + * <span>E2EE</span> 18 + * </div> 19 + * 20 + * The E2EE chip is static (there's nothing dynamic about it — the doc is 21 + * always encrypted). This helper only wires the Synced chip to the 22 + * provider's `status` + `sync` events. 23 + */ 24 + 25 + export interface StatusChipsDeps { 26 + provider: { on: (event: any, handler: any) => void }; 27 + } 28 + 29 + export function wireStatusChips(deps: StatusChipsDeps): void { 30 + const statusDot = document.getElementById('status-dot'); 31 + const statusText = document.getElementById('status-text'); 32 + 33 + // Host page hasn't opted in — no-op gracefully so the helper is safe to 34 + // call from every editor regardless of markup. 35 + if (!statusDot || !statusText) return; 36 + 37 + deps.provider.on('status', (payload: { connected: boolean }) => { 38 + statusDot.classList.toggle('connected', payload.connected); 39 + statusText.textContent = payload.connected ? 'Connected' : 'Reconnecting\u2026'; 40 + }); 41 + 42 + deps.provider.on('sync', () => { 43 + statusText.textContent = 'Synced'; 44 + statusDot.classList.add('connected'); 45 + }); 46 + }
+8
src/slides/index.html
··· 30 30 <span class="save-dot save-dot--saved"></span> 31 31 <span id="save-text">Saved</span> 32 32 </div> 33 + <div class="status-indicator" id="status"> 34 + <span class="status-dot" id="status-dot"></span> 35 + <span id="status-text">Connecting&hellip;</span> 36 + </div> 37 + <div class="status-indicator"> 38 + <span class="status-dot encrypted"></span> 39 + <span>E2EE</span> 40 + </div> 33 41 <button class="btn-secondary" id="btn-present" title="Present (F5)">&#9655; Present</button> 34 42 <button class="btn-secondary" id="btn-export" title="Export">Export</button> 35 43 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)">
+2
src/slides/main.ts
··· 12 12 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 13 13 import { wireKeyWarningForSession } from '../lib/key-warning.js'; 14 14 import { wireSaveStatus } from '../lib/save-status-ui.js'; 15 + import { wireStatusChips } from '../lib/status-chips.js'; 15 16 import { setupTooltips } from '../lib/tooltips.js'; 16 17 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 17 18 import { createDeck, slideCount } from './canvas-engine.js'; ··· 178 179 installDocGoneHandler(provider); 179 180 wireKeyWarningForSession(state.docId, document.querySelector<HTMLElement>('.app-topbar')); 180 181 wireSaveStatus({ provider, ydoc }); 182 + wireStatusChips({ provider }); 181 183 provider.on('sync', () => { 182 184 loadDeckFromYjs(); 183 185 actions.render();
+104
tests/status-chips.test.ts
··· 1 + /** 2 + * @vitest-environment jsdom 3 + * 4 + * Regression test for #695: the Synced + E2EE chip pair was previously only 5 + * rendered in docs and sheets. Slides, forms, diagrams, and calendar only 6 + * showed the Saved indicator. `wireStatusChips` unifies the Synced chip 7 + * wiring; the E2EE chip is static markup on each template. 8 + * 9 + * These tests pin the contract so future refactors can't silently regress: 10 + * - `status-dot` flips `.connected` on provider `status` events 11 + * - `status-text` transitions to `Synced` on provider `sync` events 12 + * - Helper is safe to call when the host page has not opted in to the 13 + * status-indicator markup (no-op instead of throwing). 14 + */ 15 + import { describe, it, expect, beforeEach } from 'vitest'; 16 + import { wireStatusChips } from '../src/lib/status-chips.js'; 17 + 18 + type Handler = (payload?: unknown) => void; 19 + 20 + function mockProvider() { 21 + const handlers = new Map<string, Handler[]>(); 22 + return { 23 + on(ev: string, fn: Handler) { 24 + const list = handlers.get(ev) || []; 25 + list.push(fn); 26 + handlers.set(ev, list); 27 + }, 28 + emit(ev: string, payload?: unknown) { 29 + (handlers.get(ev) || []).forEach(fn => fn(payload)); 30 + }, 31 + }; 32 + } 33 + 34 + describe('#695 — wireStatusChips', () => { 35 + beforeEach(() => { 36 + document.body.innerHTML = ''; 37 + }); 38 + 39 + it('flips status-dot .connected on provider status events', () => { 40 + document.body.innerHTML = ` 41 + <span id="status-dot"></span> 42 + <span id="status-text">Connecting…</span> 43 + `; 44 + const provider = mockProvider(); 45 + wireStatusChips({ provider }); 46 + 47 + const dot = document.getElementById('status-dot')!; 48 + const text = document.getElementById('status-text')!; 49 + expect(dot.classList.contains('connected')).toBe(false); 50 + 51 + provider.emit('status', { connected: true }); 52 + expect(dot.classList.contains('connected')).toBe(true); 53 + expect(text.textContent).toBe('Connected'); 54 + 55 + provider.emit('status', { connected: false }); 56 + expect(dot.classList.contains('connected')).toBe(false); 57 + expect(text.textContent).toBe('Reconnecting\u2026'); 58 + }); 59 + 60 + it('sets status-text to "Synced" on provider sync event and marks dot connected', () => { 61 + document.body.innerHTML = ` 62 + <span id="status-dot"></span> 63 + <span id="status-text">Connecting…</span> 64 + `; 65 + const provider = mockProvider(); 66 + wireStatusChips({ provider }); 67 + 68 + provider.emit('sync'); 69 + expect(document.getElementById('status-text')!.textContent).toBe('Synced'); 70 + expect(document.getElementById('status-dot')!.classList.contains('connected')).toBe(true); 71 + }); 72 + 73 + it('is a no-op when the host page has no status-indicator markup', () => { 74 + document.body.innerHTML = '<div></div>'; 75 + const provider = mockProvider(); 76 + expect(() => wireStatusChips({ provider })).not.toThrow(); 77 + // Emitting events should also not throw. 78 + expect(() => provider.emit('status', { connected: true })).not.toThrow(); 79 + expect(() => provider.emit('sync')).not.toThrow(); 80 + }); 81 + }); 82 + 83 + describe('#695 — every editor HTML template ships the Synced + E2EE chip pair', () => { 84 + const EDITOR_TEMPLATES = [ 85 + 'src/docs/index.html', 86 + 'src/sheets/index.html', 87 + 'src/slides/index.html', 88 + 'src/forms/index.html', 89 + 'src/diagrams/index.html', 90 + 'src/calendar/index.html', 91 + ]; 92 + 93 + for (const template of EDITOR_TEMPLATES) { 94 + it(`${template} contains both the Synced status-indicator and the E2EE chip`, async () => { 95 + const fs = await import('node:fs'); 96 + const path = await import('node:path'); 97 + const html = fs.readFileSync(path.resolve(process.cwd(), template), 'utf-8'); 98 + expect(html).toMatch(/id=["']status-dot["']/); 99 + expect(html).toMatch(/id=["']status-text["']/); 100 + expect(html).toMatch(/status-dot encrypted/); 101 + expect(html).toMatch(/>\s*E2EE\s*</); 102 + }); 103 + } 104 + });