From 60e7aa7834ae01a49e18cf70e86715b9053d7a1b Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 5 Oct 2026 00:42:04 +0000
Subject: [PATCH 1/3] Refuse a paste, fill, cut or delete that reaches live
data, and say so (#232)
A paste into a live-data cell was written, announced as pasted, and wiped
by the next refresh, past where Ctrl+Z could bring it back. Fill, Ctrl+Enter,
cut and delete were the same door.
A write whose range touches a live cell or a template's structure is now
refused whole before anything changes, as Excel refuses a paste over
protected cells; applying it around them would shift rows against each
other. The refusal names the cells and shows with the note from #207
under the cursor's cell, which now lives in the store so every path uses
it. It covers paste (ours and another program's), fill down/right and drag,
Ctrl+Enter, delete, cut, convert to dates and any other single-cell write
(the AI assistant). Replace all skips live cells as it skips the template's
structure. The template's browser alert for these paths gives way to the
same note.
Typing on a range whose first cell is live now shows the note as well.
Adds store tests (7, failing on main) and an e2e flow (4 checks fail on
main).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013pmWZsbmugfpqtYazrzfjj
---
AGENTS.md | 4 +-
README.en.md | 16 +++-
README.md | 14 ++-
scripts/check-e2e.mjs | 40 ++++++++
src/features/grid/SpreadsheetGrid.tsx | 74 +++++++++------
src/i18n/en.ts | 2 +
src/i18n/th.ts | 2 +
src/i18n/types.ts | 4 +
src/store/liveWriteRefused.test.ts | 130 ++++++++++++++++++++++++++
src/store/sheetStore.ts | 129 ++++++++++++++++++++-----
10 files changed, 352 insertions(+), 63 deletions(-)
create mode 100644 src/store/liveWriteRefused.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index f0af3d7..fe1c81c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -46,11 +46,11 @@ formula palette, AI, live data, conditional formatting, charts, pivots, find/rep
dialog, the validation, names and comment popovers, the alert shown when a save is refused, the fill swatches, the dialog asked when a file would open on top of work, the one asked before New file, the one asked before a sort that would get a formula wrong, the report of what an opened file cannot keep, a sheet-name editor saying why Excel would refuse the name, the one asked before a paste over rows a filter hides, the one a second tab on the same workbook asks and the notice on a tab that is only looking, the AI assistant's answer card and the one that declines and points at a form, the notice on a tab that edits again because the other closed, the rescue screen after a crash, the Convert to dates dialog, the sample's notice once it is opened, the cell menu, a landing problem card unfolded, the two live-data dialogs (the picker and adding a source), the connect-from-this-browser form and the checklist for IT it shows when a connection fails, the note under a live-data cell that was typed over, and five things only a phone shows (the menu, cell tools, the keys bar while a formula is typed, the picking bar after ⌖ and the "tap again to type" label after one tap, scanned at 390px only with a touch screen), the save status's popover, the toast after a file opens, and two short screens (a phone on its side at 844×390, and 200% zoom at 195×422, which also fails on sideways scroll), across `/`, `/app`, `/guide`, the 404 page and the formula pages (`/formulas` and `/formulas/sumif`) — 121 checks in all. **A new panel means a new entry in `OPENED_STATES`**, and a state that will not
open fails the gate rather than being skipped.
-`npm run check:e2e` ขับแอปจริงในเบราว์เซอร์ 58 flow — พิมพ์ทับเซลล์ข้อมูลสดแล้วมีป้ายใต้เซลล์บอกเหตุผลและ Enter ไม่พาเคอร์เซอร์หนีไปก่อนอ่าน, เปิดแผงถาม AI แล้วสิ่งที่พิมพ์ลงช่องคำถามไม่ลงเซลล์ที่ถูกบัง และ Esc ปิดแผงแล้ว focus กลับเซลล์เดิม, บนมือถือแตะเซลล์สูตรครั้งที่สองแล้วช่องแก้ไขเปิดพร้อมสูตรครบ และสิ่งที่พิมพ์ไม่ไปทับเซลล์สองแถวล่าง แตะเซลล์อื่นแล้วเลือกแค่เซลล์นั้น, บนมือถือพิมพ์หลัง Enter ลงเซลล์ที่มีค่าแล้วแทนที่ค่าเดิม ไม่ต่อท้าย และแตะซ้ำยังแก้ต่อท้ายได้, บนมือถือหลัง Enter ในแถบสูตรแตะเซลล์แล้วเลือกเซลล์นั้น ไม่ต่อที่อยู่ลงสูตรของเซลล์ถัดไป และสูตรที่แค่เปิดดูไม่ถูกชี้ใส่, พิมพ์ C02 ลงช่อง dropdown แล้วได้ C02 ไม่ใช่ตัวแรกที่ขึ้นต้นด้วย C ค่านอกรายการถูกปฏิเสธและบอกเหตุผล และเลือกจากรายการยังบันทึกได้ ทั้งที่ 1280 และ 390, เปิดไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มีแล้วแอปบอกก่อนว่าจะหาย ปิดแล้วเปิดดูจากเมนูได้ ไฟล์ธรรมดาไม่เห็นอะไร และชื่อช่วงที่นำเข้าไม่ได้อยู่ในรายงานเดียวกัน ไม่ใช่ alert แยก, แหล่งข้อมูลสดที่ตอบหลังหน้าต่างเลือกข้อมูลเปิดไม่ทำให้เซลล์กลายเป็นช่องว่าง, หน้าสูตรกด "ลองในตาราง" แล้วค่าในเซลล์ตรงกับบนหน้า ชื่อบทเรียนหายจาก URL และบนงานที่มีอยู่ถามก่อนโดยตัวเลือกที่ focus เก็บงานไว้, งานเก่าที่ชื่อชีตซ้ำหรือผิดกติกา Excel เปิดมาแล้วชื่อถูกและสูตรชี้แท็บที่ถูก ตั้งชื่อผิดกติกาแล้วถูกปฏิเสธตรงที่พิมพ์ และส่งออกได้ไฟล์ที่ชื่อชีตกับสูตรถูกต้อง, ปิดแท็บที่แก้อยู่แล้วแท็บที่ดูอยู่แก้ต่อได้เองพร้อมงานครบ, แอปพังแล้วหน้ากู้งานขึ้น ไฟล์ที่โหลดมีงานครบ และลองอีกครั้งกลับมาที่ตารางเดิม, วันที่ พ.ศ. นับได้จริงและคำสั่งแปลงเป็นวันที่จากเมนูคลิกขวาแสดงตัวอย่างและ undo ครั้งเดียวกลับ, ผู้ช่วย AI ใส่ยอดรวมใต้คอลัมน์ไม่ทับสูตรเดิม และคำถามที่มีเงื่อนไขชี้ไปฟอร์ม SUMIF, ลากเลือกช่วงบนมือถือไม่เลยคอลัมน์และแตะระหว่างพิมพ์ `=` ไม่บันทึกทับ, แตะครั้งเดียวบนมือถือแค่เลือก ไม่มีคีย์บอร์ดค้าง เซลล์บอก "แตะอีกครั้งเพื่อพิมพ์" และสิ่งที่พิมพ์ไม่หายเงียบ, Enter หรือ ↓ ที่แถวสุดท้ายเพิ่มแถวโดยไม่มีค่าไหนถูกทับและ undo ครั้งเดียวเอาค่ากับแถวออกแล้วพิมพ์ต่อได้, บนมือถือ undo ตอนช่องแก้ไขเปิดอยู่บนแถวที่ Enter งอกขึ้นมาแล้วช่องปิด บอกว่าทิ้งข้อความที่พิมพ์ค้าง และค่าถัดไปพิมพ์ลงได้, แท็บที่สองบนงานเดียวกันถามก่อน ขอใช้แล้วแท็บเดิมดูอย่างเดียวและไม่มีงานหาย, วันที่ในคอลัมน์กว้างค่าตั้งต้นของ Excel ขึ้นเป็นวันที่ไม่ใช่ `###` ทั้งที่ 1280 และ 390, ชีตชื่อไทยดาวน์โหลดเป็นไฟล์ชื่อไทย, เรียงชีตตัวอย่างแล้วทุกแถวยังคูณของตัวเองและการเรียงที่สูตรจะผิดถามก่อน, กรองแล้วกด Delete บนช่วงที่เห็นแถวที่ซ่อนไม่หาย และวางทับแถวที่ซ่อนแล้วถามก่อน, เปอร์เซ็นต์ที่บันทึกไว้ก่อน #53 ยังขึ้นเหมือนเดิมหลัง migrate, เปิดมาเป็นตารางว่างและข้อมูลตัวอย่างที่ค้างในเบราว์เซอร์ไม่กลับมา (แต่ถ้าแก้แล้วต้องอยู่) ไฟล์ใหม่ถามก่อนและ Ctrl+Z เอางานคืน, พิมพ์สูตรแล้วดูค่าขยับ, ส่งออก `.xlsx` แล้วนำกลับเข้ามา, เปิดไฟล์ทับงานแล้วแอปถามก่อน (ตัวเลือกที่ focus อยู่ต้องเก็บงานไว้ และ undo ถอนไฟล์ออกได้) พร้อม `Ctrl+B`, Tab ตามแถวแล้ว Enter กลับคอลัมน์แรก กับเมนูเซลล์จาก `Shift+F10`, ลากคอลัมน์ให้กว้างแล้ว undo ครั้งเดียวกลับ, Enter บนมือถือเข้าช่องถัดไปโดยคีย์บอร์ดไม่พับ,
+`npm run check:e2e` ขับแอปจริงในเบราว์เซอร์ 59 flow — วางทับบล็อกข้อมูลสดแล้วถูกปฏิเสธทั้งก้อนพร้อมป้าย และรีเฟรชไม่ลบอะไร, พิมพ์ทับเซลล์ข้อมูลสดแล้วมีป้ายใต้เซลล์บอกเหตุผลและ Enter ไม่พาเคอร์เซอร์หนีไปก่อนอ่าน, เปิดแผงถาม AI แล้วสิ่งที่พิมพ์ลงช่องคำถามไม่ลงเซลล์ที่ถูกบัง และ Esc ปิดแผงแล้ว focus กลับเซลล์เดิม, บนมือถือแตะเซลล์สูตรครั้งที่สองแล้วช่องแก้ไขเปิดพร้อมสูตรครบ และสิ่งที่พิมพ์ไม่ไปทับเซลล์สองแถวล่าง แตะเซลล์อื่นแล้วเลือกแค่เซลล์นั้น, บนมือถือพิมพ์หลัง Enter ลงเซลล์ที่มีค่าแล้วแทนที่ค่าเดิม ไม่ต่อท้าย และแตะซ้ำยังแก้ต่อท้ายได้, บนมือถือหลัง Enter ในแถบสูตรแตะเซลล์แล้วเลือกเซลล์นั้น ไม่ต่อที่อยู่ลงสูตรของเซลล์ถัดไป และสูตรที่แค่เปิดดูไม่ถูกชี้ใส่, พิมพ์ C02 ลงช่อง dropdown แล้วได้ C02 ไม่ใช่ตัวแรกที่ขึ้นต้นด้วย C ค่านอกรายการถูกปฏิเสธและบอกเหตุผล และเลือกจากรายการยังบันทึกได้ ทั้งที่ 1280 และ 390, เปิดไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มีแล้วแอปบอกก่อนว่าจะหาย ปิดแล้วเปิดดูจากเมนูได้ ไฟล์ธรรมดาไม่เห็นอะไร และชื่อช่วงที่นำเข้าไม่ได้อยู่ในรายงานเดียวกัน ไม่ใช่ alert แยก, แหล่งข้อมูลสดที่ตอบหลังหน้าต่างเลือกข้อมูลเปิดไม่ทำให้เซลล์กลายเป็นช่องว่าง, หน้าสูตรกด "ลองในตาราง" แล้วค่าในเซลล์ตรงกับบนหน้า ชื่อบทเรียนหายจาก URL และบนงานที่มีอยู่ถามก่อนโดยตัวเลือกที่ focus เก็บงานไว้, งานเก่าที่ชื่อชีตซ้ำหรือผิดกติกา Excel เปิดมาแล้วชื่อถูกและสูตรชี้แท็บที่ถูก ตั้งชื่อผิดกติกาแล้วถูกปฏิเสธตรงที่พิมพ์ และส่งออกได้ไฟล์ที่ชื่อชีตกับสูตรถูกต้อง, ปิดแท็บที่แก้อยู่แล้วแท็บที่ดูอยู่แก้ต่อได้เองพร้อมงานครบ, แอปพังแล้วหน้ากู้งานขึ้น ไฟล์ที่โหลดมีงานครบ และลองอีกครั้งกลับมาที่ตารางเดิม, วันที่ พ.ศ. นับได้จริงและคำสั่งแปลงเป็นวันที่จากเมนูคลิกขวาแสดงตัวอย่างและ undo ครั้งเดียวกลับ, ผู้ช่วย AI ใส่ยอดรวมใต้คอลัมน์ไม่ทับสูตรเดิม และคำถามที่มีเงื่อนไขชี้ไปฟอร์ม SUMIF, ลากเลือกช่วงบนมือถือไม่เลยคอลัมน์และแตะระหว่างพิมพ์ `=` ไม่บันทึกทับ, แตะครั้งเดียวบนมือถือแค่เลือก ไม่มีคีย์บอร์ดค้าง เซลล์บอก "แตะอีกครั้งเพื่อพิมพ์" และสิ่งที่พิมพ์ไม่หายเงียบ, Enter หรือ ↓ ที่แถวสุดท้ายเพิ่มแถวโดยไม่มีค่าไหนถูกทับและ undo ครั้งเดียวเอาค่ากับแถวออกแล้วพิมพ์ต่อได้, บนมือถือ undo ตอนช่องแก้ไขเปิดอยู่บนแถวที่ Enter งอกขึ้นมาแล้วช่องปิด บอกว่าทิ้งข้อความที่พิมพ์ค้าง และค่าถัดไปพิมพ์ลงได้, แท็บที่สองบนงานเดียวกันถามก่อน ขอใช้แล้วแท็บเดิมดูอย่างเดียวและไม่มีงานหาย, วันที่ในคอลัมน์กว้างค่าตั้งต้นของ Excel ขึ้นเป็นวันที่ไม่ใช่ `###` ทั้งที่ 1280 และ 390, ชีตชื่อไทยดาวน์โหลดเป็นไฟล์ชื่อไทย, เรียงชีตตัวอย่างแล้วทุกแถวยังคูณของตัวเองและการเรียงที่สูตรจะผิดถามก่อน, กรองแล้วกด Delete บนช่วงที่เห็นแถวที่ซ่อนไม่หาย และวางทับแถวที่ซ่อนแล้วถามก่อน, เปอร์เซ็นต์ที่บันทึกไว้ก่อน #53 ยังขึ้นเหมือนเดิมหลัง migrate, เปิดมาเป็นตารางว่างและข้อมูลตัวอย่างที่ค้างในเบราว์เซอร์ไม่กลับมา (แต่ถ้าแก้แล้วต้องอยู่) ไฟล์ใหม่ถามก่อนและ Ctrl+Z เอางานคืน, พิมพ์สูตรแล้วดูค่าขยับ, ส่งออก `.xlsx` แล้วนำกลับเข้ามา, เปิดไฟล์ทับงานแล้วแอปถามก่อน (ตัวเลือกที่ focus อยู่ต้องเก็บงานไว้ และ undo ถอนไฟล์ออกได้) พร้อม `Ctrl+B`, Tab ตามแถวแล้ว Enter กลับคอลัมน์แรก กับเมนูเซลล์จาก `Shift+F10`, ลากคอลัมน์ให้กว้างแล้ว undo ครั้งเดียวกลับ, Enter บนมือถือเข้าช่องถัดไปโดยคีย์บอร์ดไม่พับ,
เดินด้วยคีย์บอร์ดล้วน, วางตัวเลขจาก clipboard แบบที่ Excel ก๊อปออกมา (`1,250` `15%` `฿1,234.50`) แล้ว SUM ถูกและรหัสยังเป็นข้อความ, undo, แก้ต่อหลัง undo การเพิ่มชีต, ตัดข้ามชีต, แถบสูตรไม่เขียนค่าค้าง, "เปลี่ยนอะไรไกลจากเคอร์เซอร์แล้วพูดออกมาไหม", ผู้ช่วย AI (stub route ไว้
ทั้งกรณีตอบปกติและกรณีโดน rate limit) CSP (header มาจริง, ยิงออกนอก policy ไม่ได้, แอปเองไม่สะดุด) บันทึกไม่ลงแล้วแอปบอกและยังส่งออกได้, ต่อ API จากเบราว์เซอร์แล้วค่าลงตารางและรีเฟรชเอง โดยไม่มีอะไรเกี่ยวกับมันถึง `/api/*` (และ CSP ของคนที่ไม่เคยเพิ่มแหล่งยังเหมือนเดิมทุกตัวอักษร · แท็บใหม่ที่ไม่มีค่า header ขึ้นว่ารอค่า ไม่ใช่เชื่อมต่อไม่ได้), API ที่ไม่ตอบ CORS ได้รายการตรวจสำหรับ IT ไม่ใช่ error เปล่า, ไม่มีหน้าไหนเรียกตัวเองว่าเดโมทั้งสองภาษา, ทุกหน้าใน sitemap ประกาศ canonical ของตัวเองและหน้าแรกมี JSON-LD ที่ถือ nonce โดยคำถามที่พบบ่อยใน JSON-LD ตรงกับบนหน้าคำต่อคำ และที่ 390px ทางไปข้อมูลสดอยู่บนจอและมีชื่อ บนมือถือการแตะระหว่างพิมพ์ `=` ไม่บันทึกสูตรครึ่งเดียว แต่ใส่ที่อยู่เซลล์ลงสูตร ลากขยายเป็นช่วงได้ และ ⌖ เลือกช่วงจากตารางได้ หลังเปิดไฟล์บนมือถือข้อความขึ้นทีละอัน ตารางเหลือ 12 แถวขึ้นไปและปัดข้างได้ มือถือแนวนอนเห็นตาราง 6 แถวขึ้นไปและกดค้างเปิดเมนูเซลล์ และค่าข้ามชีตบนจอยังถูกหลังส่งออก PDF, sort และ undo เกณฑ์เลือก flow มีข้อเดียว:
**unit test จับได้อยู่แล้วหรือเปล่า** ถ้าจับได้ ไม่ต้องอยู่ที่นี่ ที่เหลือคือรอยต่อ ซึ่งเป็นที่ที่บั๊กของโปรเจกต์นี้อยู่ทุกตัว
-`npm run check:e2e` drives the real app in a browser through 58 flows — typing over a live-data cell getting a note under it saying why, with Enter not taking the cursor away before it is read, opening Ask AI putting what is typed in the question box rather than in the cell it covers, with Escape closing it back to that cell, on a phone, a second tap on a formula cell opening its editor with the formula whole and typing landing there rather than two rows down and a tap away selecting one cell, on a phone, typing after Enter into a filled cell replacing its value rather than going on the end, with a second tap still editing in place, on a phone, a tap after Enter in the formula bar selecting the cell rather than pointing into the next cell's formula, with a formula only opened to look at left alone, C02 typed into a dropdown cell saving C02 rather than the first option starting with C, text off the list refused out loud and a pick from the list still saving, at 1280 and 390, a real file with a picture, a chart and a function the engine lacks saying what it will lose as it opens, reopened from the menu, with nothing for a plain file, and range names that could not come in listed in the same report rather than a separate alert, a live source answering after the picker opens never blanking the cell, a formula page's "try it in the sheet" landing the page's own numbers in the sheet, leaving the address clean and, over work, asking first with keeping the work focused, an old save with duplicate or invalid sheet names opening with names Excel takes and formulas on the right tab, a bad name refused where it is typed, and an export whose sheet names and formulas are right, a view-only tab editing by itself with all the work once the editing tab closes, a crash that brings up the rescue screen, whose file holds the work and whose Try again comes back to the sheet, a Buddhist-Era date that counts and Convert to dates from the cell menu, previewed and undone in one step, the AI assistant putting a column's total under it rather than over a formula and sending a condition to the SUMIF form, a range dragged on a phone staying in its column and a tap during `=` saving nothing, one tap on a phone only selecting, with no keyboard left up, the cell saying "tap again to type" and nothing typed lost in silence, Enter or ↓ on the last row adding a row with nothing typed over and one undo taking the value and its row back with typing still landing after it, on a phone an undo with an editor open on a row Enter grew closing the editor, saying what it dropped, and the next value landing, a second tab on the same workbook asking first, with taking over turning the first view-only and no edit lost, dates in Excel's default column width showing as dates, not `###`, at 1280 and 390, a Thai sheet name downloading as a Thai file name, the sample sorted with every row still its own and a risky sort asking first, Delete on a filtered range leaving the hidden rows alone and a paste over them asking first, a percent saved before #53 still reading the same after the migration, opening blank, with a sample an older version saved not coming back (and one edited, staying), New file asking first and Ctrl+Z bringing the work back, type a formula and watch the value
+`npm run check:e2e` drives the real app in a browser through 59 flows — a paste into a live block refused whole with the note and nothing for a refresh to wipe, typing over a live-data cell getting a note under it saying why, with Enter not taking the cursor away before it is read, opening Ask AI putting what is typed in the question box rather than in the cell it covers, with Escape closing it back to that cell, on a phone, a second tap on a formula cell opening its editor with the formula whole and typing landing there rather than two rows down and a tap away selecting one cell, on a phone, typing after Enter into a filled cell replacing its value rather than going on the end, with a second tap still editing in place, on a phone, a tap after Enter in the formula bar selecting the cell rather than pointing into the next cell's formula, with a formula only opened to look at left alone, C02 typed into a dropdown cell saving C02 rather than the first option starting with C, text off the list refused out loud and a pick from the list still saving, at 1280 and 390, a real file with a picture, a chart and a function the engine lacks saying what it will lose as it opens, reopened from the menu, with nothing for a plain file, and range names that could not come in listed in the same report rather than a separate alert, a live source answering after the picker opens never blanking the cell, a formula page's "try it in the sheet" landing the page's own numbers in the sheet, leaving the address clean and, over work, asking first with keeping the work focused, an old save with duplicate or invalid sheet names opening with names Excel takes and formulas on the right tab, a bad name refused where it is typed, and an export whose sheet names and formulas are right, a view-only tab editing by itself with all the work once the editing tab closes, a crash that brings up the rescue screen, whose file holds the work and whose Try again comes back to the sheet, a Buddhist-Era date that counts and Convert to dates from the cell menu, previewed and undone in one step, the AI assistant putting a column's total under it rather than over a formula and sending a condition to the SUMIF form, a range dragged on a phone staying in its column and a tap during `=` saving nothing, one tap on a phone only selecting, with no keyboard left up, the cell saying "tap again to type" and nothing typed lost in silence, Enter or ↓ on the last row adding a row with nothing typed over and one undo taking the value and its row back with typing still landing after it, on a phone an undo with an editor open on a row Enter grew closing the editor, saying what it dropped, and the next value landing, a second tab on the same workbook asking first, with taking over turning the first view-only and no edit lost, dates in Excel's default column width showing as dates, not `###`, at 1280 and 390, a Thai sheet name downloading as a Thai file name, the sample sorted with every row still its own and a risky sort asking first, Delete on a filtered range leaving the hidden rows alone and a paste over them asking first, a percent saved before #53 still reading the same after the migration, opening blank, with a sample an older version saved not coming back (and one edited, staying), New file asking first and Ctrl+Z bringing the work back, type a formula and watch the value
move, export `.xlsx` and import it back, a file opened on top of work that asks first (the focused choice keeps the work, and undo takes the file back out) along with `Ctrl+B`, a row typed with Tab ending with Enter under its first column and the cell menu from `Shift+F10`, a column dragged wider that one undo puts back, Enter on a phone going into the next cell's editor, keyboard only, numbers pasted through the real clipboard as Excel copies them (`1,250` `15%` `฿1,234.50`) adding up while codes stay text, undo, editing after undoing a new sheet, a cut across sheets, a formula bar that never writes a stale value, whether a change away from the cursor is
announced, the AI assistant with its route stubbed (both a normal answer and a rate limit), and the CSP
(served, blocking exfiltration, and not tripping the app up), a refused save that is announced while export still works, an API connected from this browser that fills the sheet and refreshes with nothing about it reaching `/api/*` (and the CSP unchanged, character for character, for anyone who never added a source; a new tab without the header value says it is waiting, not failing), an API without CORS getting a checklist for IT rather than a bare error, no page calling itself a demo in either language, every page in the sitemap declaring its own canonical with the landing page's JSON-LD carrying the nonce and its FAQ matching the page word for word, at 390px whether the way to live data is on screen and named, on a phone a tap during `=` putting the cell into the formula (widened by a drag) rather than saving half of it, and ⌖ picking a range from the grid, a file opened on a phone leaving one message at a time with twelve rows and a working sideways swipe, a phone on its side keeping six rows with a long press opening the cell menu, and cross-sheet values still right on screen after a PDF export, a sort and its undo. Flows are picked by one rule: **would a unit test already catch it?** If yes it does not belong
diff --git a/README.en.md b/README.en.md
index a09dc71..f4b916c 100644
--- a/README.en.md
+++ b/README.en.md
@@ -403,7 +403,7 @@ Other available commands:
| `npm run check:bundle` | Size budgets, and the cloud client staying in a chunk of its own (needs a build) |
| `npm run check:mutants` | Breaks the engine on purpose and checks the suite notices — 32/32 (no build needed) |
| `npm run check:a11y` | axe on both pages at 390px and 1280px, plus sideways-scroll checks (needs a build) · `A11Y_WIDTH=390` runs one half, which is how CI runs it |
-| `npm run check:e2e` | Drives the real app through 58 flows: `999` and Enter on a live-data cell getting a note under the cell saying why, Enter putting the note away with the cursor and the value where they were, and Delete and a double-click saying so too (#207), opening Ask AI putting what is typed in the question box rather than in the cell it covers, and Escape closing it with focus back on that cell, at 1280 and 390 (#203), on a phone, a second tap on a formula cell opening its editor with the formula whole, what is typed going into that formula and the cell two rows down left alone (#201), and a tap on another cell with nothing typed selecting that cell alone rather than a range (#210), on a phone, typing after Enter into a filled cell replacing its value rather than going on the end (#202), on a phone, a tap after Enter in the formula bar selecting the cell rather than breaking the next cell's formula, and a formula only opened to look at left alone (#201), typing into a dropdown cell saving what was typed in full, text off the list refused and a pick from the list still saving, at 1280 and 390 (#198), a file with a picture and a chart saying what it will lose as it opens, a plain one saying nothing, and range names that could not come in in the same report (#83), a live source that answers after the picker opens never blanking the cell (#126), one tap on a phone only selecting and saying to tap again, with nothing typed lost in silence (#171), Enter or ↓ on the last row adding a row with nothing typed over and one undo taking the value and its row back with typing still landing after it (#170), on a phone an undo with an editor open on a row Enter grew closing the editor, saying what it dropped, and the next value landing (#191), a formula page's try-it button landing the page's own numbers in the sheet, and asking first over work (#149), numbers pasted through the real clipboard as Excel copies them adding up while codes stay text (#52), the AI assistant putting a column's total under it and sending a condition to the SUMIF form (#62–#64), a view-only tab editing by itself, with all the work, once the editing tab closes (#146), a crash bringing up the rescue screen, whose file holds the work and whose Try again comes back (#145), a Buddhist-Era date that counts and Convert to dates from the cell menu (#82), duplicate or invalid sheet names fixed on load, refused when typed, and exported with the right names and formulas (#54), a second tab asking first, and taking over turning the first view-only with no edit lost (#47), dates in Excel's default column width showing as dates rather than `###` (1280 and 390), a Thai sheet name downloading as a Thai file name, the sample sorted in both languages with every row still multiplying its own numbers and a risky sort asking first, a percent saved before #53 reading the same, opening blank with a sample left in the browser not coming back, New file asking first and undoing, formulas, `.xlsx` round trip, an import on top of work that asks first, Tab then Enter and the cell menu, dragging a column's width, Enter on a phone going into the next cell, a range dragged on a phone staying in its column and a tap during `=` saving nothing, tapped cells going into a formula that Done saves with its bracket closed, ⌖ on a phone picking from the grid, a file opened on a phone leaving one message at a time, twelve rows or more and a sideways swipe that works, with "saved in this browser" moved to the save status, a phone on its side folding the bars into two rows with six rows of grid and a long press opening the cell menu, keyboard only, undo, editing after undoing a new sheet, a cut across sheets, a formula bar that never writes a stale value, announcements, the AI assistant, the CSP, an API connected from the browser (nothing reaching `/api/*`), an API without CORS getting the IT checklist, no page calling itself a demo, every page in the sitemap naming its own canonical with the landing page carrying the Search Console tag, the phone tab bar, cross-sheet values on screen after a PDF export, a sort and undo (needs a build · `E2E_ONLY=` runs just the matching flows while editing) |
+| `npm run check:e2e` | Drives the real app through 59 flows: Ctrl+V into a live cell writing nothing, saying nothing about a paste, putting the live-data note under the cell, and a refresh changing nothing, with typing on a range that starts on a live cell answered too (#232), `999` and Enter on a live-data cell getting a note under the cell saying why, Enter putting the note away with the cursor and the value where they were, and Delete and a double-click saying so too (#207), opening Ask AI putting what is typed in the question box rather than in the cell it covers, and Escape closing it with focus back on that cell, at 1280 and 390 (#203), on a phone, a second tap on a formula cell opening its editor with the formula whole, what is typed going into that formula and the cell two rows down left alone (#201), and a tap on another cell with nothing typed selecting that cell alone rather than a range (#210), on a phone, typing after Enter into a filled cell replacing its value rather than going on the end (#202), on a phone, a tap after Enter in the formula bar selecting the cell rather than breaking the next cell's formula, and a formula only opened to look at left alone (#201), typing into a dropdown cell saving what was typed in full, text off the list refused and a pick from the list still saving, at 1280 and 390 (#198), a file with a picture and a chart saying what it will lose as it opens, a plain one saying nothing, and range names that could not come in in the same report (#83), a live source that answers after the picker opens never blanking the cell (#126), one tap on a phone only selecting and saying to tap again, with nothing typed lost in silence (#171), Enter or ↓ on the last row adding a row with nothing typed over and one undo taking the value and its row back with typing still landing after it (#170), on a phone an undo with an editor open on a row Enter grew closing the editor, saying what it dropped, and the next value landing (#191), a formula page's try-it button landing the page's own numbers in the sheet, and asking first over work (#149), numbers pasted through the real clipboard as Excel copies them adding up while codes stay text (#52), the AI assistant putting a column's total under it and sending a condition to the SUMIF form (#62–#64), a view-only tab editing by itself, with all the work, once the editing tab closes (#146), a crash bringing up the rescue screen, whose file holds the work and whose Try again comes back (#145), a Buddhist-Era date that counts and Convert to dates from the cell menu (#82), duplicate or invalid sheet names fixed on load, refused when typed, and exported with the right names and formulas (#54), a second tab asking first, and taking over turning the first view-only with no edit lost (#47), dates in Excel's default column width showing as dates rather than `###` (1280 and 390), a Thai sheet name downloading as a Thai file name, the sample sorted in both languages with every row still multiplying its own numbers and a risky sort asking first, a percent saved before #53 reading the same, opening blank with a sample left in the browser not coming back, New file asking first and undoing, formulas, `.xlsx` round trip, an import on top of work that asks first, Tab then Enter and the cell menu, dragging a column's width, Enter on a phone going into the next cell, a range dragged on a phone staying in its column and a tap during `=` saving nothing, tapped cells going into a formula that Done saves with its bracket closed, ⌖ on a phone picking from the grid, a file opened on a phone leaving one message at a time, twelve rows or more and a sideways swipe that works, with "saved in this browser" moved to the save status, a phone on its side folding the bars into two rows with six rows of grid and a long press opening the cell menu, keyboard only, undo, editing after undoing a new sheet, a cut across sheets, a formula bar that never writes a stale value, announcements, the AI assistant, the CSP, an API connected from the browser (nothing reaching `/api/*`), an API without CORS getting the IT checklist, no page calling itself a demo, every page in the sitemap naming its own canonical with the landing page carrying the Search Console tag, the phone tab bar, cross-sheet values on screen after a PDF export, a sort and undo (needs a build · `E2E_ONLY=` runs just the matching flows while editing) |
| `npm run check:ai` | Asks the real Claude with your own key and checks the formulas against what this engine can evaluate — not in `verify`, because it needs a key and costs money |
| `npm run verify` | Everything, before a push: lint → check:readme → check:screens → check:deps → test → check:mutants → build → check:bundle → check:a11y → check:e2e (~5 min) |
| `npm run verify:quick` | The same gates minus `check:mutants`, `check:a11y`, `check:e2e` and `check:deps` — **37 seconds**, for the loop while writing. Not a substitute for `verify` before a push |
@@ -939,6 +939,12 @@ Behind the scenes:
the note is up puts it away and leaves the cursor on the cell, as Excel's protected-cell message does,
because typing `999` and Enter in one go was how the value vanished in the blind test: the note came
with the first key and Enter took the cursor, and the note, away before anyone could read it.
+- **A paste, fill, cut or delete that reaches a live block is refused whole** (#232). Ctrl+V into a live cell
+ used to be written and said as pasted, then the next refresh wiped it without a word, past where Ctrl+Z could
+ bring it back. Now a paste, a fill (drag, Ctrl+D, Ctrl+R, Ctrl+Enter), a cut or a delete whose range touches even
+ one live cell changes nothing, as Excel refuses a paste over protected cells, and the note under the cursor's cell
+ names the cells, e.g. "A2:B3 is live data, so nothing was changed". It is not applied around them, since that
+ would shift rows against each other. Typing on a range whose first cell is live is answered the same way.
- Refreshes **never enter the undo history** (zundo is paused during the write) — one Ctrl+Z undoes the whole
placed block, and "Change" (clear the old block + place the new one) counts as a single step too.
- **Drag and drop still works** for people who prefer it, it's just no longer the primary path.
@@ -2752,7 +2758,8 @@ rows and columns are all refused *with a reason* — silently doing nothing read
broken rather than the template doing its job. Typing into the structure, a double-click or a second tap
on a phone says so under the cell too, with what to press (#207); it used to be silent, the typed value
gone with only a hover tooltip to explain it. Enter or Esc while the note is up puts it away and leaves
-the cursor where it is.
+the cursor where it is. A paste, fill, delete or cut over the structure says which cells with the same note
+(#232), in place of the browser alert it used to raise.
**But you're not trapped in it** — "Unlock the sheet" makes every cell editable like an ordinary
sheet, and Ctrl+Z brings the template back (the template state lives inside `sheets`, so it sits
@@ -4076,7 +4083,7 @@ assistant sent a range including its text header, because the context builder re
instead of computed values (both tested) · one new button pushed the language toggle 42px off the screen.
```bash
-npm run check:e2e # 58 flows in a real browser (needs a build)
+npm run check:e2e # 59 flows in a real browser (needs a build)
```
Flows are picked by one rule: **would a unit test already catch it?** If yes it does not belong there. What
@@ -4253,6 +4260,7 @@ code. Proving a gate means checking that the thing you meant to break actually b
| `store/sheetNaming.test.ts` | 6 | Sheet names through the real store (#54): QA's four cases (+ after a delete, a rename onto a name in use, a case twin, a forbidden character), a save from before the fix opening with valid names and formulas on the right tab, the export read as XML with cross-sheet `` values real (#101), and a failed export announced |
| `store/sheetRules.test.ts` | 11 | Both features at the store: a value outside the rule is not saved and is announced, rules and names follow row edits, deleting a name leaves the formula reading `#NAME?` rather than rewritten, and undo brings the name back |
| `store/liveBlockStructure.test.ts` | 8 | Live data and row/column edits (#46), through the real store and then a refresh: the block moves with them, a total under it is not written over, an edit inside it is refused with the reason, deleting its header unlinks it and keeps the values, and undo brings the linked block back |
+| `store/liveWriteRefused.test.ts` | 7 | A write that reaches a live block is refused whole and said (#232): a paste from the app's clipboard and one from another program that starts outside the block and runs into it, fill down/Ctrl+Enter/delete/cut, another way of writing a cell (the AI assistant), said once while the same note stays up with a paste outside the block still going in, and a paste over a template's structure said with the note rather than an alert, and Replace all skipping live cells |
| `store/livePicker.test.ts` | 5 | A single-value live cell never writes a blank (#126): the default is worked out again when the table arrives late (five rows → the whole table), a single value with no number cannot be inserted, the person's own pick is kept, and the store refuses a value block with no column on add and on change |
| `store/liveEmptyResult.test.ts` | 2 | A source answering with an empty list (#65): the block keeps its last header, the rows under it are cleared, a SUM reads 0 rather than the envelope, and it fills again when rows come back |
| `store/workbookCompute.test.ts` | 3 | Every path in the store computes with the workbook (#58): a pivot counts a value read from another sheet (175.3, not 105.3), a sort orders cross-sheet values by their numbers, and a guard that fails on any bare `computeSheet` outside the engine |
@@ -4504,7 +4512,7 @@ What's not done yet, and why — to show this is a known gap, not something forg
prerendering, measured at +10–15 ms of TTFB · an API a user connects from the browser enters that one
user's `connect-src`, through a cookie validated entry by entry. Still open: CSP cannot stop a top-level navigation.
- [x] **Tests that actually open the app (E2E) in CI** — done: `npm run check:e2e` drives Chromium
- through 58 flows as its own CI job — a formula recalculating on screen, an `.xlsx` round trip through
+ through 59 flows as its own CI job — a formula recalculating on screen, an `.xlsx` round trip through
the real buttons, keyboard-only navigation, undo, and whether anything is announced. Three bugs this
project previously found by hand are now inside the gate's reach, and each gate was proved by breaking
it. **The AI assistant is now covered too**, with `/api/ai/formula` stubbed: the range the panel
diff --git a/README.md b/README.md
index cc721cf..614293f 100644
--- a/README.md
+++ b/README.md
@@ -389,7 +389,7 @@ npm run dev
| `npm run check:bundle` | งบขนาด bundle + ไลบรารีคลาวด์ต้องอยู่ chunk แยก (ต้อง build ก่อน) |
| `npm run check:mutants` | ทุบเอนจินทีละจุดแล้วดูว่าเทสต์จับได้ไหม — 32/32 (ไม่ต้อง build) |
| `npm run check:a11y` | รัน axe บนทั้งสองหน้าที่ 390px และ 1280px + เช็กการเลื่อนแนวนอน (ต้อง build ก่อน) · `A11Y_WIDTH=390` รันครึ่งเดียว ซึ่งเป็นวิธีที่ CI ใช้ |
-| `npm run check:e2e` | ขับแอปจริงในเบราว์เซอร์ 58 flow: พิมพ์ `999` แล้ว Enter บนเซลล์ข้อมูลสดได้ป้ายใต้เซลล์บอกเหตุผล Enter ปิดป้ายโดยเคอร์เซอร์อยู่ที่เดิมและค่าไม่เปลี่ยน ส่วน Delete และดับเบิลคลิกก็บอกเหมือนกัน (#207), เปิดแผงถาม AI แล้วสิ่งที่พิมพ์ลงช่องคำถาม ไม่ลงเซลล์ที่ถูกบัง และ Esc ปิดแผงแล้ว focus กลับเซลล์เดิม ทั้งที่ 1280 และ 390 (#203), บนมือถือแตะเซลล์สูตรครั้งที่สองแล้วช่องแก้ไขเปิดพร้อมสูตรครบ สิ่งที่พิมพ์ลงสูตรนั้น และเซลล์สองแถวล่างไม่ถูกทับ (#201) แตะเซลล์อื่นโดยไม่พิมพ์แล้วเลือกแค่เซลล์นั้น ไม่ยืดเป็นช่วง (#210), บนมือถือพิมพ์หลัง Enter ลงเซลล์ที่มีค่าแล้วแทนที่ค่าเดิม ไม่ต่อท้าย (#202), บนมือถือหลัง Enter ในแถบสูตรแตะเซลล์แล้วเลือกเซลล์นั้น ไม่ทำให้สูตรของเซลล์ถัดไปพัง และสูตรที่แค่เปิดดูไม่ถูกชี้ใส่ (#201), พิมพ์ลงช่อง dropdown ได้ค่าที่พิมพ์ครบคำ ค่านอกรายการถูกปฏิเสธ และเลือกจากรายการยังบันทึกได้ ทั้งที่ 1280 และ 390 (#198), ไฟล์ที่มีรูปและกราฟบอกก่อนว่าจะหายตอนเปิด ไฟล์ธรรมดาไม่เห็นอะไร และชื่อช่วงที่นำเข้าไม่ได้อยู่ในรายงานเดียวกัน (#83), แหล่งข้อมูลสดที่ตอบช้ากว่าหน้าต่างเลือกข้อมูลไม่ทำให้เซลล์กลายเป็นช่องว่าง (#126), แตะครั้งเดียวบนมือถือแค่เลือกและบอกให้แตะอีกครั้ง สิ่งที่พิมพ์ไม่หายเงียบ (#171), Enter หรือ ↓ ที่แถวสุดท้ายเพิ่มแถวโดยไม่มีค่าไหนถูกทับและ undo ครั้งเดียวเอาค่ากับแถวออกแล้วพิมพ์ต่อได้ (#170), บนมือถือ undo ตอนช่องแก้ไขเปิดอยู่บนแถวที่ Enter งอกขึ้นมาแล้วช่องปิด บอกว่าทิ้งข้อความที่พิมพ์ค้าง และค่าถัดไปพิมพ์ลงได้ (#191), หน้าสูตรกดลองในตารางแล้วค่าตรงกับบนหน้า และบนงานที่มีอยู่ถามก่อน (#149), ตัวเลขที่วางจาก clipboard แบบที่ Excel ก๊อปออกมารวมได้ถูกและรหัสยังเป็นข้อความ (#52), ผู้ช่วย AI ใส่ยอดรวมใต้คอลัมน์และส่งคำถามที่มีเงื่อนไขไปฟอร์ม SUMIF (#62–#64), ปิดแท็บที่แก้อยู่แล้วแท็บที่ดูอยู่แก้ต่อได้เองพร้อมงานครบ (#146), แอปพังแล้วหน้ากู้งานขึ้น ไฟล์ที่โหลดมีงานครบ และลองอีกครั้งกลับมา (#145), วันที่ พ.ศ. นับได้จริงและคำสั่งแปลงเป็นวันที่จากเมนูคลิกขวา (#82), ชื่อชีตซ้ำหรือผิดกติกา Excel ถูกแก้ตอนเปิด ถูกปฏิเสธตอนพิมพ์ และส่งออกได้ไฟล์ที่ชื่อกับสูตรถูก (#54), แท็บที่สองถามก่อน ขอใช้แล้วแท็บเดิมดูอย่างเดียวและไม่มีงานหาย (#47), วันที่ในคอลัมน์กว้างค่าตั้งต้นของ Excel ขึ้นเป็นวันที่ไม่ใช่ `###` (1280 และ 390), ชีตชื่อไทยดาวน์โหลดเป็นไฟล์ชื่อไทย, เรียงชีตตัวอย่างสองภาษาแล้วทุกแถวยังคูณของตัวเอง และการเรียงที่สูตรจะผิดถามก่อน, เปอร์เซ็นต์ที่บันทึกไว้ก่อน #53 ยังขึ้นเหมือนเดิม, เปิดมาเป็นตารางว่างและข้อมูลตัวอย่างที่ค้างในเบราว์เซอร์ไม่กลับมา, ไฟล์ใหม่ถามก่อนและ undo ได้, พิมพ์สูตร, ส่งออก-นำเข้า `.xlsx`, เปิดไฟล์ทับงานแล้วถามก่อน, Tab แล้ว Enter กับเมนูเซลล์, ลากความกว้างคอลัมน์, Enter บนมือถือเข้าช่องถัดไป, ลากเลือกช่วงบนมือถือไม่เลยคอลัมน์และแตะระหว่างพิมพ์ `=` ไม่บันทึกทับ, แตะเซลล์ใส่ลงสูตรแล้วกดเสร็จได้สูตรที่ปิดวงเล็บ และ ⌖ บนมือถือเลือกช่วงจากตารางได้, หลังเปิดไฟล์บนมือถือข้อความขึ้นทีละอัน ตารางเหลือ 12 แถวขึ้นไปและปัดข้างได้ บันทึกในเบราว์เซอร์ย้ายไปอยู่ที่สถานะบนแถบบน, มือถือแนวนอนแถบรวมเหลือสองแถว เห็นตาราง 6 แถวขึ้นไป และกดค้างที่เซลล์เปิดเมนูเซลล์, คีย์บอร์ดล้วน, undo, แก้ต่อหลัง undo การเพิ่มชีต, ตัดข้ามชีต, แถบสูตรไม่เขียนค่าค้าง, เสียงประกาศ, ผู้ช่วย AI, CSP, ต่อ API จากเบราว์เซอร์ (ไม่มีอะไรถึง `/api/*`), API ที่ไม่ตอบ CORS ได้รายการตรวจสำหรับ IT, ไม่มีหน้าไหนเรียกตัวเองว่าเดโม, ทุกหน้าใน sitemap ประกาศ canonical ของตัวเองและหน้าแรกมีแท็กยืนยัน Search Console, แถบล่างบนมือถือ, ค่าข้ามชีตบนจอหลังส่งออก PDF/sort/undo (ต้อง build ก่อน · `E2E_ONLY=<ส่วนของชื่อ>` รันเฉพาะ flow ที่ชื่อตรง ระหว่างแก้) |
+| `npm run check:e2e` | ขับแอปจริงในเบราว์เซอร์ 59 flow: Ctrl+V ลงเซลล์ข้อมูลสดไม่ถูกเขียนและไม่บอกว่าวางแล้ว ป้ายใต้เซลล์บอกว่าเป็นข้อมูลสด และรีเฟรชแล้วค่าเดิมไม่เปลี่ยน ส่วนพิมพ์บนช่วงที่เซลล์แรกเป็นเซลล์สดก็บอก (#232), พิมพ์ `999` แล้ว Enter บนเซลล์ข้อมูลสดได้ป้ายใต้เซลล์บอกเหตุผล Enter ปิดป้ายโดยเคอร์เซอร์อยู่ที่เดิมและค่าไม่เปลี่ยน ส่วน Delete และดับเบิลคลิกก็บอกเหมือนกัน (#207), เปิดแผงถาม AI แล้วสิ่งที่พิมพ์ลงช่องคำถาม ไม่ลงเซลล์ที่ถูกบัง และ Esc ปิดแผงแล้ว focus กลับเซลล์เดิม ทั้งที่ 1280 และ 390 (#203), บนมือถือแตะเซลล์สูตรครั้งที่สองแล้วช่องแก้ไขเปิดพร้อมสูตรครบ สิ่งที่พิมพ์ลงสูตรนั้น และเซลล์สองแถวล่างไม่ถูกทับ (#201) แตะเซลล์อื่นโดยไม่พิมพ์แล้วเลือกแค่เซลล์นั้น ไม่ยืดเป็นช่วง (#210), บนมือถือพิมพ์หลัง Enter ลงเซลล์ที่มีค่าแล้วแทนที่ค่าเดิม ไม่ต่อท้าย (#202), บนมือถือหลัง Enter ในแถบสูตรแตะเซลล์แล้วเลือกเซลล์นั้น ไม่ทำให้สูตรของเซลล์ถัดไปพัง และสูตรที่แค่เปิดดูไม่ถูกชี้ใส่ (#201), พิมพ์ลงช่อง dropdown ได้ค่าที่พิมพ์ครบคำ ค่านอกรายการถูกปฏิเสธ และเลือกจากรายการยังบันทึกได้ ทั้งที่ 1280 และ 390 (#198), ไฟล์ที่มีรูปและกราฟบอกก่อนว่าจะหายตอนเปิด ไฟล์ธรรมดาไม่เห็นอะไร และชื่อช่วงที่นำเข้าไม่ได้อยู่ในรายงานเดียวกัน (#83), แหล่งข้อมูลสดที่ตอบช้ากว่าหน้าต่างเลือกข้อมูลไม่ทำให้เซลล์กลายเป็นช่องว่าง (#126), แตะครั้งเดียวบนมือถือแค่เลือกและบอกให้แตะอีกครั้ง สิ่งที่พิมพ์ไม่หายเงียบ (#171), Enter หรือ ↓ ที่แถวสุดท้ายเพิ่มแถวโดยไม่มีค่าไหนถูกทับและ undo ครั้งเดียวเอาค่ากับแถวออกแล้วพิมพ์ต่อได้ (#170), บนมือถือ undo ตอนช่องแก้ไขเปิดอยู่บนแถวที่ Enter งอกขึ้นมาแล้วช่องปิด บอกว่าทิ้งข้อความที่พิมพ์ค้าง และค่าถัดไปพิมพ์ลงได้ (#191), หน้าสูตรกดลองในตารางแล้วค่าตรงกับบนหน้า และบนงานที่มีอยู่ถามก่อน (#149), ตัวเลขที่วางจาก clipboard แบบที่ Excel ก๊อปออกมารวมได้ถูกและรหัสยังเป็นข้อความ (#52), ผู้ช่วย AI ใส่ยอดรวมใต้คอลัมน์และส่งคำถามที่มีเงื่อนไขไปฟอร์ม SUMIF (#62–#64), ปิดแท็บที่แก้อยู่แล้วแท็บที่ดูอยู่แก้ต่อได้เองพร้อมงานครบ (#146), แอปพังแล้วหน้ากู้งานขึ้น ไฟล์ที่โหลดมีงานครบ และลองอีกครั้งกลับมา (#145), วันที่ พ.ศ. นับได้จริงและคำสั่งแปลงเป็นวันที่จากเมนูคลิกขวา (#82), ชื่อชีตซ้ำหรือผิดกติกา Excel ถูกแก้ตอนเปิด ถูกปฏิเสธตอนพิมพ์ และส่งออกได้ไฟล์ที่ชื่อกับสูตรถูก (#54), แท็บที่สองถามก่อน ขอใช้แล้วแท็บเดิมดูอย่างเดียวและไม่มีงานหาย (#47), วันที่ในคอลัมน์กว้างค่าตั้งต้นของ Excel ขึ้นเป็นวันที่ไม่ใช่ `###` (1280 และ 390), ชีตชื่อไทยดาวน์โหลดเป็นไฟล์ชื่อไทย, เรียงชีตตัวอย่างสองภาษาแล้วทุกแถวยังคูณของตัวเอง และการเรียงที่สูตรจะผิดถามก่อน, เปอร์เซ็นต์ที่บันทึกไว้ก่อน #53 ยังขึ้นเหมือนเดิม, เปิดมาเป็นตารางว่างและข้อมูลตัวอย่างที่ค้างในเบราว์เซอร์ไม่กลับมา, ไฟล์ใหม่ถามก่อนและ undo ได้, พิมพ์สูตร, ส่งออก-นำเข้า `.xlsx`, เปิดไฟล์ทับงานแล้วถามก่อน, Tab แล้ว Enter กับเมนูเซลล์, ลากความกว้างคอลัมน์, Enter บนมือถือเข้าช่องถัดไป, ลากเลือกช่วงบนมือถือไม่เลยคอลัมน์และแตะระหว่างพิมพ์ `=` ไม่บันทึกทับ, แตะเซลล์ใส่ลงสูตรแล้วกดเสร็จได้สูตรที่ปิดวงเล็บ และ ⌖ บนมือถือเลือกช่วงจากตารางได้, หลังเปิดไฟล์บนมือถือข้อความขึ้นทีละอัน ตารางเหลือ 12 แถวขึ้นไปและปัดข้างได้ บันทึกในเบราว์เซอร์ย้ายไปอยู่ที่สถานะบนแถบบน, มือถือแนวนอนแถบรวมเหลือสองแถว เห็นตาราง 6 แถวขึ้นไป และกดค้างที่เซลล์เปิดเมนูเซลล์, คีย์บอร์ดล้วน, undo, แก้ต่อหลัง undo การเพิ่มชีต, ตัดข้ามชีต, แถบสูตรไม่เขียนค่าค้าง, เสียงประกาศ, ผู้ช่วย AI, CSP, ต่อ API จากเบราว์เซอร์ (ไม่มีอะไรถึง `/api/*`), API ที่ไม่ตอบ CORS ได้รายการตรวจสำหรับ IT, ไม่มีหน้าไหนเรียกตัวเองว่าเดโม, ทุกหน้าใน sitemap ประกาศ canonical ของตัวเองและหน้าแรกมีแท็กยืนยัน Search Console, แถบล่างบนมือถือ, ค่าข้ามชีตบนจอหลังส่งออก PDF/sort/undo (ต้อง build ก่อน · `E2E_ONLY=<ส่วนของชื่อ>` รันเฉพาะ flow ที่ชื่อตรง ระหว่างแก้) |
| `npm run check:ai` | ถาม Claude จริงด้วย key ของคุณ แล้วเช็กว่าสูตรที่ได้เอนจินนี้รันได้จริงไหม — ไม่อยู่ใน `verify` เพราะต้องใช้ key และมีค่าใช้จ่าย |
| `npm run verify` | รันรวดเดียวก่อน push: lint → check:readme → check:screens → check:deps → test → check:mutants → build → check:bundle → check:a11y → check:e2e (~5 นาที) |
| `npm run verify:quick` | ด่านเดียวกันแบบตัด `check:mutants`, `check:a11y`, `check:e2e`, `check:deps` ออก — **37 วินาที** สำหรับลูประหว่างเขียน ไม่ใช่ตัวแทนของ `verify` ตอน push |
@@ -853,6 +853,10 @@ e2e มี flow ที่ชี้ไปหา API ที่ไม่ตอบ C
Enter หรือ Esc ตอนป้ายขึ้นอยู่จะปิดป้ายโดยเคอร์เซอร์ยังอยู่ที่เดิม เหมือนกล่องเตือนเซลล์ที่ป้องกันไว้ของ Excel
เพราะพิมพ์ `999` แล้ว Enter รวดเดียวคือทางที่ค่าหายไปเงียบๆ ใน blind test: ป้ายมากับปุ่มแรก แล้ว Enter
พาเคอร์เซอร์ลงไปพร้อมป้ายก่อนใครทันอ่าน
+- **วาง เติม ตัด หรือลบทับบล็อกข้อมูลสดก็ถูกปฏิเสธทั้งก้อน** (#232) — เดิม Ctrl+V ลงเซลล์สดถูกเขียนลงจริง แอปบอกว่าวางแล้ว
+ แต่รีเฟรชรอบถัดไปทับหายเงียบ และ Ctrl+Z ก็เอาคืนไม่ได้ · ตอนนี้ถ้าช่วงที่จะวาง เติม (ลาก/Ctrl+D/Ctrl+R/Ctrl+Enter) ตัด หรือลบ
+ ไปถึงเซลล์สดแม้แต่ช่องเดียว ก็ไม่เปลี่ยนอะไรเลยแบบที่ Excel ปฏิเสธการวางทับเซลล์ที่ป้องกันไว้ แล้วป้ายใต้เซลล์ที่เคอร์เซอร์อยู่บอกว่าเซลล์ไหน
+ เช่น "A2:B3 เป็นข้อมูลสด จึงไม่ได้เปลี่ยนอะไร" ไม่วางเฉพาะช่องว่างเพราะแถวจะเลื่อนไม่ตรงกัน · พิมพ์บนช่วงที่เซลล์แรกเป็นเซลล์สดก็ขึ้นป้ายเหมือนกัน
- การรีเฟรช**ไม่ปนเข้าประวัติ undo** (หยุด zundo ชั่วคราวระหว่างเขียน) กด Ctrl+Z ครั้งเดียวจะย้อนการวางทั้งบล็อก
และปุ่ม "เปลี่ยน" (ล้างของเดิม + วางของใหม่) ก็นับเป็น 1 ขั้นตอนเดียวเช่นกัน
- ยัง**ลากวางได้เหมือนเดิม**สำหรับคนที่ถนัดลาก แต่ไม่ใช่ทางหลักอีกต่อไป
@@ -2411,7 +2415,8 @@ Excel ล็อกทุกเซลล์เป็นค่าเริ่ม
**กันพลาดครบทุกทาง** ไม่ใช่แค่ห้ามพิมพ์: กด Delete, วางทับ (Ctrl+V), เรียงลำดับ, เพิ่ม/ลบแถว-คอลัมน์ —
ถูกปฏิเสธหมดพร้อมบอกเหตุผล เพราะการเงียบๆ ไม่ทำอะไรมันอ่านเหมือนแอปพัง ไม่ใช่แม่แบบทำงาน
พิมพ์ลงช่องโครง ดับเบิลคลิก หรือแตะซ้ำบนมือถือ ก็มีป้ายขึ้นใต้เซลล์ว่าแก้ไม่ได้และต้องกดอะไร (#207) เดิมช่องนี้เงียบ
-ค่าที่พิมพ์หายไปโดยมีแค่ tooltip ตอนชี้เมาส์ · Enter หรือ Esc ตอนป้ายขึ้นอยู่จะปิดป้ายโดยไม่เลื่อนเคอร์เซอร์
+ค่าที่พิมพ์หายไปโดยมีแค่ tooltip ตอนชี้เมาส์ · Enter หรือ Esc ตอนป้ายขึ้นอยู่จะปิดป้ายโดยไม่เลื่อนเคอร์เซอร์ · การวาง เติม
+ลบ หรือตัดทับโครงก็บอกด้วยป้ายเดียวกันว่าเซลล์ไหน (#232) แทนกล่อง alert ของเบราว์เซอร์ที่เคยเด้งขึ้นมา
**แต่ไม่ได้ขังไว้** — ปุ่ม "ปลดล็อกทั้งชีต" ทำให้แก้ได้ทุกช่องเหมือนตารางทั่วไป และกด Ctrl+Z ย้อนกลับมาเป็น
แม่แบบได้ (สถานะแม่แบบอยู่ใน `sheets` จึงอยู่ใต้ undo/บันทึกอัตโนมัติเหมือนข้อมูลอื่น)
@@ -3540,7 +3545,7 @@ npm test # 1979 เคส ใน 140 ไฟล์ ด้วย Vitest
`sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px
```bash
-npm run check:e2e # 58 flow ในเบราว์เซอร์จริง (ต้อง build ก่อน)
+npm run check:e2e # 59 flow ในเบราว์เซอร์จริง (ต้อง build ก่อน)
```
เกณฑ์เลือก flow มีข้อเดียว: **unit test จับได้อยู่แล้วหรือเปล่า** ถ้าจับได้ ไม่ต้องอยู่ที่นี่ ที่เหลือคือรอยต่อ —
@@ -3694,6 +3699,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
| `store/sheetNaming.test.ts` | 6 | ชื่อชีตผ่าน store จริง (#54): สี่เคสของ QA (+ หลังลบแท็บ, เปลี่ยนชื่อทับ, ต่างแค่ตัวพิมพ์, ตัวอักษรต้องห้าม), งานที่บันทึกไว้ก่อนแก้เปิดมาแล้วชื่อถูกและสูตรชี้แท็บที่ถูก, ส่งออกแล้วอ่าน XML: `` ของสูตรข้ามชีตเป็นค่าจริง (#101) และส่งออกล้มแล้วแอปบอก |
| `store/sheetRules.test.ts` | 11 | สองอย่างนี้ที่ระดับ store: ค่าที่ผิดกติกาไม่ถูกบันทึกและถูกพูดออกมา, กติกาและชื่อเลื่อนตามแถว, ลบชื่อแล้วสูตรขึ้น `#NAME?` แทนที่จะถูกเขียนใหม่ และ undo คืนชื่อกลับมา |
| `store/liveBlockStructure.test.ts` | 8 | ข้อมูลสดกับการแทรก/ลบแถวและคอลัมน์ (#46) ผ่าน store จริงแล้วตามด้วยรอบรีเฟรช: บล็อกเลื่อนตาม แถวยอดรวมใต้บล็อกไม่ถูกเขียนทับ, แทรก/ลบกลางบล็อกถูกปฏิเสธพร้อมเหตุผล, ลบแถวหัว = เลิกเชื่อมแต่ค่ายังอยู่ และ undo คืนบล็อกที่ผูกไว้ |
+| `store/liveWriteRefused.test.ts` | 7 | การเขียนที่ไปถึงบล็อกข้อมูลสดถูกปฏิเสธทั้งก้อนและบอก (#232): วางจาก clipboard ของแอปและจากโปรแกรมอื่นที่เริ่มนอกบล็อกแล้วล้นเข้ามา, เติมลง/Ctrl+Enter/ลบ/ตัด, การเขียนเซลล์ทางอื่น (เช่นผู้ช่วย AI), บอกครั้งเดียวขณะป้ายเดิมยังอยู่ และการวางนอกบล็อกยังลงปกติ, วางทับโครงแม่แบบบอกด้วยป้ายแทน alert, แทนที่ทั้งหมดข้ามเซลล์สด |
| `store/livePicker.test.ts` | 5 | ข้อมูลสดแบบค่าเดียวไม่เขียนทับเซลล์เป็นช่องว่าง (#126): ตัวเลือกตั้งต้นคิดใหม่เมื่อตารางมาถึงทีหลัง (ห้าแถว → ตารางทั้งหมด), ค่าเดียวที่ยังไม่มีตัวเลขกดใส่ไม่ได้, คงสิ่งที่ผู้ใช้เลือก และ store ไม่รับบล็อกค่าเดียวที่ไม่มีคอลัมน์ทั้งตอนเพิ่มและตอนเปลี่ยน |
| `store/liveEmptyResult.test.ts` | 2 | แหล่งที่ตอบรายการว่าง (#65): บล็อกเหลือแถวหัวชุดล่าสุด แถวข้างใต้ถูกล้าง สูตรรวมได้ 0 ไม่ใช่ค่าของซอง และเติมกลับเมื่อมีแถวอีกครั้ง |
| `store/workbookCompute.test.ts` | 3 | ทุกทางที่ store คำนวณใช้ทั้ง workbook (#58): pivot นับค่าที่อ่านจากชีตอื่น (175.3 ไม่ใช่ 105.3), sort เรียงค่าข้ามชีตตามตัวเลข และ guard ที่ตกถ้ามีใครเรียก `computeSheet` เปล่า ๆ นอกเอนจิน |
@@ -3909,7 +3915,7 @@ CI: `npm run verify` รวมทุกอย่างไว้แล้ว —
inline script ไม่ใช่ไฟล์จึงแฮชไม่ได้ หน้าเลย hydrate ไม่ขึ้นเลย ราคาที่จ่ายคือ prerender
(TTFB +10–15 ms วัดแล้ว) · API ที่ผู้ใช้ต่อจากเบราว์เซอร์เข้า `connect-src` ของผู้ใช้คนนั้นคนเดียว ผ่านคุกกี้ที่ตรวจทุกตัว
ยังเหลือ: CSP หยุด top-level navigation ไม่ได้
-- [x] **เทสต์ที่เปิดแอปจริง (E2E) ใน CI** — ทำแล้ว: `npm run check:e2e` ขับ Chromium ผ่าน 58 flow
+- [x] **เทสต์ที่เปิดแอปจริง (E2E) ใน CI** — ทำแล้ว: `npm run check:e2e` ขับ Chromium ผ่าน 59 flow
เป็น job ของตัวเองใน CI — พิมพ์สูตรแล้วค่าขยับ, ส่งออก `.xlsx` แล้วนำกลับเข้ามา, คีย์บอร์ดล้วน, undo,
และเสียงประกาศ บั๊กสามตัวที่เคยเจอด้วยการนั่งกดเอง อยู่ในขอบเขตของด่านนี้แล้ว และพิสูจน์ด้วยการทำให้พังจริง
**และผู้ช่วย AI อยู่ในนั้นแล้ว** ด้วยการ stub `/api/ai/formula` ไว้ — ตรวจทั้งช่วงเซลล์ที่แพเนลส่งออกไป
diff --git a/scripts/check-e2e.mjs b/scripts/check-e2e.mjs
index 6068919..e81e1ab 100644
--- a/scripts/check-e2e.mjs
+++ b/scripts/check-e2e.mjs
@@ -1560,6 +1560,46 @@ const FLOWS = [
note((await cell(page, 1, 1).locator("input").count()) === 0 && REFUSED.test((await refused()) ?? ""), "a double-click opens no editor and says why");
},
},
+ {
+ // #232. Pasting was the other door into a live cell: written, announced as pasted, then wiped
+ // by the next refresh with nothing said and nothing Ctrl+Z could bring back. The seam is the
+ // clipboard meeting a block that rewrites itself every five seconds.
+ name: "pasting into a live block is refused whole and said, and a refresh changes nothing (#232)",
+ async run(page) {
+ const REFUSED = /^B3 (เป็นข้อมูลสด|is live data)/;
+ await cell(page, 0, 0).click();
+ await page.getByRole("button", { name: label.liveData }).first().click();
+ await page.getByRole("button", { name: label.trySales }).click();
+ const form = page.getByRole("dialog");
+ await form.getByRole("button", { name: label.test }).click();
+ await form.getByText(/ได้ข้อมูล 5 แถว|Got 5 rows/).waitFor({ timeout: 15_000 });
+ await form.getByRole("button", { name: label.saveAndAdd }).click();
+ const insert = page.getByRole("dialog").getByRole("button", { name: label.insert });
+ await insert.waitFor({ timeout: 15_000 });
+ await insert.click();
+ await page.waitForFunction(() => document.querySelector('td[data-row="1"][data-col="0"]')?.innerText.trim() === "CF-01", null, { timeout: 15_000 });
+
+ await typeInCell(page, 1, 8, "PASTED");
+ await cell(page, 1, 8).click();
+ await page.keyboard.press("Control+c");
+ const before = (await cell(page, 2, 1).innerText()).trim();
+ await cell(page, 2, 1).click();
+ await page.keyboard.press("Control+v");
+ const spoken = await page.evaluate(() => [...document.querySelectorAll('[role="status"][aria-live]')].map((e) => e.textContent).join(" "));
+ note(!/วาง|[Pp]asted/.test(spoken), `nothing is said as pasted ("${spoken.trim().slice(0, 80)}")`);
+ note(REFUSED.test((await cell(page, 2, 1).getAttribute("data-refused")) ?? ""), "B3 says under itself that it is live data");
+ note((await cell(page, 2, 1).innerText()).trim() === before, `B3 still holds the source's value ("${before}")`);
+ await page.waitForTimeout(6000); // past the source's 5-second refresh, when the paste used to vanish
+ const after = (await cell(page, 2, 1).innerText()).trim();
+ note(after !== "PASTED", `after a refresh B3 is the source's value, as it was all along ("${after}")`);
+
+ // A range whose first cell is live: typing is answered under that cell too.
+ await cell(page, 4, 1).click();
+ await cell(page, 5, 2).click({ modifiers: ["Shift"] });
+ await page.keyboard.type("9");
+ note(/^B5 (เป็นข้อมูลสด|is live data)/.test((await cell(page, 4, 1).getAttribute("data-refused")) ?? ""), "typing on B5:C6 says why under B5");
+ },
+ },
{
// #83. The app is used to open a file, fix it and send it back, and a file with pictures or
// Excel's own charts loses them on the way — which the person used to hear from whoever got
diff --git a/src/features/grid/SpreadsheetGrid.tsx b/src/features/grid/SpreadsheetGrid.tsx
index 2da4373..15483f0 100644
--- a/src/features/grid/SpreadsheetGrid.tsx
+++ b/src/features/grid/SpreadsheetGrid.tsx
@@ -282,7 +282,6 @@ export default function SpreadsheetGrid() {
}, [sheet]);
const hasContent = (col: number) => columnsWithContent.has(col);
const blockAt = (row: number, col: number) => boundCells.get(`${row},${col}`);
- const isBound = (row: number, col: number) => boundCells.has(`${row},${col}`);
const sourceNameOf = (sourceId: string) => sources.find((s) => s.id === sourceId)?.name ?? "";
const selectedBlock = blockAt(selection.anchorRow, selection.anchorCol);
@@ -570,28 +569,6 @@ export default function SpreadsheetGrid() {
return () => window.clearTimeout(timer);
}, [tapHintShown, tapHint]);
- /**
- * A cell that refused an edit, and why (#207).
- *
- * A live-data cell, or a template's structure, cannot be typed over, and it used to refuse in
- * silence: no editor opened, the keys went nowhere, Enter moved on, and what was typed was gone
- * with only a hover tooltip to explain it. Now the cell says so under itself, the way the tap
- * hint does, and says it out loud once. Every keystroke that is refused keeps it up a little longer.
- */
- const [refusal, setRefusal] = useState<{ row: number; col: number; shift: number; text: string } | null>(null);
- const refusalShown =
- refusal !== null &&
- !editing &&
- selection.startRow === selection.endRow &&
- selection.startCol === selection.endCol &&
- refusal.row === selection.anchorRow &&
- refusal.col === selection.anchorCol;
- useEffect(() => {
- if (!refusalShown) return;
- const timer = window.setTimeout(() => setRefusal(null), 6000);
- return () => window.clearTimeout(timer);
- }, [refusalShown, refusal]);
-
/**
* Put the browser's focus where the cursor is.
*
@@ -696,15 +673,50 @@ export default function SpreadsheetGrid() {
return Math.max(0, Math.round(overhang));
};
+ /**
+ * A cell that refused an edit, and why (#207).
+ *
+ * A live-data cell, or a template's structure, cannot be typed over, and it used to refuse in
+ * silence: no editor opened, the keys went nowhere, Enter moved on, and what was typed was gone
+ * with only a hover tooltip to explain it. Now the cell says so under itself, the way the tap
+ * hint does, and says it out loud once. Every keystroke that is refused keeps it up a little longer.
+ *
+ * The refusal lives in the store (#232), because a paste, a fill, a cut or a delete is refused
+ * there, and each says so with the same note under the cell the cursor is on. It shows whatever
+ * the selection, so typing on a range whose first cell is live data is answered too.
+ */
+ const refusal = useSheetStore((s) => s.refusal);
+ const dismissRefusal = useSheetStore((s) => s.dismissRefusal);
+ const refusalShown =
+ refusal !== null &&
+ refusal.sheetId === activeSheetId &&
+ !editing &&
+ refusal.row === selection.anchorRow &&
+ refusal.col === selection.anchorCol;
+ // Measured as a note arrives rather than kept with it: the store does not know the screen.
+ const [refusalShift, setRefusalShift] = useState(0);
+ useEffect(
+ () =>
+ useSheetStore.subscribe((now, before) => {
+ if (now.refusal && now.refusal !== before.refusal) {
+ setRefusalShift(labelShift(now.refusal.row, now.refusal.col, REFUSAL_ROOM));
+ }
+ }),
+ []
+ );
+ useEffect(() => {
+ if (!refusalShown) return;
+ const timer = window.setTimeout(dismissRefusal, 6000);
+ return () => window.clearTimeout(timer);
+ }, [refusalShown, refusal?.seq, dismissRefusal]);
+
/** Say under the cell why it did not take an edit (#207), out loud only the first time in a row. */
const refuseEdit = (row: number, col: number) => {
const text = boundCells.has(`${row},${col}`)
? t.data.liveCellRefused(cellRef(row, col))
: t.template.lockedCellRefused(cellRef(row, col));
- const again = refusalShown && refusal.row === row && refusal.col === col;
setTapHint(null);
- setRefusal({ row, col, shift: labelShift(row, col, REFUSAL_ROOM), text });
- if (!again) announce(text);
+ useSheetStore.getState().refuseEdit(row, col, text);
};
const startEdit = (row: number, col: number, initialValue?: string) => {
@@ -712,7 +724,7 @@ export default function SpreadsheetGrid() {
// A tab that is only looking (#47) says so rather than opening an editor it cannot save.
if (!isEditingTab()) return noteRefusedEdit();
setTapHint(null);
- setRefusal(null);
+ if (refusal) dismissRefusal();
setEditing({ row, col, value: initialValue ?? rawAt(row, col), typed: initialValue !== undefined });
};
@@ -825,7 +837,7 @@ export default function SpreadsheetGrid() {
// Typing and pressing Enter in one go was how the value vanished: the note came with the first
// key and the cursor left with Enter, taking the note with it before anyone could read it.
if (refusalShown && (e.key === "Enter" || e.key === "Escape") && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
- setRefusal(null);
+ dismissRefusal();
e.preventDefault();
return;
}
@@ -1020,8 +1032,8 @@ export default function SpreadsheetGrid() {
}
case "Delete":
case "Backspace":
- if (!isBound(row, col)) clearSelection();
- else refuseEdit(row, col);
+ // Refused in the store when the range reaches live data or a template's structure (#232).
+ clearSelection();
e.preventDefault();
break;
case "F2":
@@ -1344,7 +1356,7 @@ export default function SpreadsheetGrid() {
? ({ "--tap-hint-shift": `${tapHint.shift}px` } as React.CSSProperties)
: null),
...(refusalShown && refusal.row === r && refusal.col === c
- ? ({ "--refused-shift": `${refusal.shift}px` } as React.CSSProperties)
+ ? ({ "--refused-shift": `${refusalShift}px` } as React.CSSProperties)
: null),
};
})()}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index d9393d4..27ca2a7 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -64,6 +64,7 @@ export const en: Messages = {
liveCellTitle: (source) => `Live data from "${source}" — click for options`,
liveCellReadOnly: "This cell is live data and can't be edited — click \"Remove\" first to type over it",
liveCellRefused: (address) => `${address} is live data and can't be typed over — press "Remove" first to edit it yourself`,
+ liveCellsRefused: (cells) => `${cells} is live data, so nothing was changed — press "Remove" first to edit it yourself`,
partial: (rows) => `Only the first ${rows.toLocaleString("en-US")} rows`,
partialHint: "This source has more data — raise the row limit in the source's settings",
partialSizeHint: (mb) =>
@@ -523,6 +524,7 @@ export const en: Messages = {
hint: "The white cells are yours to fill in; the grey ones are the template's structure",
lockedCell: "This cell is part of the template's structure — click \"Unlock the sheet\" to change it",
lockedCellRefused: (address) => `${address} is part of the template's structure — press "Unlock the sheet" to change it`,
+ lockedCellsRefused: (cells) => `${cells} is part of the template's structure, so nothing was changed — press "Unlock the sheet" to change it`,
unlock: "Unlock the sheet",
confirmUnlock: "Unlock this template? Every cell becomes editable like an ordinary sheet and the dropdowns are dropped (Ctrl+Z undoes it).",
unlocked: "Unlocked — every cell is editable",
diff --git a/src/i18n/th.ts b/src/i18n/th.ts
index 7e95516..a21c54d 100644
--- a/src/i18n/th.ts
+++ b/src/i18n/th.ts
@@ -65,6 +65,7 @@ export const th: Messages = {
liveCellReadOnly: "เซลล์นี้เป็นข้อมูลสด แก้ไขเองไม่ได้ — กด \"เอาออก\" ก่อนถ้าต้องการพิมพ์ทับ",
// U+2060 (word joiner) keeps a button's name on one line when the note under the cell wraps.
liveCellRefused: (address) => `${address} เป็นข้อมูลสด พิมพ์ทับไม่ได้ — กด "เอา\u2060ออก" ก่อนถ้าจะแก้เอง`,
+ liveCellsRefused: (cells) => `${cells} เป็นข้อมูลสด จึงไม่ได้เปลี่ยนอะไร — กด "เอา\u2060ออก" ก่อนถ้าจะแก้เอง`,
partial: (rows) => `ได้มาแค่ ${rows.toLocaleString("th-TH")} แถวแรก`,
partialHint: "แหล่งข้อมูลนี้ยังมีข้อมูลมากกว่านี้ — เพิ่มขีดจำกัดแถวได้ที่การตั้งค่าแหล่งข้อมูล",
partialSizeHint: (mb) =>
@@ -523,6 +524,7 @@ export const th: Messages = {
hint: "ช่องสีขาวคือช่องที่กรอกได้ ช่องสีเทาเป็นโครงของแม่แบบ แก้ไม่ได้",
lockedCell: "ช่องนี้เป็นโครงของแม่แบบ แก้ไม่ได้ — กด \"ปลดล็อกทั้งชีต\" ถ้าต้องการแก้",
lockedCellRefused: (address) => `${address} เป็นโครงของแม่แบบ แก้ไม่ได้ — กด "ปลด\u2060ล็อก\u2060ทั้ง\u2060ชีต" ถ้าต้องการแก้`,
+ lockedCellsRefused: (cells) => `${cells} เป็นโครงของแม่แบบ จึงไม่ได้เปลี่ยนอะไร — กด "ปลด\u2060ล็อก\u2060ทั้ง\u2060ชีต" ถ้าต้องการแก้`,
unlock: "ปลดล็อกทั้งชีต",
confirmUnlock: "ปลดล็อกแม่แบบ? จะแก้ได้ทุกช่องเหมือนตารางทั่วไป และตัวเลือก dropdown จะหายไป (กด Ctrl+Z ย้อนได้)",
unlocked: "ปลดล็อกแล้ว — แก้ได้ทุกช่อง",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index 7e17358..bf53191 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -94,6 +94,8 @@ export interface Messages {
liveCellReadOnly: string;
/** Under a live cell that was typed into (#207): which cell, and how to type over it after all. */
liveCellRefused: (address: string) => string;
+ /** A paste, fill, cut or delete refused because it reached live cells (#232), which it names. */
+ liveCellsRefused: (cells: string) => string;
/** Shown wherever a paginated source's table is only part of the data. */
partial: (rows: number) => string;
partialHint: string;
@@ -354,6 +356,8 @@ export interface Messages {
lockedCell: string;
/** Under a template cell that was typed into (#207). */
lockedCellRefused: (address: string) => string;
+ /** The same for a template's structure (#232). */
+ lockedCellsRefused: (cells: string) => string;
unlock: string;
confirmUnlock: string;
unlocked: string;
diff --git a/src/store/liveWriteRefused.test.ts b/src/store/liveWriteRefused.test.ts
new file mode 100644
index 0000000..b8a6115
--- /dev/null
+++ b/src/store/liveWriteRefused.test.ts
@@ -0,0 +1,130 @@
+// Copyright 2026 Suruch Chakrapeesirisuk
+// SPDX-License-Identifier: Apache-2.0
+
+import { beforeEach, describe, expect, it } from "vitest";
+import type { TableData } from "@/lib/dataSources/types";
+import { DEFAULT_SEARCH_OPTIONS } from "@/lib/sheetSearch";
+import { useSheetStore } from "./sheetStore";
+
+/**
+ * Writes that reach a live block other than typing (#232).
+ *
+ * A paste into a live cell was written, said as pasted, and wiped by the next refresh, with Ctrl+Z
+ * unable to bring it back. Fill, Ctrl+Enter, cut and delete were the same door. Each is refused
+ * whole now, before anything changes, and says which cells refused, under the cell the cursor is on.
+ */
+const state = () => useSheetStore.getState();
+const cells = () => state().sheets[0].sheet.cells;
+const select = (startRow: number, startCol: number, endRow = startRow, endCol = startCol) =>
+ state().setSelection({ startRow, startCol, endRow, endCol, anchorRow: startRow, anchorCol: startCol });
+
+const TABLE: TableData = {
+ columns: [
+ { key: "sku", label: "sku", numeric: false },
+ { key: "qty", label: "qty", numeric: true },
+ ],
+ rows: [
+ ["CF-01", 10],
+ ["CF-02", 20],
+ ],
+ fetchedAt: new Date(0).toISOString(),
+};
+const refresh = () => state().applyLiveData("sales", TABLE);
+const REFUSED = /^(B2|B3|A2:B3|B2:B3) (เป็นข้อมูลสด|is live data)/;
+
+let before: string[][];
+beforeEach(() => {
+ state().startBlank();
+ state().clearClipboard();
+ useSheetStore.temporal.getState().clear();
+ // The block is A1:B3 (header + two rows); C2 and D2 are the person's own.
+ select(0, 0);
+ state().addLiveBlock({ sourceId: "sales", anchorRow: 0, anchorCol: 0, kind: "table" }, TABLE);
+ state().setCellRaw(1, 2, "PASTED");
+ state().setCellRaw(1, 3, "7");
+ before = cells().map((row) => [...row]);
+});
+
+const unchanged = () => expect(cells().slice(0, 3).map((r) => r.slice(0, 4))).toEqual(before.slice(0, 3).map((r) => r.slice(0, 4)));
+
+describe("a write that reaches live data is refused whole and said (#232)", () => {
+ it("a paste of our own clipboard into a live cell: nothing written, nothing said as pasted, the note under the cell", () => {
+ select(1, 2);
+ state().copySelection();
+ select(1, 1);
+ state().pasteAtSelection();
+ unchanged();
+ expect(state().announcement?.text).toMatch(REFUSED);
+ expect(state().refusal).toMatchObject({ row: 1, col: 1 });
+ refresh();
+ unchanged();
+ });
+
+ it("a paste from another program that starts outside the block and runs into it is refused, not applied around it", () => {
+ select(1, 3);
+ state().pasteAtSelection("a\tb\r\nc\td\r\n");
+ // D2:E3 does not reach the block: it goes in.
+ expect(cells()[1][3]).toBe("a");
+ select(1, 0);
+ state().pasteAtSelection("x\ty\r\nz\tw\r\n");
+ expect(cells()[1].slice(0, 2)).toEqual(before[1].slice(0, 2));
+ expect(state().announcement?.text).toMatch(/^A2:B3 /);
+ });
+
+ it("fill down, Ctrl+Enter, delete and cut over a range with live cells", () => {
+ select(1, 1, 2, 2);
+ state().fillWithinSelection("down");
+ unchanged();
+ // Fill down writes row 3 from row 2, so it is B3 that refuses.
+ expect(state().announcement?.text).toMatch(/^B3 /);
+
+ state().fillSelectionFromAnchor();
+ unchanged();
+
+ state().clearSelection();
+ unchanged();
+ expect(state().announcement?.text).toMatch(REFUSED);
+
+ state().cutSelection();
+ expect(state().clipboard).toBeNull();
+ expect(state().announcement?.text).toMatch(REFUSED);
+ });
+
+ it("anything else that writes a cell, such as the AI assistant: refused with the note", () => {
+ state().setCellRaw(1, 1, "=1+1");
+ unchanged();
+ expect(state().refusal).toMatchObject({ row: 1, col: 1 });
+ expect(state().announcement?.text).toMatch(REFUSED);
+ });
+
+ it("says it once while the same note stays up, and a write outside the block still goes in", () => {
+ select(1, 1);
+ state().pasteAtSelection("x");
+ const seq = state().announcement?.seq;
+ state().pasteAtSelection("y");
+ expect(state().announcement?.seq).toBe(seq);
+ select(4, 4);
+ state().pasteAtSelection("free");
+ expect(cells()[4][4]).toBe("free");
+ });
+
+ it("a paste over a template's structure says so the same way, instead of a browser alert", () => {
+ useSheetStore.setState((st) => ({
+ sheets: st.sheets.map((t) =>
+ t.id === st.activeSheetId ? { ...t, liveBlocks: [], sheet: { ...t.sheet, template: { inputs: { "5,0": true }, choices: {} } } } : t
+ ),
+ }));
+ select(5, 0);
+ state().pasteAtSelection("one\r\ntwo\r\n");
+ expect(cells()[5][0]).toBe("");
+ expect(state().announcement?.text).toMatch(/^A7 (เป็นโครงของแม่แบบ|is part of the template)/);
+ expect(state().refusal).toMatchObject({ row: 5, col: 0 });
+ });
+
+ it("replace all leaves live cells alone and replaces the person's own", () => {
+ state().setCellRaw(4, 0, "CF-01");
+ expect(state().replaceAll("CF-01", "X", DEFAULT_SEARCH_OPTIONS)).toBe(1);
+ expect(cells()[1][0]).toBe(before[1][0]);
+ expect(cells()[4][0]).toBe("X");
+ });
+});
diff --git a/src/store/sheetStore.ts b/src/store/sheetStore.ts
index bc514e9..0281462 100644
--- a/src/store/sheetStore.ts
+++ b/src/store/sheetStore.ts
@@ -101,7 +101,7 @@ import { useLocaleStore } from "@/store/localeStore";
import { TableData } from "@/lib/dataSources/types";
import { dateTextAt, valuesWithIsoDates, withFormulaDateFormat, withRoomForDateTime } from "@/lib/dateCells";
import { afterWrite, boundCellsOf, clearLiveBlock, LiveBlock, liveBlockCells, shiftLiveBlocks, writeLiveBlock } from "@/lib/liveBlocks";
-import { isTemplateLocked, rangeHasLockedCells } from "@/lib/sheetTemplate";
+import { isTemplateLocked } from "@/lib/sheetTemplate";
export type SidebarMode = "palette" | "ai" | "data" | "cf" | "chart" | "pivot" | "cloud" | "none";
export type { ApplyScope };
@@ -267,6 +267,15 @@ interface SheetState {
* announcer swaps between two regions on the parity of this, which guarantees a change either way.
*/
announcement: { text: string; seq: number } | null;
+ /**
+ * A cell that refused an edit, and why (#207, #232): a live-data cell or a template's structure.
+ * The grid draws `text` under the cell while the cursor is on it. `seq` moves on every refusal, so
+ * another refused keystroke keeps the note up for longer.
+ */
+ refusal: { sheetId: string; row: number; col: number; text: string; seq: number } | null;
+ /** Shows the note under a cell that refused an edit, said out loud once while it stays the same. */
+ refuseEdit: (row: number, col: number, text: string) => void;
+ dismissRefusal: () => void;
clipboard: ClipboardState | null;
/** Which source the "what do you want to insert" dialog is open for, and whether it's changing
* an existing block rather than adding one. Opened from the panel and from a block's toolbar. */
@@ -611,13 +620,58 @@ function clampSelectionToBounds(sheet: SheetModel, selection: SelectionRect): Se
return singleCellSelection(Math.min(selection.anchorRow, sheet.rows - 1), Math.min(selection.anchorCol, sheet.cols - 1));
}
-/** A template exists to stop the form being broken by accident, so operations that would write
- * over its fixed cells are refused rather than partially applied — and the user is told why,
- * since silently doing nothing reads as the app being broken. */
-function refusedByTemplate(sheet: SheetModel, startRow: number, startCol: number, endRow: number, endCol: number): boolean {
- if (!rangeHasLockedCells(sheet.template, startRow, startCol, endRow, endCol)) return false;
- alert(getMessages().template.lockedCell);
- return true;
+let refusalSeq = 0;
+
+/**
+ * The state that shows a refusal under `(row, col)` and says it, or only keeps the note up when the
+ * same note is already there: a second refused keystroke is not a second announcement.
+ */
+function refusalAt(s: SheetState, row: number, col: number, text: string): Partial {
+ const sheetId = s.activeSheetId;
+ const r = s.refusal;
+ const again = r !== null && r.sheetId === sheetId && r.row === row && r.col === col && r.text === text;
+ return {
+ refusal: { sheetId, row, col, text, seq: ++refusalSeq },
+ ...(again ? {} : say(text)),
+ };
+}
+
+/**
+ * Whether a write over a rectangle of the active sheet is refused, and the state that says so.
+ *
+ * Refused whole, never applied around the cells it may not touch, as Excel refuses a paste over
+ * protected cells: a paste that skipped a live column would shift its rows against each other.
+ * Two kinds of cell refuse (#232): a live-data cell, which the next refresh writes over, and a
+ * template's structure. The note goes under `at`, the cell the person is on, and names the cells
+ * that refused.
+ */
+function refusedWrite(
+ s: SheetState,
+ startRow: number,
+ startCol: number,
+ endRow: number,
+ endCol: number,
+ at: { row: number; col: number } = { row: startRow, col: startCol }
+): Partial | null {
+ const tab = activeTab(s);
+ const bound = boundCellsOf(tab.liveBlocks ?? []);
+ const template = tab.sheet.template;
+ if (bound.size === 0 && !template) return null;
+ let live: SelectionRect | null = null;
+ let locked: SelectionRect | null = null;
+ const grow = (box: SelectionRect | null, r: number, c: number): SelectionRect =>
+ box
+ ? { ...box, startRow: Math.min(box.startRow, r), startCol: Math.min(box.startCol, c), endRow: Math.max(box.endRow, r), endCol: Math.max(box.endCol, c) }
+ : singleCellSelection(r, c);
+ for (let r = startRow; r <= endRow; r++) {
+ for (let c = startCol; c <= endCol; c++) {
+ if (bound.has(`${r},${c}`)) live = grow(live, r, c);
+ else if (isTemplateLocked(template, r, c)) locked = grow(locked, r, c);
+ }
+ }
+ if (live) return refusalAt(s, at.row, at.col, getMessages().data.liveCellsRefused(rangeLabel(live)));
+ if (locked) return refusalAt(s, at.row, at.col, getMessages().template.lockedCellsRefused(rangeLabel(locked)));
+ return null;
}
function refusedStructuralChange(sheet: SheetModel): boolean {
@@ -793,7 +847,11 @@ function applyFill(
): void {
const s = get();
const { sheet } = activeTab(s);
- if (refusedByTemplate(sheet, target.startRow, target.startCol, target.endRow, target.endCol)) return;
+ const refused = refusedWrite(s, target.startRow, target.startCol, target.endRow, target.endCol, {
+ row: source.startRow,
+ col: source.startCol,
+ });
+ if (refused) return set(() => refused);
const writes = fillBlock((r, c) => sheet.cells[r]?.[c] ?? "", source, target);
if (writes.length === 0) return;
// Rows a filter hides are not filled (#50): Excel fills what is on screen, and a fill that
@@ -954,6 +1012,9 @@ export const useSheetStore = create()(
fileLosses: null,
fileLossesOpen: false,
announcement: null,
+ refusal: null,
+ refuseEdit: (row, col, text) => set((s) => refusalAt(s, row, col, text)),
+ dismissRefusal: () => set({ refusal: null }),
clipboard: null,
dataPicker: null,
@@ -1037,7 +1098,11 @@ export const useSheetStore = create()(
// holding an address that undo took away (#191, an editor left open on a row Enter
// grew). The grid closes that editor itself; this keeps a stale caller from throwing.
if (row < 0 || col < 0 || row >= sheet.rows || col >= sheet.cols) return {};
- if (isTemplateLocked(sheet.template, row, col)) return {};
+ // A live-data cell or a template's structure: the grid opens no editor on one, and
+ // anything else that writes a cell (the AI assistant, the formula bar) is refused the
+ // same way, with the note, rather than writing what the next refresh wipes (#232).
+ const refused = refusedWrite(s, row, col, row, col);
+ if (refused) return refused;
// Refused rather than warned about afterwards: a warning on a cell that already holds
// the wrong thing is a note about a mistake; refusing is the mistake not happening.
// Said out loud, because a keystroke that does nothing and says nothing is
@@ -1320,7 +1385,8 @@ export const useSheetStore = create()(
// push an undo step that changes nothing, and Ctrl+Z would look broken.
if (sel.startRow === sel.endRow && sel.startCol === sel.endCol) return {};
const { sheet } = activeTab(s);
- if (refusedByTemplate(sheet, sel.startRow, sel.startCol, sel.endRow, sel.endCol)) return {};
+ const refused = refusedWrite(s, sel.startRow, sel.startCol, sel.endRow, sel.endCol, { row: sel.anchorRow, col: sel.anchorCol });
+ if (refused) return refused;
const source = sheet.cells[sel.anchorRow]?.[sel.anchorCol] ?? "";
const hidden = hiddenRowsOfActive(s);
@@ -1362,7 +1428,10 @@ export const useSheetStore = create()(
const raw = tab.sheet.cells[match.row]?.[match.col] ?? "";
const next = replaceIn(raw, needle, replacement, options);
if (next === raw) return false;
- if (isTemplateLocked(tab.sheet.template, match.row, match.col)) return false;
+ // A live cell is skipped as the template's structure is: the next refresh would undo it (#232).
+ if (isTemplateLocked(tab.sheet.template, match.row, match.col) || boundCellsOf(tab.liveBlocks ?? []).has(`${match.row},${match.col}`)) {
+ return false;
+ }
set({
sheets: s.sheets.map((t) =>
t.id === match.sheetId ? { ...t, sheet: setCellRaw(t.sheet, match.row, match.col, next) } : t
@@ -1393,6 +1462,7 @@ export const useSheetStore = create()(
if (!tab) continue;
const current = edited.get(m.sheetId) ?? tab.sheet;
if (isTemplateLocked(current.template, m.row, m.col)) continue;
+ if (boundCellsOf(tab.liveBlocks ?? []).has(`${m.row},${m.col}`)) continue;
const raw = current.cells[m.row]?.[m.col] ?? "";
const next = replaceIn(raw, needle, replacement, options);
if (next === raw) continue;
@@ -1410,7 +1480,8 @@ export const useSheetStore = create()(
clearSelection: () =>
set((s) => {
const sel = activeSelectionOf(s);
- if (refusedByTemplate(activeTab(s).sheet, sel.startRow, sel.startCol, sel.endRow, sel.endCol)) return {};
+ const refused = refusedWrite(s, sel.startRow, sel.startCol, sel.endRow, sel.endCol, { row: sel.anchorRow, col: sel.anchorCol });
+ if (refused) return refused;
const hidden = hiddenRowsOfActive(s);
return {
sheets: updateActiveSheet(s, (sheet, selection) =>
@@ -1427,7 +1498,8 @@ export const useSheetStore = create()(
set((s) => {
const sel = activeSelectionOf(s);
const { sheet } = activeTab(s);
- if (refusedByTemplate(sheet, sel.startRow, sel.startCol, sel.endRow, sel.endCol)) return {};
+ const refused = refusedWrite(s, sel.startRow, sel.startCol, sel.endRow, sel.endCol, { row: sel.anchorRow, col: sel.anchorCol });
+ if (refused) return refused;
// Rows a filter hides are left as they are (#50), as delete and fill leave them.
const plan = planConversion((r, c) => sheet.cells[r]?.[c] ?? "", sel, order, calendar, hiddenRowsOfActive(s));
const said = say(getMessages().live.datesConverted(plan.changes.length, plan.unreadable.length));
@@ -1464,6 +1536,13 @@ export const useSheetStore = create()(
const s = get();
const { sheet } = activeTab(s);
const selection = activeSelectionOf(s);
+ // A cut empties its cells on paste, and a live block or a template's structure cannot be
+ // emptied (#232): refused here, before anything reaches the clipboard.
+ const refused = refusedWrite(s, selection.startRow, selection.startCol, selection.endRow, selection.endCol, {
+ row: selection.anchorRow,
+ col: selection.anchorCol,
+ });
+ if (refused) return set(refused);
const block = copyRange(sheet, selection.startRow, selection.startCol, selection.endRow, selection.endCol, hiddenRowsOfActive(s));
set({
clipboard: { ...block, cut: true, sheetId: activeTab(s).id },
@@ -1479,6 +1558,20 @@ export const useSheetStore = create()(
set((s) => {
const { sheet } = activeTab(s);
const selection = activeSelectionOf(s);
+ {
+ // Sized to what would actually be written, not just the selected cell. A paste that
+ // would land on a live-data cell used to be written, said as pasted, and then wiped by
+ // the next refresh (#232); it is refused whole, before anything changes.
+ const height = s.clipboard?.rows.length ?? parseTsv(externalText ?? "").length;
+ const width = s.clipboard?.rows[0]?.length ?? parseTsv(externalText ?? "")[0]?.length ?? 1;
+ const endRow = selection.anchorRow + Math.max(0, height - 1);
+ const endCol = selection.anchorCol + Math.max(0, width - 1);
+ const refused = refusedWrite(s, selection.anchorRow, selection.anchorCol, endRow, endCol, {
+ row: selection.anchorRow,
+ col: selection.anchorCol,
+ });
+ if (refused) return { ...refused, pasteWarning: null };
+ }
// A paste goes down in one piece, so under a filter it can land on rows nobody can see
// (#50). Delete and fill skip those; a paste cannot, so it asks first (PO's call).
if (!force) {
@@ -1488,14 +1581,6 @@ export const useSheetStore = create()(
for (let r = selection.anchorRow; r < selection.anchorRow + height; r++) if (hidden.has(r)) covered++;
if (covered > 0) return { pasteWarning: { hidden: covered, text: externalText } };
}
- if (sheet.template) {
- // Size the guard to what would actually be written, not just the selected cell.
- const height = s.clipboard?.rows.length ?? parseTsv(externalText ?? "").length;
- const width = s.clipboard?.rows[0]?.length ?? parseTsv(externalText ?? "")[0]?.length ?? 1;
- const endRow = selection.anchorRow + Math.max(0, height - 1);
- const endCol = selection.anchorCol + Math.max(0, width - 1);
- if (refusedByTemplate(sheet, selection.anchorRow, selection.anchorCol, endRow, endCol)) return {};
- }
const { clipboard } = s;
const targetRow = selection.anchorRow;
const targetCol = selection.anchorCol;
From fd0f99669a3a9a35a1b5f150936d9f6f750d3197 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 5 Oct 2026 00:42:50 +0000
Subject: [PATCH 2/3] Recount to 1991 tests in 142 files; retake picture 27 and
the social card
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013pmWZsbmugfpqtYazrzfjj
---
README.en.md | 20 ++++++++++----------
README.md | 20 ++++++++++----------
public/screenshots/27-landing-stats.png | Bin 63822 -> 63006 bytes
public/screenshots/en/27-landing-stats.png | Bin 61304 -> 60602 bytes
public/screenshots/screenshots.json | 4 ++--
public/social-preview.png | Bin 110645 -> 110147 bytes
src/app/opengraph-image.tsx | 2 +-
src/i18n/en.ts | 2 +-
src/i18n/th.ts | 2 +-
9 files changed, 25 insertions(+), 25 deletions(-)
diff --git a/README.en.md b/README.en.md
index 1119948..42dbcba 100644
--- a/README.en.md
+++ b/README.en.md
@@ -44,7 +44,7 @@ right here, with the URL, the header and the data never passing through our serv
-
+
@@ -65,7 +65,7 @@ self-hosted, straight from PostgreSQL/MySQL — one saved read-only query, and n
and full-fidelity Excel/PDF export, where a chart exported to `.xlsx` is a real, editable chart
bound to its cells, because the OOXML chart parts are written by hand (ExcelJS writes none). Plus optional
bring-your-own-backend cloud save and live co-editing over it — presence, last-writer-wins with the loser told, and
-an undo that does not erase the other person's work. Bilingual UI (Thai/English), 1984 automated tests.
+an undo that does not erase the other person's work. Bilingual UI (Thai/English), 1991 automated tests.
---
@@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark
---
-### 🧪 What 1984 passing tests could not catch
+### 🧪 What 1991 passing tests could not catch
Every test of the assistant **mocks the model** — it returns what I imagined it would. Put a real
API key behind it, ask fourteen ordinary questions, and **six answers used functions this engine
@@ -120,7 +120,7 @@ Then **the first fix made it worse.** The rule started as "give the closest form
allows", so _"join all the names into one line"_ came back as `=SUM(A2:A20)` — `0` in the cell, no
error, nothing to notice. **A visible `#NAME?` traded for an invisible wrong number.**
-**And it happened again, in a different place.** With every gate green — 1984 tests, `axe` clean on
+**And it happened again, in a different place.** With every gate green — 1991 tests, `axe` clean on
both pages at two widths — an hour of clicking through the public build the way a first-time visitor
would found three things no gate can see:
@@ -395,7 +395,7 @@ Other available commands:
| `npm run build` | Build a production bundle |
| `npm run start` | Run the production build (run `npm run build` first) |
| `npm run lint` | Check code quality with ESLint |
-| `npm test` | Run the 1984-case Vitest suite |
+| `npm test` | Run the 1991-case Vitest suite |
| `npm run check:readme` | Check the READMEs still match the code (links/images/test count/new modules/both languages) |
| `npm run check:screens` | Figures printed on a screenshot still match the source |
| `npm run check:rls` | Two real accounts against your own Supabase: does the database refuse what the policies say it should (needs env) |
@@ -3030,7 +3030,7 @@ architecture behind it.
| `@anthropic-ai/sdk` | Connects to the Claude API for the AI assistant |
| `lucide-react` | UI icons |
| `clsx` | Conditional className composition |
-| `vitest` | Unit tests for the formula engine, sort logic, JSON-to-table conversion, pagination, rate limiting, templates, file fidelity, conditional formatting and live blocks (1984 cases) |
+| `vitest` | Unit tests for the formula engine, sort logic, JSON-to-table conversion, pagination, rate limiting, templates, file fidelity, conditional formatting and live blocks (1991 cases) |
> **Note:** No off-the-shelf formula library (e.g. HyperFormula) is used — the **formula engine is hand-written**
> (tokenizer, parser, evaluator, and functions) to keep full control over its behavior. See
@@ -4076,13 +4076,13 @@ the framework bundle itself, which isn't a trade worth making here. Written down
## 🧪 Testing
```bash
-npm test # 1984 cases across 141 files, via Vitest
+npm test # 1991 cases across 142 files, via Vitest
```
Testing is focused on the **formula engine, sort logic, JSON-to-table conversion, pagination, rate-limit backoff, Excel templates and live-block placement** — pure functions with no React/DOM dependency, so
they run fast and give high confidence.
-**But not one of those 1984 cases opens the app**, and nearly every bug this project found by hand lived in
+**But not one of those 1991 cases opens the app**, and nearly every bug this project found by hand lived in
the wiring *between* pieces that all passed their tests — the toolbar's "+ row" called `addRow`, which
announced nothing, while `insertRowAtSelection` next to it announced correctly (both tested) · the AI
assistant sent a range including its text header, because the context builder read raw `sheet.cells`
@@ -4201,10 +4201,10 @@ once; disable `ArrowRight` in the grid and two assertions in the third fail. (Th
second one stayed green: the `case` I inserted landed *after* the existing `case "ArrowRight"` and was dead
code. Proving a gate means checking that the thing you meant to break actually broke.)
-> **1984 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock
+> **1991 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock
> the model, so it returns what the test author imagined. A test count says what you thought to ask,
> not whether you asked enough. Only a real API key found this: see
-> [What 1984 passing tests could not catch](#-what-1984-passing-tests-could-not-catch), repeatable
+> [What 1991 passing tests could not catch](#-what-1991-passing-tests-could-not-catch), repeatable
> with `npm run check:ai`.
| File | Cases | Tests |
diff --git a/README.md b/README.md
index ca88ba3..5b41814 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@
-
+
@@ -61,7 +61,7 @@ and saying so when data came back incomplete. A range can be given a name (in Th
what it means, and a cell can carry a rule about what may go in it, refused before it is written rather than
flagged afterwards. Conditional formatting re-colours cells from their current values — comparisons,
top/bottom ranks, colour scales and data bars — and round-trips through Excel's own rule format. Bilingual UI
-(Thai/English), 1984 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
+(Thai/English), 1991 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
---
@@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel'
---
-### 🧪 สิ่งที่เทสต์ 1984 เคสจับไม่ได้
+### 🧪 สิ่งที่เทสต์ 1991 เคสจับไม่ได้
เทสต์ของผู้ช่วย AI ใช้ **mock ทั้งหมด** — มันตอบตามที่ผมคิดว่ามันจะตอบ พอเอา API key จริงมายิงคำถามธรรมดา
14 ข้อ **6 คำตอบใช้ฟังก์ชันที่เอนจินนี้ไม่มี** (`TEXTJOIN` `FIND` `RANK.EQ` `SUMPRODUCT` `CEILING` `CHAR`)
@@ -116,7 +116,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel'
มันจึงตอบคำถาม *"ต่อชื่อทั้งหมดเป็นบรรทัดเดียว"* ด้วย `=SUM(A2:A20)` ซึ่งได้ `0` โดยไม่มีอะไรบอกว่าผิด
**เปลี่ยน error ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น**
-**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 1984 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า
+**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 1991 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า
ที่สองความกว้าง — ผมลองไล่คลิก build สาธารณะแบบคนที่เพิ่งเข้ามาครั้งแรกอยู่ชั่วโมงหนึ่ง แล้วเจอสามอย่าง
ที่ไม่มีด่านไหนมองเห็น:
@@ -381,7 +381,7 @@ npm run dev
| `npm run build` | build เป็นเวอร์ชัน production |
| `npm run start` | รันเวอร์ชันที่ build แล้ว (ต้อง `npm run build` ก่อน) |
| `npm run lint` | ตรวจสอบคุณภาพโค้ดด้วย ESLint |
-| `npm test` | รัน unit test 1984 เคสด้วย Vitest |
+| `npm test` | รัน unit test 1991 เคสด้วย Vitest |
| `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) |
| `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม |
| `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) |
@@ -2635,7 +2635,7 @@ stack ของ `font-mono` จึงต่อท้ายด้วย Plex Sans
| `@anthropic-ai/sdk` | เชื่อมต่อ Claude API สำหรับผู้ช่วย AI |
| `lucide-react` | ไอคอน UI |
| `clsx` | รวม className แบบมีเงื่อนไข |
-| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (1984 เคส) |
+| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (1991 เคส) |
> **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง**
> ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ
@@ -3537,13 +3537,13 @@ B อ่านและแก้ได้ แต่ยังยึดเป็
## 🧪 การทดสอบ
```bash
-npm test # 1984 เคส ใน 141 ไฟล์ ด้วย Vitest
+npm test # 1991 เคส ใน 142 ไฟล์ ด้วย Vitest
```
โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM
จึงเทสต์ได้เร็วและมั่นใจได้สูง
-**แต่ 1984 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
+**แต่ 1991 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection`
ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน
`sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px
@@ -3643,9 +3643,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง
`case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง)
-> **1984 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
+> **1991 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
> มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม
-> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 1984 เคสจับไม่ได้](#-สิ่งที่เทสต์-1984-เคสจับไม่ได้)
+> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 1991 เคสจับไม่ได้](#-สิ่งที่เทสต์-1991-เคสจับไม่ได้)
> และรันซ้ำเองได้ด้วย `npm run check:ai`
| ไฟล์ | เคส | ทดสอบอะไร |
diff --git a/public/screenshots/27-landing-stats.png b/public/screenshots/27-landing-stats.png
index 40839822d89e5fb28e2b0d27918dd2dc09815827..fbb8c4da53b8ff3678d2eab89f2d50b516dba584 100644
GIT binary patch
literal 63006
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%0AZ>1__%7lAb+&
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