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 'fix: dark mode cell contrast + save pipeline hardening' (#92) from fix/save-audit-and-contrast into main

scott 7e944af7 88e62bde

+190 -35
+11
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.9.4] — 2026-03-23 11 + 12 + ### Fixed 13 + - **Dark mode cell text contrast**: cells with a background color but no explicit text color now auto-pick black or white text based on WCAG 2.1 luminance, preventing invisible text in dark mode (#202) 14 + - **Eliminate unsaved-changes prompt**: replaced beforeunload prompt with silent `sendBeacon` + IDB emergency save; added `pagehide` listener for mobile Safari reliability (#201) 15 + - **Save lock**: concurrent `_saveSnapshot` calls no longer race — a `_saveInProgress` flag serializes saves (#201) 16 + - **Cached encrypted state**: last encrypted snapshot is cached for instant synchronous `sendBeacon` during page teardown (#201) 17 + - **Stuck "saving" status**: bail paths in `_saveSnapshot` now reset save status instead of leaving UI stuck on "saving" (#201) 18 + - **Snapshot interval reduced**: periodic save interval lowered from 10s to 5s for tighter data safety (#201) 19 + - **IDB-only save clears unsaved flag**: when server save fails but IDB succeeds, `_hasUnsavedChanges` is cleared since data is safe locally (#201) 20 + 10 21 ## [0.9.3] — 2026-03-22 11 22 12 23 ### Fixed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.9.3", 3 + "version": "0.9.4", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+62 -22
src/lib/provider.ts
··· 22 22 const MSG_UPDATE = 2 as const; 23 23 const MSG_AWARENESS = 3 as const; 24 24 25 - const SNAPSHOT_INTERVAL = 10_000; // Save snapshot every 10s 25 + const SNAPSHOT_INTERVAL = 5_000; // Save snapshot every 5s 26 26 const SAVE_DEBOUNCE = 500; // Debounce save after changes 27 27 const MAX_SAVE_WAIT = 5_000; // Force save after this many ms of continuous edits 28 28 const MIN_SNAPSHOT_BYTES = 10; // Minimum plausible Yjs state size ··· 87 87 _lastVersionTime: number | undefined; 88 88 _snapshotLoadFailed: boolean; 89 89 _lastDebounceTrigger: number; 90 + _lastEncrypted: ArrayBuffer | Uint8Array | null; 91 + _saveInProgress: boolean; 90 92 91 93 _onDocUpdate: (update: Uint8Array, origin: unknown) => void; 92 94 _onAwarenessUpdate: (payload: AwarenessUpdatePayload) => void; ··· 111 113 this._hasUnsavedChanges = false; 112 114 this._snapshotLoadFailed = false; // Track if server had data we couldn't load 113 115 this._lastDebounceTrigger = 0; // Timestamp of first debounce in current burst 116 + this._lastEncrypted = null; // Cached encrypted state for synchronous sendBeacon 117 + this._saveInProgress = false; // Prevents concurrent _saveSnapshot calls 114 118 115 119 // Bind handlers 116 120 this._onDocUpdate = this._handleDocUpdate.bind(this); ··· 123 127 // Save before tab close or switch 124 128 if (typeof window !== 'undefined') { 125 129 window.addEventListener('beforeunload', this._onBeforeUnload); 130 + window.addEventListener('pagehide', this._handlePageHide); 126 131 document.addEventListener('visibilitychange', () => { 127 132 if (document.hidden) this._saveSnapshot(); 128 133 }); ··· 307 312 }, SAVE_DEBOUNCE); 308 313 } 309 314 310 - _handleBeforeUnload(event: BeforeUnloadEvent): void { 311 - if (this._hasUnsavedChanges) { 312 - // Warn user about unsaved changes 313 - event.preventDefault(); 314 - } 315 + _handleBeforeUnload(_event: BeforeUnloadEvent): void { 316 + // No prompt — sendBeacon + IDB backup make it unnecessary. 317 + // Prompting users about "unsaved changes" creates friction when data IS safe. 318 + this._emergencySave(); 319 + } 315 320 316 - // Always attempt emergency saves — even if synced=false, local data matters 321 + /** Also handle pagehide — more reliable than beforeunload on mobile Safari. */ 322 + _handlePageHide = (): void => { 323 + this._emergencySave(); 324 + }; 325 + 326 + /** Emergency save for page teardown: uses cached encrypted state + sendBeacon + IDB. */ 327 + private _emergencySave(): void { 317 328 try { 318 - const state = Y.encodeStateAsUpdate(this.doc); 319 - 320 329 // Skip saving empty/trivial state if we know data existed 330 + if ((this._hadSnapshot || this._snapshotLoadFailed)) { 331 + // Use cached encrypted state if available (synchronous, no async encrypt needed) 332 + if (this._lastEncrypted) { 333 + // Server: sendBeacon is synchronous enqueue — survives page teardown 334 + if (typeof navigator !== 'undefined' && navigator.sendBeacon) { 335 + try { 336 + const blob = new Blob([new Uint8Array(this._lastEncrypted as ArrayBuffer)], { type: 'application/octet-stream' }); 337 + navigator.sendBeacon(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, blob); 338 + } catch { /* best effort */ } 339 + } 340 + // IDB: fire-and-forget (browser gives brief window) 341 + saveLocalBackup(this.roomId, this._lastEncrypted).catch(() => { /* best effort */ }); 342 + return; 343 + } 344 + } 345 + 346 + // Fallback: encode + encrypt (async, best effort during teardown) 347 + const state = Y.encodeStateAsUpdate(this.doc); 321 348 if ((this._hadSnapshot || this._snapshotLoadFailed) && state.byteLength < MIN_SNAPSHOT_BYTES) { 322 349 return; 323 350 } 324 351 325 - // Server save: use sendBeacon (survives page teardown, unlike fetch) 326 - if (typeof navigator !== 'undefined' && navigator.sendBeacon && this.synced) { 327 - try { 328 - const plain = state; 329 - // sendBeacon is synchronous enqueue — encrypt inline if possible 330 - // Since encrypt is async, we fall back to sending the last known encrypted state 331 - // For reliability, also try the async path 332 - encrypt(plain, this.cryptoKey).then(encrypted => { 333 - const blob = new Blob([encrypted], { type: 'application/octet-stream' }); 334 - navigator.sendBeacon(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, blob); 335 - }).catch(() => { /* best effort */ }); 336 - } catch { /* best effort */ } 352 + // Server: try sendBeacon with async encrypt 353 + if (typeof navigator !== 'undefined' && navigator.sendBeacon) { 354 + encrypt(state, this.cryptoKey).then(encrypted => { 355 + const blob = new Blob([new Uint8Array(encrypted)], { type: 'application/octet-stream' }); 356 + navigator.sendBeacon(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, blob); 357 + }).catch(() => { /* best effort */ }); 337 358 } 338 359 339 - // IDB backup: always attempt, regardless of sync state 360 + // IDB: always attempt 340 361 encrypt(state, this.cryptoKey).then(encrypted => { 341 362 saveLocalBackup(this.roomId, encrypted).catch(() => { /* best effort */ }); 342 363 }).catch(() => { /* best effort */ }); ··· 410 431 // Don't save before sync completes — we'd overwrite real data with empty state 411 432 if (!this.synced) return; 412 433 434 + // Prevent concurrent saves from racing 435 + if (this._saveInProgress) return; 436 + 413 437 // Don't save while an import is in progress — the doc may be partially populated 414 438 // Schedule a save for when the import finishes 415 439 if (typeof window !== 'undefined' && (window as Window & { __importInProgress?: boolean }).__importInProgress) { ··· 422 446 return; 423 447 } 424 448 449 + this._saveInProgress = true; 425 450 this._setSaveStatus('saving'); 426 451 427 452 try { ··· 432 457 // OR if we know the server had data we couldn't load (prevents overwrite) 433 458 if ((this._hadSnapshot || this._snapshotLoadFailed) && state.byteLength < MIN_SNAPSHOT_BYTES) { 434 459 console.warn('Snapshot save skipped: state too small (%d bytes) — possible data loss', state.byteLength); 460 + this._setSaveStatus('saved'); // Reset status so UI doesn't show stuck "saving" 461 + this._saveInProgress = false; 435 462 return; 436 463 } 437 464 ··· 451 478 } 452 479 453 480 const encrypted = await encrypt(state, this.cryptoKey); 481 + 482 + // Cache encrypted state for synchronous use in emergency save (sendBeacon) 483 + this._lastEncrypted = encrypted; 454 484 455 485 // Save to server with retry logic 456 486 let saved = false; ··· 474 504 } 475 505 476 506 // Always save to local IDB backup (regardless of server success) 507 + let idbSaved = false; 477 508 try { 478 509 await saveLocalBackup(this.roomId, encrypted); 510 + idbSaved = true; 479 511 } catch { /* IDB backup is best-effort */ } 480 512 481 513 if (saved) { ··· 487 519 if (state.byteLength >= MIN_SNAPSHOT_BYTES) { 488 520 this._hadSnapshot = true; 489 521 } 522 + } else if (idbSaved) { 523 + // Server failed but IDB succeeded — data is safe locally 524 + this._hasUnsavedChanges = false; 525 + console.warn('Server save failed after %d retries, but IDB backup succeeded', MAX_SAVE_RETRIES); 526 + this._setSaveStatus('error'); 490 527 } else { 491 528 console.warn('Failed to save snapshot after %d retries', MAX_SAVE_RETRIES); 492 529 this._setSaveStatus('error'); ··· 494 531 } catch (err: unknown) { 495 532 console.warn('Failed to save snapshot', err); 496 533 this._setSaveStatus('error'); 534 + } finally { 535 + this._saveInProgress = false; 497 536 } 498 537 } 499 538 ··· 520 559 this.awareness.off('update', this._onAwarenessUpdate); 521 560 if (typeof window !== 'undefined') { 522 561 window.removeEventListener('beforeunload', this._onBeforeUnload); 562 + window.removeEventListener('pagehide', this._handlePageHide); 523 563 } 524 564 removeAwarenessStates(this.awareness, [this.doc.clientID], null); 525 565 }
+38 -2
src/sheets/main.ts
··· 740 740 }; 741 741 } 742 742 743 + /** Parse a hex color (#rgb or #rrggbb) to WCAG 2.1 relative luminance. */ 744 + function hexLuminance(hex: string): number { 745 + const h = hex.replace('#', ''); 746 + let r: number, g: number, b: number; 747 + if (h.length === 3) { 748 + r = parseInt(h[0] + h[0], 16); 749 + g = parseInt(h[1] + h[1], 16); 750 + b = parseInt(h[2] + h[2], 16); 751 + } else { 752 + r = parseInt(h.slice(0, 2), 16); 753 + g = parseInt(h.slice(2, 4), 16); 754 + b = parseInt(h.slice(4, 6), 16); 755 + } 756 + const toLinear = (c: number) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }; 757 + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); 758 + } 759 + 760 + /** Returns a contrasting text color for the given background hex. */ 761 + function contrastTextColor(bgHex: string): string { 762 + return hexLuminance(bgHex) > 0.179 ? '#1a1815' : '#ffffff'; 763 + } 764 + 743 765 // Resolve background color for a cell (explicit style > CF), returns color string or '' 744 766 function getCellBgColor(cellData, cfStyleStr) { 745 767 if (cellData?.s?.bg) return cellData.s.bg; ··· 758 780 759 781 function getCellStyle(cellData, cfStyleStr) { 760 782 let style = ''; 783 + let hasExplicitColor = false; 761 784 if (cellData?.s) { 762 785 const s = cellData.s; 763 786 // Skip emitting inline color when it matches a theme default — let CSS variable handle it 764 - if (s.color && s.color !== '#1a1815' && s.color !== '#ddd8ce') style += 'color:' + s.color + ';'; 787 + if (s.color && s.color !== '#1a1815' && s.color !== '#ddd8ce') { 788 + style += 'color:' + s.color + ';'; 789 + hasExplicitColor = true; 790 + } 765 791 // bg is now on the <td>, not .cell-display 766 792 if (s.bold) style += 'font-weight:600;'; 767 793 if (s.italic) style += 'font-style:italic;'; ··· 795 821 if (cfNoBg) { 796 822 if (!cellData?.s?.color && cfNoBg.includes('color:')) { 797 823 const colorMatch = cfNoBg.match(/(?:^|;)(color:[^;]+;)/); 798 - if (colorMatch) style += colorMatch[1]; 824 + if (colorMatch) { style += colorMatch[1]; hasExplicitColor = true; } 799 825 } else if (!cellData?.s?.color) { 826 + if (cfNoBg.includes('color:')) hasExplicitColor = true; 800 827 style += cfNoBg; 801 828 } 829 + } 830 + } 831 + // Auto-contrast: when a cell has a background color but no explicit text color, 832 + // pick black or white based on the background luminance. This prevents light-on-light 833 + // (dark mode default text on light cell bg) and dark-on-dark scenarios. 834 + if (!hasExplicitColor) { 835 + const bg = getCellBgColor(cellData, cfStyleStr); 836 + if (bg && bg.startsWith('#')) { 837 + style += 'color:' + contrastTextColor(bg) + ';'; 802 838 } 803 839 } 804 840 return style;
+78 -10
tests/provider-save.test.ts
··· 38 38 const source = await import('fs').then(fs => 39 39 fs.readFileSync('src/lib/provider.ts', 'utf-8') 40 40 ); 41 - // Extract the full beforeunload handler (from method signature to the next method) 42 - const beforeUnloadBlock = source.match(/_handleBeforeUnload\(event[\s\S]*?\n \}/)?.[0] || ''; 43 - // IDB save (saveLocalBackup) must be present and NOT gated behind this.synced 44 - expect(beforeUnloadBlock).toContain('saveLocalBackup'); 41 + // _handleBeforeUnload delegates to _emergencySave, which contains the actual logic 42 + const beforeUnloadBlock = source.match(/_handleBeforeUnload\(_event[\s\S]*?\n \}/)?.[0] || ''; 43 + // Must call _emergencySave (which contains saveLocalBackup) 44 + expect(beforeUnloadBlock).toContain('_emergencySave'); 45 + 46 + // Extract the emergency save method definition 47 + const emergencySaveBlock = source.match(/private _emergencySave\(\):[\s\S]*?\n \}/)?.[0] || ''; 48 + expect(emergencySaveBlock).toContain('saveLocalBackup'); 49 + 45 50 // The IDB save path should not be inside a `if (this.synced)` gate 46 - // sendBeacon can be gated on synced, but IDB must always run 47 - const idbLine = beforeUnloadBlock.indexOf('saveLocalBackup'); 48 - const syncedGateBeforeIdb = beforeUnloadBlock.lastIndexOf('this.synced', idbLine); 51 + const idbLine = emergencySaveBlock.indexOf('saveLocalBackup'); 52 + const syncedGateBeforeIdb = emergencySaveBlock.lastIndexOf('this.synced', idbLine); 49 53 // If there's a synced check, it should be for sendBeacon, not for the IDB save 50 54 if (syncedGateBeforeIdb > -1) { 51 - // The synced check should be in a separate block (sendBeacon), not wrapping IDB 52 - const blockBetween = beforeUnloadBlock.substring(syncedGateBeforeIdb, idbLine); 53 - expect(blockBetween).toContain('}'); // There should be a closing brace between them 55 + const blockBetween = emergencySaveBlock.substring(syncedGateBeforeIdb, idbLine); 56 + expect(blockBetween).toContain('}'); 54 57 } 55 58 }); 56 59 }); ··· 103 106 // _onSynced should trigger a save if there are unsaved changes 104 107 const onSyncedMethod = source.match(/_onSynced[\s\S]*?(?=\n _send)/)?.[0] || ''; 105 108 expect(onSyncedMethod).toMatch(/_saveSnapshot|_debouncedSave|_hasUnsavedChanges/); 109 + }); 110 + }); 111 + 112 + describe('beforeunload should not prompt when save mechanisms exist', () => { 113 + it('should NOT call event.preventDefault when sendBeacon and IDB are available', async () => { 114 + const source = await import('fs').then(fs => 115 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 116 + ); 117 + const beforeUnloadBlock = source.match(/_handleBeforeUnload\(event[\s\S]*?\n \}/)?.[0] || ''; 118 + // event.preventDefault() should NOT be called — the sendBeacon + IDB combo handles it 119 + expect(beforeUnloadBlock).not.toContain('event.preventDefault'); 120 + }); 121 + }); 122 + 123 + describe('concurrent save protection', () => { 124 + it('should have a save lock to prevent overlapping saves', async () => { 125 + const source = await import('fs').then(fs => 126 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 127 + ); 128 + // There should be a _saving flag or mutex preventing concurrent _saveSnapshot calls 129 + expect(source).toMatch(/_saving|_saveInProgress/); 130 + }); 131 + }); 132 + 133 + describe('pagehide listener for mobile Safari', () => { 134 + it('should listen for pagehide event in addition to beforeunload', async () => { 135 + const source = await import('fs').then(fs => 136 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 137 + ); 138 + expect(source).toContain('pagehide'); 139 + }); 140 + }); 141 + 142 + describe('cached encrypted state for instant sendBeacon', () => { 143 + it('should cache the last encrypted state for use in page teardown', async () => { 144 + const source = await import('fs').then(fs => 145 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 146 + ); 147 + // There should be a cached encrypted buffer that sendBeacon can use synchronously 148 + expect(source).toMatch(/_lastEncrypted|_cachedEncrypted/); 149 + }); 150 + }); 151 + 152 + describe('save status stuck prevention', () => { 153 + it('should not leave save status as saving when save bails early', async () => { 154 + const source = await import('fs').then(fs => 155 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 156 + ); 157 + // After the small-state check bail, status should be reset 158 + const saveMethod = source.match(/async _saveSnapshot[\s\S]*?(?=\n \/\*\*|\n set|\n disconnect)/)?.[0] || ''; 159 + // The bail path (state too small) should set status back 160 + const smallStateBlock = saveMethod.match(/state\.byteLength < MIN_SNAPSHOT_BYTES[\s\S]*?return;/)?.[0] || ''; 161 + expect(smallStateBlock).toMatch(/_setSaveStatus/); 162 + }); 163 + }); 164 + 165 + describe('periodic save interval', () => { 166 + it('should use a save interval of 5 seconds or less', async () => { 167 + const source = await import('fs').then(fs => 168 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 169 + ); 170 + const match = source.match(/SNAPSHOT_INTERVAL\s*=\s*(\d[\d_]*)/); 171 + expect(match).toBeTruthy(); 172 + const interval = parseInt(match![1].replace(/_/g, '')); 173 + expect(interval).toBeLessThanOrEqual(5000); 106 174 }); 107 175 }); 108 176 });