···55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7788+## [0.23.5] — 2026-04-06
99+1010+### Fixed
1111+- Sheets: spill system no longer silently overwrites user-entered data in cells that were previously spill targets (#431)
1212+- Sheets: frozen columns/rows now always have an opaque background in dark mode, preventing scrolled content from showing through (#432)
1313+1414+### Added
1515+- Sheets: `RecalcEngine.clearSpillTarget()` method for notifying the engine when a user edits a spill target cell
1616+- Tests: 7 data integrity tests for spill system to prevent cell data overwrites during recalc
1717+818## [0.23.2] — 2026-04-06
9191020### Fixed
2121+- Sheets: @ts-nocheck in main.ts disables all type checking - high risk of runtime type errors in 3000+ line file (#409)
1122- Slides XSS: use textContent instead of innerHTML for user text elements (#363)
1223- Slides: save textContent on blur instead of innerHTML to prevent stored XSS (#363)
1324- Slides: use DOM API for image elements instead of innerHTML (#363)
···236247- Fix E2E test flakiness: replace page reload with addInitScript, add waitForURL before waitForSelector (#305)
237248238249### Changed
250250+- No unit tests for server API endpoints or WebSocket relay (#413)
251251+- No unit tests for forms builder, conditional logic, or response pipeline (#412)
252252+- CSS: dark mode colors use oklch() which is not supported in Firefox <113 or Safari <15.4 - no fallback colors defined (#408)
239253- Fit and finish round 3: provider listener leak, fetch error handling, test gaps (#361)
240254- Add E2E tests for database views (kanban, gallery, calendar, timeline, pivot) (#302)
241255- Add E2E tests for forms builder and submission (#301)
···255269- Add 11 tests for `initChatWiring` (config propagation, toggle, send/stop/clear, editor-type labels)
256270257271### Security
272272+- Key-sync: encryption keys stored in plaintext in localStorage and sent to server in plaintext - keys should be wrapped with a user-derived key (#405)
258273- Replace `Math.random()` with `crypto.getRandomValues`/`crypto.randomUUID` in 6 files (#239)
259274- Add defense-in-depth XSS escaping for code block language attributes (#238)
260275- Fix XSS + review findings from AI chat PR #160 (#232)
···592592 const cfResult = evaluateRules(displayValue, cfRules) || (colorScaleStyles && colorScaleStyles.get(id)) || null;
593593 const cfStyleStr = buildCfStyle(cfResult);
594594595595- // Background on td so inset box-shadow grid lines paint on top
596596- tdStyle += getCellBgStyle(cellData, cfStyleStr);
595595+ // Background on td so inset box-shadow grid lines paint on top.
596596+ // Frozen cells MUST have an opaque background to occlude scrolled content behind them.
597597+ const cellBg = getCellBgColor(cellData, cfStyleStr);
598598+ if (cellBg) {
599599+ tdStyle += 'background:' + cellBg + ';';
600600+ } else if (c <= freezeC || r <= freezeR) {
601601+ tdStyle += 'background:var(--color-bg);';
602602+ }
597603598604 const styleAttr = tdStyle ? ' style="' + tdStyle + '"' : '';
599605 let spanAttrs = '';
+28-2
src/sheets/recalc.ts
···141141 }
142142143143 /**
144144+ * Notify the engine that a user has edited a cell that was a spill target.
145145+ * Removes the cell from spill tracking so subsequent recalculations
146146+ * will detect it as occupied and not silently overwrite the user's data.
147147+ */
148148+ clearSpillTarget(cellId: string): void {
149149+ const sourceId = this._spillTargets.get(cellId);
150150+ if (!sourceId) return;
151151+152152+ // Remove from reverse map
153153+ this._spillTargets.delete(cellId);
154154+155155+ // Remove from source's target list
156156+ const targets = this._spillRanges.get(sourceId);
157157+ if (targets) {
158158+ const idx = targets.indexOf(cellId);
159159+ if (idx !== -1) targets.splice(idx, 1);
160160+ if (targets.length === 0) this._spillRanges.delete(sourceId);
161161+ }
162162+ }
163163+164164+ /**
144165 * Recalculate after a single cell edit.
145166 * Dirty-marks the edited cell + all transitive dependents,
146167 * then recalculates in topological order.
···479500 if (!oldTargets) return;
480501481502 for (const targetId of oldTargets) {
503503+ // Only clear cells that are still tracked as spill targets from this source.
504504+ // If the user edited the cell (clearSpillTarget was called), the mapping
505505+ // will be gone and we must NOT blank their data.
506506+ if (this._spillTargets.get(targetId) !== sourceId) continue;
507507+482508 this._spillTargets.delete(targetId);
483509 const targetCell = this.store.get(targetId);
484510 if (targetCell && !targetCell.f) {
···514540 // Check for collisions: target cell has content and is not a spill target from this source
515541 const oldTargets = new Set(this._spillRanges.get(sourceId) || []);
516542 for (const targetId of targets) {
517517- // Skip cells that are our own previous spill targets
518518- if (oldTargets.has(targetId)) continue;
543543+ // Skip cells that are still tracked as our spill targets (not edited by user)
544544+ if (oldTargets.has(targetId) && this._spillTargets.get(targetId) === sourceId) continue;
519545 // Skip cells that are our own current spill targets (from _spillTargets pointing to us)
520546 if (this._spillTargets.get(targetId) === sourceId) continue;
521547
+178
tests/recalc-spill.test.ts
···290290});
291291292292// =====================================================================
293293+// DATA INTEGRITY — spill must NEVER silently overwrite user data
294294+// =====================================================================
295295+296296+describe('RecalcEngine — spill data integrity', () => {
297297+ it('detects collision when user edits a former spill target', () => {
298298+ // Setup: A1 has SEQUENCE(3) that spills into A2, A3
299299+ const store = makeCellStore({
300300+ A1: { v: '', f: 'SEQUENCE(3)' },
301301+ });
302302+303303+ const engine = new RecalcEngine(store);
304304+ engine.buildFullGraph();
305305+ engine.recalculate('A1');
306306+307307+ // Verify initial spill
308308+ expect(store.get('A1')?.v).toBe(1);
309309+ expect(store.get('A2')?.v).toBe(2);
310310+ expect(store.get('A3')?.v).toBe(3);
311311+312312+ // User types "my data" into A2 (a spill target cell)
313313+ store.set('A2', { v: 'my data', f: '' });
314314+ // Clear spill tracking for A2 since user edited it
315315+ engine.clearSpillTarget('A2');
316316+317317+ // Re-trigger recalc of A1
318318+ engine.recalculate('A1');
319319+320320+ // A1 should show #SPILL! because A2 is now occupied by user data
321321+ expect(store.get('A1')?.v).toBe('#SPILL!');
322322+ // User's data must be preserved
323323+ expect(store.get('A2')?.v).toBe('my data');
324324+ });
325325+326326+ it('_clearSpill does not blank cells where user entered data', () => {
327327+ // Setup: A1 has SEQUENCE(3) that spills into A2, A3
328328+ const store = makeCellStore({
329329+ A1: { v: '', f: 'SEQUENCE(3)' },
330330+ });
331331+332332+ const engine = new RecalcEngine(store);
333333+ engine.buildFullGraph();
334334+ engine.recalculate('A1');
335335+336336+ expect(store.get('A2')?.v).toBe(2);
337337+ expect(store.get('A3')?.v).toBe(3);
338338+339339+ // User edits A3 (former spill target) to enter their own data
340340+ store.set('A3', { v: 'user value', f: '' });
341341+ engine.clearSpillTarget('A3');
342342+343343+ // Now change formula to scalar — triggers _clearSpill
344344+ store.set('A1', { v: '', f: '42' });
345345+ engine.updateCell('A1');
346346+ engine.recalculate('A1');
347347+348348+ expect(store.get('A1')?.v).toBe(42);
349349+ // A2 was still a spill target → should be cleared
350350+ expect(store.get('A2')?.v).toBe('');
351351+ // A3 was edited by user → must NOT be blanked
352352+ expect(store.get('A3')?.v).toBe('user value');
353353+ });
354354+355355+ it('recalculate never overwrites non-formula cells with spill values', () => {
356356+ // A1 has SEQUENCE(4) that would spill into A2, A3, A4
357357+ // A3 has user data (not a formula, not a spill target)
358358+ const store = makeCellStore({
359359+ A1: { v: '', f: 'SEQUENCE(4)' },
360360+ A3: { v: 'important data', f: '' },
361361+ });
362362+363363+ const engine = new RecalcEngine(store);
364364+ engine.buildFullGraph();
365365+ engine.recalculate('A1');
366366+367367+ // A1 should show #SPILL! because A3 is occupied
368368+ expect(store.get('A1')?.v).toBe('#SPILL!');
369369+ expect(store.get('A3')?.v).toBe('important data');
370370+ });
371371+372372+ it('spill range update detects user-edited cells in old targets', () => {
373373+ // A1 has SEQUENCE(3), spilling to A2, A3
374374+ const store = makeCellStore({
375375+ A1: { v: '', f: 'SEQUENCE(3)' },
376376+ });
377377+378378+ const engine = new RecalcEngine(store);
379379+ engine.buildFullGraph();
380380+ engine.recalculate('A1');
381381+382382+ expect(store.get('A2')?.v).toBe(2);
383383+ expect(store.get('A3')?.v).toBe(3);
384384+385385+ // User edits A2 (replacing spill value with their data)
386386+ store.set('A2', { v: 'edited by user', f: '' });
387387+ engine.clearSpillTarget('A2');
388388+389389+ // Change formula to still produce an array that needs A2
390390+ store.set('A1', { v: '', f: 'SEQUENCE(3,1,10,10)' });
391391+ engine.updateCell('A1');
392392+ engine.recalculate('A1');
393393+394394+ // Should detect collision with user's data in A2
395395+ expect(store.get('A1')?.v).toBe('#SPILL!');
396396+ expect(store.get('A2')?.v).toBe('edited by user');
397397+ });
398398+399399+ it('multiple array formulas do not overwrite each other\'s spill ranges', () => {
400400+ // A1 spills into A2, A3; C1 should not interfere
401401+ const store = makeCellStore({
402402+ A1: { v: '', f: 'SEQUENCE(3)' },
403403+ C1: { v: '', f: 'SEQUENCE(3,1,10,10)' },
404404+ });
405405+406406+ const engine = new RecalcEngine(store);
407407+ engine.buildFullGraph();
408408+ engine.recalculate('A1');
409409+ engine.recalculate('C1');
410410+411411+ // Both should spill independently
412412+ expect(store.get('A1')?.v).toBe(1);
413413+ expect(store.get('A2')?.v).toBe(2);
414414+ expect(store.get('A3')?.v).toBe(3);
415415+ expect(store.get('C1')?.v).toBe(10);
416416+ expect(store.get('C2')?.v).toBe(20);
417417+ expect(store.get('C3')?.v).toBe(30);
418418+ });
419419+420420+ it('rapid re-evaluation preserves user data in adjacent cells', () => {
421421+ // Simulates rapid recalc rebuilds (e.g., from Yjs sync during deploy restart)
422422+ const store = makeCellStore({
423423+ A1: { v: '', f: 'SEQUENCE(2)' },
424424+ A3: { v: 'user notes', f: '' },
425425+ });
426426+427427+ const engine = new RecalcEngine(store);
428428+ engine.buildFullGraph();
429429+430430+ // Multiple rapid recalculations
431431+ engine.recalculate('A1');
432432+ engine.recalculate('A1');
433433+ engine.recalculate('A1');
434434+435435+ expect(store.get('A1')?.v).toBe(1);
436436+ expect(store.get('A2')?.v).toBe(2);
437437+ // User's data in A3 must never be touched
438438+ expect(store.get('A3')?.v).toBe('user notes');
439439+ });
440440+441441+ it('shrinking spill range does not blank user-edited former targets', () => {
442442+ const store = makeCellStore({
443443+ A1: { v: '', f: 'SEQUENCE(4)' },
444444+ });
445445+446446+ const engine = new RecalcEngine(store);
447447+ engine.buildFullGraph();
448448+ engine.recalculate('A1');
449449+450450+ expect(store.get('A4')?.v).toBe(4);
451451+452452+ // User edits A4 (was last spill target)
453453+ store.set('A4', { v: 'keep me', f: '' });
454454+ engine.clearSpillTarget('A4');
455455+456456+ // Shrink array to 2 rows (only needs A2)
457457+ store.set('A1', { v: '', f: 'SEQUENCE(2)' });
458458+ engine.updateCell('A1');
459459+ engine.recalculate('A1');
460460+461461+ expect(store.get('A1')?.v).toBe(1);
462462+ expect(store.get('A2')?.v).toBe(2);
463463+ // A3 was a spill target → cleared to ''
464464+ expect(store.get('A3')?.v).toBe('');
465465+ // A4 was edited by user → must be preserved
466466+ expect(store.get('A4')?.v).toBe('keep me');
467467+ });
468468+});
469469+470470+// =====================================================================
293471// isVolatile EDGE CASES
294472// =====================================================================
295473