@sheetpane/grid 0.3.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 (74) 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 +3027 -239
  32. package/dist/llms.txt +92 -86
  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 +12 -4
@@ -1,16 +1,19 @@
1
1
  // <sheetpane-excel> — the sheet: the view and edit components as two modes of one
2
2
  // element (W-01). Plain DOM in an open shadow root (grid verdict decisions 2, 6);
3
3
  // every byte of state lives in the pane's store (L7); the contract is the DOM's (L4).
4
- import { cellRef, codeOf, colName, detailOf, download, parseRange, parseRef, spError, spMessage } from '@sheetpane/export';
4
+ import { cellRef, codeOf, colName, detailOf, download, parseRange, parseRef, shiftIndex, shiftRangePos, spError, spMessage } from '@sheetpane/export';
5
5
  import { controlsFor } from '../plugins/host.js';
6
6
  import { acquirePane, attach, detach, isPrimary, releasePane } from '../pane/registry.js';
7
+ import { LOCKED_MESSAGE, STRUCTURE_LOCKED_MESSAGE, protectionModeOf } from '../pane/protection.js';
7
8
  import { createWorkbook, parseInput, splitKey } from '../pane/store.js';
8
- import { BUILTIN_TYPE_NAMES, DEFAULT_ERROR_TYPES, SEVERITY, machineAuthored } from '../pane/types.js';
9
- import { ElementBase, ICON, esc, fire } from '../shared/dom.js';
9
+ import { BUILTIN_TYPE_NAMES, DEFAULT_ERROR_TYPES, SEVERITY, SEVERITY_ORDER, errorScope, isResult, machineAuthored, resultsLevelOf } from '../pane/types.js';
10
+ import { DETAIL_CSS, DetailRegion, acceptedHtml, inDetail } from '../shared/detail.js';
11
+ import { ElementBase, ICON, esc, fire, layersAttr } from '../shared/dom.js';
10
12
  import { pluginsFromIds } from '../plugins/host.js';
11
13
  import { COL_MAX, COL_MIN, COL_VIRTUAL_AFTER, DEFAULT_COL_PX, HEAD_H, ROWHEAD_W, ROW_H, ROW_MAX, ROW_MIN, colAt, colWidth, hiddenSpans, isHiddenAt, mergeAt, rowAt, rowHeight, rowTop, sheetGeometry, visibleWindow } from './geometry.js';
12
14
  import { cellCount, clipOf, dataEdge, growForMerges, rangeText, refLabel, spanOf, splitByPanes, totalsOf, usedRange } from './selection.js';
13
15
  import { EXCEL_CSS } from './styles.js';
16
+ import { parseTarget, readLink, splitSheetRef, writeLink } from './link.js';
14
17
  const ZOOMS = [0.75, 1, 1.25, 1.5];
15
18
  const MAX_BYTES = 50 * 2 ** 20;
16
19
  /** A size kept inside the limits (W-47 DoD 1): columns 16–1,200 px, rows 12–600 px, at 100 %. */
@@ -37,8 +40,9 @@ export async function bytesFromSrc(src) {
37
40
  }
38
41
  /** The text the cell bar shows for a value: raw, as Excel's formula bar does. */
39
42
  const rawText = (v) => (v === null || v === undefined ? '' : typeof v === 'boolean' ? (v ? 'TRUE' : 'FALSE') : String(v));
40
- const sevOrder = { error: 3, warning: 2, note: 1 };
43
+ const sevOrder = SEVERITY_ORDER;
41
44
  const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
45
+ const TONES = ['info', 'pass', 'fail'];
42
46
  /**
43
47
  * `<sheetpane-excel>` — the sheet: views and edits a `.xlsx` or `.csv` file held in the pane's
44
48
  * in-memory workbook (SR-GRID decisions 9–22e). Every change stays in memory and `export()`
@@ -58,30 +62,43 @@ const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
58
62
  * - `on-invalid` — `flag` (the default) or `block`: what a failing column rule does.
59
63
  * - `rules-src` — a URL to a rules file (read with a GET).
60
64
  * - `flagging` — `on` (the default) or `off`: no Flag issue anywhere.
65
+ * - `ref` — the cell or range selected once a file is read, scrolled into view: `B4`, `Data!B4`,
66
+ * `'Q3 data'!B4:D8`; setting it later selects it then. One that names no cell is warned (SP-3019) and
67
+ * the sheet opens at A1. React, Vue and Solid keep `ref` for their own refs — set it with `setAttribute`.
68
+ * - `link` — `none` (the default) or `hash`: the selection is kept in the URL fragment as
69
+ * `#sheetpane=<pane>:<Sheet>!<ref>`, read when a file is read and on back and forward, and *Copy link*
70
+ * joins the cell menu. The rest of the fragment is the host's and is kept as it was.
71
+ * - `results` — `summary` (the default), `marks` or `off`: how `pass` and `skipped` results show, pane-wide (W-50).
72
+ * - `layers` — space-separated review layers to show (W-51); none shows every layer. Shared by the pane's elements.
73
+ * - `protection` — `honour` (the default) or `ignore`: whether a protected sheet's locked cells refuse edits and a
74
+ * protected structure keeps its sheets, pane-wide (W-62). `ignore` is the host's decision; the file is unchanged.
61
75
  *
62
76
  * Properties: `workbook`, `errors`, `errorTypes`, `file`, `mode`, `theme`, `open`, `src`, `sizes`,
63
- * `rules`, `flagging`, `columns`, `validate`, `onInvalid`.
77
+ * `rules`, `flagging`, `results`, `protection`, `columns`, `validate`, `onInvalid`, `renderDetail`, `ref`, `link`, `layers`.
64
78
  *
65
79
  * Methods: `load()`, `export()`, `select()`, `getErrors()`, `setErrors()`, `undo()`, `redo()`,
66
- * `shortcuts()`, `column()`, `renderNow()`, `geometryFor()`, `reveal()`, `unreveal()`.
80
+ * `shortcuts()`, `column()`, `renderNow()`, `geometryFor()`, `reveal()`, `unreveal()`, `highlight()`,
81
+ * `clearHighlight()`.
67
82
  *
68
83
  * Every event bubbles and is composed; an action's event fires once, on the element where it
69
84
  * happened. Cell payloads also carry `column: { index, letter, header }`.
70
85
  *
71
- * @fires sheetpane-load `{ status: 'ready', name, kind, sheets, readMs }` when a file is read, or the refusal `{ status: 'error', code, spCode, message, name, detail? }`.
72
- * @fires sheetpane-change `{ sheet, ref, kind, was, now }` for each change, undo and redo included.
73
- * @fires sheetpane-errors `{ errors, plugins }` when the pane's errors change.
86
+ * @fires sheetpane-load `{ status: 'ready', name, kind, sheets, readMs, sha256 }` when a file is read (`sha256`: the file's SHA-256, W-65), or the refusal `{ status: 'error', code, spCode, message, name, detail? }`.
87
+ * @fires sheetpane-change `{ sheet, ref, kind, was, now, op?, at?, count? }` for each change, undo and redo included; rows or columns inserted or deleted (W-52) are one event, `kind: 'structure'`, with `op`, `at` (0-based) and `count`.
88
+ * @fires sheetpane-errors `{ v, errors, plugins, layers, changed }` when the pane's errors change — the envelope to save a review (`v: 2`, W-51); `changed` names the layers whose errors changed.
89
+ * @fires sheetpane-layers `{ visible, layers }` when the visible review layers change (W-51).
74
90
  * @fires sheetpane-select `{ sheet, ref, range, column, value }` when the selection settles — `range` (`B3:D8`) when more than one cell is selected; a drag fires once, on release.
75
91
  * @fires sheetpane-visibility `{ sheet, axis, indexes, hidden, via }` when rows, columns or a sheet are hidden, unhidden or shown for viewing (`via: 'reveal'`) (W-49).
76
92
  * @fires sheetpane-resize `{ sheet, axis, indexes, px, via }` when a column width or row height is set by a drag, a fit, the header menu or a reset — on release, never during a drag (W-47).
93
+ * @fires sheetpane-hover `{ sheet, ref, column }` when a mouse or pen pointer moves onto another cell (a merge's top-left), at most once per frame, or `null` when it leaves the cells; never for a finger (W-56).
77
94
  * @fires sheetpane-sheet `{ sheet }` when the selection moves to another sheet.
78
95
  * @fires sheetpane-mode `{ mode }` when the pane switches between View and Edit.
79
96
  * @fires sheetpane-history `{ reason, canUndo, canRedo }` on undo, redo, or the stack cleared by `setErrors()`.
80
- * @fires sheetpane-editstart `{ sheet, ref, column, value }` before a cell edit starts; cancellable — the cell stays read-only.
97
+ * @fires sheetpane-editstart `{ sheet, ref, column, value, richText?: true }` before a cell edit starts (`richText: true` on a rich-text cell, whose edit saves plain text — W-53); cancellable — the cell stays read-only.
81
98
  * @fires sheetpane-input `{ sheet, ref, column, draft, was }` as the cell-bar draft is typed.
82
99
  * @fires sheetpane-beforechange `{ sheet, ref, column, was, now }` before a value is stored; cancellable — the value is refused.
83
- * @fires sheetpane-editcancel `{ sheet, ref }` when Esc restores a draft, or `{ sheet, ref, reason: 'blocked', code: 'SP-6003', message }` when a `block` rule refuses it.
84
- * @fires sheetpane-export `{ blob, kind, name, guarded, format, converted, original? }` on `export()` and Download; cancellable — the Download control then saves nothing.
100
+ * @fires sheetpane-editcancel `{ sheet, ref }` when Esc restores a draft, `{ sheet, ref, reason: 'blocked', code: 'SP-6003', message }` when a `block` rule refuses it, or `{ sheet, ref, reason: 'locked', code: 'SP-3026', message }` when the cell is locked on a protected sheet (W-62).
101
+ * @fires sheetpane-export `{ blob, kind, name, guarded, format, converted, sha256, original? }` on `export()` and Download (`sha256`: the blob's SHA-256, W-65); cancellable — the Download control then saves nothing.
85
102
  * @fires sheetpane-close `{}` when the pop-up closes.
86
103
  *
87
104
  * @slot format-row - Host controls for the format row (Edit only); the row shows when a plugin or this slot puts something in it.
@@ -91,6 +108,7 @@ const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
91
108
  * @csspart format-row - The format row (Edit): plugin controls and the `format-row` slot.
92
109
  * @csspart status - The status line under the sheet tabs.
93
110
  * @csspart dialog - The modal `<dialog>` the root moves into when `display="popup"`.
111
+ * @csspart detail - The host's region in the error card, after the message: what `renderDetail` returns (W-68).
94
112
  *
95
113
  * @example
96
114
  * ```ts
@@ -108,7 +126,7 @@ const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
108
126
  */
109
127
  export class SheetpaneExcel extends ElementBase {
110
128
  /** The attributes the element reacts to. */
111
- static observedAttributes = ['pane', 'src', 'mode', 'display', 'open', 'author', 'plugins', 'header-row', 'on-invalid', 'rules-src', 'flagging'];
129
+ static observedAttributes = ['pane', 'src', 'mode', 'display', 'open', 'author', 'plugins', 'header-row', 'on-invalid', 'rules-src', 'flagging', 'ref', 'link', 'results', 'layers', 'protection'];
112
130
  _store;
113
131
  _explicitStore;
114
132
  _paneId;
@@ -116,6 +134,7 @@ export class SheetpaneExcel extends ElementBase {
116
134
  unsub = [];
117
135
  shadow;
118
136
  q;
137
+ detailRegion = new DetailRegion();
119
138
  built = false;
120
139
  raf = 0;
121
140
  zoom = 1;
@@ -161,6 +180,14 @@ export class SheetpaneExcel extends ElementBase {
161
180
  lastBody = '';
162
181
  cellFocusStart;
163
182
  overlayCache = new Map();
183
+ /** A mouse or pen pointer over the grid (W-56); `card` when it is over a menu or callout, not a cell. */
184
+ hoverAt;
185
+ hoverRaf = 0;
186
+ /** The cell last told in `sheetpane-hover` (`sheet!ref`), `''` for none. */
187
+ hovered = '';
188
+ /** The host's highlight (W-56): outlines on the overlay — a view setting, never a change or the selection; `shown` is the sheet shown when it was set. */
189
+ hl;
190
+ scrollToHl;
164
191
  onUnload = (e) => { if (this._store && this._store.changeCount > 0) {
165
192
  e.preventDefault();
166
193
  e.returnValue = '';
@@ -177,6 +204,8 @@ export class SheetpaneExcel extends ElementBase {
177
204
  const m = this.getAttribute('mode'); // the element's mode applies to the store it is given, as on connect
178
205
  if (m === 'edit' || m === 'view')
179
206
  this._store.setMode(m, this);
207
+ if (this.hasAttribute('layers'))
208
+ this._store.setVisibleLayers(layersAttr(this.getAttribute('layers')), this);
180
209
  this.schedule();
181
210
  }
182
211
  /** A copy of the pane's errors; assigning restores a saved review, like `setErrors()`. */
@@ -185,9 +214,26 @@ export class SheetpaneExcel extends ElementBase {
185
214
  /** The types offered in the Flag issue form; an empty list falls back to `DEFAULT_ERROR_TYPES`. */
186
215
  get errorTypes() { return this._errorTypes; }
187
216
  set errorTypes(t) { this._errorTypes = t.length ? t : DEFAULT_ERROR_TYPES; this.schedule(); }
217
+ /**
218
+ * The host's extra detail for an error (W-68): called with a copy of the error when its card opens in the sheet, and
219
+ * drawn after the message in a `part="detail"` region. A string is text, never HTML; a Node is the host's own. Called
220
+ * again only when the error changes (its `meta` included) or this is replaced; a throw is logged as SP-3017. Set it on
221
+ * each element that should show it — `<sheetpane-errors>` has its own.
222
+ */
223
+ get renderDetail() { return this.detailRegion.render; }
224
+ set renderDetail(f) { this.detailRegion.render = f; this.schedule(); }
188
225
  /** Loads a file, like `load()`; `null` does nothing. */
189
226
  set file(f) { if (f)
190
227
  void this.load(f); }
228
+ /**
229
+ * The review layers shown (W-51) — the pane's, shared with `<sheetpane-errors>`: every layer until the `layers`
230
+ * attribute or the switch names some. Setting it sets the attribute; `null` or `[]` shows every layer.
231
+ */
232
+ get layers() { return [...(this._store?.getSnapshot().visibleLayers ?? layersAttr(this.getAttribute('layers')) ?? [])]; }
233
+ set layers(v) { if (v && v.length)
234
+ this.setAttribute('layers', v.join(' '));
235
+ else
236
+ this.removeAttribute('layers'); this._store?.setVisibleLayers(v, this); }
191
237
  /** The pane's mode, `view` or `edit`; setting it sets the `mode` attribute. */
192
238
  get mode() { return this._store?.getSnapshot().mode ?? (this.getAttribute('mode') || 'view'); }
193
239
  set mode(m) { this.setAttribute('mode', m); }
@@ -225,6 +271,12 @@ export class SheetpaneExcel extends ElementBase {
225
271
  /** `flagging="off"`: no Flag issue anywhere, no composer, no callout card — the errors panel is the one place errors are read. */
226
272
  get flagging() { return this.getAttribute('flagging') === 'off' ? 'off' : 'on'; }
227
273
  set flagging(v) { this.setAttribute('flagging', v); }
274
+ /** The pane's results level (W-50): `summary` (the default), `marks` or `off`; setting it sets the `results` attribute. */
275
+ get results() { return this._store?.results ?? resultsLevelOf(this.getAttribute('results')); }
276
+ set results(v) { this.setAttribute('results', v); }
277
+ /** The pane's protection mode (W-62): `honour` (the default) keeps the file's protection; `ignore` is the host's decision. Setting it sets the `protection` attribute. */
278
+ get protection() { return this._store?.protection ?? protectionModeOf(this.getAttribute('protection')); }
279
+ set protection(v) { this.setAttribute('protection', v); }
228
280
  /** Rules by column title, as plain JSON (`required`, `type`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `oneOf`, `unique`, `message`, `severity`, `onInvalid`). */
229
281
  get columns() { return this.rulesCfg?.columns; }
230
282
  set columns(c) { this.rulesCfg = { ...this.rulesCfg, columns: c ?? {} }; this.applyRules(); }
@@ -287,6 +339,15 @@ export class SheetpaneExcel extends ElementBase {
287
339
  this.removeAttribute('src');
288
340
  else
289
341
  this.setAttribute('src', v); }
342
+ /** The `ref` attribute: the cell or range selected once a file is read (W-66); `null`, `undefined` or `''` removes it. */
343
+ get ref() { return this.getAttribute('ref'); }
344
+ set ref(v) { if (v === null || v === undefined || v === '')
345
+ this.removeAttribute('ref');
346
+ else
347
+ this.setAttribute('ref', v); }
348
+ /** `none` (the default) or `hash`: the selection kept in the URL fragment, and *Copy link* in the cell menu (W-66). */
349
+ get link() { return this.getAttribute('link') === 'hash' ? 'hash' : 'none'; }
350
+ set link(v) { this.setAttribute('link', v); }
290
351
  // ── methods ──────────────────────────────────────────────────────────────
291
352
  /** Reads a file into the pane: a File, Blob, bytes, a URL, or base64. */
292
353
  async load(src, name) {
@@ -317,21 +378,141 @@ export class SheetpaneExcel extends ElementBase {
317
378
  this.showEmpty = false;
318
379
  await store.load(data, nm, this);
319
380
  }
320
- /** Every change applied; the upload untouched. Also dispatches `sheetpane-export`. */
381
+ /** Every change applied; the upload untouched. Also dispatches `sheetpane-export`, with the blob's SHA-256 (W-65). */
321
382
  async export(options = {}, o = {}) {
322
383
  const store = this.ensureStore();
323
384
  const r = await store.exportFile({ options, ...o, ...(o.format === 'csv' && !o.sheet ? { sheet: store.getSnapshot().selection.sheet } : {}) });
324
- this.lastExport = { ok: fire(this, 'sheetpane-export', { blob: r.blob, kind: r.kind, name: r.name, guarded: r.guarded, format: r.kind, converted: r.converted }, true), name: r.name };
385
+ this.lastExport = { ok: fire(this, 'sheetpane-export', { blob: r.blob, kind: r.kind, name: r.name, guarded: r.guarded, format: r.kind, converted: r.converted, sha256: r.sha256 }, true), name: r.name };
386
+ this.schedule(); // a plugin's status may report on the export (W-58: rules not written to Excel)
325
387
  return r.blob;
326
388
  }
327
389
  lastExport;
328
390
  lastSheet = '';
329
391
  /** Selects a cell and scrolls to it. */
330
392
  select(sheet, ref) { this.ensureStore().select({ sheet, ref }, this); this.scrollToSel = true; }
331
- /** A copy of the pane's errors, to save a review. */
332
- getErrors() { return this.errors; }
333
- /** Restores a saved review; not a user action, so the undo stack is cleared. */
334
- setErrors(list) { this.ensureStore().setErrors(list, this); }
393
+ /** A copy of the pane's errors, to save a review; `{ layer }` gives one layer's (W-51). */
394
+ getErrors(o = {}) { return this._store?.getErrors(o) ?? []; }
395
+ /** Restores a saved review — a list or the `sheetpane-errors` envelope — clearing the undo stack; `{ layer }` replaces that layer only (W-51). */
396
+ setErrors(list, o = {}) { this.ensureStore().setErrors(list, o, this); }
397
+ /**
398
+ * Outlines cells from outside without selecting them (W-56): a pass-through outline on the overlay —
399
+ * not a change, not undone, not in the download, and the selection stays where it is. Each call
400
+ * replaces the last; the outline goes on a sheet switch unless `pinned`, and with a new file. A range
401
+ * that is not A1 (`B3`, `B3:D8`, `Data!B3`, `'Q3 data'!B3:D8`) or names a sheet the workbook lacks is
402
+ * skipped. A view setting of this element, like zoom.
403
+ * @param ranges - One range or several; an object gives one its own sheet or tone.
404
+ * @param options - `tone`, `label` (said once by the status line), `sheet`, `pinned`, `reveal`.
405
+ * @returns How many ranges are outlined; 0 before a file is loaded.
406
+ */
407
+ highlight(ranges, options = {}) {
408
+ const s = this._store, snap = s?.getSnapshot();
409
+ if (!s || !snap?.book) {
410
+ this.clearHighlight();
411
+ return 0;
412
+ }
413
+ const shown = snap.selection.sheet, tone = TONES.includes(options.tone) ? options.tone : 'info';
414
+ const items = [];
415
+ for (const it of (Array.isArray(ranges) ? ranges : [ranges])) {
416
+ const o = typeof it === 'string' ? { ref: it } : it;
417
+ if (!o || typeof o.ref !== 'string')
418
+ continue;
419
+ const at = splitSheetRef(o.ref), sheet = o.sheet ?? at.sheet ?? options.sheet ?? shown, range = parseRange(at.ref);
420
+ if (range && s.sheet(sheet))
421
+ items.push({ sheet, range, tone: o.tone && TONES.includes(o.tone) ? o.tone : tone });
422
+ }
423
+ if (!items.length) {
424
+ this.clearHighlight();
425
+ return 0;
426
+ }
427
+ this.hl = { items, label: (options.label ?? '').trim(), pinned: !!options.pinned, shown };
428
+ const first = options.reveal ? items.find((x) => x.sheet === shown) : undefined;
429
+ this.scrollToHl = first;
430
+ if (first) { // a hidden row or column under its top-left cell is shown for viewing, as a selection there would be (W-49)
431
+ const hid = s.hidden(shown), r = first.range;
432
+ if (hid.rows.includes(r.top) && !this.isRevealed(shown, 'rows', r.top))
433
+ this.reveal(shown, 'rows', [r.top]);
434
+ if (hid.cols.includes(r.left) && !this.isRevealed(shown, 'cols', r.left))
435
+ this.reveal(shown, 'cols', [r.left]);
436
+ }
437
+ this.schedule();
438
+ return items.length;
439
+ }
440
+ /** Removes the outline `highlight()` drew (W-56). */
441
+ clearHighlight() {
442
+ if (!this.hl && !this.scrollToHl)
443
+ return;
444
+ this.hl = undefined;
445
+ this.scrollToHl = undefined;
446
+ this.schedule();
447
+ }
448
+ // ── W-66: open at a cell — `ref`, and the selection in the URL fragment (`link="hash"`) ──
449
+ /** This element's name in a link: its `pane`, `''` when it has none. */
450
+ get linkPane() { return this.getAttribute('pane') ?? ''; }
451
+ linkTimer;
452
+ /** The fragment last read or written, so a navigation that fires both `hashchange` and `popstate` is acted on once. */
453
+ seenHash = '';
454
+ /** Back, forward, a followed link or the host setting `location.hash`: go to the cell the fragment names for this pane. */
455
+ onHash = () => {
456
+ if (this.link !== 'hash' || location.hash === this.seenHash)
457
+ return;
458
+ this.seenHash = location.hash;
459
+ clearTimeout(this.linkTimer);
460
+ this.linkTimer = undefined;
461
+ const t = readLink(location.hash, this.linkPane);
462
+ if (t !== undefined)
463
+ this.goTo(t, 'link');
464
+ };
465
+ /** Selects a `ref` or a link's cell or range and scrolls to it; one that names no cell is warned (SP-3019). True when it selected. */
466
+ goTo(text, from) {
467
+ const s = this._store, snap = s?.getSnapshot();
468
+ if (!s || !snap?.book)
469
+ return false;
470
+ const t = parseTarget(text), sheet = t?.sheet ?? snap.selection.sheet;
471
+ if (!t || !s.sheet(sheet)) {
472
+ console.warn(spMessage('SP-3019', `sheetpane: ${from === 'ref' ? 'the ref attribute' : 'the link in the URL'} "${text}" names no cell in this workbook`));
473
+ return false;
474
+ }
475
+ const r = t.range, ref = cellRef(r.top, r.left);
476
+ this.anchor = { row: r.top, col: r.left };
477
+ s.select(r.top === r.bottom && r.left === r.right ? { sheet, ref } : { sheet, ref, range: rangeText(r) }, this);
478
+ this.scrollToSel = true;
479
+ this.schedule();
480
+ return true;
481
+ }
482
+ /** Once a file is read: the fragment's link (`link="hash"`), else `ref`, else A1 as the store left it. True when it selected. */
483
+ openAt() {
484
+ const link = this.link === 'hash' ? readLink(location.hash, this.linkPane) : undefined;
485
+ if (link !== undefined && this.goTo(link, 'link'))
486
+ return true;
487
+ const ref = this.getAttribute('ref');
488
+ return !!ref && this.goTo(ref, 'ref');
489
+ }
490
+ /** The fragment with this pane's selection in it (or without this pane's link). */
491
+ linkHash(off = false) {
492
+ const sel = this._store?.getSnapshot().selection;
493
+ return writeLink(location.hash, this.linkPane, off || !sel?.sheet ? undefined : { sheet: sel.sheet, ref: sel.range ?? sel.ref });
494
+ }
495
+ /** The page's URL with this pane's selection as its link — what *Copy link* copies. */
496
+ linkUrl() { const h = this.linkHash(); return `${location.href.split('#')[0]}${h ? `#${h}` : ''}`; }
497
+ /**
498
+ * Writes the fragment 300 ms after the selection settles (a held arrow key is one write; browsers throttle
499
+ * `replaceState`). `replaceState` keeps the host's `history.state`, adds no history entry and fires no
500
+ * `hashchange`, so a host's router hears nothing.
501
+ */
502
+ queueLinkWrite() {
503
+ clearTimeout(this.linkTimer);
504
+ this.linkTimer = setTimeout(() => { this.linkTimer = undefined; if (this.link === 'hash' && this.isConnected)
505
+ this.writeHash(this.linkHash()); }, 300);
506
+ }
507
+ writeHash(h) {
508
+ if (h === location.hash.replace(/^#/, ''))
509
+ return;
510
+ try {
511
+ history.replaceState(history.state, '', `${location.href.split('#')[0]}${h ? `#${h}` : ''}`);
512
+ }
513
+ catch { /* a browser throttling replaceState: the next selection writes it */ }
514
+ this.seenHash = location.hash;
515
+ }
335
516
  /** Undoes the pane's last step. */
336
517
  undo() { this._store?.undo(this); }
337
518
  /** Redoes the last undone step. */
@@ -345,6 +526,8 @@ export class SheetpaneExcel extends ElementBase {
345
526
  const m = this.getAttribute('mode');
346
527
  if (m === 'edit' || m === 'view')
347
528
  this._store.setMode(m, this);
529
+ if (this.hasAttribute('layers'))
530
+ this._store.setVisibleLayers(layersAttr(this.getAttribute('layers')), this);
348
531
  const src = this.getAttribute('src');
349
532
  if (src && this._store.getSnapshot().load.status === 'empty')
350
533
  void this.load(src);
@@ -352,14 +535,25 @@ export class SheetpaneExcel extends ElementBase {
352
535
  if (rsrc && this.rules === undefined)
353
536
  void this.loadRules(rsrc);
354
537
  addEventListener('beforeunload', this.onUnload);
538
+ addEventListener('hashchange', this.onHash);
539
+ addEventListener('popstate', this.onHash);
540
+ this.seenHash = location.hash;
355
541
  this.place();
356
542
  this.schedule();
357
543
  }
358
544
  /** Releases the pane's store. */
359
545
  disconnectedCallback() {
360
546
  removeEventListener('beforeunload', this.onUnload);
547
+ removeEventListener('hashchange', this.onHash);
548
+ removeEventListener('popstate', this.onHash);
549
+ clearTimeout(this.linkTimer);
550
+ this.linkTimer = undefined;
361
551
  this.unbind();
362
552
  cancelAnimationFrame(this.raf);
553
+ cancelAnimationFrame(this.hoverRaf);
554
+ this.hoverRaf = 0;
555
+ this.hoverAt = undefined;
556
+ this.hovered = '';
363
557
  }
364
558
  /** Reacts to an observed attribute. */
365
559
  attributeChangedCallback(name, old, val) {
@@ -385,6 +579,26 @@ export class SheetpaneExcel extends ElementBase {
385
579
  }
386
580
  else if (name === 'flagging')
387
581
  this._store?.setFlagging(val !== 'off');
582
+ else if (name === 'ref' && val)
583
+ this.goTo(val, 'ref'); // before a file is read it waits for the load
584
+ else if (name === 'link') {
585
+ clearTimeout(this.linkTimer);
586
+ this.linkTimer = undefined;
587
+ if (val === 'hash') {
588
+ const t = readLink(location.hash, this.linkPane);
589
+ this.seenHash = location.hash;
590
+ if (t !== undefined)
591
+ this.goTo(t, 'link');
592
+ }
593
+ else if (old === 'hash')
594
+ this.writeHash(this.linkHash(true)); // off: this pane's link leaves the URL; the host's part stays
595
+ }
596
+ else if (name === 'results' && val !== null)
597
+ this._store?.setResults(resultsLevelOf(val));
598
+ else if (name === 'layers')
599
+ this._store?.setVisibleLayers(layersAttr(val), this);
600
+ else if (name === 'protection')
601
+ this._store?.setProtection(protectionModeOf(val));
388
602
  this.schedule();
389
603
  }
390
604
  ensureStore() {
@@ -411,6 +625,10 @@ export class SheetpaneExcel extends ElementBase {
411
625
  this.applyRules();
412
626
  if (this.hasAttribute('flagging'))
413
627
  s.setFlagging(this.getAttribute('flagging') !== 'off');
628
+ if (this.hasAttribute('results'))
629
+ s.setResults(resultsLevelOf(this.getAttribute('results')));
630
+ if (this.hasAttribute('protection'))
631
+ s.setProtection(protectionModeOf(this.getAttribute('protection')));
414
632
  }
415
633
  unbind() {
416
634
  for (const u of this.unsub)
@@ -428,6 +646,16 @@ export class SheetpaneExcel extends ElementBase {
428
646
  this.scrollToSel = true;
429
647
  if (e.type === 'select')
430
648
  this.revealFor(e.sheet, e.ref);
649
+ if (e.type === 'select' && this.link === 'hash')
650
+ this.queueLinkWrite(); // W-66: the URL follows the pane's selection
651
+ // W-56: a highlight belongs to the sheet it was set on, unless pinned; a new file clears it. A pointer
652
+ // that stays put may now be over another cell.
653
+ if ((e.type === 'select' && this.hl && !this.hl.pinned && e.sheet !== this.hl.shown) || e.type === 'load') {
654
+ this.hl = undefined;
655
+ this.scrollToHl = undefined;
656
+ }
657
+ if ((e.type === 'select' || e.type === 'load') && this.hoverAt)
658
+ this.queueHover();
431
659
  if ((e.type === 'change' || e.type === 'load') && this.copied) {
432
660
  this.copied = undefined;
433
661
  this.selKey = '';
@@ -451,13 +679,20 @@ export class SheetpaneExcel extends ElementBase {
451
679
  this.revealNote = undefined;
452
680
  this.revealVer++;
453
681
  this.hiddenMenuOpen = false;
454
- // a workbook opens on a visible sheet, even when the file's active tab is hidden
682
+ // W-66: it opens at the link in the URL or `ref` (after every element has heard of the load); else on a
683
+ // visible sheet, even when the file's active tab is hidden
455
684
  const st = this._store, snap0 = st.getSnapshot();
456
- if (snap0.book && st.hidden(snap0.selection.sheet).state !== 'visible') {
457
- const first = snap0.book.sheets.find((x) => st.hidden(x.name).state === 'visible');
458
- if (first)
459
- queueMicrotask(() => st.select({ sheet: first.name, ref: 'A1' }));
460
- }
685
+ if (snap0.book)
686
+ queueMicrotask(() => {
687
+ if (this._store !== st || st.getSnapshot().book !== snap0.book || this.openAt())
688
+ return;
689
+ const sel = st.getSnapshot().selection;
690
+ if (sel.sheet !== snap0.selection.sheet || st.hidden(sel.sheet).state === 'visible')
691
+ return;
692
+ const first = snap0.book.sheets.find((x) => st.hidden(x.name).state === 'visible');
693
+ if (first)
694
+ st.select({ sheet: first.name, ref: 'A1' });
695
+ });
461
696
  }
462
697
  if (e.type === 'load') {
463
698
  this._sizes = {};
@@ -471,6 +706,8 @@ export class SheetpaneExcel extends ElementBase {
471
706
  el.remove();
472
707
  this.overlayCache.clear();
473
708
  }
709
+ if (e.type === 'change' && e.kind === 'structure')
710
+ this.followStructure(e);
474
711
  const mine = e.origin === this || (e.origin === undefined && isPrimary(this._store, this));
475
712
  if (!mine)
476
713
  return;
@@ -479,17 +716,20 @@ export class SheetpaneExcel extends ElementBase {
479
716
  case 'load': {
480
717
  const snap = s.getSnapshot();
481
718
  fire(this, 'sheetpane-load', snap.load.status === 'ready'
482
- ? { status: 'ready', name: snap.source.name, kind: snap.source.kind, sheets: snap.book.sheets.map((x) => x.name), readMs: snap.source.readMs }
719
+ ? { status: 'ready', name: snap.source.name, kind: snap.source.kind, sheets: snap.book.sheets.map((x) => x.name), readMs: snap.source.readMs, sha256: snap.source.sha256 }
483
720
  : { ...snap.load });
484
721
  break;
485
722
  }
486
723
  case 'change':
487
- fire(this, 'sheetpane-change', { sheet: e.sheet, ref: e.ref, kind: e.kind, was: e.was, now: e.now });
724
+ fire(this, 'sheetpane-change', e.kind === 'structure' ? { sheet: e.sheet, ref: e.ref, kind: e.kind, was: e.was, now: e.now, op: e.op, at: e.at, count: e.count } : { sheet: e.sheet, ref: e.ref, kind: e.kind, was: e.was, now: e.now });
488
725
  if (e.kind === 'hidden' || e.kind === 'sheetState')
489
726
  this.visibilityEvent(e.sheet, e.ref, e.kind, e.was, e.now);
490
727
  break;
491
728
  case 'errors':
492
- fire(this, 'sheetpane-errors', { errors: s.getErrors(), plugins: s.pluginIds });
729
+ fire(this, 'sheetpane-errors', s.envelope(e.layers));
730
+ break;
731
+ case 'layers':
732
+ fire(this, 'sheetpane-layers', { visible: [...s.getSnapshot().visibleLayers], layers: [...s.getSnapshot().layers] });
493
733
  break;
494
734
  case 'select':
495
735
  if (e.sheet !== sheetBefore)
@@ -509,7 +749,7 @@ export class SheetpaneExcel extends ElementBase {
509
749
  this.built = true;
510
750
  this.shadow = this.attachShadow({ mode: 'open' });
511
751
  const zoomOpts = ZOOMS.map((z) => `<option value="${z}"${z === 1 ? ' selected' : ''}>${z * 100} %</option>`).join('');
512
- this.shadow.innerHTML = `<style>${EXCEL_CSS}</style><dialog part="dialog" aria-label="Spreadsheet"></dialog>
752
+ this.shadow.innerHTML = `<style>${EXCEL_CSS}${DETAIL_CSS}</style><dialog part="dialog" aria-label="Spreadsheet"></dialog>
513
753
  <div class="sp" part="root">
514
754
  <div class="bar" part="toolbar">
515
755
  <span class="ico">${ICON.sheet}</span><span class="fname"></span><span class="chip meta"></span>
@@ -524,7 +764,7 @@ export class SheetpaneExcel extends ElementBase {
524
764
  <button type="button" class="btn btn-g btn-i freeze" aria-label="Freeze panes at the selected cell" title="Freeze rows above and columns left of the selected cell">${ICON.freeze}</button>
525
765
  <span class="xs faint changes"></span>
526
766
  </span>
527
- <span class="split dl"><button type="button" class="btn export" data-sp="download">${ICON.download}<span class="lbl">Download</span><span class="badge-n" hidden></span></button><button type="button" class="btn btn-i dlmore" data-sp="download-menu" aria-label="Choose download format" aria-haspopup="menu" aria-expanded="false">▾</button><span class="dlslot"></span></span>
767
+ <span class="split dl"><button type="button" class="btn export" data-sp="download">${ICON.download}<span class="lbl">Download</span><span class="badge-n" hidden></span></button><button type="button" class="btn btn-i dlmore" data-sp="download-menu" aria-label="Choose download format" aria-haspopup="menu" aria-expanded="false" title="Download as .xlsx or .csv">${ICON.chevron}</button><span class="dlslot"></span></span>
528
768
  <button type="button" class="btn btn-g btn-i help" aria-label="Keyboard shortcuts" aria-expanded="false" title="Keyboard shortcuts">?</button>
529
769
  <button type="button" class="btn btn-g btn-i close" aria-label="Close (Esc)" title="Close">${ICON.close}</button>
530
770
  </div>
@@ -535,8 +775,11 @@ export class SheetpaneExcel extends ElementBase {
535
775
  <span class="tag merge"></span>
536
776
  <span class="chip chip-info changed"></span><button type="button" class="btn btn-g revert">Revert</button>
537
777
  <span class="chip issue"></span>
778
+ <span class="chip result"></span>
538
779
  <span class="chip chip-info hint">Describe it in the Errors panel</span>
539
780
  <span class="chip rulemsg" role="status"></span>
781
+ <span class="chip chip-warn richmsg" data-sp="rich-text" role="status" hidden></span>
782
+ <span class="chip chip-info lockmsg" data-sp="locked" role="status" title="The file protects this sheet and locks this cell (SP-3026)" hidden></span>
540
783
  <span class="plug-cellbar" style="display:contents"></span>
541
784
  <span class="dock" role="toolbar" aria-label="Selection"></span>
542
785
  </div>
@@ -558,7 +801,7 @@ export class SheetpaneExcel extends ElementBase {
558
801
  this.q = {
559
802
  root: $('.sp'), dialog: $('dialog'), fname: $('.fname'), meta: $('.meta'), counts: $('.counts'), findin: $('.findin'), findcnt: $('.find .cnt'), zoom: $('.zoom'),
560
803
  editonly: $('.editonly'), undo: $('.undo'), redo: $('.redo'), freeze: $('.freeze'), changes: $('.changes'), export: $('.export'), dlmore: $('.dlmore'), dlslot: $('.dlslot'), close: $('.close'), dock: $('.dock'),
561
- help: $('.help'), ref: $('.ref'), cell: $('.cell'), cellname: $('.cellname'), val: $('.val'), merge: $('.merge'), changed: $('.changed'), revert: $('.revert'), issue: $('.issue'), hint: $('.hint'), rulemsg: $('.rulemsg'),
804
+ help: $('.help'), ref: $('.ref'), cell: $('.cell'), cellname: $('.cellname'), val: $('.val'), merge: $('.merge'), changed: $('.changed'), revert: $('.revert'), issue: $('.issue'), result: $('.result'), hint: $('.hint'), rulemsg: $('.rulemsg'), richmsg: $('.richmsg'), lockmsg: $('.lockmsg'),
562
805
  plugCellbar: $('.plug-cellbar'), frow: $('.frow'), plugFrow: $('.plug-frow'), body: $('.body'), scroller: $('.scroller'), sheet: $('.sheet'), hrow: $('.hrow'),
563
806
  grid: $('.grid'), frozen: $('.frozenrows'), rows: $('.rows'), sellayer: $('.sellayer'), hidlayer: $('.hidlayer'), hidbar: $('.hidbar'), hidmenuslot: $('.hidmenuslot'), selsay: $('.selsay'), overlay: $('.overlay'), state: $('.state'), tabs: $('.tabs'), status: $('.status'), picker: $('.picker'), modeseg: $('.modeseg'),
564
807
  };
@@ -656,6 +899,11 @@ export class SheetpaneExcel extends ElementBase {
656
899
  this.schedule();
657
900
  } });
658
901
  q.dock.addEventListener('click', (e) => this.onDockClick(e));
902
+ if (typeof ResizeObserver !== 'undefined')
903
+ new ResizeObserver(() => this.fitDock()).observe(q.dock.parentElement);
904
+ // the grid's focus ring shows for keyboard use only
905
+ q.root.addEventListener('keydown', () => q.root.setAttribute('data-kbd', ''), true);
906
+ q.root.addEventListener('pointerdown', () => q.root.removeAttribute('data-kbd'), true);
659
907
  q.dock.addEventListener('keydown', (e) => this.onMenuKey(e, '.moremenu'));
660
908
  q.help.addEventListener('click', () => { this.helpOpen = !this.helpOpen; q.help.setAttribute('aria-expanded', String(this.helpOpen)); this.schedule(); });
661
909
  q.revert.addEventListener('click', () => { const sel = s().getSnapshot().selection; s().revert(sel.sheet, sel.ref, this); });
@@ -663,7 +911,8 @@ export class SheetpaneExcel extends ElementBase {
663
911
  const cell = q.cell;
664
912
  cell.addEventListener('focus', () => {
665
913
  const sel = s().getSnapshot().selection;
666
- const allowed = fire(this, 'sheetpane-editstart', { sheet: sel.sheet, ref: sel.ref, column: this.column(sel.sheet, sel.ref), value: s().value(sel.sheet, sel.ref) }, true);
914
+ const rich = !!s().runs(sel.sheet, sel.ref); // W-53: the edit writes plain text, the runs' formatting goes
915
+ const allowed = fire(this, 'sheetpane-editstart', { sheet: sel.sheet, ref: sel.ref, column: this.column(sel.sheet, sel.ref), value: s().value(sel.sheet, sel.ref), ...(rich ? { richText: true } : {}) }, true);
667
916
  if (!allowed) {
668
917
  cell.readOnly = true;
669
918
  this.editing = undefined;
@@ -761,7 +1010,13 @@ export class SheetpaneExcel extends ElementBase {
761
1010
  this.schedule();
762
1011
  this.q.tabs.querySelector('[data-act=hidden-sheets]')?.focus();
763
1012
  } });
764
- q.scroller.addEventListener('scroll', () => this.schedule(), { passive: true });
1013
+ q.scroller.addEventListener('scroll', () => { this.schedule(); if (this.hoverAt)
1014
+ this.queueHover(); }, { passive: true });
1015
+ // W-56: the cell under a mouse or pen pointer, told once per frame and only when it changes; a finger never hovers
1016
+ q.scroller.addEventListener('pointermove', (e) => { if (e.pointerType === 'touch')
1017
+ return; this.hoverAt = { x: e.clientX, y: e.clientY, card: !!e.target.closest?.('.card') }; this.queueHover(); }, { passive: true });
1018
+ q.scroller.addEventListener('pointerleave', (e) => { if (e.pointerType === 'touch' || !this.hoverAt)
1019
+ return; this.hoverAt = undefined; this.queueHover(); });
765
1020
  // W-46: mouse and pen drag with Pointer Events (captured, so the drag outlives the grid); a finger
766
1021
  // pans, and its tap arrives as the compatibility mousedown — as do synthetic clicks
767
1022
  q.scroller.addEventListener('pointerdown', (e) => this.onPointerDown(e));
@@ -885,7 +1140,35 @@ export class SheetpaneExcel extends ElementBase {
885
1140
  });
886
1141
  q.overlay.addEventListener('input', (e) => this.onComposerInput(e));
887
1142
  q.overlay.addEventListener('change', (e) => this.onComposerInput(e));
888
- q.plugFrow.addEventListener('click', (e) => this.runControl(e));
1143
+ q.plugFrow.addEventListener('click', (e) => {
1144
+ if (e.target.closest('.fmore-btn')) {
1145
+ this.frowMoreOpen = !this.frowMoreOpen;
1146
+ const menu = q.plugFrow.querySelector('.fmenu'), btn = q.plugFrow.querySelector('.fmore-btn');
1147
+ if (menu)
1148
+ menu.hidden = !this.frowMoreOpen;
1149
+ btn?.setAttribute('aria-expanded', String(this.frowMoreOpen));
1150
+ return;
1151
+ }
1152
+ this.runControl(e);
1153
+ });
1154
+ q.root.addEventListener('pointerdown', (e) => { if (this.frowMoreOpen && !e.target.closest('.fmore')) {
1155
+ this.frowMoreOpen = false;
1156
+ const m = q.plugFrow.querySelector('.fmenu');
1157
+ if (m)
1158
+ m.hidden = true;
1159
+ q.plugFrow.querySelector('.fmore-btn')?.setAttribute('aria-expanded', 'false');
1160
+ } });
1161
+ q.frow.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.frowMoreOpen) {
1162
+ e.stopPropagation();
1163
+ this.frowMoreOpen = false;
1164
+ const m = q.plugFrow.querySelector('.fmenu');
1165
+ if (m)
1166
+ m.hidden = true;
1167
+ q.plugFrow.querySelector('.fmore-btn')?.focus();
1168
+ } });
1169
+ if (typeof ResizeObserver !== 'undefined')
1170
+ new ResizeObserver(() => { if (!q.frow.hidden)
1171
+ this.fitFrow(); }).observe(q.frow);
889
1172
  q.plugCellbar.addEventListener('click', (e) => this.runControl(e));
890
1173
  for (const box of [q.plugFrow, q.plugCellbar, q.overlay])
891
1174
  box.addEventListener('change', (e) => this.chooseControl(e));
@@ -933,6 +1216,10 @@ export class SheetpaneExcel extends ElementBase {
933
1216
  /** `sheetpane-beforechange` (cancellable) for one cell; the value it would store is `now`. Then a column rule set to `block` (W-27). */
934
1217
  allowChange(sheet, ref, now, text = now === null || now === undefined ? '' : String(now)) {
935
1218
  const s = this._store;
1219
+ if (s.isLocked(sheet, ref)) {
1220
+ this.refuseLocked(sheet, ref);
1221
+ return false;
1222
+ } // W-62: before the host is asked
936
1223
  if (!fire(this, 'sheetpane-beforechange', { sheet, ref, column: this.column(sheet, ref), was: s.value(sheet, ref), now }, true))
937
1224
  return false;
938
1225
  const b = s.ruleBlocks(sheet, ref, now, text);
@@ -944,6 +1231,19 @@ export class SheetpaneExcel extends ElementBase {
944
1231
  this.schedule();
945
1232
  return false;
946
1233
  }
1234
+ /** A locked cell of a protected sheet refused an edit (W-62): `sheetpane-editcancel` with SP-3026; nothing is stored. */
1235
+ lockedRefused = 0;
1236
+ refuseLocked(sheet, ref) {
1237
+ this.lockedRefused++;
1238
+ fire(this, 'sheetpane-editcancel', { sheet, ref, reason: 'locked', code: 'SP-3026', message: LOCKED_MESSAGE });
1239
+ }
1240
+ /** After a paste or a clear: says how many locked cells were left as they were. */
1241
+ sayLocked() {
1242
+ const n = this.lockedRefused;
1243
+ this.lockedRefused = 0;
1244
+ if (n)
1245
+ this.say0(`${n} locked cell${n === 1 ? '' : 's'} left as ${n === 1 ? 'it was' : 'they were'} — ${LOCKED_MESSAGE}`);
1246
+ }
947
1247
  // ── column rules: the input phase (an outline and a tooltip, nothing stored) ──
948
1248
  ruleMsg;
949
1249
  ruleTimer;
@@ -999,6 +1299,7 @@ export class SheetpaneExcel extends ElementBase {
999
1299
  if (text === undefined)
1000
1300
  return;
1001
1301
  e.preventDefault();
1302
+ this.lockedRefused = 0;
1002
1303
  const rows = text.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n').map((r) => r.split('\t'));
1003
1304
  const p = parseRef(snap.selection.ref);
1004
1305
  const book = s.book;
@@ -1021,6 +1322,7 @@ export class SheetpaneExcel extends ElementBase {
1021
1322
  cells.push({ sheet: snap.selection.sheet, ref, input: v });
1022
1323
  }
1023
1324
  s.setCells(cells, { origin: this });
1325
+ this.sayLocked();
1024
1326
  return;
1025
1327
  }
1026
1328
  rows.forEach((row, dr) => row.forEach((v, dc) => {
@@ -1029,10 +1331,13 @@ export class SheetpaneExcel extends ElementBase {
1029
1331
  cells.push({ sheet: snap.selection.sheet, ref, input: v });
1030
1332
  }));
1031
1333
  s.setCells(cells, { origin: this });
1334
+ this.sayLocked();
1032
1335
  }
1033
1336
  // ── selection actions (W-31): docked in the cell bar, and on right-click ──
1034
1337
  ctxOpen = false;
1035
1338
  moreOpen = false;
1339
+ /** The format row's More ▾ (W-14 narrow widths) is open. */
1340
+ frowMoreOpen = false;
1036
1341
  notice = '';
1037
1342
  noticeTimer;
1038
1343
  /** Copies to the clipboard; without a secure context it says so in the status line. */
@@ -1060,16 +1365,68 @@ export class SheetpaneExcel extends ElementBase {
1060
1365
  requestAnimationFrame(() => this.q.overlay.querySelector('.ctxmenu [role=menuitem]:not([disabled])')?.focus({ preventScroll: true }));
1061
1366
  }
1062
1367
  /** The actions as menu items: Flag issue (Edit), Copy ref, Copy value, then the `selbar` plugin controls. */
1368
+ /** The format row's controls, one wrapper per group, then a More ▾ that holds the groups that do not fit (W-14). */
1369
+ frowHtml(list) {
1370
+ const groups = [];
1371
+ for (const c of list) {
1372
+ const g = groups[groups.length - 1];
1373
+ if (g && g[0].group === c.group)
1374
+ g.push(c);
1375
+ else
1376
+ groups.push([c]);
1377
+ }
1378
+ const body = groups.map((g, i) => `<span class="fgrp" data-g="${esc(g[0].group)}">${i ? '<span class="sep" aria-hidden="true"></span>' : ''}${this.controlsHtml(g, 'btn btn-g')}</span>`).join('');
1379
+ return `${body}<span class="fmore" hidden><button type="button" class="btn btn-g fmore-btn" aria-haspopup="true" aria-expanded="${this.frowMoreOpen}">More<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg></button><span class="card fmenu" role="group" aria-label="More formatting"${this.frowMoreOpen ? '' : ' hidden'}></span></span>`;
1380
+ }
1381
+ /** Narrow widths: format-row groups move into More ▾ from the right; Number format and Text never move (W-14). */
1382
+ fitFrow() {
1383
+ const box = this.q.frow, more = this.q.plugFrow.querySelector('.fmore'), menu = more?.querySelector('.fmenu');
1384
+ if (!more || !menu)
1385
+ return;
1386
+ for (const g of Array.from(menu.children))
1387
+ more.before(g);
1388
+ more.hidden = true;
1389
+ if (box.scrollWidth <= box.clientWidth + 1) {
1390
+ this.frowMoreOpen = false;
1391
+ return;
1392
+ }
1393
+ more.hidden = false;
1394
+ const movable = Array.from(this.q.plugFrow.querySelectorAll(':scope > .fgrp')).filter((g) => g.dataset.g !== 'number' && g.dataset.g !== 'text').reverse();
1395
+ for (const g of movable) {
1396
+ if (box.scrollWidth <= box.clientWidth + 1)
1397
+ break;
1398
+ menu.prepend(g);
1399
+ }
1400
+ if (!menu.children.length)
1401
+ more.hidden = true;
1402
+ }
1403
+ /** Fold the docked plugin buttons into ⋯ when they would squeeze the value field below 200 px (SR-GRID 13). */
1404
+ fitDock() {
1405
+ const q = this.q, fx = q.dock.parentElement;
1406
+ if (!fx)
1407
+ return;
1408
+ fx.classList.remove('folded');
1409
+ if (q.dock.hidden || !q.dock.querySelector('.plug'))
1410
+ return;
1411
+ const field = q.cell.hidden ? q.val : q.cell;
1412
+ if (field.getBoundingClientRect().width < 200)
1413
+ fx.classList.add('folded');
1414
+ }
1063
1415
  actionItems(edit, err, o) {
1064
1416
  const s = this._store, snap = s.getSnapshot();
1065
1417
  const item = (act, label, kbd = '') => `<button type="button" role="menuitem" class="mi" data-act="${act}">${esc(label)}${kbd ? `<span class="kbd">${esc(kbd)}</span>` : ''}</button>`;
1066
- const out = [o.flag && edit && !err && s.flagging ? item('flag', 'Flag issue', 'F') : '', item('copy-ref', 'Copy ref'), item('copy-value', snap.selection.range ? 'Copy values' : 'Copy value', 'Ctrl/⌘+C')];
1067
- if (o.plugins && edit)
1068
- for (const c of controlsFor(s.plugins, 'selbar', 'edit').filter((x) => !x.menus || x.menus.includes('cell'))) {
1069
- const label = c.labelFor?.(snap, snap.selection) ?? c.label;
1070
- const off = c.disabled?.(snap, snap.selection) ?? false;
1071
- out.push(`<button type="button" role="menuitem" class="mi fold" data-control="${esc(c.id)}"${off ? ' disabled' : ''}>${esc(label)}${c.shortcut ? `<span class="kbd">${esc(c.shortcut)}</span>` : ''}</button>`);
1072
- }
1418
+ const flag = o.flag && edit && !err && s.flagging;
1419
+ const out = [flag ? item('flag', 'Flag issue', 'F') + '<div role="separator" class="msep"></div>' : '', item('copy-ref', 'Copy ref'), this.link === 'hash' ? item('copy-link', 'Copy link') : '', item('copy-value', snap.selection.range ? 'Copy values' : 'Copy value', 'Ctrl/⌘+C')];
1420
+ if (edit)
1421
+ out.push(this.structureItems('cell'));
1422
+ const plugs = o.plugins && edit ? controlsFor(s.plugins, 'selbar', 'edit').filter((x) => !x.menus || x.menus.includes('cell')) : [];
1423
+ if (plugs.length)
1424
+ out.push('<div role="separator" class="msep fold"></div>');
1425
+ for (const c of plugs) {
1426
+ const label = c.labelFor?.(snap, snap.selection) ?? c.label;
1427
+ const off = c.disabled?.(snap, snap.selection) ?? false;
1428
+ out.push(`<button type="button" role="menuitem" class="mi fold" data-control="${esc(c.id)}"${off ? ' disabled' : ''}>${esc(label)}${c.shortcut ? `<span class="kbd">${esc(c.shortcut)}</span>` : ''}</button>`);
1429
+ }
1073
1430
  return out.join('');
1074
1431
  }
1075
1432
  contextMenuHtml(x, y, edit, err) {
@@ -1126,16 +1483,30 @@ export class SheetpaneExcel extends ElementBase {
1126
1483
  this.flag();
1127
1484
  else if (act === 'copy-ref')
1128
1485
  this.copy(this.refOf(sel));
1486
+ else if (act === 'copy-link')
1487
+ this.copy(this.linkUrl());
1129
1488
  else if (act === 'copy-value')
1130
1489
  this.copySelection();
1490
+ else if (act && /^(ins-(above|below|left|right)|del-(rows|cols))$/.test(act))
1491
+ this.runStructure(act);
1131
1492
  else if (t.closest('[data-control]'))
1132
1493
  this.runControl(e);
1133
1494
  this.schedule();
1134
1495
  }
1135
- /** Untouched: no change, and nothing a plugin would write (a review exported as comments). */
1496
+ /**
1497
+ * Untouched: no change, and nothing a plugin would write — a review exported as comments, or a switch that applies
1498
+ * and is on (W-58). A plugin whose every switch says when it applies is judged by its switches, not by the errors.
1499
+ */
1136
1500
  untouched() {
1137
1501
  const s = this._store;
1138
- return s.changeCount === 0 && !(s.getSnapshot().errors.length && s.plugins.some((p) => p.provides.includes('exportPatch')));
1502
+ if (s.changeCount !== 0)
1503
+ return false;
1504
+ const judged = (p) => !!p.exportOptions?.length && p.exportOptions.every((o) => o.applies);
1505
+ // results never make a review (W-50)
1506
+ if (s.resultCounts().problems && s.plugins.some((p) => p.provides.includes('exportPatch') && !judged(p)))
1507
+ return false;
1508
+ const on = this.exportChoices();
1509
+ return !s.plugins.flatMap((p) => p.exportOptions ?? []).some((o) => on[o.key] && o.applies?.(s));
1139
1510
  }
1140
1511
  // ── Download (W-31, W-34): the main part in the upload's format; ▾ for either format and plugin options ──
1141
1512
  dlOpen = false;
@@ -1155,7 +1526,7 @@ export class SheetpaneExcel extends ElementBase {
1155
1526
  const fmt = format ?? src.kind;
1156
1527
  if (fmt === src.kind && this.untouched()) {
1157
1528
  const blob = new Blob([src.bytes], { type: src.kind === 'csv' ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
1158
- if (fire(this, 'sheetpane-export', { blob, kind: src.kind, name: src.name, guarded: false, original: true, format: fmt, converted: false }, true))
1529
+ if (fire(this, 'sheetpane-export', { blob, kind: src.kind, name: src.name, guarded: false, original: true, format: fmt, converted: false, sha256: src.sha256 }, true))
1159
1530
  download(src.bytes, src.name);
1160
1531
  return;
1161
1532
  }
@@ -1168,7 +1539,8 @@ export class SheetpaneExcel extends ElementBase {
1168
1539
  const note = (f) => (f === kind ? '' : f === 'csv' ? 'The current sheet only — values as shown now, no formatting' : 'One sheet with the latest values');
1169
1540
  const items = order.map((f) => `<button type="button" role="menuitem" class="mi" data-fmt="${f}"><span>Download as .${f}</span>${f === kind ? '<span class="kbd">default</span>' : ''}</button>${note(f) ? `<span class="mnote">${note(f)}</span>` : ''}`).join('');
1170
1541
  const choices = this.exportChoices();
1171
- const opts = (this._store?.plugins ?? []).flatMap((p) => p.exportOptions ?? []);
1542
+ const store = this._store;
1543
+ const opts = (store?.plugins ?? []).flatMap((p) => p.exportOptions ?? []).filter((o) => !o.applies || (store && o.applies(store)));
1172
1544
  const optHtml = opts.length ? `<div role="separator" class="msep"></div>${opts.map((o) => `<button type="button" role="menuitemcheckbox" class="mi" data-opt="${esc(o.key)}" aria-checked="${choices[o.key]}"><span>${choices[o.key] ? '✓ ' : ''}${esc(o.label)}</span></button>`).join('')}` : '';
1173
1545
  return `<div class="card dlmenu" role="menu" aria-label="Download format">${items}${optHtml}</div>`;
1174
1546
  }
@@ -1182,6 +1554,7 @@ export class SheetpaneExcel extends ElementBase {
1182
1554
  ['Home / End', 'Row start / end'], ['Ctrl + Home / End', 'First / last cell'],
1183
1555
  ['Page Up / Down', 'Move a screen'], ['Enter or F2', 'Edit the cell (Edit)'], ['Type', 'Replace the cell (Edit)'], ['Delete', 'Clear the cell or range (Edit)'],
1184
1556
  ['F', 'Flag issue (Edit)'], ['Esc', 'Cancel a drag or copy · close the pop-up'], ['Ctrl/⌘ + Z', 'Undo'], ['Ctrl + Y, ⌘ + Shift + Z', 'Redo'], ['Ctrl/⌘ + C', 'Copy the value or the range'], ['Shift + F10 or Menu', 'Selection actions'],
1557
+ ['Ctrl/⌘ + Shift + =', 'Insert rows above (whole columns: columns left) (Edit)'], ['Ctrl/⌘ + −', 'Delete the selected rows or columns (Edit)'],
1185
1558
  ].map(([keys, what]) => ({ keys: keys, what: what }));
1186
1559
  const plug = (this._store?.plugins ?? []).flatMap((p) => p.controls ?? []).filter((c) => c.shortcut).map((c) => ({ keys: c.shortcut, what: c.label }));
1187
1560
  return [...(this._store && !this._store.flagging ? own.filter((x) => x.keys !== 'F') : own), ...plug];
@@ -1314,6 +1687,37 @@ export class SheetpaneExcel extends ElementBase {
1314
1687
  if (!this.dragRaf)
1315
1688
  this.dragRaf = requestAnimationFrame(() => { this.dragRaf = 0; this.dragStep(); });
1316
1689
  }
1690
+ // ── hover (W-56): the cell under a mouse or pen pointer, once per frame ──
1691
+ queueHover() {
1692
+ if (!this.hoverRaf)
1693
+ this.hoverRaf = requestAnimationFrame(() => { this.hoverRaf = 0; this.hoverStep(); });
1694
+ }
1695
+ /** One frame of hover: the cell under the pointer, told only when it changed — two binary searches, nothing rendered. */
1696
+ hoverStep() {
1697
+ const h = this.hoverAt, g = h && !h.card && this.built && this._store?.getSnapshot().load.status === 'ready' ? this.geometry() : undefined;
1698
+ const p = g ? this.cellAtPoint(g, h.x, h.y) : undefined;
1699
+ const sheet = g?.sheet.name ?? '', ref = p ? cellRef(p.row, p.col) : '';
1700
+ const key = ref ? `${sheet}!${ref}` : '';
1701
+ if (key === this.hovered)
1702
+ return;
1703
+ this.hovered = key;
1704
+ fire(this, 'sheetpane-hover', ref ? { sheet, ref, column: this.column(sheet, ref) } : null);
1705
+ }
1706
+ /** The cell at a viewport point (a merge's top-left); none over the headers, a scrollbar, or past the sheet's cells. Frozen panes do not scroll. */
1707
+ cellAtPoint(g, x, y) {
1708
+ const sc = this.q.scroller, z = this.zoom, gg = g.g, HH = this.headH() * z, RW = this.rowHeadW() * z;
1709
+ const rect = sc.getBoundingClientRect();
1710
+ const vx = x - rect.left - sc.clientLeft, vy = y - rect.top - sc.clientTop;
1711
+ if (vx < RW || vy < HH || vx >= sc.clientWidth || vy >= sc.clientHeight)
1712
+ return undefined;
1713
+ const fz = this._store.freeze(g.sheet.name), fr = Math.min(fz.rows, gg.rows), fc = Math.min(fz.cols, gg.cols);
1714
+ const paneTop = HH + gg.rowY[fr] * z, paneLeft = RW + gg.colX[fc] * z;
1715
+ const ly = ((vy < paneTop && fr > 0 ? vy : vy + sc.scrollTop) - HH) / z, lx = ((vx < paneLeft && fc > 0 ? vx : vx + sc.scrollLeft) - RW) / z;
1716
+ if (ly >= gg.rowY[gg.rows] || lx >= gg.colX[gg.cols])
1717
+ return undefined;
1718
+ const row = rowAt(gg, ly), col = colAt(gg, lx), m = mergeAt(gg, row, col);
1719
+ return m ? { row: m.top, col: m.left } : { row, col };
1720
+ }
1317
1721
  /** One frame of a drag: autoscroll at an edge, the cell under the pointer, the layer redrawn. */
1318
1722
  dragStep() {
1319
1723
  const d = this.drag, g = this.geometry();
@@ -1573,7 +1977,7 @@ export class SheetpaneExcel extends ElementBase {
1573
1977
  hiddenMarks(sheet, g, win, fr, fc) {
1574
1978
  const z = this.zoom, HH = this.headH() * z, RW = this.rowHeadW() * z, s = this._store;
1575
1979
  const X = (c) => g.colX[Math.min(c, g.cols)] * z, Y = (r) => rowTop(g, r) * z;
1576
- const errs = s.getSnapshot().errors.filter((e) => e.sheet === sheet && e.status === 'open');
1980
+ const errs = s.getSnapshot().visibleErrors.filter((e) => e.sheet === sheet && e.status === 'open' && !isResult(e));
1577
1981
  const inSpan = (axis, a, b) => {
1578
1982
  let worst, n = 0;
1579
1983
  for (const e of errs) {
@@ -1730,7 +2134,7 @@ export class SheetpaneExcel extends ElementBase {
1730
2134
  const hidden = book.sheets.filter((x) => s.hidden(x.name).state !== 'visible');
1731
2135
  if (!hidden.length)
1732
2136
  return '';
1733
- const errs = snap.mode === 'edit' ? snap.errors.filter((e) => e.status === 'open' && hidden.some((h) => h.name === e.sheet)).length : 0;
2137
+ const errs = snap.mode === 'edit' ? snap.visibleErrors.filter((e) => e.status === 'open' && !isResult(e) && hidden.some((h) => h.name === e.sheet)).length : 0;
1734
2138
  const btn = `<button type="button" class="tab hidbtn" data-act="hidden-sheets" aria-haspopup="menu" aria-expanded="${this.hiddenMenuOpen}" title="Sheets hidden in the file">⊘ ${hidden.length} hidden ▾${errs ? `<span class="cnt" aria-label="${errs} open">${errs}</span>` : ''}</button>`;
1735
2139
  return btn;
1736
2140
  }
@@ -1956,6 +2360,132 @@ export class SheetpaneExcel extends ElementBase {
1956
2360
  this.schedule();
1957
2361
  this.q.grid.focus({ preventScroll: true });
1958
2362
  }
2363
+ // ── rows and columns (W-52): the cell menu, the header menu and Ctrl+Shift+= / Ctrl+− ──
2364
+ /** The rows and columns the selection spans, and whether it is whole rows or whole columns. */
2365
+ selSpan() {
2366
+ const s = this._store, g = this.geometry();
2367
+ if (!s || !g)
2368
+ return undefined;
2369
+ const sel = s.getSnapshot().selection, p = parseRef(sel.ref);
2370
+ const r = (sel.range ? parseRange(sel.range) : undefined) ?? (p && { top: p.row, bottom: p.row, left: p.col, right: p.col });
2371
+ if (!r)
2372
+ return undefined;
2373
+ return { sheet: sel.sheet, ...r, wholeRows: r.left === 0 && r.right >= g.g.cols - 1, wholeCols: r.top === 0 && r.bottom >= g.g.rows - 1 };
2374
+ }
2375
+ /** The operation a menu item or key names, over the selection (or the header menu's indexes). */
2376
+ structureOp(act, indexes) {
2377
+ const sp = this.selSpan();
2378
+ if (!sp)
2379
+ return undefined;
2380
+ const from = indexes?.length ? Math.min(...indexes) : undefined, to = indexes?.length ? Math.max(...indexes) : undefined;
2381
+ const top = from ?? sp.top, bottom = to ?? sp.bottom, left = from ?? sp.left, right = to ?? sp.right;
2382
+ const rows = bottom - top + 1, cols = right - left + 1;
2383
+ switch (act) {
2384
+ case 'ins-above': return { sheet: sp.sheet, op: 'insertRows', at: top, count: rows };
2385
+ case 'ins-below': return { sheet: sp.sheet, op: 'insertRows', at: bottom + 1, count: rows };
2386
+ case 'ins-left': return { sheet: sp.sheet, op: 'insertCols', at: left, count: cols };
2387
+ case 'ins-right': return { sheet: sp.sheet, op: 'insertCols', at: right + 1, count: cols };
2388
+ case 'del-rows': return { sheet: sp.sheet, op: 'deleteRows', at: top, count: rows };
2389
+ case 'del-cols': return { sheet: sp.sheet, op: 'deleteCols', at: left, count: cols };
2390
+ }
2391
+ return undefined;
2392
+ }
2393
+ /** Insert and delete as menu items: greyed, with the reason in the tooltip, when refused. `rows` / `cols`: the header menu's axis only. */
2394
+ structureItems(which, indexes) {
2395
+ const s = this._store;
2396
+ if (!s || s.getSnapshot().load.status !== 'ready')
2397
+ return '';
2398
+ const sp = this.selSpan();
2399
+ if (!sp)
2400
+ return '';
2401
+ const n = (a, b) => (indexes?.length ? Math.max(...indexes) - Math.min(...indexes) : b - a) + 1;
2402
+ const nr = n(sp.top, sp.bottom), nc = n(sp.left, sp.right);
2403
+ const rowsWord = (k) => (k === 1 ? 'row' : `${k} rows`), colsWord = (k) => (k === 1 ? 'column' : `${k} columns`);
2404
+ const item = (act, label, kbd = '') => {
2405
+ const o = this.structureOp(act, indexes), no = o && s.structureRefusal(o);
2406
+ return `<button type="button" role="menuitem" class="mi" data-act="${act}"${no ? ` disabled title="${esc(no.message)}"` : ''}>${esc(label)}${kbd ? `<span class="kbd">${esc(kbd)}</span>` : ''}</button>`;
2407
+ };
2408
+ const rows = [item('ins-above', `Insert ${rowsWord(nr)} above`, which === 'cols' ? '' : 'Ctrl/⌘+Shift+='), item('ins-below', `Insert ${rowsWord(nr)} below`), item('del-rows', `Delete ${rowsWord(nr)}`, which === 'rows' ? 'Ctrl/⌘+−' : '')];
2409
+ const cols = [item('ins-left', `Insert ${colsWord(nc)} left`, which === 'cols' ? 'Ctrl/⌘+Shift+=' : ''), item('ins-right', `Insert ${colsWord(nc)} right`), item('del-cols', `Delete ${colsWord(nc)}`, which === 'cols' ? 'Ctrl/⌘+−' : '')];
2410
+ const list = which === 'rows' ? rows : which === 'cols' ? cols : [rows[0], rows[1], cols[0], cols[1], rows[2], cols[2]]; // as Google Sheets lists them: inserts, then deletes
2411
+ return `<div role="separator" class="msep"></div>${list.join('')}`;
2412
+ }
2413
+ /** Runs one insert or delete; a refusal is said in the status line, never thrown. The selection keeps its address, as in Excel. */
2414
+ runStructure(act, indexes) {
2415
+ const s = this._store, o = this.structureOp(act, indexes);
2416
+ this.ctxOpen = false;
2417
+ this.moreOpen = false;
2418
+ if (!s || !o || s.getSnapshot().mode !== 'edit') {
2419
+ this.schedule();
2420
+ return;
2421
+ }
2422
+ const no = s.structureRefusal(o);
2423
+ if (no) {
2424
+ this.say0(no.message);
2425
+ return;
2426
+ }
2427
+ const sel = s.getSnapshot().selection;
2428
+ const done = o.op === 'insertRows' ? s.insertRows(o.sheet, o.at, o.count, this) : o.op === 'deleteRows' ? s.deleteRows(o.sheet, o.at, o.count, this) : o.op === 'insertCols' ? s.insertCols(o.sheet, o.at, o.count, this) : s.deleteCols(o.sheet, o.at, o.count, this);
2429
+ if (!done) {
2430
+ this.schedule();
2431
+ return;
2432
+ }
2433
+ const g = this.geometry();
2434
+ const clamp = (ref) => { const p = parseRef(ref); return p && g ? cellRef(Math.min(p.row, g.g.rows - 1), Math.min(p.col, g.g.cols - 1)) : ref; };
2435
+ const r = sel.range ? parseRange(sel.range) : undefined;
2436
+ const range = r && g ? rangeText({ top: Math.min(r.top, g.g.rows - 1), left: Math.min(r.left, g.g.cols - 1), bottom: Math.min(r.bottom, g.g.rows - 1), right: Math.min(r.right, g.g.cols - 1) }) : undefined;
2437
+ s.select(range ? { sheet: sel.sheet, ref: clamp(sel.ref), range } : { sheet: sel.sheet, ref: clamp(sel.ref) }, this);
2438
+ const what = `${o.count} ${o.op.endsWith('Rows') ? (o.count === 1 ? 'row' : 'rows') : o.count === 1 ? 'column' : 'columns'}`;
2439
+ this.say0(`${o.op.startsWith('insert') ? 'Inserted' : 'Deleted'} ${what}`);
2440
+ }
2441
+ /** Ctrl/⌘+Shift+= inserts above (whole columns: left); Ctrl/⌘+− deletes whole rows or columns, and asks for them otherwise. */
2442
+ structureKey(kind) {
2443
+ const sp = this.selSpan();
2444
+ if (!sp)
2445
+ return;
2446
+ if (kind === 'insert') {
2447
+ this.runStructure(sp.wholeCols && !sp.wholeRows ? 'ins-left' : 'ins-above');
2448
+ return;
2449
+ }
2450
+ if (sp.wholeRows)
2451
+ this.runStructure('del-rows');
2452
+ else if (sp.wholeCols)
2453
+ this.runStructure('del-cols');
2454
+ else
2455
+ this.say0('Select whole rows (Shift+Space) or columns (Ctrl+Space) to delete them');
2456
+ }
2457
+ /** What this element keeps per row or column follows an insert or delete: sizes, what it shows of hidden rows, the highlight. */
2458
+ followStructure(e) {
2459
+ const o = { sheet: e.sheet, op: e.op, at: e.at, count: e.count };
2460
+ const rows = o.op.endsWith('Rows');
2461
+ const move = (rec) => { const out = {}; for (const [k, v] of Object.entries(rec)) {
2462
+ const j = shiftIndex(Number(k), o);
2463
+ if (j !== undefined)
2464
+ out[j] = v;
2465
+ } return out; };
2466
+ const z = this._sizes[e.sheet];
2467
+ if (z)
2468
+ this._sizes[e.sheet] = rows ? { ...z, rows: move(z.rows) } : { ...z, cols: move(z.cols) };
2469
+ const rv = this.revealed[e.sheet];
2470
+ if (rv) {
2471
+ const set = (x) => new Set([...x].flatMap((i) => { const j = shiftIndex(i, o); return j === undefined ? [] : [j]; }));
2472
+ this.revealed[e.sheet] = rows ? { ...rv, rows: set(rv.rows) } : { ...rv, cols: set(rv.cols) };
2473
+ }
2474
+ if (this.hl)
2475
+ this.hl = { ...this.hl, items: this.hl.items.flatMap((h) => { if (h.sheet !== e.sheet)
2476
+ return [h]; const m = shiftRangePos(h.range, o); return m ? [{ ...h, range: m }] : []; }) };
2477
+ this.sizesVer++;
2478
+ this.revealVer++;
2479
+ this.anchor = undefined;
2480
+ this.hdrMenu = undefined;
2481
+ this.rz = undefined;
2482
+ this.geom = undefined;
2483
+ this.findHits = [];
2484
+ this.findVersion = -1;
2485
+ for (const el of this.overlayCache.values())
2486
+ el.remove();
2487
+ this.overlayCache.clear();
2488
+ }
1959
2489
  /** The `selbar` controls that join a menu (W-49 `menus`), as menu items under a separator. */
1960
2490
  pluginMenuItems(menu, sel) {
1961
2491
  const s = this._store, snap = s.getSnapshot();
@@ -1990,7 +2520,7 @@ export class SheetpaneExcel extends ElementBase {
1990
2520
  return `<div class="card szpop" role="dialog" aria-label="${what}" style="top:${y}px;left:${x}px;width:220px"><div class="bd"><label class="field"><span>${what} (px)</span><input class="input" type="number" data-field="size" min="${m.axis === 'col' ? COL_MIN : ROW_MIN}" max="${m.axis === 'col' ? COL_MAX : ROW_MAX}" value="${cur}" aria-label="${what} in pixels"></label><span class="xs faint">Arrows ±1 · Shift ±10 · Enter applies${m.indexes.length > 1 ? ` to ${m.indexes.length} ${m.axis === 'col' ? 'columns' : 'rows'}` : ''}</span></div><div class="ft"><span class="spacer"></span><button type="button" class="btn btn-g" data-act="hcancel">Cancel</button><button type="button" class="btn btn-p" data-act="happly">Apply</button></div></div>`;
1991
2521
  }
1992
2522
  const item = (act, label) => `<button type="button" role="menuitem" class="mi" data-act="${act}">${esc(label)}</button>`;
1993
- return `<div class="card ctxmenu hdrmenu" role="menu" aria-label="${m.axis === 'col' ? 'Column' : 'Row'} menu" data-sp="header-menu" style="top:${y}px;left:${x}px">${item('hfit', 'Fit to data')}${item('hsize', `${what}…`)}${item('hreset', 'Reset to the file’s size')}${this.pluginMenuItems('header')}</div>`;
2523
+ return `<div class="card ctxmenu hdrmenu" role="menu" aria-label="${m.axis === 'col' ? 'Column' : 'Row'} menu" data-sp="header-menu" style="top:${y}px;left:${x}px">${item('hfit', 'Fit to data')}${item('hsize', `${what}…`)}${item('hreset', 'Reset to the file’s size')}${this._store.getSnapshot().mode === 'edit' ? this.structureItems(m.axis === 'col' ? 'cols' : 'rows', m.indexes) : ''}${this.pluginMenuItems('header')}</div>`;
1994
2524
  }
1995
2525
  /** A header click: the whole column, row or sheet; with Shift, out to it from the anchor. */
1996
2526
  selectHeader(h, extend) {
@@ -2086,7 +2616,7 @@ export class SheetpaneExcel extends ElementBase {
2086
2616
  return;
2087
2617
  }
2088
2618
  }
2089
- if (e.altKey && !e.shiftKey && k === 'ArrowDown' && edit) {
2619
+ if (e.altKey && !e.shiftKey && k === 'ArrowDown' && edit && !s.isLocked(snap.selection.sheet, snap.selection.ref)) {
2090
2620
  const ctx = s.cellContext(snap.selection.sheet, snap.selection.ref);
2091
2621
  const mc = ctx && controlsFor(s.plugins, 'cell', 'edit').find((c) => c.cellOptions && c.choose && c.appliesTo?.(ctx));
2092
2622
  if (mc && ctx) {
@@ -2152,6 +2682,10 @@ export class SheetpaneExcel extends ElementBase {
2152
2682
  s.redo(this);
2153
2683
  else if (mod && (k === 'c' || k === 'C'))
2154
2684
  this.copySelection();
2685
+ else if (edit && mod && !e.altKey && (k === '+' || (e.shiftKey && k === '=')))
2686
+ this.structureKey('insert');
2687
+ else if (edit && mod && !e.altKey && !e.shiftKey && (k === '-' || k === '_'))
2688
+ this.structureKey('delete');
2155
2689
  else if ((k === 'F10' && e.shiftKey) || k === 'ContextMenu') {
2156
2690
  // a whole column or row selected: its size menu (W-47); otherwise the selection actions
2157
2691
  const whole = range && (range.top === 0 && range.bottom >= g.g.rows - 1 ? 'col' : range.left === 0 && range.right >= g.g.cols - 1 ? 'row' : undefined);
@@ -2327,6 +2861,7 @@ export class SheetpaneExcel extends ElementBase {
2327
2861
  if (!g)
2328
2862
  return;
2329
2863
  const cells = [];
2864
+ this.lockedRefused = 0;
2330
2865
  const left = this.hiddenIn(g.g, r);
2331
2866
  if (left)
2332
2867
  this.say0(`Cleared the shown cells — ${left} hidden cell${left === 1 ? '' : 's'} left out`);
@@ -2342,6 +2877,7 @@ export class SheetpaneExcel extends ElementBase {
2342
2877
  }
2343
2878
  if (cells.length)
2344
2879
  s.setCells(cells, { origin: this });
2880
+ this.sayLocked();
2345
2881
  }
2346
2882
  rangeEnd(sel) {
2347
2883
  const r = parseRange(sel.range), a = this.anchor;
@@ -2349,6 +2885,11 @@ export class SheetpaneExcel extends ElementBase {
2349
2885
  }
2350
2886
  editCell(first) {
2351
2887
  const cell = this.q.cell;
2888
+ const sel = this._store?.getSnapshot().selection;
2889
+ if (sel && this._store.isLocked(sel.sheet, sel.ref)) {
2890
+ this.refuseLocked(sel.sheet, sel.ref);
2891
+ return;
2892
+ } // W-62: the cell bar's chip says why
2352
2893
  if (cell.hidden)
2353
2894
  return;
2354
2895
  cell.focus();
@@ -2421,11 +2962,13 @@ export class SheetpaneExcel extends ElementBase {
2421
2962
  }
2422
2963
  }
2423
2964
  onOverlayClick(e) {
2965
+ if (inDetail(e.target))
2966
+ return; // the host's region in the card: its clicks are the host's (W-68)
2424
2967
  const t = e.target, s = this._store, snap = s.getSnapshot();
2425
2968
  const act = t.closest('[data-act]')?.dataset.act;
2426
2969
  const pin = t.closest('[data-pin]');
2427
2970
  if (pin) {
2428
- const err = snap.errors.find((x) => x.id === pin.dataset.pin);
2971
+ const err = snap.visibleErrors.find((x) => x.id === pin.dataset.pin);
2429
2972
  if (err) {
2430
2973
  const r = parseRange(err.range);
2431
2974
  this.anchor = { row: r.top, col: r.left };
@@ -2485,12 +3028,27 @@ export class SheetpaneExcel extends ElementBase {
2485
3028
  this.schedule();
2486
3029
  this.q.grid.focus({ preventScroll: true });
2487
3030
  break;
3031
+ case 'ins-above':
3032
+ case 'ins-below':
3033
+ case 'ins-left':
3034
+ case 'ins-right':
3035
+ case 'del-rows':
3036
+ case 'del-cols': {
3037
+ const ix = this.hdrMenu?.indexes;
3038
+ this.hdrMenu = undefined;
3039
+ this.runStructure(act, ix);
3040
+ this.q.grid.focus({ preventScroll: true });
3041
+ break;
3042
+ }
2488
3043
  case 'flag':
2489
3044
  this.flag();
2490
3045
  break;
2491
3046
  case 'copy-ref':
2492
3047
  this.copy(this.refOf(sel));
2493
3048
  break;
3049
+ case 'copy-link':
3050
+ this.copy(this.linkUrl());
3051
+ break;
2494
3052
  case 'copy-value':
2495
3053
  this.copySelection();
2496
3054
  break;
@@ -2530,6 +3088,8 @@ export class SheetpaneExcel extends ElementBase {
2530
3088
  }
2531
3089
  }
2532
3090
  onComposerInput(e) {
3091
+ if (inDetail(e.target))
3092
+ return;
2533
3093
  const t = e.target;
2534
3094
  const f = t.dataset.field;
2535
3095
  if (!f)
@@ -2564,7 +3124,7 @@ export class SheetpaneExcel extends ElementBase {
2564
3124
  }
2565
3125
  stepError(dir) {
2566
3126
  const s = this._store, snap = s.getSnapshot();
2567
- const list = snap.errors.filter((e) => e.sheet === snap.selection.sheet);
3127
+ const list = snap.visibleErrors.filter((e) => e.sheet === snap.selection.sheet && errorScope(e) === 'cell' && !isResult(e)); // a sheet-level error has no cell to step to (W-55); a result is not a problem (W-50)
2568
3128
  if (!list.length)
2569
3129
  return;
2570
3130
  const cur = this.errorAt(snap.selection.sheet, snap.selection.ref);
@@ -2574,13 +3134,15 @@ export class SheetpaneExcel extends ElementBase {
2574
3134
  this.anchor = { row: r.top, col: r.left };
2575
3135
  s.select({ sheet: nx.sheet, ref: cellRef(r.top, r.left) }, this);
2576
3136
  }
3137
+ /** A result's check — its message, else its type's name: what its mark, chip and group say (W-50). */
3138
+ checkOf(e) { return e.message || (this._errorTypes.find((t) => t.id === e.type)?.name ?? BUILTIN_TYPE_NAMES[e.type] ?? e.type); }
2577
3139
  errorAt(sheet, ref) {
2578
3140
  const p = parseRef(ref);
2579
3141
  if (!p)
2580
3142
  return undefined;
2581
- const hits = this._store.getSnapshot().errors.filter((e) => {
2582
- if (e.sheet !== sheet)
2583
- return false;
3143
+ const hits = this._store.getSnapshot().visibleErrors.filter((e) => {
3144
+ if (e.sheet !== sheet || isResult(e))
3145
+ return false; // a result is never the cell's error (W-50)
2584
3146
  const r = parseRange(e.range);
2585
3147
  return !!r && p.row >= r.top && p.row <= r.bottom && p.col >= r.left && p.col <= r.right;
2586
3148
  });
@@ -2669,6 +3231,7 @@ export class SheetpaneExcel extends ElementBase {
2669
3231
  this.renderState();
2670
3232
  q.frow.hidden = true;
2671
3233
  q.status.textContent = 'Read in this tab — nothing is uploaded';
3234
+ delete q.status.dataset.html;
2672
3235
  return;
2673
3236
  }
2674
3237
  const book = snap.book, src = snap.source;
@@ -2678,11 +3241,12 @@ export class SheetpaneExcel extends ElementBase {
2678
3241
  // toolbar
2679
3242
  q.fname.textContent = src.name;
2680
3243
  q.meta.textContent = `${src.kind} · ${fmtSize(src.size)} · ${src.kind === 'csv' ? delimName(book.delimiter) : `${book.sheets.length} sheet${book.sheets.length === 1 ? '' : 's'}`}`;
3244
+ q.fname.title = `${src.name} · ${q.meta.textContent}`; // the meta chip is shed at narrow widths
2681
3245
  for (const b of Array.from(q.modeseg.querySelectorAll('button')))
2682
3246
  b.setAttribute('aria-pressed', String(b.dataset.mode === snap.mode));
2683
3247
  const open = { error: 0, warning: 0, note: 0 };
2684
- for (const e of snap.errors)
2685
- if (e.status === 'open')
3248
+ for (const e of snap.visibleErrors)
3249
+ if (e.status === 'open' && !isResult(e))
2686
3250
  open[e.severity]++;
2687
3251
  q.counts.innerHTML = edit ? `<span class="chip chip-fail" title="Open errors">✕ ${open.error}</span> <span class="chip chip-warn" title="Open warnings">! ${open.warning}</span> <span class="chip chip-info" title="Open notes">i ${open.note}</span>` : '';
2688
3252
  q.editonly.style.display = edit ? 'contents' : 'none';
@@ -2716,7 +3280,8 @@ export class SheetpaneExcel extends ElementBase {
2716
3280
  const merge = mergeAt(g.g, p.row, p.col);
2717
3281
  q.ref.textContent = sel.range ?? (merge ? `${cellRef(merge.top, merge.left)}:${cellRef(merge.bottom, merge.right)}` : sel.ref);
2718
3282
  const composing = !!snap.composing;
2719
- const editable = edit && !composing;
3283
+ const locked = edit && s.isLocked(sel.sheet, sel.ref); // W-62: a locked cell shows its value, not an input
3284
+ const editable = edit && !composing && !locked;
2720
3285
  const cell = q.cell;
2721
3286
  cell.hidden = !editable;
2722
3287
  q.val.hidden = editable;
@@ -2740,6 +3305,13 @@ export class SheetpaneExcel extends ElementBase {
2740
3305
  q.issue.className = `chip ${err.status === 'resolved' ? 'chip-pass' : err.severity === 'error' ? 'chip-fail' : err.severity === 'warning' ? 'chip-warn' : 'chip-info'}`;
2741
3306
  q.issue.textContent = `${err.status === 'resolved' ? '✓' : SEVERITY[err.severity].glyph} Error ${errNum(err)}`;
2742
3307
  }
3308
+ // a result cell (W-50): the one place its mark is explained in words — "✓ Passed · Fee rate 0.00%–2.00%"
3309
+ const res = s.results !== 'off' ? s.resultAt(sel.sheet, p.row, p.col) : undefined;
3310
+ q.result.hidden = !res;
3311
+ if (res) {
3312
+ q.result.className = `chip result ${res.severity === 'pass' ? 'chip-pass' : 'chip-skip'}`;
3313
+ q.result.textContent = `${SEVERITY[res.severity].glyph} ${SEVERITY[res.severity].name} · ${this.checkOf(res)}`;
3314
+ }
2743
3315
  q.hint.hidden = !(composing && s.hasErrorsView);
2744
3316
  // column rules: the draft's failure as typed, or a refused value (W-27)
2745
3317
  const rm = this.ruleMsg && this.ruleMsg.sheet === sel.sheet && this.ruleMsg.ref === sel.ref ? this.ruleMsg : undefined;
@@ -2755,13 +3327,23 @@ export class SheetpaneExcel extends ElementBase {
2755
3327
  }
2756
3328
  else
2757
3329
  cell.removeAttribute('title');
3330
+ // a rich-text cell being edited (W-53): saying so before the edit lands, since it writes plain text
3331
+ const richEdit = edit && !!this.editing && this.editing.sheet === sel.sheet && this.editing.ref === sel.ref && !!s.runs(sel.sheet, sel.ref);
3332
+ q.richmsg.hidden = !richEdit;
3333
+ if (richEdit)
3334
+ q.richmsg.textContent = `${SEVERITY.warning.glyph} Mixed formatting — an edit saves plain text`;
3335
+ q.lockmsg.hidden = !locked;
3336
+ if (locked)
3337
+ q.lockmsg.textContent = `🔒 ${LOCKED_MESSAGE}`;
2758
3338
  q.plugCellbar.innerHTML = this.controlsHtml(controlsFor(s.plugins, 'cellbar', snap.mode), 'btn btn-g');
2759
3339
  // docked selection actions (W-31): Flag issue · selbar plugin controls · ⋯ — hidden while typing or composing
2760
3340
  const typing = !!this.editing && this.shadow.activeElement === cell;
2761
3341
  q.dock.hidden = typing || composing;
2762
3342
  if (!q.dock.hidden) {
2763
3343
  const flagBtn = edit && !err && s.flagging ? `<button type="button" class="btn btn-g" data-act="flag" data-sp="flag" title="Flag issue (F)" aria-keyshortcuts="F">${ICON.flag}Flag issue<span class="kbd" aria-hidden="true">F</span></button>` : '';
2764
- const plug = edit ? this.controlsHtml(controlsFor(s.plugins, 'selbar', 'edit'), 'btn btn-g') : '';
3344
+ // the bar holds the cell's quick actions (Bold · Fill · Merge, greyed when they do not apply); a menu action (W-49 `menus`)
3345
+ // shows here only when it joins the cell menu and applies now (Unhide with nothing hidden, Group, Hide sheet stay in their menus)
3346
+ const plug = edit ? this.controlsHtml(controlsFor(s.plugins, 'selbar', 'edit').filter((c) => !c.menus || (c.menus.includes('cell') && !c.disabled?.(snap, snap.selection))), 'btn btn-g') : '';
2765
3347
  const more = `<button type="button" class="btn btn-g btn-i" data-act="more" aria-haspopup="menu" aria-expanded="${this.moreOpen}" title="More actions (Shift+F10)" aria-label="More actions (Shift+F10)">⋯</button>`;
2766
3348
  // the ⋯ menu: Copy ref, Copy value, and the plugin buttons, which show here only once they fold out of the bar
2767
3349
  const menu = this.moreOpen ? `<div class="card moremenu" role="menu" aria-label="More actions">${this.actionItems(edit, err, { flag: false, plugins: true })}</div>` : '';
@@ -2770,6 +3352,7 @@ export class SheetpaneExcel extends ElementBase {
2770
3352
  q.dock.innerHTML = html;
2771
3353
  q.dock.dataset.html = html;
2772
3354
  }
3355
+ this.fitDock();
2773
3356
  }
2774
3357
  else if (this.moreOpen)
2775
3358
  this.moreOpen = false;
@@ -2777,7 +3360,9 @@ export class SheetpaneExcel extends ElementBase {
2777
3360
  const frow = controlsFor(s.plugins, 'format-row', 'edit');
2778
3361
  const hostSlot = q.frow.querySelector('slot').assignedElements().length > 0;
2779
3362
  q.frow.hidden = !edit || (!frow.length && !hostSlot);
2780
- q.plugFrow.innerHTML = this.controlsHtml(frow, 'btn btn-g', true);
3363
+ q.plugFrow.innerHTML = this.frowHtml(frow);
3364
+ if (!q.frow.hidden)
3365
+ this.fitFrow();
2781
3366
  // find
2782
3367
  if (this.findText)
2783
3368
  this.computeHits();
@@ -2788,9 +3373,10 @@ export class SheetpaneExcel extends ElementBase {
2788
3373
  q.tabs.hidden = src.kind === 'csv';
2789
3374
  // W-49: hidden sheets are not tabs — one ⊘ button lists them; one opened for viewing shows dashed
2790
3375
  const tabsHtml = book.sheets.filter((sh) => s.hidden(sh.name).state === 'visible' || this.openedSheets.has(sh.name) || sh.name === sheet.name).map((sh) => {
2791
- const cnt = edit ? snap.errors.filter((e) => e.sheet === sh.name && e.status === 'open').length : 0;
3376
+ const mine = edit ? snap.visibleErrors.filter((e) => e.sheet === sh.name && e.status === 'open' && !isResult(e)) : [];
3377
+ const cnt = mine.length, whole = mine.filter((e) => e.range === '').length; // W-55: a whole-sheet error is counted on its tab; results are not (W-50)
2792
3378
  const hid = s.hidden(sh.name).state !== 'visible';
2793
- return `<button type="button" role="tab" class="tab${hid ? ' hid' : ''}" data-sheet="${esc(sh.name)}" data-sp-sheet="${esc(sh.name)}" aria-selected="${sh.name === sheet.name}"${hid ? ' title="Hidden in the file — shown for viewing"' : ''}>${hid ? '⊘ ' : ''}${esc(sh.name)}${cnt ? `<span class="cnt" aria-label="${cnt} open">${cnt}</span>` : ''}</button>`;
3379
+ return `<button type="button" role="tab" class="tab${hid ? ' hid' : ''}" data-sheet="${esc(sh.name)}" data-sp-sheet="${esc(sh.name)}" aria-selected="${sh.name === sheet.name}"${hid ? ' title="Hidden in the file — shown for viewing"' : ''}>${hid ? '⊘ ' : ''}${esc(sh.name)}${cnt ? `<span class="cnt" aria-label="${cnt} open${whole ? `, ${whole} about the whole sheet` : ''}">${cnt}</span>` : ''}</button>`;
2794
3380
  }).join('');
2795
3381
  const tabsHtml2 = `<div class="tablist" role="tablist" aria-label="Sheets">${tabsHtml}</div>${this.hiddenSheetsHtml()}`;
2796
3382
  if (q.tabs.dataset.html !== tabsHtml2) {
@@ -2806,21 +3392,32 @@ export class SheetpaneExcel extends ElementBase {
2806
3392
  const curHidden = s.hidden(sheet.name).state !== 'visible';
2807
3393
  const note = this.revealNote?.sheet === sheet.name ? this.revealNote : undefined;
2808
3394
  const barHtml = curHidden
2809
- ? `<span>⊘ This sheet is hidden in the file</span><button type="button" class="btn btn-g" data-act="hide-sheet-again">Hide again</button>${this.canUnhide() ? '<button type="button" class="btn btn-g" data-act="unhide-sheet">Unhide</button>' : ''}`
3395
+ ? `<span>⊘ This sheet is hidden in the file</span><button type="button" class="btn btn-g" data-act="hide-sheet-again">Hide again</button>${this.canUnhide() ? (s.structureLocked() ? `<button type="button" class="btn btn-g" data-act="unhide-sheet" disabled title="${esc(STRUCTURE_LOCKED_MESSAGE)}">Unhide</button>` : '<button type="button" class="btn btn-g" data-act="unhide-sheet">Unhide</button>') : ''}`
2810
3396
  : note ? `<span>${esc(note.text)}</span><button type="button" class="btn btn-g" data-act="hide-again">Hide again</button><button type="button" class="btn btn-g btn-i" data-act="note-close" aria-label="Dismiss">${ICON.close}</button>` : '';
2811
3397
  q.hidbar.hidden = !barHtml;
2812
3398
  if (q.hidbar.dataset.html !== barHtml) {
2813
3399
  q.hidbar.innerHTML = barHtml;
2814
3400
  q.hidbar.dataset.html = barHtml;
2815
3401
  }
2816
- this.renderGrid(g.g, sheet, snap.mode);
2817
- // status line
3402
+ // status line — before the grid, so a scroll to the selection measures the grid's final height (W-66: a
3403
+ // file opened at a ref; the line's first content after a load would otherwise shrink it afterwards)
2818
3404
  const statusPlug = controlsFor(s.plugins, 'status', snap.mode).map((c) => c.text?.(snap, sel) ?? '').filter(Boolean);
2819
3405
  const dataRows = sheet.rows.length, dataCols = sheet.rows.reduce((m, r) => Math.max(m, r.length), 0);
2820
- q.status.innerHTML = [
3406
+ // W-56: the host's highlight label, while an outline shows on this sheet
3407
+ const hlLabel = this.hl?.label && this.hl.items.some((x) => x.sheet === sheet.name) ? this.hl.label : '';
3408
+ // the line is a live region: its nodes are replaced only when its text changes, so a label is said once
3409
+ const statusHtml = [
2821
3410
  `<span>${esc(sheet.name)} · ${dataRows} row${dataRows === 1 ? '' : 's'} × ${dataCols} column${dataCols === 1 ? '' : 's'}${g.g.merges.length ? ` · ${g.g.merges.length} merged range${g.g.merges.length === 1 ? '' : 's'}` : ''}</span>`,
2822
3411
  n ? `<span>· ${n} cell change${n === 1 ? '' : 's'} kept in memory until you export</span>` : '',
2823
3412
  (() => { const t = this.totalsFor(g.g); return t ? `<span class="totals" data-sp="totals">· ${esc(t)}</span>` : ''; })(),
3413
+ // the host's coverage line (W-50): "186 of 214 checked · 6 problems" — only when the pane holds results
3414
+ (() => {
3415
+ const rc = s.resultCounts();
3416
+ if (s.results === 'off' || !(rc.pass + rc.skipped))
3417
+ return '';
3418
+ const checked = rc.pass + rc.problems;
3419
+ return `<span class="checked">· ${checked} of ${checked + rc.skipped} checked · ${rc.open} problem${rc.open === 1 ? '' : 's'}</span>`;
3420
+ })(),
2824
3421
  (() => {
2825
3422
  const h = s.hidden(sheet.name);
2826
3423
  if (!h.rows.length && !h.cols.length)
@@ -2830,11 +3427,17 @@ export class SheetpaneExcel extends ElementBase {
2830
3427
  return `<span>· ${parts} hidden · <button type="button" class="link" data-act="show-hidden" aria-pressed="${!!all}">${all ? 'Hide them again' : 'Show hidden'}</button></span>`;
2831
3428
  })(),
2832
3429
  ...statusPlug.map((t) => `<span>${esc(t)}</span>`),
3430
+ hlLabel ? `<span class="hlnote">· ${esc(hlLabel)}</span>` : '',
2833
3431
  this.notice ? `<span role="status" class="warn-t">· ${esc(this.notice)}</span>` : '',
2834
3432
  s.ruleProgress ? `<span role="status">· Checking column rules · ${Math.floor((100 * s.ruleProgress.done) / Math.max(1, s.ruleProgress.total))} %</span>` : '',
2835
3433
  `<span class="spacer"></span><span>Read in this tab — nothing uploaded</span>`,
2836
3434
  this.getAttribute('display') === 'popup' ? '<span>Esc to close</span>' : '',
2837
3435
  ].join('');
3436
+ if (q.status.dataset.html !== statusHtml) {
3437
+ q.status.innerHTML = statusHtml;
3438
+ q.status.dataset.html = statusHtml;
3439
+ }
3440
+ this.renderGrid(g.g, sheet, snap.mode);
2838
3441
  }
2839
3442
  /** Count · Sum · Average of a range of two or more cells (W-46 DoD 8), cached until the range or the cells change. */
2840
3443
  totalsFor(g) {
@@ -2914,6 +3517,12 @@ export class SheetpaneExcel extends ElementBase {
2914
3517
  this.scrollToSel = false;
2915
3518
  this.scrollSelIntoView(g, z, fr, fc);
2916
3519
  }
3520
+ if (this.scrollToHl && sc.clientWidth > 0) {
3521
+ const t = this.scrollToHl;
3522
+ this.scrollToHl = undefined;
3523
+ if (t.sheet === sheet.name)
3524
+ this.scrollRangeIntoView(g, z, fr, fc, t.range, 4);
3525
+ } // W-56 `reveal`: the outline and its halo show too
2917
3526
  const win = visibleWindow(g, z, sc.scrollTop, sc.scrollLeft, sc.clientHeight || 600, sc.clientWidth || 900, { rows: fr, cols: fc });
2918
3527
  const grid = q.grid;
2919
3528
  grid.style.cssText = `position:relative;width:${totalW}px;height:${totalH}px`;
@@ -2928,13 +3537,13 @@ export class SheetpaneExcel extends ElementBase {
2928
3537
  const marks = new Map();
2929
3538
  const rowWorst = new Map();
2930
3539
  if (edit)
2931
- for (const e of snap.errors) {
2932
- if (e.sheet !== sheet.name)
2933
- continue;
3540
+ for (const e of snap.visibleErrors) {
3541
+ if (e.sheet !== sheet.name || isResult(e))
3542
+ continue; // results: a corner glyph, never a fill or a row glyph (W-50)
2934
3543
  const r = parseRange(e.range);
2935
3544
  if (!r)
2936
3545
  continue;
2937
- const m = e.status === 'resolved' ? 'm-pass' : `m-${e.severity}`;
3546
+ const m = e.status === 'resolved' ? 'm-pass' : e.status === 'accepted' ? 'm-accepted' : `m-${e.severity}`; // an accepted deviation is marked quietly (W-69)
2938
3547
  for (let rr = r.top; rr <= Math.min(r.bottom, r.top + 2000); rr++)
2939
3548
  for (let cc = r.left; cc <= r.right; cc++)
2940
3549
  marks.set(`${rr},${cc}`, m);
@@ -2945,6 +3554,8 @@ export class SheetpaneExcel extends ElementBase {
2945
3554
  rowWorst.set(rr, e.severity);
2946
3555
  }
2947
3556
  }
3557
+ // result marks (W-50): every result cell at `marks`; at `summary` only the range Show ▸ asked for
3558
+ const markAll = s.results === 'marks', focus = s.results !== 'off' && s.resultFocus?.sheet === sheet.name ? parseRange(s.resultFocus.range) : undefined;
2948
3559
  const cellControls = controlsFor(s.plugins, 'cell', mode);
2949
3560
  const decorating = s.plugins.some((p) => p.provides.includes('decorate'));
2950
3561
  const covered = (r, c) => { const m = mergeAt(g, r, c); return m && !(m.top === r && m.left === c) ? m : undefined; };
@@ -2994,12 +3605,21 @@ export class SheetpaneExcel extends ElementBase {
2994
3605
  let extra = '';
2995
3606
  if (cellControls.length) {
2996
3607
  const ctx = s.cellContext(sheet.name, ref);
3608
+ const locked = cellControls.some((cc) => cc.choose) && s.isLocked(sheet.name, ref); // W-62: no picker that would write a locked cell
2997
3609
  for (const cc of cellControls)
2998
- if (ctx && cc.appliesTo?.(ctx))
3610
+ if (ctx && !(locked && cc.choose) && cc.appliesTo?.(ctx))
2999
3611
  extra += `<button type="button" class="mk" data-control="${esc(cc.id)}" aria-label="${esc(cc.label)}" tabindex="-1">${cc.icon ?? '▾'}</button>`;
3000
3612
  }
3613
+ if (markAll || (focus && r >= focus.top && r <= focus.bottom && c >= focus.left && c <= focus.right)) {
3614
+ const rs = s.resultAt(sheet.name, r, c); // a problem always wins: none there
3615
+ if (rs)
3616
+ extra += `<span class="rmk ${rs.severity}" role="img" aria-label="${SEVERITY[rs.severity].name}: ${esc(this.checkOf(rs))}">${SEVERITY[rs.severity].glyph}</span>`;
3617
+ }
3001
3618
  const title = stale ? ' title="May be stale — Excel recalculates on open"' : text.length > 24 ? ` title="${esc(text)}"` : '';
3002
- return `<div id="c${r}_${c}" role="gridcell" class="${cls.join(' ')}" data-r="${r}" data-c="${c}" data-sp-ref="${cellRef(r, c)}" aria-colindex="${c + 2}" aria-selected="false" style="${style}"${title}><span class="v">${esc(text)}</span>${extra}</div>`;
3619
+ // a rich-text cell (W-53): its runs as spans when a decorate plugin draws them, else the plain text
3620
+ const pieces = decorating ? s.decorateRuns(sheet.name, ref) : undefined;
3621
+ const inner = pieces ? pieces.map((x) => (x.style ? `<span style="${esc(Object.entries(x.style).map(([k2, v2]) => `${k2}:${v2}`).join(';'))}">${esc(x.text)}</span>` : esc(x.text))).join('') : esc(text);
3622
+ return `<div id="c${r}_${c}" role="gridcell" class="${cls.join(' ')}" data-r="${r}" data-c="${c}" data-sp-ref="${cellRef(r, c)}" aria-colindex="${c + 2}" aria-selected="false" style="${style}"${title}><span class="v">${inner}</span>${extra}</div>`;
3003
3623
  };
3004
3624
  const rowHead = (r) => {
3005
3625
  const w = rowWorst.get(r);
@@ -3164,22 +3784,27 @@ export class SheetpaneExcel extends ElementBase {
3164
3784
  this.ariaOn = next;
3165
3785
  }
3166
3786
  scrollSelIntoView(g, z, fr, fc) {
3167
- const s = this._store, p = parseRef(s.getSnapshot().selection.ref), sc = this.q.scroller;
3168
- const HH = this.headH() * z, RW = this.rowHeadW() * z;
3169
- const top = rowTop(g, p.row) * z, bottom = rowTop(g, p.row + 1) * z;
3787
+ const p = parseRef(this._store.getSnapshot().selection.ref);
3788
+ this.scrollRangeIntoView(g, z, fr, fc, { top: p.row, left: p.col, bottom: p.row, right: p.col });
3789
+ }
3790
+ /** Scrolls so a range shows, `pad` px around it — its top-left first when it does not fit; frozen rows and columns never scroll. */
3791
+ scrollRangeIntoView(g, z, fr, fc, r, pad = 0) {
3792
+ const sc = this.q.scroller, HH = this.headH() * z, RW = this.rowHeadW() * z;
3170
3793
  const stickTop = HH + rowTop(g, fr) * z, stickLeft = RW + g.colX[fc] * z;
3171
- if (p.row >= fr) {
3172
- if (top + HH < sc.scrollTop + stickTop)
3173
- sc.scrollTop = Math.max(0, top + HH - stickTop);
3174
- else if (bottom + HH > sc.scrollTop + sc.clientHeight)
3175
- sc.scrollTop = bottom + HH - sc.clientHeight;
3176
- }
3177
- if (p.col >= fc) {
3178
- const left = g.colX[p.col] * z + RW, right = (g.colX[p.col + 1] ?? g.colX[p.col]) * z + RW;
3794
+ if (r.top >= fr) {
3795
+ const top = rowTop(g, r.top) * z + HH - pad, bottom = rowTop(g, r.bottom + 1) * z + HH + pad;
3796
+ if (top < sc.scrollTop + stickTop)
3797
+ sc.scrollTop = Math.max(0, top - stickTop);
3798
+ else if (bottom > sc.scrollTop + sc.clientHeight)
3799
+ sc.scrollTop = Math.min(top - stickTop, bottom - sc.clientHeight);
3800
+ }
3801
+ const left0 = Math.min(r.left, g.cols - 1), right0 = Math.min(r.right, g.cols - 1);
3802
+ if (left0 >= fc) {
3803
+ const left = g.colX[left0] * z + RW - pad, right = (g.colX[right0 + 1] ?? g.colX[right0]) * z + RW + pad;
3179
3804
  if (left < sc.scrollLeft + stickLeft)
3180
3805
  sc.scrollLeft = Math.max(0, left - stickLeft);
3181
3806
  else if (right > sc.scrollLeft + sc.clientWidth)
3182
- sc.scrollLeft = right - sc.clientWidth;
3807
+ sc.scrollLeft = Math.min(left - stickLeft, right - sc.clientWidth);
3183
3808
  }
3184
3809
  }
3185
3810
  renderOverlay(g, sheet, z, RW, HH, Y, H, X, W, win) {
@@ -3196,9 +3821,9 @@ export class SheetpaneExcel extends ElementBase {
3196
3821
  }
3197
3822
  const sel = snap.selection, selP = parseRef(sel.ref);
3198
3823
  if (edit) {
3199
- for (const e of snap.errors) {
3200
- if (e.sheet !== sheet.name)
3201
- continue;
3824
+ for (const e of snap.visibleErrors) {
3825
+ if (e.sheet !== sheet.name || isResult(e))
3826
+ continue; // a result has no pin (W-50)
3202
3827
  const r = parseRange(e.range);
3203
3828
  if (!r || r.top > win.r1 + 40)
3204
3829
  continue;
@@ -3206,7 +3831,7 @@ export class SheetpaneExcel extends ElementBase {
3206
3831
  const on = r.top === selP.row && selP.col >= r.left && selP.col <= r.right;
3207
3832
  const mach = machineAuthored(e.author);
3208
3833
  const left = mach ? RW + X(r.right + 1) - 18 : p.x - 6;
3209
- parts.push(`<button type="button" class="pin ${e.status === 'resolved' ? 'resolved' : e.severity}${on ? ' on' : ''}${mach ? ' square' : ''}" data-pin="${esc(e.id)}" aria-label="${mach ? `${esc(BUILTIN_TYPE_NAMES[e.type] ?? e.type)}` : `Error ${errNum(e)}`}, ${esc(e.range)}, ${SEVERITY[e.severity].name}${e.status === 'resolved' ? ', resolved' : ''}" style="top:${p.y - 8}px;left:${left}px">${e.status === 'resolved' ? '✓' : mach ? '⚠' : errNum(e)}</button>`);
3834
+ parts.push(`<button type="button" class="pin ${e.status === 'open' ? e.severity : e.status}${on ? ' on' : ''}${mach ? ' square' : ''}" data-pin="${esc(e.id)}" aria-label="${mach ? `${esc(BUILTIN_TYPE_NAMES[e.type] ?? e.type)}` : `Error ${errNum(e)}`}, ${esc(e.range)}, ${SEVERITY[e.severity].name}${e.status === 'open' ? '' : `, ${e.status}`}" style="top:${p.y - 8}px;left:${left}px">${e.status === 'resolved' ? '✓' : e.status === 'accepted' ? 'A' : mach ? '⚠' : errNum(e)}</button>`);
3210
3835
  }
3211
3836
  }
3212
3837
  const composing = snap.composing;
@@ -3291,8 +3916,45 @@ export class SheetpaneExcel extends ElementBase {
3291
3916
  }
3292
3917
  }
3293
3918
  }
3919
+ this.detailRegion.sync(cardEl, card.startsWith('<div class="card callout"') ? err : undefined); // the host's region in the card (W-68)
3920
+ this.renderHighlight(g, sheet, RW, HH, Y, X, ov, fixed);
3294
3921
  this.renderPluginOverlays(sheet, z, RW, HH, ov);
3295
3922
  }
3923
+ /**
3924
+ * The host's highlight (W-56): one box per range on this sheet, 2 px outside the cells so the
3925
+ * selection ring stays visible, in the overlay under the pins and cards; `pointer-events: none`, so
3926
+ * the pointer and clicks reach the cells below. Hidden rows and columns are 0 px, so a range wholly in
3927
+ * them draws nothing.
3928
+ */
3929
+ renderHighlight(g, sheet, RW, HH, Y, X, ov, before) {
3930
+ let html = '';
3931
+ for (const it of this.hl?.items ?? []) {
3932
+ const r = it.range;
3933
+ if (it.sheet !== sheet.name || r.top >= g.rows || r.left >= g.cols)
3934
+ continue;
3935
+ const b = Math.min(r.bottom, g.rows - 1), rt = Math.min(r.right, g.cols - 1);
3936
+ const x = RW + X(r.left), y = HH + Y(r.top), w = X(rt + 1) - X(r.left), h = Y(b + 1) - Y(r.top);
3937
+ if (w > 0 && h > 0)
3938
+ html += `<div class="hlbox ${it.tone}" style="left:${x - 2}px;top:${y - 2}px;width:${w + 4}px;height:${h + 4}px"></div>`;
3939
+ }
3940
+ let layer = ov.querySelector(':scope > .hl');
3941
+ if (!html) {
3942
+ layer?.remove();
3943
+ return;
3944
+ }
3945
+ if (!layer) {
3946
+ layer = document.createElement('div');
3947
+ layer.className = 'hl';
3948
+ layer.setAttribute('aria-hidden', 'true');
3949
+ layer.style.cssText = 'position:absolute;inset:0;pointer-events:none';
3950
+ }
3951
+ if (layer.nextSibling !== before)
3952
+ ov.insertBefore(layer, before);
3953
+ if (layer.dataset.html !== html) {
3954
+ layer.innerHTML = html;
3955
+ layer.dataset.html = html;
3956
+ }
3957
+ }
3296
3958
  renderPluginOverlays(sheet, z, RW, HH, ov) {
3297
3959
  const s = this._store;
3298
3960
  const providers = s.plugins.filter((p) => p.provides.includes('overlay'));
@@ -3353,9 +4015,9 @@ export class SheetpaneExcel extends ElementBase {
3353
4015
  const meta = [sv.name, e.author || '', e.code ?? '', e.createdAt ? new Date(e.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '', e.cellChanged ? 'cell changed' : ''].filter(Boolean).join(' · ');
3354
4016
  const ro = machineAuthored(e.author);
3355
4017
  return `<div class="card callout" role="group" aria-label="Error details" data-sp="callout" style="top:${top}px;left:${left}px;width:300px">
3356
- <div class="hd"><span class="g ${e.status === 'resolved' ? 'g-pass' : `g-${e.severity}`}" aria-hidden="true">${e.status === 'resolved' ? '✓' : sv.glyph}</span><span class="t">${esc(t?.name ?? BUILTIN_TYPE_NAMES[e.type] ?? e.type)}</span><span class="xs faint">${esc(e.range)}</span><span class="spacer"></span><button type="button" class="btn btn-g btn-i" data-act="prev" aria-label="Previous error">${ICON.prev}</button><button type="button" class="btn btn-g btn-i" data-act="next" aria-label="Next error">${ICON.next}</button></div>
3357
- <div class="bd"><dl class="fe"><dt>Found</dt><dd><span class="found">${esc(e.found)}</span></dd><dt>Should be</dt><dd><span class="want">${esc(e.expected || '—')}</span></dd></dl>${e.message ? `<p style="margin:0;font-size:13px">${esc(e.message)}</p>` : ''}${tags ? `<div style="display:flex;gap:6px">${tags}</div>` : ''}<span class="xs faint">${esc(meta)}</span></div>
3358
- ${ro ? '' : `<div class="ft"><button type="button" class="btn" data-act="resolve">${e.status === 'resolved' ? 'Reopen' : '✓ Resolve'}</button><span class="spacer"></span><button type="button" class="btn btn-g btn-d" data-act="delete">Delete</button></div>`}</div>`;
4018
+ <div class="hd"><span class="g ${e.status === 'resolved' ? 'g-pass' : `g-${e.status === 'accepted' ? 'accepted' : e.severity}`}" aria-hidden="true">${e.status === 'resolved' ? '✓' : e.status === 'accepted' ? 'A' : sv.glyph}</span><span class="t">${esc(t?.name ?? BUILTIN_TYPE_NAMES[e.type] ?? e.type)}</span><span class="xs faint">${esc(e.range)}</span><span class="spacer"></span><button type="button" class="btn btn-g btn-i" data-act="prev" aria-label="Previous error">${ICON.prev}</button><button type="button" class="btn btn-g btn-i" data-act="next" aria-label="Next error">${ICON.next}</button></div>
4019
+ <div class="bd"><dl class="fe"><dt>Found</dt><dd><span class="found">${esc(e.found)}</span></dd><dt>Should be</dt><dd><span class="want">${esc(e.expected || '—')}</span></dd></dl>${e.message ? `<p style="margin:0;font-size:13px">${esc(e.message)}</p>` : ''}${this.detailRegion.html()}${acceptedHtml(e)}${tags ? `<div style="display:flex;gap:6px">${tags}</div>` : ''}<span class="xs faint">${esc(meta)}</span></div>
4020
+ ${ro ? '' : `<div class="ft"><button type="button" class="btn" data-act="resolve">${e.status === 'open' ? '✓ Resolve' : 'Reopen'}</button><span class="spacer"></span><button type="button" class="btn btn-g btn-d" data-act="delete">Delete</button></div>`}</div>`;
3359
4021
  }
3360
4022
  composerHtml(left, top, ref) {
3361
4023
  const s = this._store, snap = s.getSnapshot(), c = snap.composing;