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

Configure Feed

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

fix: spill data integrity + frozen column dark mode background (#255)

scott fb8bb7f5 37e2107c

+230 -5
+15
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.23.5] — 2026-04-06 9 + 10 + ### Fixed 11 + - Sheets: spill system no longer silently overwrites user-entered data in cells that were previously spill targets (#431) 12 + - Sheets: frozen columns/rows now always have an opaque background in dark mode, preventing scrolled content from showing through (#432) 13 + 14 + ### Added 15 + - Sheets: `RecalcEngine.clearSpillTarget()` method for notifying the engine when a user edits a spill target cell 16 + - Tests: 7 data integrity tests for spill system to prevent cell data overwrites during recalc 17 + 8 18 ## [0.23.2] — 2026-04-06 9 19 10 20 ### Fixed 21 + - Sheets: @ts-nocheck in main.ts disables all type checking - high risk of runtime type errors in 3000+ line file (#409) 11 22 - Slides XSS: use textContent instead of innerHTML for user text elements (#363) 12 23 - Slides: save textContent on blur instead of innerHTML to prevent stored XSS (#363) 13 24 - Slides: use DOM API for image elements instead of innerHTML (#363) ··· 236 247 - Fix E2E test flakiness: replace page reload with addInitScript, add waitForURL before waitForSelector (#305) 237 248 238 249 ### Changed 250 + - No unit tests for server API endpoints or WebSocket relay (#413) 251 + - No unit tests for forms builder, conditional logic, or response pipeline (#412) 252 + - CSS: dark mode colors use oklch() which is not supported in Firefox <113 or Safari <15.4 - no fallback colors defined (#408) 239 253 - Fit and finish round 3: provider listener leak, fetch error handling, test gaps (#361) 240 254 - Add E2E tests for database views (kanban, gallery, calendar, timeline, pivot) (#302) 241 255 - Add E2E tests for forms builder and submission (#301) ··· 255 269 - Add 11 tests for `initChatWiring` (config propagation, toggle, send/stop/clear, editor-type labels) 256 270 257 271 ### Security 272 + - 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) 258 273 - Replace `Math.random()` with `crypto.getRandomValues`/`crypto.randomUUID` in 6 files (#239) 259 274 - Add defense-in-depth XSS escaping for code block language attributes (#238) 260 275 - Fix XSS + review findings from AI chat PR #160 (#232)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.23.4", 3 + "version": "0.23.5", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+8 -2
src/sheets/main.ts
··· 592 592 const cfResult = evaluateRules(displayValue, cfRules) || (colorScaleStyles && colorScaleStyles.get(id)) || null; 593 593 const cfStyleStr = buildCfStyle(cfResult); 594 594 595 - // Background on td so inset box-shadow grid lines paint on top 596 - tdStyle += getCellBgStyle(cellData, cfStyleStr); 595 + // Background on td so inset box-shadow grid lines paint on top. 596 + // Frozen cells MUST have an opaque background to occlude scrolled content behind them. 597 + const cellBg = getCellBgColor(cellData, cfStyleStr); 598 + if (cellBg) { 599 + tdStyle += 'background:' + cellBg + ';'; 600 + } else if (c <= freezeC || r <= freezeR) { 601 + tdStyle += 'background:var(--color-bg);'; 602 + } 597 603 598 604 const styleAttr = tdStyle ? ' style="' + tdStyle + '"' : ''; 599 605 let spanAttrs = '';
+28 -2
src/sheets/recalc.ts
··· 141 141 } 142 142 143 143 /** 144 + * Notify the engine that a user has edited a cell that was a spill target. 145 + * Removes the cell from spill tracking so subsequent recalculations 146 + * will detect it as occupied and not silently overwrite the user's data. 147 + */ 148 + clearSpillTarget(cellId: string): void { 149 + const sourceId = this._spillTargets.get(cellId); 150 + if (!sourceId) return; 151 + 152 + // Remove from reverse map 153 + this._spillTargets.delete(cellId); 154 + 155 + // Remove from source's target list 156 + const targets = this._spillRanges.get(sourceId); 157 + if (targets) { 158 + const idx = targets.indexOf(cellId); 159 + if (idx !== -1) targets.splice(idx, 1); 160 + if (targets.length === 0) this._spillRanges.delete(sourceId); 161 + } 162 + } 163 + 164 + /** 144 165 * Recalculate after a single cell edit. 145 166 * Dirty-marks the edited cell + all transitive dependents, 146 167 * then recalculates in topological order. ··· 479 500 if (!oldTargets) return; 480 501 481 502 for (const targetId of oldTargets) { 503 + // Only clear cells that are still tracked as spill targets from this source. 504 + // If the user edited the cell (clearSpillTarget was called), the mapping 505 + // will be gone and we must NOT blank their data. 506 + if (this._spillTargets.get(targetId) !== sourceId) continue; 507 + 482 508 this._spillTargets.delete(targetId); 483 509 const targetCell = this.store.get(targetId); 484 510 if (targetCell && !targetCell.f) { ··· 514 540 // Check for collisions: target cell has content and is not a spill target from this source 515 541 const oldTargets = new Set(this._spillRanges.get(sourceId) || []); 516 542 for (const targetId of targets) { 517 - // Skip cells that are our own previous spill targets 518 - if (oldTargets.has(targetId)) continue; 543 + // Skip cells that are still tracked as our spill targets (not edited by user) 544 + if (oldTargets.has(targetId) && this._spillTargets.get(targetId) === sourceId) continue; 519 545 // Skip cells that are our own current spill targets (from _spillTargets pointing to us) 520 546 if (this._spillTargets.get(targetId) === sourceId) continue; 521 547
+178
tests/recalc-spill.test.ts
··· 290 290 }); 291 291 292 292 // ===================================================================== 293 + // DATA INTEGRITY — spill must NEVER silently overwrite user data 294 + // ===================================================================== 295 + 296 + describe('RecalcEngine — spill data integrity', () => { 297 + it('detects collision when user edits a former spill target', () => { 298 + // Setup: A1 has SEQUENCE(3) that spills into A2, A3 299 + const store = makeCellStore({ 300 + A1: { v: '', f: 'SEQUENCE(3)' }, 301 + }); 302 + 303 + const engine = new RecalcEngine(store); 304 + engine.buildFullGraph(); 305 + engine.recalculate('A1'); 306 + 307 + // Verify initial spill 308 + expect(store.get('A1')?.v).toBe(1); 309 + expect(store.get('A2')?.v).toBe(2); 310 + expect(store.get('A3')?.v).toBe(3); 311 + 312 + // User types "my data" into A2 (a spill target cell) 313 + store.set('A2', { v: 'my data', f: '' }); 314 + // Clear spill tracking for A2 since user edited it 315 + engine.clearSpillTarget('A2'); 316 + 317 + // Re-trigger recalc of A1 318 + engine.recalculate('A1'); 319 + 320 + // A1 should show #SPILL! because A2 is now occupied by user data 321 + expect(store.get('A1')?.v).toBe('#SPILL!'); 322 + // User's data must be preserved 323 + expect(store.get('A2')?.v).toBe('my data'); 324 + }); 325 + 326 + it('_clearSpill does not blank cells where user entered data', () => { 327 + // Setup: A1 has SEQUENCE(3) that spills into A2, A3 328 + const store = makeCellStore({ 329 + A1: { v: '', f: 'SEQUENCE(3)' }, 330 + }); 331 + 332 + const engine = new RecalcEngine(store); 333 + engine.buildFullGraph(); 334 + engine.recalculate('A1'); 335 + 336 + expect(store.get('A2')?.v).toBe(2); 337 + expect(store.get('A3')?.v).toBe(3); 338 + 339 + // User edits A3 (former spill target) to enter their own data 340 + store.set('A3', { v: 'user value', f: '' }); 341 + engine.clearSpillTarget('A3'); 342 + 343 + // Now change formula to scalar — triggers _clearSpill 344 + store.set('A1', { v: '', f: '42' }); 345 + engine.updateCell('A1'); 346 + engine.recalculate('A1'); 347 + 348 + expect(store.get('A1')?.v).toBe(42); 349 + // A2 was still a spill target → should be cleared 350 + expect(store.get('A2')?.v).toBe(''); 351 + // A3 was edited by user → must NOT be blanked 352 + expect(store.get('A3')?.v).toBe('user value'); 353 + }); 354 + 355 + it('recalculate never overwrites non-formula cells with spill values', () => { 356 + // A1 has SEQUENCE(4) that would spill into A2, A3, A4 357 + // A3 has user data (not a formula, not a spill target) 358 + const store = makeCellStore({ 359 + A1: { v: '', f: 'SEQUENCE(4)' }, 360 + A3: { v: 'important data', f: '' }, 361 + }); 362 + 363 + const engine = new RecalcEngine(store); 364 + engine.buildFullGraph(); 365 + engine.recalculate('A1'); 366 + 367 + // A1 should show #SPILL! because A3 is occupied 368 + expect(store.get('A1')?.v).toBe('#SPILL!'); 369 + expect(store.get('A3')?.v).toBe('important data'); 370 + }); 371 + 372 + it('spill range update detects user-edited cells in old targets', () => { 373 + // A1 has SEQUENCE(3), spilling to A2, A3 374 + const store = makeCellStore({ 375 + A1: { v: '', f: 'SEQUENCE(3)' }, 376 + }); 377 + 378 + const engine = new RecalcEngine(store); 379 + engine.buildFullGraph(); 380 + engine.recalculate('A1'); 381 + 382 + expect(store.get('A2')?.v).toBe(2); 383 + expect(store.get('A3')?.v).toBe(3); 384 + 385 + // User edits A2 (replacing spill value with their data) 386 + store.set('A2', { v: 'edited by user', f: '' }); 387 + engine.clearSpillTarget('A2'); 388 + 389 + // Change formula to still produce an array that needs A2 390 + store.set('A1', { v: '', f: 'SEQUENCE(3,1,10,10)' }); 391 + engine.updateCell('A1'); 392 + engine.recalculate('A1'); 393 + 394 + // Should detect collision with user's data in A2 395 + expect(store.get('A1')?.v).toBe('#SPILL!'); 396 + expect(store.get('A2')?.v).toBe('edited by user'); 397 + }); 398 + 399 + it('multiple array formulas do not overwrite each other\'s spill ranges', () => { 400 + // A1 spills into A2, A3; C1 should not interfere 401 + const store = makeCellStore({ 402 + A1: { v: '', f: 'SEQUENCE(3)' }, 403 + C1: { v: '', f: 'SEQUENCE(3,1,10,10)' }, 404 + }); 405 + 406 + const engine = new RecalcEngine(store); 407 + engine.buildFullGraph(); 408 + engine.recalculate('A1'); 409 + engine.recalculate('C1'); 410 + 411 + // Both should spill independently 412 + expect(store.get('A1')?.v).toBe(1); 413 + expect(store.get('A2')?.v).toBe(2); 414 + expect(store.get('A3')?.v).toBe(3); 415 + expect(store.get('C1')?.v).toBe(10); 416 + expect(store.get('C2')?.v).toBe(20); 417 + expect(store.get('C3')?.v).toBe(30); 418 + }); 419 + 420 + it('rapid re-evaluation preserves user data in adjacent cells', () => { 421 + // Simulates rapid recalc rebuilds (e.g., from Yjs sync during deploy restart) 422 + const store = makeCellStore({ 423 + A1: { v: '', f: 'SEQUENCE(2)' }, 424 + A3: { v: 'user notes', f: '' }, 425 + }); 426 + 427 + const engine = new RecalcEngine(store); 428 + engine.buildFullGraph(); 429 + 430 + // Multiple rapid recalculations 431 + engine.recalculate('A1'); 432 + engine.recalculate('A1'); 433 + engine.recalculate('A1'); 434 + 435 + expect(store.get('A1')?.v).toBe(1); 436 + expect(store.get('A2')?.v).toBe(2); 437 + // User's data in A3 must never be touched 438 + expect(store.get('A3')?.v).toBe('user notes'); 439 + }); 440 + 441 + it('shrinking spill range does not blank user-edited former targets', () => { 442 + const store = makeCellStore({ 443 + A1: { v: '', f: 'SEQUENCE(4)' }, 444 + }); 445 + 446 + const engine = new RecalcEngine(store); 447 + engine.buildFullGraph(); 448 + engine.recalculate('A1'); 449 + 450 + expect(store.get('A4')?.v).toBe(4); 451 + 452 + // User edits A4 (was last spill target) 453 + store.set('A4', { v: 'keep me', f: '' }); 454 + engine.clearSpillTarget('A4'); 455 + 456 + // Shrink array to 2 rows (only needs A2) 457 + store.set('A1', { v: '', f: 'SEQUENCE(2)' }); 458 + engine.updateCell('A1'); 459 + engine.recalculate('A1'); 460 + 461 + expect(store.get('A1')?.v).toBe(1); 462 + expect(store.get('A2')?.v).toBe(2); 463 + // A3 was a spill target → cleared to '' 464 + expect(store.get('A3')?.v).toBe(''); 465 + // A4 was edited by user → must be preserved 466 + expect(store.get('A4')?.v).toBe('keep me'); 467 + }); 468 + }); 469 + 470 + // ===================================================================== 293 471 // isVolatile EDGE CASES 294 472 // ===================================================================== 295 473