@sheetpane/grid 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +294 -13
  2. package/custom-elements.json +292 -27
  3. package/dist/check.d.ts +75 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +151 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/errors/element.d.ts +90 -12
  8. package/dist/errors/element.d.ts.map +1 -1
  9. package/dist/errors/element.js +572 -50
  10. package/dist/errors/element.js.map +1 -1
  11. package/dist/excel/element.d.ts +168 -15
  12. package/dist/excel/element.d.ts.map +1 -1
  13. package/dist/excel/element.js +752 -90
  14. package/dist/excel/element.js.map +1 -1
  15. package/dist/excel/link.d.ts +40 -0
  16. package/dist/excel/link.d.ts.map +1 -0
  17. package/dist/excel/link.js +90 -0
  18. package/dist/excel/link.js.map +1 -0
  19. package/dist/excel/styles.d.ts.map +1 -1
  20. package/dist/excel/styles.js +37 -5
  21. package/dist/excel/styles.js.map +1 -1
  22. package/dist/frameworks/elements.d.ts +57 -17
  23. package/dist/frameworks/jsx.d.ts +15 -9
  24. package/dist/frameworks/solid.d.ts +51 -11
  25. package/dist/frameworks/svelte.d.ts +28 -16
  26. package/dist/frameworks/vue.d.ts +15 -9
  27. package/dist/index.d.ts +9 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +8 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/llms-full.txt +3290 -544
  32. package/dist/llms.txt +92 -89
  33. package/dist/pane/protection.d.ts +61 -0
  34. package/dist/pane/protection.d.ts.map +1 -0
  35. package/dist/pane/protection.js +89 -0
  36. package/dist/pane/protection.js.map +1 -0
  37. package/dist/pane/rules.d.ts +1 -1
  38. package/dist/pane/rules.d.ts.map +1 -1
  39. package/dist/pane/store.d.ts +336 -15
  40. package/dist/pane/store.d.ts.map +1 -1
  41. package/dist/pane/store.js +891 -44
  42. package/dist/pane/store.js.map +1 -1
  43. package/dist/pane/structure.d.ts +58 -0
  44. package/dist/pane/structure.d.ts.map +1 -0
  45. package/dist/pane/structure.js +238 -0
  46. package/dist/pane/structure.js.map +1 -0
  47. package/dist/pane/types.d.ts +195 -6
  48. package/dist/pane/types.d.ts.map +1 -1
  49. package/dist/pane/types.js +57 -0
  50. package/dist/pane/types.js.map +1 -1
  51. package/dist/plugins/types.d.ts +34 -6
  52. package/dist/plugins/types.d.ts.map +1 -1
  53. package/dist/plugins/types.js.map +1 -1
  54. package/dist/shared/detail.d.ts +24 -0
  55. package/dist/shared/detail.d.ts.map +1 -0
  56. package/dist/shared/detail.js +75 -0
  57. package/dist/shared/detail.js.map +1 -0
  58. package/dist/shared/dom.d.ts +3 -0
  59. package/dist/shared/dom.d.ts.map +1 -1
  60. package/dist/shared/dom.js +3 -0
  61. package/dist/shared/dom.js.map +1 -1
  62. package/dist/static.d.ts +81 -0
  63. package/dist/static.d.ts.map +1 -0
  64. package/dist/static.js +352 -0
  65. package/dist/static.js.map +1 -0
  66. package/dist/style.css +3 -3
  67. package/dist/tokens.d.ts +1 -0
  68. package/dist/tokens.d.ts.map +1 -1
  69. package/dist/tokens.js +2 -2
  70. package/dist/tokens.js.map +1 -1
  71. package/dist/vscode.css-custom-data.json +11 -1
  72. package/dist/vscode.html-custom-data.json +89 -2
  73. package/dist/web-types.json +204 -21
  74. package/package.json +13 -7
  75. package/dist/errors/index.d.ts +0 -2
  76. package/dist/errors/index.d.ts.map +0 -1
  77. package/dist/errors/index.js +0 -3
  78. package/dist/errors/index.js.map +0 -1
  79. package/dist/excel/index.d.ts +0 -2
  80. package/dist/excel/index.d.ts.map +0 -1
  81. package/dist/excel/index.js +0 -3
  82. package/dist/excel/index.js.map +0 -1
  83. package/dist/pane/index.d.ts +0 -2
  84. package/dist/pane/index.d.ts.map +0 -1
  85. package/dist/pane/index.js +0 -3
  86. package/dist/pane/index.js.map +0 -1
@@ -2,14 +2,22 @@
2
2
  // SR-LAW-7, SR-LAW-9). Framework-free: subscribe / getSnapshot, and mutators.
3
3
  // Every mutation goes through commit(): undo (W-23), derive and validate
4
4
  // (W-14), "matches suggestion" (B-022) and one notification.
5
- import { cellRef, detailOf, parseRange, parseRef, patchXlsx, rangesIntersect, spError, spMessage, toCsv, toXlsx } from '@sheetpane/export';
6
- import { formatCode, general, ImportError, read, subsetCode } from '@sheetpane/import';
5
+ import { cellRef, detailOf, inverseOp, isRowOp, parseRange, parseRef, patchXlsx, rangesIntersect, spError, spMessage, toCsv, toXlsx } from '@sheetpane/export';
6
+ import { formatCode, general, ImportError, read, restructureWorkbook, sha256, subsetCode } from '@sheetpane/import';
7
7
  import { checkPlugins, mergeDecorations } from '../plugins/host.js';
8
+ import { STYLE_PROPS } from '../plugins/types.js';
9
+ import { formulaHiddenIn, lockedIn, protectionModeOf, sheetProtectionIn, structureLockedIn, structureProtectionIn } from './protection.js';
8
10
  import { HostRules } from './rules.js';
9
- import { machineAuthored } from './types.js';
11
+ import { DEFAULT_LAYER, ERROR_META_MAX, RESULTS_LEVELS, SEVERITY_ORDER, copyError, isLayerName, isResult, layerOf, machineAuthored } from './types.js';
12
+ import { STRUCTURE_REF, shiftChanges, shiftComposing, shiftDerived, shiftErrors, shiftPluginErrors, shiftSelection, structureRefusal } from './structure.js';
10
13
  /** The undo stack's depth per pane; the oldest entry goes first (W-23, SR-GRID decision 7). */
11
14
  export const HISTORY_CAP = 500;
12
15
  const BLANK = '(blank)';
16
+ /** The CSS a run's span may carry (W-53): the `decorate` allow-list. */
17
+ const ALLOWED_PROPS = new Set(STYLE_PROPS);
18
+ /** The cells a range covers. */
19
+ const areaOf = (r) => (r.bottom - r.top + 1) * (r.right - r.left + 1);
20
+ const inBox = (r, row, col) => row >= r.top && row <= r.bottom && col >= r.left && col <= r.right;
13
21
  /**
14
22
  * The change-map key of a cell: `Sheet1!B3` (exported as `cellKey`).
15
23
  * @param sheet - The sheet name.
@@ -55,8 +63,103 @@ export function parseInput(text, original, source) {
55
63
  }
56
64
  return text;
57
65
  }
66
+ /**
67
+ * The cell or range a change-map ref is about (W-64): `B3` → `B3`, `#style:B3` → `B3`, `#merge:A1:C1` → `A1:C1`,
68
+ * and `''` for a ref about the whole sheet (`#freeze`, `#hiddenRows`, `#sheetState`).
69
+ * @param ref - A change-map ref (the part of the key after `sheet!`).
70
+ * @returns The A1 cell or range, or `''`.
71
+ */
72
+ export function editRef(ref) {
73
+ if (!ref.startsWith('#'))
74
+ return ref;
75
+ const m = /^#[A-Za-z]+:(.+)$/.exec(ref);
76
+ return m ? m[1] : '';
77
+ }
78
+ /** An edit's range at most this many cells is looked up cell by cell; a larger one scans the sheet's problems. */
79
+ const EDIT_LOOKUP_CELLS = 4096;
58
80
  /** A sheet's state as read: `state` (W-49), else the older `hidden` flag. */
59
81
  export const sheetStateOf = (s) => (s.state ?? (s.hidden ? 'hidden' : 'visible'));
82
+ /** An error's `meta` as kept (W-68): its JSON round trip, or `undefined` — logged as SP-3016 — when it is not JSON or weighs over ERROR_META_MAX. */
83
+ function cleanMeta(id, meta) {
84
+ let json;
85
+ try {
86
+ json = JSON.stringify(meta);
87
+ }
88
+ catch {
89
+ json = undefined;
90
+ } // a cycle or a BigInt
91
+ if (json === undefined) {
92
+ console.warn(spMessage('SP-3016', `${id || 'a new error'}: its meta is not JSON, so it was not kept`));
93
+ return undefined;
94
+ }
95
+ // UTF-8 weighs one to three bytes per UTF-16 unit: encode only when the length cannot tell
96
+ const bytes = json.length * 3 <= ERROR_META_MAX ? json.length : new TextEncoder().encode(json).length;
97
+ if (bytes > ERROR_META_MAX) {
98
+ console.warn(spMessage('SP-3016', `${id || 'a new error'}: its meta is ${bytes} bytes of JSON, over the ${ERROR_META_MAX}-byte cap, so it was not kept`));
99
+ return undefined;
100
+ }
101
+ return JSON.parse(json);
102
+ }
103
+ /** The error with `meta` set to what may be kept, or without it. */
104
+ function withMeta(e, meta) {
105
+ const { meta: _was, ...rest } = e;
106
+ const m = meta === undefined ? undefined : cleanMeta(e.id, meta);
107
+ return m === undefined ? rest : { ...rest, meta: m };
108
+ }
109
+ /** The error without who, when and why (W-69): only an `accepted` error carries them. */
110
+ function withoutAcceptance(e) {
111
+ if (!('acceptedBy' in e || 'acceptedAt' in e || 'reason' in e))
112
+ return e;
113
+ const { acceptedBy: _by, acceptedAt: _at, reason: _why, ...rest } = e;
114
+ return rest;
115
+ }
116
+ /** Why an error may not stand as `accepted` (W-69), or `undefined` when it may: it needs a reason, and the workbook's own errors are B-029's. */
117
+ function acceptRefusal(e) {
118
+ if (e.author === 'workbook' || e.author === 'file')
119
+ return `${e.id}: it is the workbook's own (author "${e.author}"), which is not accepted here`;
120
+ if (typeof e.reason !== 'string' || !e.reason.trim())
121
+ return `${e.id || 'an error'}: no reason was given`;
122
+ return undefined;
123
+ }
124
+ /** An error as a restore keeps it (W-69): `accepted` with who, when and why — without a reason it comes back `open`, logged as SP-3018; any other status, without them. */
125
+ function restoredStatus(e) {
126
+ if (e.status !== 'accepted')
127
+ return withoutAcceptance(e);
128
+ const why = acceptRefusal(e);
129
+ if (why) {
130
+ console.warn(spMessage('SP-3018', `${why}, so it was restored as open`));
131
+ return { ...withoutAcceptance(e), status: 'open' };
132
+ }
133
+ return { ...e, acceptedBy: typeof e.acceptedBy === 'string' ? e.acceptedBy : '', acceptedAt: typeof e.acceptedAt === 'string' ? e.acceptedAt : '' };
134
+ }
135
+ /** An error's layer as a restore keeps it (W-51): its own name, or `review` — an unusable one logged as SP-3021. */
136
+ function keptLayer(e) {
137
+ if (e.layer === undefined)
138
+ return DEFAULT_LAYER;
139
+ if (isLayerName(e.layer))
140
+ return e.layer;
141
+ console.warn(spMessage('SP-3021', `${e.id || 'an error'}: its layer ${JSON.stringify(e.layer)} is not a layer name, so it went into "${DEFAULT_LAYER}"`));
142
+ return DEFAULT_LAYER;
143
+ }
144
+ /** The layers whose errors differ between two lists of the pane's errors (W-51): an error added, removed or changed. */
145
+ function changedLayers(a, b) {
146
+ const out = new Set();
147
+ if (a === b)
148
+ return out;
149
+ const was = new Map(a.map((e) => [e.id, e]));
150
+ for (const e of b) {
151
+ const w = was.get(e.id);
152
+ if (w !== e) {
153
+ out.add(layerOf(e));
154
+ if (w)
155
+ out.add(layerOf(w));
156
+ }
157
+ was.delete(e.id);
158
+ }
159
+ for (const e of was.values())
160
+ out.add(layerOf(e));
161
+ return out;
162
+ }
60
163
  const same = (a, b) => a === b || (typeof a === 'number' && typeof b === 'number' && Object.is(a, b)) || JSON.stringify(a) === JSON.stringify(b);
61
164
  /**
62
165
  * The in-memory workbook one pane shares (SR-GRID decisions 1–8): the uploaded file, every
@@ -95,6 +198,27 @@ export class WorkbookStore {
95
198
  /** Plugin errors by `plugin|sheet|range|type` → error id. */
96
199
  pluginErrors = new Map();
97
200
  errorsViews = 0;
201
+ // results (W-50): the pane's level, the range Show ▸ marks, and the index the grid reads per cell
202
+ resultsLevel = 'summary';
203
+ focus;
204
+ resultIdx;
205
+ /** The pane's layers in the order first named (W-51); `shown` the visible ones, `null` for every layer. */
206
+ known = [];
207
+ shown = null;
208
+ layersDirty = false;
209
+ /** Plugins already told their draft's layer is no name (SP-3021, once each). */
210
+ badLayerWarned = new Set();
211
+ // the edits list (W-64): when each change-map entry was written (a commit's sequence and time) and the problems on its
212
+ // cells before the cell first changed — keyed by the entry object, so undo and redo bring both back with the entry
213
+ editMeta = new WeakMap();
214
+ effects = new WeakMap();
215
+ editSeq = 0;
216
+ /** The workbook as read, before any insert or delete (W-52); undefined before a load. */
217
+ baseBook;
218
+ /** A moved or rewritten change-map entry keeps its edit's time and baseline (W-52 × W-64). */
219
+ carryEdit = (from, to) => { const m = this.editMeta.get(from); if (m && !this.editMeta.has(to))
220
+ this.editMeta.set(to, m); };
221
+ problemIdx;
98
222
  /** The host's column rules (W-27): a built-in `validate` provider, never part of the frozen plugin set. */
99
223
  rules = new HostRules({
100
224
  bookSheets: () => this.snap.book?.sheets,
@@ -112,9 +236,9 @@ export class WorkbookStore {
112
236
  this.plugins = checkPlugins(o.plugins ?? []);
113
237
  this.snap = {
114
238
  load: { status: 'empty' }, source: undefined, book: undefined,
115
- changes: new Map(), derived: new Map(), errors: [],
239
+ changes: new Map(), derived: new Map(), errors: [], layers: [], visibleLayers: [], visibleErrors: [],
116
240
  selection: { sheet: '', ref: 'A1' }, mode: 'view', composing: null,
117
- canUndo: false, canRedo: false, version: 0,
241
+ canUndo: false, canRedo: false, protection: 'honour', version: 0,
118
242
  };
119
243
  }
120
244
  // ── subscription (useSyncExternalStore-shaped) ───────────────────────────
@@ -125,7 +249,29 @@ export class WorkbookStore {
125
249
  /** Store events, for the elements to turn into DOM events. */
126
250
  on(fn) { this.eventListeners.add(fn); return () => this.eventListeners.delete(fn); }
127
251
  set(patch, events = []) {
128
- this.snap = { ...this.snap, ...patch, canUndo: this.undoStack.length > 0, canRedo: this.redoStack.length > 0, version: this.snap.version + 1 };
252
+ const prev = this.snap;
253
+ const next = { ...prev, ...patch, canUndo: this.undoStack.length > 0, canRedo: this.redoStack.length > 0, version: prev.version + 1 };
254
+ if (next.errors !== prev.errors || this.layersDirty) { // W-51: the layers named so far, the visible ones and their errors
255
+ const seen = new Set(this.known);
256
+ for (const l of [...next.errors.map(layerOf), ...(this.shown ?? [])])
257
+ if (!seen.has(l)) {
258
+ seen.add(l);
259
+ this.known.push(l);
260
+ }
261
+ if (this.layersDirty || this.known.length !== prev.layers.length) {
262
+ next.layers = [...this.known];
263
+ next.visibleLayers = this.shown ? [...this.shown] : next.layers;
264
+ }
265
+ const vis = this.shown && new Set(this.shown);
266
+ next.visibleErrors = vis ? next.errors.filter((e) => vis.has(layerOf(e))) : next.errors;
267
+ this.layersDirty = false;
268
+ }
269
+ for (const e of events)
270
+ if (e.type === 'errors' && !e.layers) {
271
+ const c = changedLayers(prev.errors, next.errors);
272
+ e.layers = next.layers.filter((l) => c.has(l));
273
+ }
274
+ this.snap = next;
129
275
  for (const fn of [...this.listeners])
130
276
  fn();
131
277
  for (const e of events)
@@ -161,12 +307,16 @@ export class WorkbookStore {
161
307
  }
162
308
  if (seq !== this.loadSeq)
163
309
  return;
164
- const source = { name, kind: book.source, size: bytes.length, bytes, readMs: Math.round(performance.now() - t0) };
310
+ const source = { name, kind: book.source, size: bytes.length, bytes, readMs: Math.round(performance.now() - t0), ...(book.sha256 ? { sha256: book.sha256 } : {}) };
165
311
  this.undoStack = [];
166
312
  this.redoStack = [];
167
313
  this.textCache.clear();
168
314
  this.pluginErrors.clear();
315
+ this.focus = undefined;
316
+ this.known = [];
317
+ this.layersDirty = true; // W-51: a new workbook starts with the visible layers named, and no others
169
318
  const first = book.sheets[book.activeSheet] ?? book.sheets[0];
319
+ this.baseBook = book; // the workbook as read: a reverted `structure` edit replays what still stands on it (W-52 × W-64)
170
320
  this.set({ load: { status: 'ready' }, source, book, changes: new Map(), derived: new Map(), errors: [], composing: null, selection: { sheet: first.name, ref: 'A1' } }, [{ type: 'load', origin }]);
171
321
  this.rules.reset(); // column rules: the `load` phase (time-sliced; it re-runs validate when done)
172
322
  this.runValidate(book.sheets.map((s) => s.name), origin);
@@ -211,7 +361,8 @@ export class WorkbookStore {
211
361
  }
212
362
  /** What the cell bar edits: the formula when a `derive` provider (a formula engine) is installed, else the raw value. */
213
363
  input(sheet, ref) {
214
- const f = this.plugins.some((x) => x.provides.includes('derive')) ? this.formula(sheet, ref) : undefined;
364
+ // a hidden formula on a protected sheet shows its value only (W-62)
365
+ const f = this.plugins.some((x) => x.provides.includes('derive')) && !this.isFormulaHidden(sheet, ref) ? this.formula(sheet, ref) : undefined;
215
366
  if (f !== undefined)
216
367
  return f;
217
368
  const v = this.value(sheet, ref);
@@ -280,7 +431,49 @@ export class WorkbookStore {
280
431
  const book = this.snap.book, s = this.sheet(sheet), p = parseRef(ref);
281
432
  if (!book || !s || !p)
282
433
  return undefined;
283
- return { store: this, book, sheet: s, ref, row: p.row, col: p.col, value: value === undefined ? this.value(sheet, ref) : value, styleIndex: s.styleIndex[ref] };
434
+ const runs = this.runs(sheet, ref);
435
+ return { store: this, book, sheet: s, ref, row: p.row, col: p.col, value: value === undefined ? this.value(sheet, ref) : value, styleIndex: s.styleIndex[ref], ...(runs ? { runs } : {}) };
436
+ }
437
+ /**
438
+ * A rich-text cell's runs from the file (W-53, SR-IMPORT decision 12) while it still holds the file's value — no
439
+ * value or formula change, no derived value; `undefined` otherwise. An edit writes plain text, so its runs go.
440
+ */
441
+ runs(sheet, ref) {
442
+ const r = this.sheet(sheet)?.runs?.[ref];
443
+ if (!r)
444
+ return undefined;
445
+ const k = key(sheet, ref), c = this.snap.changes.get(k);
446
+ if ((c && (c.kind === 'value' || c.kind === 'formula')) || this.snap.derived.has(k))
447
+ return undefined;
448
+ return r;
449
+ }
450
+ /**
451
+ * A rich-text cell as styled pieces (W-53): the last `decorate` provider's `decorateRuns` that answers, its CSS
452
+ * allow-listed to `STYLE_PROPS`; a `font-family` throws (SP-3010). `undefined` — draw the text plain — when the
453
+ * cell has no runs, no provider answers, or the pieces' text is not the display text.
454
+ */
455
+ decorateRuns(sheet, ref) {
456
+ if (!this.runs(sheet, ref))
457
+ return undefined;
458
+ const ctx = this.cellContext(sheet, ref);
459
+ if (!ctx)
460
+ return undefined;
461
+ let out;
462
+ for (const p of this.plugins) {
463
+ if (!p.provides.includes('decorate') || !p.decorateRuns)
464
+ continue;
465
+ const pieces = p.decorateRuns(ctx);
466
+ if (!pieces)
467
+ continue;
468
+ out = pieces.map((x) => {
469
+ const style = mergeDecorations([{ id: p.id, style: x.style }]);
470
+ for (const k of Object.keys(style))
471
+ if (!ALLOWED_PROPS.has(k))
472
+ delete style[k];
473
+ return Object.keys(style).length ? { text: x.text, style } : { text: x.text };
474
+ });
475
+ }
476
+ return out && out.map((x) => x.text).join('') === this.text(sheet, ref) ? out : undefined;
284
477
  }
285
478
  /** The merged `decorate` result for a cell; `{}` with no decorate plugin. */
286
479
  decorate(sheet, ref) {
@@ -307,6 +500,7 @@ export class WorkbookStore {
307
500
  this.textCache.delete(key(sheet, baseRef(ref)));
308
501
  }
309
502
  const errors = this.withMatches(next.errors, next.changes);
503
+ this.stampEdits(before, next.changes, keys);
310
504
  if (o.record !== false) {
311
505
  const last = this.undoStack[this.undoStack.length - 1];
312
506
  if (o.coalesce && last && last.coalesce === o.coalesce) {
@@ -373,6 +567,8 @@ export class WorkbookStore {
373
567
  if (!book || !p || !this.sheet(sheet))
374
568
  return undefined;
375
569
  const a1 = cellRef(p.row, p.col);
570
+ if (this.isLocked(sheet, a1))
571
+ return undefined; // W-62: a locked cell of a protected sheet refuses every write
376
572
  const orig = this.original(sheet, a1);
377
573
  const now = typeof input === 'string' && o.parse !== false ? parseInput(input, orig.value, book.source) : input;
378
574
  const k = key(sheet, a1);
@@ -443,6 +639,168 @@ export class WorkbookStore {
443
639
  if (keys.length)
444
640
  this.commit({ changes, errors: this.snap.errors }, keys, origin, events);
445
641
  }
642
+ // ── the edits list (W-64): the change map read as a list, newest first ──
643
+ /** Stamps the entries a commit wrote with its sequence and time, and the problems on their cells before they first changed. */
644
+ stampEdits(before, changes, keys) {
645
+ const seq = ++this.editSeq, at = Date.now();
646
+ for (const k of keys) {
647
+ const entry = changes.get(k), prev = before.changes.get(k);
648
+ if (!entry || entry === prev || this.editMeta.has(entry))
649
+ continue;
650
+ this.editMeta.set(entry, { seq, at, base: prev ? this.editMeta.get(prev)?.base : this.baselineOf(before.errors, k) });
651
+ }
652
+ }
653
+ /**
654
+ * Every edit in the pane — the change map as a list, newest first (W-64): one entry per change-map key, whatever its
655
+ * kind, so a paste lists each cell and a change a plugin keeps as one entry is one. Derived cells are not edits.
656
+ * @returns The edits, newest first; the edits one step made keep the change map's order.
657
+ * @example
658
+ * ```ts
659
+ * import { createWorkbook } from '@sheetpane/grid';
660
+ * const wb = createWorkbook();
661
+ * await wb.load(bytes, 'q3.xlsx');
662
+ * wb.setCell('Holdings', 'G7', '6.5');
663
+ * wb.edits(); // [{ key: 'Holdings!G7', sheet: 'Holdings', ref: 'G7', kind: 'value', was: 6.63, now: 6.5, at: '2026-10-19T…' }]
664
+ * ```
665
+ */
666
+ edits() {
667
+ const out = [];
668
+ for (const [k, c] of this.snap.changes) {
669
+ const { sheet, ref } = splitKey(k), m = this.editMeta.get(c);
670
+ out.push({ seq: m?.seq ?? 0, e: { key: k, sheet, ref: editRef(ref), kind: c.kind, was: c.was, now: c.now, at: new Date(m?.at ?? 0).toISOString() } });
671
+ }
672
+ return out.sort((a, b) => b.seq - a.seq).map((x) => x.e); // stable: one step's edits stay in map order
673
+ }
674
+ /**
675
+ * What one edit did to the problems on its cells (W-64), compared with before its cell first changed: workbook, file
676
+ * and rule errors `cleared` (now resolved or gone) and `raised` (open now, not before), and a person's errors whose
677
+ * suggestion the cell now `matched`. An edit about a whole sheet has none.
678
+ * @param k - The edit's change-map key (`Edit.key`).
679
+ * @returns Error ids, each list in the errors' order.
680
+ */
681
+ editEffects(k) {
682
+ const c = this.snap.changes.get(k);
683
+ const none = { cleared: [], raised: [], matched: [] };
684
+ const base = c && this.editMeta.get(c)?.base;
685
+ if (!c || !base)
686
+ return none;
687
+ const errors = this.snap.errors, hit = this.effects.get(c);
688
+ if (hit?.errors === errors)
689
+ return hit.fx;
690
+ const now = this.problemsOn(errors, k) ?? new Map();
691
+ const byId = this.indexProblems(errors).byId;
692
+ const fx = { cleared: [], raised: [], matched: [] };
693
+ for (const [id, b] of base) {
694
+ if (!b.open || !b.machine)
695
+ continue;
696
+ const e = byId.get(id);
697
+ if (!e || e.status === 'resolved')
698
+ fx.cleared.push(id);
699
+ }
700
+ for (const e of now.values()) {
701
+ const b = base.get(e.id);
702
+ if (machineAuthored(e.author)) {
703
+ if (e.status === 'open' && !b?.open)
704
+ fx.raised.push(e.id);
705
+ }
706
+ else if (e.matchesExpected && !b?.matches)
707
+ fx.matched.push(e.id);
708
+ }
709
+ this.effects.set(c, { errors, fx });
710
+ return fx;
711
+ }
712
+ /**
713
+ * Reverts edits to the file as one undo step (W-64): each key's change-map entry goes, whatever its kind. A sheet's
714
+ * `structure` entry (W-52) takes every insert and delete on that sheet back with it — the workbook as read, the other
715
+ * edits moved back. Undo brings them back, each with its time.
716
+ * @param keys - Change-map keys (`Edit.key`); omitted, every edit in the pane (*Revert all*).
717
+ * @param origin - The element that asked.
718
+ * @returns How many edits were reverted.
719
+ */
720
+ revertEdits(keys, origin) {
721
+ const all = keys ?? [...this.snap.changes.keys()];
722
+ if (all.some((k) => this.snap.changes.get(k)?.kind === 'structure'))
723
+ return this.revertStructure(all, origin);
724
+ const changes = new Map(this.snap.changes);
725
+ const done = [], events = [];
726
+ for (const k of all) {
727
+ const prev = changes.get(k);
728
+ if (!prev)
729
+ continue;
730
+ changes.delete(k);
731
+ done.push(k);
732
+ const { sheet, ref } = splitKey(k);
733
+ events.push({ type: 'change', origin, sheet, ref: baseRef(ref), kind: prev.kind, was: prev.now, now: prev.was });
734
+ }
735
+ if (done.length)
736
+ this.commit({ changes, errors: this.snap.errors }, done, origin, events);
737
+ return done.length;
738
+ }
739
+ /** The problems an edit's key covers before its cell first changed, or undefined for an edit about a whole sheet. */
740
+ baselineOf(errors, k) {
741
+ const on = this.problemsOn(errors, k);
742
+ if (!on)
743
+ return undefined;
744
+ const out = new Map();
745
+ for (const e of on.values())
746
+ out.set(e.id, { open: e.status === 'open', matches: e.matchesExpected, machine: machineAuthored(e.author) });
747
+ return out;
748
+ }
749
+ /** The problems (not results) whose range meets an edit key's cells, by id; undefined when the key is about no cell. */
750
+ problemsOn(errors, k) {
751
+ const { sheet, ref } = splitKey(k), r = parseRange(editRef(ref));
752
+ if (!r)
753
+ return undefined;
754
+ const out = new Map();
755
+ const x = this.indexProblems(errors).sheets.get(sheet);
756
+ if (!x)
757
+ return out;
758
+ if (areaOf(r) <= EDIT_LOOKUP_CELLS) {
759
+ for (let row = r.top; row <= r.bottom; row++)
760
+ for (let col = r.left; col <= r.right; col++)
761
+ for (const e of x.cells.get(`${row},${col}`) ?? [])
762
+ out.set(e.id, e);
763
+ }
764
+ else {
765
+ for (const [c, list] of x.cells) {
766
+ const [row, col] = c.split(',').map(Number);
767
+ if (inBox(r, row, col))
768
+ for (const e of list)
769
+ out.set(e.id, e);
770
+ }
771
+ }
772
+ for (const y of x.ranges)
773
+ if (rangesIntersect(y.r, r))
774
+ out.set(y.e.id, y.e);
775
+ return out;
776
+ }
777
+ indexProblems(errors) {
778
+ if (this.problemIdx?.errors === errors)
779
+ return this.problemIdx;
780
+ const sheets = new Map(), byId = new Map();
781
+ for (const e of errors) {
782
+ byId.set(e.id, e);
783
+ if (isResult(e))
784
+ continue;
785
+ const r = parseRange(e.range);
786
+ if (!r)
787
+ continue;
788
+ let x = sheets.get(e.sheet);
789
+ if (!x)
790
+ sheets.set(e.sheet, (x = { cells: new Map(), ranges: [] }));
791
+ if (r.top === r.bottom && r.left === r.right) {
792
+ const c = `${r.top},${r.left}`, l = x.cells.get(c);
793
+ if (l)
794
+ l.push(e);
795
+ else
796
+ x.cells.set(c, [e]);
797
+ }
798
+ else
799
+ x.ranges.push({ r, e });
800
+ }
801
+ this.problemIdx = { errors, sheets, byId };
802
+ return this.problemIdx;
803
+ }
446
804
  // ── merged ranges: core, changed by plugins as kind `merge` (key `sheet!#merge:A1:C1`, now true | false) ──
447
805
  /** The sheet's merged ranges: the file's with `merge` changes applied. */
448
806
  merges(sheet) {
@@ -510,8 +868,8 @@ export class WorkbookStore {
510
868
  */
511
869
  setSheetState(sheet, state, origin) {
512
870
  const s = this.sheet(sheet), book = this.snap.book;
513
- if (!s || !book)
514
- return false;
871
+ if (!s || !book || this.structureLocked())
872
+ return false; // W-62: a protected structure keeps its sheets as they are
515
873
  if (state !== 'visible' && !book.sheets.some((x) => x.name !== sheet && this.hidden(x.name).state === 'visible'))
516
874
  return false;
517
875
  this.setChange(sheet, '#sheetState', 'sheetState', state, sheetStateOf(s), origin);
@@ -610,8 +968,10 @@ export class WorkbookStore {
610
968
  return m === e.matchesExpected ? e : { ...e, matchesExpected: m };
611
969
  });
612
970
  }
613
- /** The uploaded display text of a range's first cell, as `found` freezes it. */
971
+ /** The uploaded display text of a range's first cell, as `found` freezes it; `""` for a sheet- or workbook-level error (no cell, W-55). */
614
972
  foundFor(sheet, range) {
973
+ if (range === '')
974
+ return '';
615
975
  const r = parseRange(range);
616
976
  if (!r)
617
977
  return BLANK;
@@ -628,18 +988,27 @@ export class WorkbookStore {
628
988
  return false;
629
989
  return (this.original(e.sheet, cellRef(r.top, r.left)).text || BLANK) !== e.found;
630
990
  }
631
- /** Adds a person's error with a new id; `cellChanged` also writes `expected` into the cell in the same undo step. */
991
+ /** Adds a person's error with a new id; `cellChanged` also writes `expected` into the cell in the same undo step. `range: ''` is the whole sheet, and with `sheet: ''` the workbook (W-55): no cell, so `found` is `''` and nothing is written. `meta` is the host's data (W-68): kept as JSON, or dropped with SP-3016. */
632
992
  addError(d, origin) {
633
- const e = {
993
+ let layer = this.defaultLayer;
994
+ if (d.layer !== undefined) {
995
+ if (isLayerName(d.layer))
996
+ layer = d.layer;
997
+ else
998
+ console.warn(spMessage('SP-3021', `addError: ${JSON.stringify(d.layer)} is not a layer name, so the error went into "${layer}"`));
999
+ }
1000
+ const e = withMeta({
634
1001
  v: 1, id: this.newId(), sheet: d.sheet, range: d.range, type: d.type, severity: d.severity,
635
1002
  found: this.foundFor(d.sheet, d.range), expected: d.expected ?? '', message: d.message ?? '',
636
- cellChanged: !!d.cellChanged, status: 'open', matchesExpected: false, author: d.author ?? '', createdAt: new Date().toISOString(),
637
- };
1003
+ cellChanged: !!d.cellChanged, status: 'open', matchesExpected: false, author: d.author ?? '', createdAt: new Date().toISOString(), layer,
1004
+ }, d.meta);
1005
+ if (this.resultsLevel === 'off' && isResult(e))
1006
+ return e; // `results="off"`: a result is dropped on arrival (W-50)
638
1007
  let changes = this.snap.changes;
639
1008
  const keys = [];
640
1009
  const events = [{ type: 'errors', origin }];
641
1010
  const r = parseRange(d.range);
642
- if (d.cellChanged && r && e.expected !== '') {
1011
+ if (d.cellChanged && r && e.expected !== '' && !this.isLocked(d.sheet, cellRef(r.top, r.left))) { // W-62: a locked cell is not written
643
1012
  const ref = cellRef(r.top, r.left), k = key(d.sheet, ref), orig = this.original(d.sheet, ref);
644
1013
  const now = parseInput(e.expected, orig.value, this.snap.book?.source ?? 'xlsx');
645
1014
  const m = new Map(changes);
@@ -654,11 +1023,64 @@ export class WorkbookStore {
654
1023
  this.commit({ changes, errors: [...this.snap.errors, e] }, keys, origin, events, { extra: { composing: null } });
655
1024
  return this.snap.errors.find((x) => x.id === e.id);
656
1025
  }
657
- /** Changes an error's status, severity, expected value, message or type; undoable. */
1026
+ /**
1027
+ * Changes an error's status, severity, expected value, message, type or `meta` (W-68: `meta: undefined` removes it; a
1028
+ * `meta` that may not be kept is dropped with SP-3016 and the error keeps its own); undoable. `status: 'accepted'`
1029
+ * (W-69) needs a `reason` and stamps `acceptedAt`; any other status drops `acceptedBy`, `acceptedAt` and `reason`.
1030
+ * @returns `false` when nothing changed: no such error, or an acceptance refused (SP-3018).
1031
+ */
658
1032
  updateError(id, patch, origin) {
659
- if (!this.snap.errors.some((e) => e.id === id))
660
- return;
661
- this.commit({ changes: this.snap.changes, errors: this.snap.errors.map((e) => (e.id === id ? { ...e, ...patch } : e)) }, [], origin, [{ type: 'errors', origin }]);
1033
+ const cur = this.snap.errors.find((e) => e.id === id);
1034
+ if (!cur)
1035
+ return false;
1036
+ const { meta, reason, acceptedBy, layer: _layer, ...rest } = patch; // an edit never moves an error to another layer (W-51)
1037
+ let out = { ...cur, ...rest };
1038
+ if (out.status === 'accepted') {
1039
+ if (reason !== undefined)
1040
+ out.reason = typeof reason === 'string' ? reason.trim() : reason;
1041
+ if (acceptedBy !== undefined || cur.status !== 'accepted')
1042
+ out.acceptedBy = acceptedBy ?? '';
1043
+ if (patch.status === 'accepted' || cur.status !== 'accepted')
1044
+ out.acceptedAt = new Date().toISOString();
1045
+ const why = acceptRefusal(out);
1046
+ if (why) {
1047
+ console.warn(spMessage('SP-3018', why));
1048
+ return false;
1049
+ }
1050
+ out = { ...withoutAcceptance(out), acceptedBy: out.acceptedBy, acceptedAt: out.acceptedAt, reason: out.reason }; // keep the key order stable
1051
+ }
1052
+ else
1053
+ out = withoutAcceptance(out);
1054
+ if ('meta' in patch) {
1055
+ if (meta === undefined)
1056
+ out = withMeta(out, undefined);
1057
+ else {
1058
+ const m = cleanMeta(id, meta);
1059
+ if (m !== undefined)
1060
+ out = { ...out, meta: m };
1061
+ }
1062
+ }
1063
+ this.commit({ changes: this.snap.changes, errors: this.snap.errors.map((e) => (e.id === id ? out : e)) }, [], origin, [{ type: 'errors', origin }]);
1064
+ return true;
1065
+ }
1066
+ /**
1067
+ * Accepts an error as a known deviation (W-69): it stays in the review and the download, recorded with who, when and
1068
+ * why, and is no longer counted as open. One undo step; Reopen (`updateError(id, { status: 'open' })`) takes it back.
1069
+ * The workbook's own errors (author `workbook` or `file`) are not accepted here.
1070
+ * @param id - The error's id.
1071
+ * @param reason - Why the deviation is accepted — required; blank is refused with SP-3018.
1072
+ * @param o - `by`: who accepted it (the errors element passes its `author`).
1073
+ * @returns `false` when nothing changed.
1074
+ * @example
1075
+ * ```ts
1076
+ * import { createWorkbook } from '@sheetpane/grid';
1077
+ * const wb = createWorkbook();
1078
+ * const e = wb.addError({ sheet: '', range: '', type: 'other', severity: 'warning', message: 'Page 4 is 2 mm wider than the spec' });
1079
+ * wb.acceptError(e.id, 'Approved by the customer on 18 Oct', { by: 'Ravi' }); // true
1080
+ * ```
1081
+ */
1082
+ acceptError(id, reason, o = {}, origin) {
1083
+ return this.updateError(id, { status: 'accepted', reason, acceptedBy: o.by ?? '' }, origin);
662
1084
  }
663
1085
  /** Deletes an error; a cell it changed keeps the change (contract decision 6). */
664
1086
  deleteError(id, origin) {
@@ -666,22 +1088,128 @@ export class WorkbookStore {
666
1088
  return;
667
1089
  this.commit({ changes: this.snap.changes, errors: this.snap.errors.filter((e) => e.id !== id) }, [], origin, [{ type: 'errors', origin }]);
668
1090
  }
669
- /** A copy of every error in the pane. */
670
- getErrors() { return this.snap.errors.map((e) => ({ ...e })); }
671
- /** The host restores a saved review. Not a user action: the undo stack is cleared (W-23). */
672
- setErrors(list, origin) {
673
- const errors = list.map((e) => ({ ...e, v: 1, matchesExpected: false }));
674
- for (const e of errors) {
675
- const n = /^ERR-(\d+)$/.exec(e.id);
676
- if (n)
677
- this.nextError = Math.max(this.nextError, Number(n[1]) + 1);
1091
+ /**
1092
+ * A copy of the pane's errors, `meta` included (W-68): every layer's, or with `layer` that layer's only (W-51).
1093
+ * @param o - `layer`: one layer's name; an unusable one is logged as SP-3021 and gives `[]`.
1094
+ * @returns The errors, each with its `layer`.
1095
+ */
1096
+ getErrors(o = {}) {
1097
+ if (o.layer === undefined)
1098
+ return this.snap.errors.map(copyError);
1099
+ if (!isLayerName(o.layer)) {
1100
+ console.warn(spMessage('SP-3021', `getErrors: ${JSON.stringify(o.layer)} is not a layer name`));
1101
+ return [];
1102
+ }
1103
+ return this.snap.errors.filter((e) => layerOf(e) === o.layer).map(copyError);
1104
+ }
1105
+ /** Bumps the id counter past an `ERR-n` id, so a new error never reuses it. */
1106
+ reserve(id) { const n = /^ERR-(\d+)$/.exec(id); if (n)
1107
+ this.nextError = Math.max(this.nextError, Number(n[1]) + 1); }
1108
+ /**
1109
+ * The host restores a saved review, or replaces one layer (W-51). Each `meta` is kept as JSON, or dropped with SP-3016 (W-68).
1110
+ *
1111
+ * - **The whole review** (no `layer`): a list, or the `sheetpane-errors` envelope — each error in its own `layer`, one
1112
+ * without it in `review`; the envelope's `layers` come back too, an empty one included. Not a user action: the undo
1113
+ * stack is cleared (W-23).
1114
+ * - **One layer** (`layer`): the list becomes that layer's errors, each stamped with it; every other layer is left as it
1115
+ * is. An id another layer holds (or a blank one) gets a new one. The undo stack is kept, and every step in it sees the
1116
+ * new layer, so undo neither brings back the replaced errors nor takes away the new ones.
1117
+ *
1118
+ * @param review - The errors, or a saved envelope (`{ v?, errors, plugins?, layers? }`; one saved before W-51 has no `v`).
1119
+ * @param o - `layer`: the one layer to replace; an unusable name is logged as SP-3021 and nothing changes.
1120
+ * @param origin - The element that asked.
1121
+ * @example
1122
+ * ```ts
1123
+ * import { createWorkbook } from '@sheetpane/grid';
1124
+ * const wb = createWorkbook();
1125
+ * wb.setErrors(validationResults, { layer: 'validation' });
1126
+ * wb.setErrors(pdfResults, { layer: 'pdf' }); // the validation layer is untouched
1127
+ * wb.setVisibleLayers(['pdf']);
1128
+ * ```
1129
+ */
1130
+ setErrors(review, o = {}, origin) {
1131
+ const env = Array.isArray(review) ? undefined : review;
1132
+ const list = env ? (Array.isArray(env.errors) ? env.errors : []) : review;
1133
+ if (o.layer !== undefined) {
1134
+ if (!isLayerName(o.layer)) {
1135
+ console.warn(spMessage('SP-3021', `setErrors: ${JSON.stringify(o.layer)} is not a layer name, so nothing changed`));
1136
+ return;
1137
+ }
1138
+ this.replaceLayer(list, o.layer, origin);
1139
+ return;
678
1140
  }
1141
+ const errors = this.arriving(list).map((e) => restoredStatus(withMeta({ ...e, v: 1, matchesExpected: false, layer: keptLayer(e) }, e.meta)));
1142
+ for (const e of errors)
1143
+ this.reserve(e.id);
679
1144
  this.undoStack = [];
680
1145
  this.redoStack = [];
681
1146
  this.pluginErrors.clear(); // ids now name the restored list's errors; validate re-adopts its own by what they say, never by id
1147
+ this.known = (env && Array.isArray(env.layers) ? env.layers : []).filter((l, i, a) => keptLayer({ layer: l }) === l && a.indexOf(l) === i);
1148
+ this.layersDirty = true;
682
1149
  this.set({ errors: this.withMatches(errors, this.snap.changes) }, [{ type: 'errors', origin }, { type: 'history', origin, reason: 'cleared' }]);
683
1150
  this.runValidate(this.allSheets(), origin);
684
1151
  }
1152
+ /** `setErrors(list, { layer })` (W-51): one layer replaced; the other layers, and the undo stack rebased onto the new layer, kept. */
1153
+ replaceLayer(list, layer, origin) {
1154
+ const mine = (e) => layerOf(e) === layer;
1155
+ const history = [...this.undoStack, ...this.redoStack];
1156
+ const taken = new Set(this.snap.errors.filter((e) => !mine(e)).map((e) => e.id));
1157
+ for (const h of history)
1158
+ for (const e of [...h.before.errors, ...h.after.errors])
1159
+ if (!mine(e))
1160
+ taken.add(e.id);
1161
+ const incoming = this.arriving(list).map((e) => restoredStatus(withMeta({ ...e, v: 1, matchesExpected: false, layer }, e.meta)));
1162
+ for (const e of incoming)
1163
+ this.reserve(e.id);
1164
+ const fresh = incoming.map((e) => { const ok = typeof e.id === 'string' && e.id !== '' && !taken.has(e.id); const x = ok ? e : { ...e, id: this.newId() }; taken.add(x.id); return x; });
1165
+ const swap = (errors) => [...errors.filter((e) => !mine(e)), ...fresh];
1166
+ for (const h of history) {
1167
+ h.before = { changes: h.before.changes, errors: swap(h.before.errors) };
1168
+ h.after = { changes: h.after.changes, errors: swap(h.after.errors) };
1169
+ }
1170
+ const gone = new Set(this.snap.errors.filter(mine).map((e) => e.id));
1171
+ for (const [k, id] of this.pluginErrors)
1172
+ if (gone.has(id))
1173
+ this.pluginErrors.delete(k); // a provider re-adopts its own by what they say
1174
+ if (!this.known.includes(layer))
1175
+ this.known.push(layer); // a check that found nothing is still a layer
1176
+ this.layersDirty = true;
1177
+ this.set({ errors: this.withMatches(swap(this.snap.errors), this.snap.changes) }, [{ type: 'errors', origin }]);
1178
+ this.runValidate(this.allSheets(), origin);
1179
+ }
1180
+ /**
1181
+ * Shows the errors of some layers only (W-51): the sheet's marks, pins and counts, the tab counts, the error list and
1182
+ * the comments an export writes all follow. `null` or `[]` shows every layer. Naming a layer that has no errors yet
1183
+ * adds it to the pane's layers. Not undone; it is view state, like the mode.
1184
+ * @param names - The layers to show, or `null` for every layer.
1185
+ * @param origin - The element that asked.
1186
+ * @returns `false` when a name is unusable (logged as SP-3021) — nothing changes.
1187
+ */
1188
+ setVisibleLayers(names, origin) {
1189
+ const list = names && names.length ? [...new Set(names)] : null;
1190
+ const bad = list?.find((l) => !isLayerName(l));
1191
+ if (list && bad !== undefined) {
1192
+ console.warn(spMessage('SP-3021', `layers: ${JSON.stringify(bad)} is not a layer name, so the visible layers did not change`));
1193
+ return false;
1194
+ }
1195
+ if ((list ?? []).join(' ') === (this.shown ?? []).join(' '))
1196
+ return true;
1197
+ this.shown = list;
1198
+ this.layersDirty = true;
1199
+ this.set({}, [{ type: 'layers', origin }]);
1200
+ return true;
1201
+ }
1202
+ /** The layer a new error goes into when none is named (W-51): `review` while it is visible, else the first visible layer — so what a person adds stays in sight. */
1203
+ get defaultLayer() { const v = this.shown; return !v || v.includes(DEFAULT_LAYER) ? DEFAULT_LAYER : v[0]; }
1204
+ /**
1205
+ * What `sheetpane-errors` carries (W-51, `v: 2`): every error, the plugin ids and the pane's layers — what a host saves
1206
+ * and gives back to `setErrors()`.
1207
+ * @param changed - The layers whose errors changed in this event.
1208
+ * @returns A new envelope; its errors are copies.
1209
+ */
1210
+ envelope(changed = []) {
1211
+ return { v: 2, errors: this.getErrors(), plugins: this.pluginIds, layers: [...this.snap.layers], changed: [...changed] };
1212
+ }
685
1213
  // ── undo / redo (W-23): one stack per pane ──────────────────────────────
686
1214
  /** Undoes the pane's last step (changes and errors; never selection or mode). */
687
1215
  undo(origin) { this.travel(this.undoStack, this.redoStack, 'before', origin, 'undo'); }
@@ -694,6 +1222,10 @@ export class WorkbookStore {
694
1222
  h.coalesce = undefined;
695
1223
  to.push(h);
696
1224
  const state = h[side];
1225
+ if (h.structure) {
1226
+ this.travelStructure(h, side, origin, reason);
1227
+ return;
1228
+ }
697
1229
  const events = [{ type: 'history', origin, reason }];
698
1230
  const cur = this.snap.changes;
699
1231
  for (const k of new Set([...h.keys, ...[...cur.keys()].filter((x) => cur.get(x) !== state.changes.get(x))])) {
@@ -706,7 +1238,7 @@ export class WorkbookStore {
706
1238
  }
707
1239
  if (this.snap.errors !== state.errors)
708
1240
  events.push({ type: 'errors', origin });
709
- this.set({ changes: state.changes, errors: this.withMatches(state.errors, state.changes) }, events);
1241
+ this.set({ changes: state.changes, errors: this.withMatches(this.arriving(state.errors), state.changes) }, events);
710
1242
  const sheets = [...new Set(h.keys.map((k) => splitKey(k).sheet))];
711
1243
  if (sheets.length) {
712
1244
  this.rules.touch(h.keys.map(splitKey).filter((c) => !c.ref.startsWith('#')));
@@ -716,6 +1248,162 @@ export class WorkbookStore {
716
1248
  void this.runDerive({ kind: 'change', cells });
717
1249
  }
718
1250
  }
1251
+ // ── rows and columns (W-52): one change of kind `structure` per operation ──
1252
+ /**
1253
+ * Inserts rows above row `at` (0-based): everything at and below it moves down — cells, changes,
1254
+ * errors, merges, rules, notes, drawings, sizes, hidden and outline state, and every formula that
1255
+ * points there. One undo step; written into the download.
1256
+ * @param sheet - The sheet's name.
1257
+ * @param at - The first new row, 0-based.
1258
+ * @param count - How many rows (default 1).
1259
+ * @param origin - The element that asked.
1260
+ * @returns False when refused (see {@link WorkbookStore.structureRefusal}) or nothing is loaded.
1261
+ */
1262
+ insertRows(sheet, at, count = 1, origin) { return this.restructure({ sheet, op: 'insertRows', at, count }, origin); }
1263
+ /**
1264
+ * Deletes rows `at` … `at + count − 1` (0-based) and what sits on them; references to them become `#REF!`.
1265
+ * @param sheet - The sheet's name.
1266
+ * @param at - The first row to delete, 0-based.
1267
+ * @param count - How many rows (default 1).
1268
+ * @param origin - The element that asked.
1269
+ * @returns False when refused or nothing is loaded.
1270
+ */
1271
+ deleteRows(sheet, at, count = 1, origin) { return this.restructure({ sheet, op: 'deleteRows', at, count }, origin); }
1272
+ /**
1273
+ * Inserts columns left of column `at` (0-based: A is 0); everything at and right of it moves right.
1274
+ * @param sheet - The sheet's name.
1275
+ * @param at - The first new column, 0-based.
1276
+ * @param count - How many columns (default 1).
1277
+ * @param origin - The element that asked.
1278
+ * @returns False when refused or nothing is loaded.
1279
+ */
1280
+ insertCols(sheet, at, count = 1, origin) { return this.restructure({ sheet, op: 'insertCols', at, count }, origin); }
1281
+ /**
1282
+ * Deletes columns `at` … `at + count − 1` (0-based) and what sits on them.
1283
+ * @param sheet - The sheet's name.
1284
+ * @param at - The first column to delete, 0-based.
1285
+ * @param count - How many columns (default 1).
1286
+ * @param origin - The element that asked.
1287
+ * @returns False when refused or nothing is loaded.
1288
+ */
1289
+ deleteCols(sheet, at, count = 1, origin) { return this.restructure({ sheet, op: 'deleteCols', at, count }, origin); }
1290
+ /**
1291
+ * Why an insert or delete would be refused — the file's protection first (SP-3026, W-62: the workbook's structure
1292
+ * is protected, or the sheet is protected without the matching permission), then a merge cut (SP-3022), a pivot
1293
+ * moved (SP-3023), cells pushed off the sheet (SP-3024), a table cut (SP-3025) — or `undefined` when it may go ahead.
1294
+ * @param o - The operation.
1295
+ * @returns The SP code and a sentence for the person, or `undefined`.
1296
+ */
1297
+ structureRefusal(o) {
1298
+ const book = this.snap.book;
1299
+ return book ? structureProtectionIn(this.snap, o) ?? structureRefusal(book, this.merges(o.sheet), o) : undefined;
1300
+ }
1301
+ /** Every insert and delete made since the file was read, in order per sheet — what the download replays first. */
1302
+ structureOps() {
1303
+ return [...this.snap.changes].filter(([, c]) => c.kind === 'structure').flatMap(([, c]) => c.now);
1304
+ }
1305
+ /** The one structural path: check, move every table, one undo step, one `structure` change event. */
1306
+ restructure(o, origin) {
1307
+ const book = this.snap.book;
1308
+ if (!book || !this.sheet(o.sheet) || !Number.isInteger(o.at) || o.at < 0 || !Number.isInteger(o.count) || o.count < 1)
1309
+ return false;
1310
+ const no = this.structureRefusal(o);
1311
+ if (no) {
1312
+ console.warn(spMessage(no.code, `sheetpane: ${no.message}`));
1313
+ return false;
1314
+ }
1315
+ const before = { book, derived: this.snap.derived, pluginErrors: new Map(this.pluginErrors) };
1316
+ const moved = shiftErrors(this.snap.errors, o);
1317
+ const after = { book: restructureWorkbook(book, o), derived: shiftDerived(this.snap.derived, o), pluginErrors: shiftPluginErrors(this.pluginErrors, this.snap.errors, moved.errors, moved.gone) };
1318
+ const changes = shiftChanges(this.snap.changes, o, this.carryEdit);
1319
+ const k = key(o.sheet, STRUCTURE_REF);
1320
+ this.editMeta.set(changes.get(k), { seq: ++this.editSeq, at: Date.now(), base: undefined }); // the sheet's one Edits row, newest (W-64)
1321
+ this.undoStack.push({ before: { changes: this.snap.changes, errors: this.snap.errors }, after: { changes, errors: moved.errors }, keys: [k], coalesce: undefined, structure: { ops: [o], before, after } });
1322
+ if (this.undoStack.length > HISTORY_CAP)
1323
+ this.undoStack.shift();
1324
+ this.redoStack = [];
1325
+ this.applyStructure([o], after, changes, moved.errors, origin, [{ type: 'errors', origin }]);
1326
+ return true;
1327
+ }
1328
+ /**
1329
+ * Reverts edits that include a sheet's `structure` entry (W-52 × W-64) as one undo step: the other edits named go,
1330
+ * then each named sheet's inserts and deletes are undone, newest first — the changes, errors, derived values and
1331
+ * selection moved back — and the workbook is the one as read with every operation still standing replayed on it, so
1332
+ * deleted rows come back as the file had them. An edit made inside a deleted row was dropped with it and stays gone.
1333
+ */
1334
+ revertStructure(all, origin) {
1335
+ const book0 = this.snap.book;
1336
+ if (!book0 || !this.baseBook)
1337
+ return 0;
1338
+ let changes = new Map(this.snap.changes);
1339
+ const struct = [], done = [], events = [];
1340
+ for (const k of all) {
1341
+ const prev = changes.get(k);
1342
+ if (!prev)
1343
+ continue;
1344
+ if (prev.kind === 'structure') {
1345
+ struct.push(k);
1346
+ continue;
1347
+ }
1348
+ changes.delete(k);
1349
+ done.push(k);
1350
+ const { sheet, ref } = splitKey(k);
1351
+ events.push({ type: 'change', origin, sheet, ref: baseRef(ref), kind: prev.kind, was: prev.now, now: prev.was });
1352
+ }
1353
+ const opsOf = (c) => (Array.isArray(c?.now) ? c.now : []);
1354
+ const ops = struct.flatMap((k) => [...opsOf(this.snap.changes.get(k))].reverse().map(inverseOp));
1355
+ let errors = this.snap.errors, derived = this.snap.derived, plugin = new Map(this.pluginErrors);
1356
+ for (const o of ops) {
1357
+ const moved = shiftErrors(errors, o);
1358
+ plugin = shiftPluginErrors(plugin, errors, moved.errors, moved.gone);
1359
+ errors = moved.errors;
1360
+ changes = shiftChanges(changes, o, this.carryEdit);
1361
+ derived = shiftDerived(derived, o);
1362
+ }
1363
+ for (const k of struct)
1364
+ changes.delete(k); // the undoing operations were appended to the entry: it goes whole
1365
+ let book = this.baseBook;
1366
+ for (const [, c] of changes)
1367
+ if (c.kind === 'structure')
1368
+ for (const o of opsOf(c))
1369
+ book = restructureWorkbook(book, o);
1370
+ const before = { book: book0, derived: this.snap.derived, pluginErrors: new Map(this.pluginErrors) };
1371
+ const after = { book, derived, pluginErrors: plugin };
1372
+ this.undoStack.push({ before: { changes: this.snap.changes, errors: this.snap.errors }, after: { changes, errors }, keys: [...done, ...struct], coalesce: undefined, structure: { ops, before, after } });
1373
+ if (this.undoStack.length > HISTORY_CAP)
1374
+ this.undoStack.shift();
1375
+ this.redoStack = [];
1376
+ this.applyStructure(ops, after, changes, errors, origin, [{ type: 'errors', origin }], events);
1377
+ return done.length + struct.length;
1378
+ }
1379
+ travelStructure(h, side, origin, reason) {
1380
+ const s = h.structure, ops = side === 'before' ? [...s.ops].reverse().map(inverseOp) : s.ops;
1381
+ this.applyStructure(ops, s[side], h[side].changes, h[side].errors, origin, [{ type: 'history', origin, reason }, { type: 'errors', origin }]);
1382
+ }
1383
+ /**
1384
+ * Puts a structural state in place: the moved book first (formatting reads it), then one notification — a `structure`
1385
+ * change event per operation, after `events[0]`, then `extra` — then validate and derive.
1386
+ */
1387
+ applyStructure(ops, st, changes, errors, origin, events, extra = []) {
1388
+ this.textCache.clear();
1389
+ this.pluginErrors.clear();
1390
+ this.focus = undefined; // W-50: Show ▸'s range named cells that have moved
1391
+ for (const [k, v] of st.pluginErrors)
1392
+ this.pluginErrors.set(k, v);
1393
+ this.snap = { ...this.snap, book: st.book, changes, derived: st.derived };
1394
+ const moves = ops.map((o) => {
1395
+ const last = o.at + o.count - 1;
1396
+ const ref = isRowOp(o) ? `${o.at + 1}:${last + 1}` : `${cellRef(0, o.at).replace(/\d+$/, '')}:${cellRef(0, last).replace(/\d+$/, '')}`;
1397
+ return { type: 'change', origin, sheet: o.sheet, ref, kind: 'structure', was: null, now: null, op: o.op, at: o.at, count: o.count };
1398
+ });
1399
+ const selection = ops.reduce(shiftSelection, this.snap.selection), composing = ops.reduce(shiftComposing, this.snap.composing);
1400
+ this.set({ book: st.book, changes, derived: st.derived, errors: this.withMatches(errors, changes), selection, composing }, [events[0], ...moves, ...extra, ...events.slice(1)]);
1401
+ this.rules.reset();
1402
+ this.runValidate(this.allSheets(), origin);
1403
+ const o = ops[ops.length - 1];
1404
+ if (o)
1405
+ void this.runDerive({ kind: 'restructure', sheet: o.sheet, op: o.op, at: o.at, count: o.count }); // the whole derived set comes back
1406
+ }
719
1407
  // ── plugin hooks: derive, validate ───────────────────────────────────────
720
1408
  async runDerive(input) {
721
1409
  const p = this.plugins.find((x) => x.provides.includes('derive'));
@@ -732,7 +1420,7 @@ export class WorkbookStore {
732
1420
  }
733
1421
  if (load !== this.loadSeq)
734
1422
  return; // a newer workbook replaced this one
735
- const derived = new Map(input.kind === 'load' ? [] : this.snap.derived);
1423
+ const derived = new Map(input.kind === 'load' || input.kind === 'restructure' ? [] : this.snap.derived); // a load or a restructure returns the whole set
736
1424
  for (const d of out) {
737
1425
  const k = key(d.sheet, d.ref);
738
1426
  if (d.clear)
@@ -754,10 +1442,11 @@ export class WorkbookStore {
754
1442
  return;
755
1443
  let errors = [...this.snap.errors];
756
1444
  let changed = false;
1445
+ const gone = new Set();
757
1446
  const at = new Map(errors.map((e, i) => [e.id, i]));
758
1447
  // restored errors a provider may adopt (setErrors): by what they say, built once
759
1448
  let adoptable;
760
- const contentKey = (e) => `${e.sheet}|${e.range}|${e.type}|${e.author}`;
1449
+ const contentKey = (e) => `${e.sheet}|${e.range}|${e.type}|${e.author}|${layerOf(e)}`;
761
1450
  const adopt = (d) => {
762
1451
  if (!adoptable) {
763
1452
  adoptable = new Map();
@@ -784,11 +1473,22 @@ export class WorkbookStore {
784
1473
  continue;
785
1474
  }
786
1475
  const seen = new Set();
787
- for (const d of drafts) {
788
- const k = `${p.id}|${d.sheet}|${d.range}|${d.type}${d.key ? `|${d.key}` : ''}`;
1476
+ for (const raw of drafts) {
1477
+ if (this.resultsLevel === 'off' && isResult(raw))
1478
+ continue; // `results="off"` (W-50)
1479
+ // W-51: a draft lands in `review` unless its plugin names a layer
1480
+ let layer = DEFAULT_LAYER;
1481
+ if (raw.layer !== undefined && isLayerName(raw.layer))
1482
+ layer = raw.layer;
1483
+ else if (raw.layer !== undefined && !this.badLayerWarned.has(p.id)) {
1484
+ this.badLayerWarned.add(p.id);
1485
+ console.warn(spMessage('SP-3021', `plugin "${p.id}": ${JSON.stringify(raw.layer)} is not a layer name, so its errors went into "${DEFAULT_LAYER}"`));
1486
+ }
1487
+ const d = { ...raw, layer };
1488
+ const k = `${p.id}|${d.sheet}|${d.range}|${d.type}${d.key ? `|${d.key}` : ''}${layer === DEFAULT_LAYER ? '' : `|@${layer}`}`;
789
1489
  seen.add(k);
790
1490
  const id = this.pluginErrors.get(k);
791
- const mine = (e) => !!e && e.sheet === d.sheet && e.range === d.range && e.type === d.type && e.author === d.author;
1491
+ const mine = (e) => !!e && e.sheet === d.sheet && e.range === d.range && e.type === d.type && e.author === d.author && layerOf(e) === layer;
792
1492
  let i = id !== undefined ? at.get(id) ?? -1 : -1;
793
1493
  if (i >= 0 && !mine(errors[i]))
794
1494
  i = -1;
@@ -798,7 +1498,7 @@ export class WorkbookStore {
798
1498
  this.pluginErrors.set(k, errors[i].id);
799
1499
  }
800
1500
  if (i < 0) {
801
- const e = { v: 1, id: this.newId(), sheet: d.sheet, range: d.range, type: d.type, severity: d.severity, found: d.found, expected: d.expected, message: d.message, cellChanged: !!d.cellChanged, status: d.status ?? 'open', matchesExpected: false, author: d.author, createdAt: new Date().toISOString() }; // `key` stays out of the error
1501
+ const e = { v: 1, id: this.newId(), sheet: d.sheet, range: d.range, type: d.type, severity: d.severity, found: d.found, expected: d.expected, message: d.message, cellChanged: !!d.cellChanged, status: d.status ?? 'open', matchesExpected: false, author: d.author, createdAt: new Date().toISOString(), layer }; // `key` stays out of the error
802
1502
  if (d.code)
803
1503
  e.code = d.code;
804
1504
  this.pluginErrors.set(k, e.id);
@@ -806,9 +1506,9 @@ export class WorkbookStore {
806
1506
  errors.push(e);
807
1507
  changed = true;
808
1508
  }
809
- else if (errors[i].author !== 'file' && (errors[i].status === 'resolved' || errors[i].found !== d.found || errors[i].message !== d.message || errors[i].severity !== d.severity || errors[i].code !== d.code)) {
810
- // a machine-authored error follows the cell: reopened, and its found / message refreshed
811
- const { code: _was, ...rest } = errors[i];
1509
+ else if (errors[i].author !== 'file' && (errors[i].status === 'resolved' || errors[i].found !== d.found || errors[i].expected !== d.expected || errors[i].message !== d.message || errors[i].severity !== d.severity || errors[i].code !== d.code)) {
1510
+ // a machine-authored error follows the cell: reopened, and its found / expected / message refreshed (W-60: a dependent list's choices change with its parent)
1511
+ const { code: _was, ...rest } = withoutAcceptance(errors[i]); // an acceptance was for what it found then (W-69)
812
1512
  errors[i] = { ...rest, status: d.status ?? 'open', message: d.message, expected: d.expected, found: d.found, severity: d.severity, ...(d.code ? { code: d.code } : {}) };
813
1513
  changed = true;
814
1514
  }
@@ -818,16 +1518,117 @@ export class WorkbookStore {
818
1518
  if (!k.startsWith(`${p.id}|${sheet}|`) || seen.has(k))
819
1519
  continue;
820
1520
  const i = at.get(id) ?? -1;
821
- if (i >= 0 && (errors[i].author === 'workbook' || errors[i].author === 'host') && errors[i].status === 'open') {
822
- errors[i] = { ...errors[i], status: 'resolved' };
1521
+ // a result the provider no longer reports is no longer true: it goes, it never resolves (W-50)
1522
+ if (i >= 0 && isResult(errors[i])) {
1523
+ gone.add(id);
1524
+ this.pluginErrors.delete(k);
1525
+ changed = true;
1526
+ }
1527
+ else if (i >= 0 && (errors[i].author === 'workbook' || errors[i].author === 'host') && (errors[i].status === 'open' || errors[i].status === 'accepted')) {
1528
+ errors[i] = { ...withoutAcceptance(errors[i]), status: 'resolved' };
823
1529
  changed = true;
824
1530
  }
825
1531
  }
826
1532
  }
827
1533
  }
1534
+ if (gone.size)
1535
+ errors = errors.filter((e) => !gone.has(e.id));
828
1536
  if (changed)
829
1537
  this.set({ errors: this.withMatches(errors, this.snap.changes) }, [{ type: 'errors', origin }]);
830
1538
  }
1539
+ // ── results (W-50): `pass` and `skipped` entries — counted apart, marked only when asked ──
1540
+ /** How much of the results the pane shows: `off`, `summary` (the default) or `marks`. */
1541
+ get results() { return this.resultsLevel; }
1542
+ /**
1543
+ * Sets the pane's results level — a view setting, never a change or an undo step. `off` drops
1544
+ * the results the pane holds and every one that arrives after.
1545
+ * @param level - `off`, `summary` or `marks`; anything else is ignored.
1546
+ */
1547
+ setResults(level) {
1548
+ if (!RESULTS_LEVELS.includes(level) || level === this.resultsLevel)
1549
+ return;
1550
+ this.resultsLevel = level;
1551
+ this.focus = undefined;
1552
+ if (level === 'off' && this.snap.errors.some(isResult))
1553
+ this.set({ errors: this.snap.errors.filter((e) => !isResult(e)) }, [{ type: 'errors', origin: undefined }]);
1554
+ else
1555
+ this.set({});
1556
+ }
1557
+ /** The range whose results are marked on the sheet below `marks` (Show ▸ in the errors panel), or undefined. */
1558
+ get resultFocus() { return this.focus; }
1559
+ /**
1560
+ * Marks one range's results on the sheet even at `summary` (Show ▸); `undefined` clears it. A view setting.
1561
+ * @param sheet - The sheet's name.
1562
+ * @param range - An A1 range, or undefined to clear.
1563
+ */
1564
+ focusResults(sheet, range) {
1565
+ const next = range && parseRange(range) ? { sheet, range } : undefined;
1566
+ if (next?.sheet === this.focus?.sheet && next?.range === this.focus?.range)
1567
+ return;
1568
+ this.focus = next;
1569
+ this.set({});
1570
+ }
1571
+ /**
1572
+ * The result a cell shows — `pass` or `skipped` — or undefined. A problem always wins: a cell any
1573
+ * problem covers has no result. Indexed once per errors list, so the grid asks per cell.
1574
+ * @param sheet - The sheet's name.
1575
+ * @param row - The 0-based row.
1576
+ * @param col - The 0-based column.
1577
+ * @returns The result entry, or undefined.
1578
+ */
1579
+ resultAt(sheet, row, col) {
1580
+ const x = this.indexResults().sheets.get(sheet);
1581
+ if (!x)
1582
+ return undefined;
1583
+ const k = `${row},${col}`;
1584
+ if (x.problemCells.has(k) || x.problemRanges.some((r) => inBox(r, row, col)))
1585
+ return undefined;
1586
+ return x.cells.get(k) ?? x.ranges.find((y) => inBox(y.r, row, col))?.e;
1587
+ }
1588
+ /** Problems (entries) and results (cells) in the pane — what the summary line and the status line say. */
1589
+ resultCounts() { return this.indexResults().counts; }
1590
+ indexResults() {
1591
+ const errors = this.snap.visibleErrors; // W-51: what the visible layers hold
1592
+ if (this.resultIdx?.errors === errors)
1593
+ return this.resultIdx;
1594
+ const sheets = new Map(), counts = { problems: 0, open: 0, pass: 0, skipped: 0 };
1595
+ for (const e of errors) {
1596
+ const r = parseRange(e.range);
1597
+ const result = isResult(e);
1598
+ if (!result) {
1599
+ counts.problems++;
1600
+ if (e.status === 'open')
1601
+ counts.open++;
1602
+ }
1603
+ if (!r)
1604
+ continue;
1605
+ if (result)
1606
+ counts[e.severity] += areaOf(r);
1607
+ let x = sheets.get(e.sheet);
1608
+ if (!x)
1609
+ sheets.set(e.sheet, (x = { cells: new Map(), ranges: [], problemCells: new Set(), problemRanges: [] }));
1610
+ const one = r.top === r.bottom && r.left === r.right, k = `${r.top},${r.left}`;
1611
+ if (!result) {
1612
+ if (one)
1613
+ x.problemCells.add(k);
1614
+ else
1615
+ x.problemRanges.push(r);
1616
+ }
1617
+ else if (!one)
1618
+ x.ranges.push({ r, e });
1619
+ else {
1620
+ const was = x.cells.get(k);
1621
+ if (!was || SEVERITY_ORDER[e.severity] > SEVERITY_ORDER[was.severity])
1622
+ x.cells.set(k, e);
1623
+ }
1624
+ }
1625
+ this.resultIdx = { errors, sheets, counts };
1626
+ return this.resultIdx;
1627
+ }
1628
+ /** A list as it arrives in the pane: at `off`, without its results. */
1629
+ arriving(list) {
1630
+ return this.resultsLevel === 'off' ? list.filter((e) => !isResult(e)) : list;
1631
+ }
831
1632
  // ── column rules (W-27) ──────────────────────────────────────────────────
832
1633
  /** The host's column rules for this pane. Setting them checks every row again; host errors no longer produced go. */
833
1634
  setColumnRules(c) { this.rules.configure(c); }
@@ -837,6 +1638,44 @@ export class WorkbookStore {
837
1638
  get ruleProgress() { return this.rules.progress; }
838
1639
  /** Whether a rules file (W-33) is in force for this pane. */
839
1640
  get hasRulesDocument() { return this.rules.hasDocument; }
1641
+ // ── protection (W-62, SR-GRID decision 21e): what the file locks, kept unless the host rules otherwise ──
1642
+ /** `honour` (the default) keeps the file's sheet and workbook protection; `ignore` is the host's decision. */
1643
+ get protection() { return this.snap.protection; }
1644
+ /**
1645
+ * Keeps (`honour`) or sets aside (`ignore`) the file's protection for this pane — a view setting, never a change or
1646
+ * an undo step. The `protection` attribute of `<sheetpane-excel>` sets it.
1647
+ * @param mode - `honour` or `ignore`; anything else is `honour`.
1648
+ */
1649
+ setProtection(mode) { const m = protectionModeOf(mode); if (m !== this.snap.protection)
1650
+ this.set({ protection: m }); }
1651
+ /**
1652
+ * A sheet's protection while it is honoured: the file's switches (`true` = refused while protected) and whether a
1653
+ * password is stored — never the password.
1654
+ * @param sheet - The sheet's name.
1655
+ * @returns The switches, or undefined when the sheet is not protected or protection is ignored.
1656
+ */
1657
+ sheetProtection(sheet) { return sheetProtectionIn(this.snap, sheet); }
1658
+ /**
1659
+ * Whether a cell refuses edits: a protected sheet, and the cell's format locked (Excel's default). `setCell`,
1660
+ * `setCells` and an error's "Also change the cell" leave it as it is.
1661
+ * @param sheet - The sheet's name.
1662
+ * @param ref - The cell, `A1`.
1663
+ * @returns True when the cell is locked.
1664
+ */
1665
+ isLocked(sheet, ref) { return lockedIn(this.snap, sheet, ref); }
1666
+ /**
1667
+ * Whether a cell's formula is hidden: a protected sheet, and the cell's format `hidden`. It shows its value only.
1668
+ * @param sheet - The sheet's name.
1669
+ * @param ref - The cell, `A1`.
1670
+ * @returns True when the formula is not shown.
1671
+ */
1672
+ isFormulaHidden(sheet, ref) { return formulaHiddenIn(this.snap, sheet, ref); }
1673
+ /**
1674
+ * Whether the workbook's structure is protected (`lockStructure`, honoured): hiding or unhiding a sheet is
1675
+ * refused, and inserting or deleting rows and columns. The one predicate a structural control asks.
1676
+ * @returns True while structural edits are refused.
1677
+ */
1678
+ structureLocked() { return structureLockedIn(this.snap); }
840
1679
  // ── flagging (W-33): `off` leaves the errors panel as the one place errors are read ──
841
1680
  flaggingOn = true;
842
1681
  /** Whether Flag issue is on for this pane (SR-GRID decision 22e). */
@@ -912,8 +1751,15 @@ export class WorkbookStore {
912
1751
  }
913
1752
  return Array.from(rows, (r) => Array.from(r ?? [], (v) => v ?? null));
914
1753
  }
915
- /** Every change applied; the upload never modified (SR-LAW-6). */
1754
+ /**
1755
+ * Every change applied; the upload never modified (SR-LAW-6). `sha256` is the hash of the
1756
+ * blob's bytes, taken once per export (W-65) — `undefined` where Web Crypto is missing (SP-1009).
1757
+ */
916
1758
  async exportFile(o = {}) {
1759
+ const r = await this.buildExport(o);
1760
+ return { ...r, sha256: await sha256(r.blob) };
1761
+ }
1762
+ async buildExport(o) {
917
1763
  const src = this.snap.source, book = this.snap.book;
918
1764
  if (!src || !book)
919
1765
  throw spError('SP-3001', 'sheetpane: nothing is loaded to export');
@@ -948,7 +1794,8 @@ export class WorkbookStore {
948
1794
  const outline = has('#outlineRows') || has('#outlineCols') ? { outline: { ...(has('#outlineRows') ? { rows: o.rows } : {}), ...(has('#outlineCols') ? { cols: o.cols } : {}) } } : {};
949
1795
  return { sheet, ...(has('#hiddenRows') ? { rows: h.rows } : {}), ...(has('#hiddenCols') ? { cols: h.cols } : {}), ...(has('#sheetState') ? { state: h.state } : {}), ...outline };
950
1796
  });
951
- const bytes = await patchXlsx(src.bytes, this.exportChanges(), { exportPatch: hooks, ...(freeze.length ? { freeze } : {}), ...(merges.length ? { merges } : {}), ...(hidden.length ? { hidden } : {}) });
1797
+ const structure = this.structureOps(); // replayed on the upload first; everything below is in the sheet as it stands now
1798
+ const bytes = await patchXlsx(src.bytes, this.exportChanges(), { exportPatch: hooks, ...(structure.length ? { structure } : {}), ...(freeze.length ? { freeze } : {}), ...(merges.length ? { merges } : {}), ...(hidden.length ? { hidden } : {}) });
952
1799
  return { blob: new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), kind: 'xlsx', name: src.name, guarded: [], converted: false };
953
1800
  }
954
1801
  // ── stale formula marks (W-01 DoD 15) ───────────────────────────────────