···7788## [Unreleased]
991010+## [0.9.4] — 2026-03-23
1111+1212+### Fixed
1313+- **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)
1414+- **Eliminate unsaved-changes prompt**: replaced beforeunload prompt with silent `sendBeacon` + IDB emergency save; added `pagehide` listener for mobile Safari reliability (#201)
1515+- **Save lock**: concurrent `_saveSnapshot` calls no longer race — a `_saveInProgress` flag serializes saves (#201)
1616+- **Cached encrypted state**: last encrypted snapshot is cached for instant synchronous `sendBeacon` during page teardown (#201)
1717+- **Stuck "saving" status**: bail paths in `_saveSnapshot` now reset save status instead of leaving UI stuck on "saving" (#201)
1818+- **Snapshot interval reduced**: periodic save interval lowered from 10s to 5s for tighter data safety (#201)
1919+- **IDB-only save clears unsaved flag**: when server save fails but IDB succeeds, `_hasUnsavedChanges` is cleared since data is safe locally (#201)
2020+1021## [0.9.3] — 2026-03-22
11221223### Fixed
···2222const MSG_UPDATE = 2 as const;
2323const MSG_AWARENESS = 3 as const;
24242525-const SNAPSHOT_INTERVAL = 10_000; // Save snapshot every 10s
2525+const SNAPSHOT_INTERVAL = 5_000; // Save snapshot every 5s
2626const SAVE_DEBOUNCE = 500; // Debounce save after changes
2727const MAX_SAVE_WAIT = 5_000; // Force save after this many ms of continuous edits
2828const MIN_SNAPSHOT_BYTES = 10; // Minimum plausible Yjs state size
···8787 _lastVersionTime: number | undefined;
8888 _snapshotLoadFailed: boolean;
8989 _lastDebounceTrigger: number;
9090+ _lastEncrypted: ArrayBuffer | Uint8Array | null;
9191+ _saveInProgress: boolean;
90929193 _onDocUpdate: (update: Uint8Array, origin: unknown) => void;
9294 _onAwarenessUpdate: (payload: AwarenessUpdatePayload) => void;
···111113 this._hasUnsavedChanges = false;
112114 this._snapshotLoadFailed = false; // Track if server had data we couldn't load
113115 this._lastDebounceTrigger = 0; // Timestamp of first debounce in current burst
116116+ this._lastEncrypted = null; // Cached encrypted state for synchronous sendBeacon
117117+ this._saveInProgress = false; // Prevents concurrent _saveSnapshot calls
114118115119 // Bind handlers
116120 this._onDocUpdate = this._handleDocUpdate.bind(this);
···123127 // Save before tab close or switch
124128 if (typeof window !== 'undefined') {
125129 window.addEventListener('beforeunload', this._onBeforeUnload);
130130+ window.addEventListener('pagehide', this._handlePageHide);
126131 document.addEventListener('visibilitychange', () => {
127132 if (document.hidden) this._saveSnapshot();
128133 });
···307312 }, SAVE_DEBOUNCE);
308313 }
309314310310- _handleBeforeUnload(event: BeforeUnloadEvent): void {
311311- if (this._hasUnsavedChanges) {
312312- // Warn user about unsaved changes
313313- event.preventDefault();
314314- }
315315+ _handleBeforeUnload(_event: BeforeUnloadEvent): void {
316316+ // No prompt — sendBeacon + IDB backup make it unnecessary.
317317+ // Prompting users about "unsaved changes" creates friction when data IS safe.
318318+ this._emergencySave();
319319+ }
315320316316- // Always attempt emergency saves — even if synced=false, local data matters
321321+ /** Also handle pagehide — more reliable than beforeunload on mobile Safari. */
322322+ _handlePageHide = (): void => {
323323+ this._emergencySave();
324324+ };
325325+326326+ /** Emergency save for page teardown: uses cached encrypted state + sendBeacon + IDB. */
327327+ private _emergencySave(): void {
317328 try {
318318- const state = Y.encodeStateAsUpdate(this.doc);
319319-320329 // Skip saving empty/trivial state if we know data existed
330330+ if ((this._hadSnapshot || this._snapshotLoadFailed)) {
331331+ // Use cached encrypted state if available (synchronous, no async encrypt needed)
332332+ if (this._lastEncrypted) {
333333+ // Server: sendBeacon is synchronous enqueue — survives page teardown
334334+ if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
335335+ try {
336336+ const blob = new Blob([new Uint8Array(this._lastEncrypted as ArrayBuffer)], { type: 'application/octet-stream' });
337337+ navigator.sendBeacon(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, blob);
338338+ } catch { /* best effort */ }
339339+ }
340340+ // IDB: fire-and-forget (browser gives brief window)
341341+ saveLocalBackup(this.roomId, this._lastEncrypted).catch(() => { /* best effort */ });
342342+ return;
343343+ }
344344+ }
345345+346346+ // Fallback: encode + encrypt (async, best effort during teardown)
347347+ const state = Y.encodeStateAsUpdate(this.doc);
321348 if ((this._hadSnapshot || this._snapshotLoadFailed) && state.byteLength < MIN_SNAPSHOT_BYTES) {
322349 return;
323350 }
324351325325- // Server save: use sendBeacon (survives page teardown, unlike fetch)
326326- if (typeof navigator !== 'undefined' && navigator.sendBeacon && this.synced) {
327327- try {
328328- const plain = state;
329329- // sendBeacon is synchronous enqueue — encrypt inline if possible
330330- // Since encrypt is async, we fall back to sending the last known encrypted state
331331- // For reliability, also try the async path
332332- encrypt(plain, this.cryptoKey).then(encrypted => {
333333- const blob = new Blob([encrypted], { type: 'application/octet-stream' });
334334- navigator.sendBeacon(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, blob);
335335- }).catch(() => { /* best effort */ });
336336- } catch { /* best effort */ }
352352+ // Server: try sendBeacon with async encrypt
353353+ if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
354354+ encrypt(state, this.cryptoKey).then(encrypted => {
355355+ const blob = new Blob([new Uint8Array(encrypted)], { type: 'application/octet-stream' });
356356+ navigator.sendBeacon(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, blob);
357357+ }).catch(() => { /* best effort */ });
337358 }
338359339339- // IDB backup: always attempt, regardless of sync state
360360+ // IDB: always attempt
340361 encrypt(state, this.cryptoKey).then(encrypted => {
341362 saveLocalBackup(this.roomId, encrypted).catch(() => { /* best effort */ });
342363 }).catch(() => { /* best effort */ });
···410431 // Don't save before sync completes — we'd overwrite real data with empty state
411432 if (!this.synced) return;
412433434434+ // Prevent concurrent saves from racing
435435+ if (this._saveInProgress) return;
436436+413437 // Don't save while an import is in progress — the doc may be partially populated
414438 // Schedule a save for when the import finishes
415439 if (typeof window !== 'undefined' && (window as Window & { __importInProgress?: boolean }).__importInProgress) {
···422446 return;
423447 }
424448449449+ this._saveInProgress = true;
425450 this._setSaveStatus('saving');
426451427452 try {
···432457 // OR if we know the server had data we couldn't load (prevents overwrite)
433458 if ((this._hadSnapshot || this._snapshotLoadFailed) && state.byteLength < MIN_SNAPSHOT_BYTES) {
434459 console.warn('Snapshot save skipped: state too small (%d bytes) — possible data loss', state.byteLength);
460460+ this._setSaveStatus('saved'); // Reset status so UI doesn't show stuck "saving"
461461+ this._saveInProgress = false;
435462 return;
436463 }
437464···451478 }
452479453480 const encrypted = await encrypt(state, this.cryptoKey);
481481+482482+ // Cache encrypted state for synchronous use in emergency save (sendBeacon)
483483+ this._lastEncrypted = encrypted;
454484455485 // Save to server with retry logic
456486 let saved = false;
···474504 }
475505476506 // Always save to local IDB backup (regardless of server success)
507507+ let idbSaved = false;
477508 try {
478509 await saveLocalBackup(this.roomId, encrypted);
510510+ idbSaved = true;
479511 } catch { /* IDB backup is best-effort */ }
480512481513 if (saved) {
···487519 if (state.byteLength >= MIN_SNAPSHOT_BYTES) {
488520 this._hadSnapshot = true;
489521 }
522522+ } else if (idbSaved) {
523523+ // Server failed but IDB succeeded — data is safe locally
524524+ this._hasUnsavedChanges = false;
525525+ console.warn('Server save failed after %d retries, but IDB backup succeeded', MAX_SAVE_RETRIES);
526526+ this._setSaveStatus('error');
490527 } else {
491528 console.warn('Failed to save snapshot after %d retries', MAX_SAVE_RETRIES);
492529 this._setSaveStatus('error');
···494531 } catch (err: unknown) {
495532 console.warn('Failed to save snapshot', err);
496533 this._setSaveStatus('error');
534534+ } finally {
535535+ this._saveInProgress = false;
497536 }
498537 }
499538···520559 this.awareness.off('update', this._onAwarenessUpdate);
521560 if (typeof window !== 'undefined') {
522561 window.removeEventListener('beforeunload', this._onBeforeUnload);
562562+ window.removeEventListener('pagehide', this._handlePageHide);
523563 }
524564 removeAwarenessStates(this.awareness, [this.doc.clientID], null);
525565 }
+38-2
src/sheets/main.ts
···740740 };
741741}
742742743743+/** Parse a hex color (#rgb or #rrggbb) to WCAG 2.1 relative luminance. */
744744+function hexLuminance(hex: string): number {
745745+ const h = hex.replace('#', '');
746746+ let r: number, g: number, b: number;
747747+ if (h.length === 3) {
748748+ r = parseInt(h[0] + h[0], 16);
749749+ g = parseInt(h[1] + h[1], 16);
750750+ b = parseInt(h[2] + h[2], 16);
751751+ } else {
752752+ r = parseInt(h.slice(0, 2), 16);
753753+ g = parseInt(h.slice(2, 4), 16);
754754+ b = parseInt(h.slice(4, 6), 16);
755755+ }
756756+ const toLinear = (c: number) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); };
757757+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
758758+}
759759+760760+/** Returns a contrasting text color for the given background hex. */
761761+function contrastTextColor(bgHex: string): string {
762762+ return hexLuminance(bgHex) > 0.179 ? '#1a1815' : '#ffffff';
763763+}
764764+743765// Resolve background color for a cell (explicit style > CF), returns color string or ''
744766function getCellBgColor(cellData, cfStyleStr) {
745767 if (cellData?.s?.bg) return cellData.s.bg;
···758780759781function getCellStyle(cellData, cfStyleStr) {
760782 let style = '';
783783+ let hasExplicitColor = false;
761784 if (cellData?.s) {
762785 const s = cellData.s;
763786 // Skip emitting inline color when it matches a theme default — let CSS variable handle it
764764- if (s.color && s.color !== '#1a1815' && s.color !== '#ddd8ce') style += 'color:' + s.color + ';';
787787+ if (s.color && s.color !== '#1a1815' && s.color !== '#ddd8ce') {
788788+ style += 'color:' + s.color + ';';
789789+ hasExplicitColor = true;
790790+ }
765791 // bg is now on the <td>, not .cell-display
766792 if (s.bold) style += 'font-weight:600;';
767793 if (s.italic) style += 'font-style:italic;';
···795821 if (cfNoBg) {
796822 if (!cellData?.s?.color && cfNoBg.includes('color:')) {
797823 const colorMatch = cfNoBg.match(/(?:^|;)(color:[^;]+;)/);
798798- if (colorMatch) style += colorMatch[1];
824824+ if (colorMatch) { style += colorMatch[1]; hasExplicitColor = true; }
799825 } else if (!cellData?.s?.color) {
826826+ if (cfNoBg.includes('color:')) hasExplicitColor = true;
800827 style += cfNoBg;
801828 }
829829+ }
830830+ }
831831+ // Auto-contrast: when a cell has a background color but no explicit text color,
832832+ // pick black or white based on the background luminance. This prevents light-on-light
833833+ // (dark mode default text on light cell bg) and dark-on-dark scenarios.
834834+ if (!hasExplicitColor) {
835835+ const bg = getCellBgColor(cellData, cfStyleStr);
836836+ if (bg && bg.startsWith('#')) {
837837+ style += 'color:' + contrastTextColor(bg) + ';';
802838 }
803839 }
804840 return style;
+78-10
tests/provider-save.test.ts
···3838 const source = await import('fs').then(fs =>
3939 fs.readFileSync('src/lib/provider.ts', 'utf-8')
4040 );
4141- // Extract the full beforeunload handler (from method signature to the next method)
4242- const beforeUnloadBlock = source.match(/_handleBeforeUnload\(event[\s\S]*?\n \}/)?.[0] || '';
4343- // IDB save (saveLocalBackup) must be present and NOT gated behind this.synced
4444- expect(beforeUnloadBlock).toContain('saveLocalBackup');
4141+ // _handleBeforeUnload delegates to _emergencySave, which contains the actual logic
4242+ const beforeUnloadBlock = source.match(/_handleBeforeUnload\(_event[\s\S]*?\n \}/)?.[0] || '';
4343+ // Must call _emergencySave (which contains saveLocalBackup)
4444+ expect(beforeUnloadBlock).toContain('_emergencySave');
4545+4646+ // Extract the emergency save method definition
4747+ const emergencySaveBlock = source.match(/private _emergencySave\(\):[\s\S]*?\n \}/)?.[0] || '';
4848+ expect(emergencySaveBlock).toContain('saveLocalBackup');
4949+4550 // The IDB save path should not be inside a `if (this.synced)` gate
4646- // sendBeacon can be gated on synced, but IDB must always run
4747- const idbLine = beforeUnloadBlock.indexOf('saveLocalBackup');
4848- const syncedGateBeforeIdb = beforeUnloadBlock.lastIndexOf('this.synced', idbLine);
5151+ const idbLine = emergencySaveBlock.indexOf('saveLocalBackup');
5252+ const syncedGateBeforeIdb = emergencySaveBlock.lastIndexOf('this.synced', idbLine);
4953 // If there's a synced check, it should be for sendBeacon, not for the IDB save
5054 if (syncedGateBeforeIdb > -1) {
5151- // The synced check should be in a separate block (sendBeacon), not wrapping IDB
5252- const blockBetween = beforeUnloadBlock.substring(syncedGateBeforeIdb, idbLine);
5353- expect(blockBetween).toContain('}'); // There should be a closing brace between them
5555+ const blockBetween = emergencySaveBlock.substring(syncedGateBeforeIdb, idbLine);
5656+ expect(blockBetween).toContain('}');
5457 }
5558 });
5659 });
···103106 // _onSynced should trigger a save if there are unsaved changes
104107 const onSyncedMethod = source.match(/_onSynced[\s\S]*?(?=\n _send)/)?.[0] || '';
105108 expect(onSyncedMethod).toMatch(/_saveSnapshot|_debouncedSave|_hasUnsavedChanges/);
109109+ });
110110+ });
111111+112112+ describe('beforeunload should not prompt when save mechanisms exist', () => {
113113+ it('should NOT call event.preventDefault when sendBeacon and IDB are available', async () => {
114114+ const source = await import('fs').then(fs =>
115115+ fs.readFileSync('src/lib/provider.ts', 'utf-8')
116116+ );
117117+ const beforeUnloadBlock = source.match(/_handleBeforeUnload\(event[\s\S]*?\n \}/)?.[0] || '';
118118+ // event.preventDefault() should NOT be called — the sendBeacon + IDB combo handles it
119119+ expect(beforeUnloadBlock).not.toContain('event.preventDefault');
120120+ });
121121+ });
122122+123123+ describe('concurrent save protection', () => {
124124+ it('should have a save lock to prevent overlapping saves', async () => {
125125+ const source = await import('fs').then(fs =>
126126+ fs.readFileSync('src/lib/provider.ts', 'utf-8')
127127+ );
128128+ // There should be a _saving flag or mutex preventing concurrent _saveSnapshot calls
129129+ expect(source).toMatch(/_saving|_saveInProgress/);
130130+ });
131131+ });
132132+133133+ describe('pagehide listener for mobile Safari', () => {
134134+ it('should listen for pagehide event in addition to beforeunload', async () => {
135135+ const source = await import('fs').then(fs =>
136136+ fs.readFileSync('src/lib/provider.ts', 'utf-8')
137137+ );
138138+ expect(source).toContain('pagehide');
139139+ });
140140+ });
141141+142142+ describe('cached encrypted state for instant sendBeacon', () => {
143143+ it('should cache the last encrypted state for use in page teardown', async () => {
144144+ const source = await import('fs').then(fs =>
145145+ fs.readFileSync('src/lib/provider.ts', 'utf-8')
146146+ );
147147+ // There should be a cached encrypted buffer that sendBeacon can use synchronously
148148+ expect(source).toMatch(/_lastEncrypted|_cachedEncrypted/);
149149+ });
150150+ });
151151+152152+ describe('save status stuck prevention', () => {
153153+ it('should not leave save status as saving when save bails early', async () => {
154154+ const source = await import('fs').then(fs =>
155155+ fs.readFileSync('src/lib/provider.ts', 'utf-8')
156156+ );
157157+ // After the small-state check bail, status should be reset
158158+ const saveMethod = source.match(/async _saveSnapshot[\s\S]*?(?=\n \/\*\*|\n set|\n disconnect)/)?.[0] || '';
159159+ // The bail path (state too small) should set status back
160160+ const smallStateBlock = saveMethod.match(/state\.byteLength < MIN_SNAPSHOT_BYTES[\s\S]*?return;/)?.[0] || '';
161161+ expect(smallStateBlock).toMatch(/_setSaveStatus/);
162162+ });
163163+ });
164164+165165+ describe('periodic save interval', () => {
166166+ it('should use a save interval of 5 seconds or less', async () => {
167167+ const source = await import('fs').then(fs =>
168168+ fs.readFileSync('src/lib/provider.ts', 'utf-8')
169169+ );
170170+ const match = source.match(/SNAPSHOT_INTERVAL\s*=\s*(\d[\d_]*)/);
171171+ expect(match).toBeTruthy();
172172+ const interval = parseInt(match![1].replace(/_/g, ''));
173173+ expect(interval).toBeLessThanOrEqual(5000);
106174 });
107175 });
108176});