@svgrid/enterprise 3.1.21 → 3.1.23

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 (89) hide show
  1. package/dist/SvGridBoard.svelte +39 -0
  2. package/dist/SvGridGallery.svelte +23 -0
  3. package/dist/SvGridRowCount.svelte +23 -0
  4. package/dist/SvGridRowCount.svelte.d.ts +6 -0
  5. package/dist/SvGridScheduler.svelte +25 -7
  6. package/dist/cdn/svgrid-enterprise.svelte-external.js +13838 -13554
  7. package/dist/designer/assets/GridFooter-DKMfZpL4.js +2 -0
  8. package/dist/designer/assets/GridMenus-DWDghydL.js +6 -0
  9. package/dist/designer/assets/{PinSplitter-C5AwA1xV.js → PinSplitter-BfIjd7pi.js} +1 -1
  10. package/dist/designer/assets/grid-extension.svelte-G1ng0xd2.js +1 -0
  11. package/dist/designer/assets/index-CHx3F4Ij.css +1 -0
  12. package/dist/designer/assets/{index-DLJjuVA4.js → index-CIxqRzKJ.js} +324 -324
  13. package/dist/designer/assets/merges-Bg-KqPEl.js +1 -0
  14. package/dist/designer/index.html +3 -3
  15. package/dist/expressions/compile.js +11 -7
  16. package/dist/expressions/evaluate.js +4 -0
  17. package/dist/expressions/expression-columns.d.ts +4 -1
  18. package/dist/expressions/expression-columns.js +12 -3
  19. package/dist/expressions/parse.js +19 -7
  20. package/dist/fields/RecordView.svelte +5 -2
  21. package/dist/fields/activity.svelte.d.ts +5 -2
  22. package/dist/fields/activity.svelte.js +4 -3
  23. package/dist/fields/grid-access.d.ts +1 -1
  24. package/dist/fields/grid-access.js +13 -3
  25. package/dist/fields/index.d.ts +18 -0
  26. package/dist/fields/index.js +31 -0
  27. package/dist/filter-labels.d.ts +16 -0
  28. package/dist/filter-labels.js +83 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.js +7 -0
  31. package/dist/install.js +8 -0
  32. package/dist/node/studio.js +12 -5
  33. package/dist/relative-date-core.d.ts +40 -0
  34. package/dist/relative-date-core.js +85 -0
  35. package/dist/relative-dates.d.ts +28 -0
  36. package/dist/relative-dates.js +61 -0
  37. package/dist/review/GhostCell.svelte +60 -0
  38. package/dist/review/GhostCell.svelte.d.ts +14 -0
  39. package/dist/review/RichCellMark.svelte +72 -0
  40. package/dist/review/RichCellMark.svelte.d.ts +8 -0
  41. package/dist/review/SvReviewPanel.svelte +759 -0
  42. package/dist/review/SvReviewPanel.svelte.d.ts +16 -0
  43. package/dist/review/adapters.d.ts +79 -0
  44. package/dist/review/adapters.js +119 -0
  45. package/dist/review/agent.d.ts +52 -0
  46. package/dist/review/agent.js +232 -0
  47. package/dist/review/card-marks.svelte.d.ts +42 -0
  48. package/dist/review/card-marks.svelte.js +51 -0
  49. package/dist/review/cluster.d.ts +26 -0
  50. package/dist/review/cluster.js +377 -0
  51. package/dist/review/equal.d.ts +9 -0
  52. package/dist/review/equal.js +35 -0
  53. package/dist/review/formats.d.ts +31 -0
  54. package/dist/review/formats.js +37 -0
  55. package/dist/review/ghost.d.ts +17 -0
  56. package/dist/review/ghost.js +104 -0
  57. package/dist/review/index.d.ts +42 -0
  58. package/dist/review/index.js +36 -0
  59. package/dist/review/messages.d.ts +113 -0
  60. package/dist/review/messages.js +135 -0
  61. package/dist/review/model.svelte.d.ts +262 -0
  62. package/dist/review/model.svelte.js +883 -0
  63. package/dist/review/pending-filter.d.ts +26 -0
  64. package/dist/review/pending-filter.js +71 -0
  65. package/dist/review/rich-cells.d.ts +1 -0
  66. package/dist/review/rich-cells.js +36 -0
  67. package/dist/review/types.d.ts +83 -0
  68. package/dist/review/types.js +7 -0
  69. package/dist/row-count.d.ts +28 -0
  70. package/dist/row-count.js +39 -0
  71. package/dist/sort-blanks.d.ts +8 -0
  72. package/dist/sort-blanks.js +47 -0
  73. package/dist/studio/ui-components.generated.js +1 -1
  74. package/dist/upgrade-prompt.d.ts +1 -1
  75. package/dist/version.d.ts +2 -2
  76. package/dist/version.js +2 -2
  77. package/dist/wc/GridFooter-CKC4L36O.js +162 -0
  78. package/dist/wc/{GridMenus-DVGuqp2l.js → GridMenus-h7RsvcmI.js} +320 -303
  79. package/dist/wc/{PinSplitter-Btco0txr.js → PinSplitter-BN6ybmjs.js} +1 -1
  80. package/dist/wc/grid-extension.svelte-CDf5EUyz.js +27 -0
  81. package/dist/wc/merges-DQTOlMWM.js +109 -0
  82. package/dist/wc/sv-sheet-element.js +6753 -6792
  83. package/package.json +9 -3
  84. package/dist/designer/assets/GridFooter-CKBVItAU.js +0 -2
  85. package/dist/designer/assets/GridMenus-Dzwu7M9r.js +0 -6
  86. package/dist/designer/assets/grid-extension.svelte-BZ7AqyJI.js +0 -1
  87. package/dist/designer/assets/index-WCxj6WfJ.css +0 -1
  88. package/dist/wc/GridFooter-BgoA77TJ.js +0 -145
  89. package/dist/wc/grid-extension.svelte-CucBS4FL.js +0 -20
@@ -0,0 +1,759 @@
1
+ <!--
2
+ SvReviewPanel - review a change review's proposals by group.
3
+
4
+ Groups come from `review.groups()`, risky ones first (no pattern, one
5
+ target from many values, low confidence), rule groups last. Each group is
6
+ accepted or rejected as shown: the ids rendered are the ids decided, so a
7
+ proposal that arrives meanwhile is never decided unseen. Every decision is
8
+ one Ctrl+Z in the grid.
9
+
10
+ Keys inside the list: J / K move between groups, Enter opens one, A
11
+ accepts it, R rejects it.
12
+
13
+ A plain panel, not a drawer: the grid stays usable beside it. Put it in a
14
+ `<SvDrawer>` or a split layout of your own. In a box that scrolls, the
15
+ header stays in view.
16
+ -->
17
+ <script lang="ts">
18
+ import { tick } from 'svelte'
19
+ import { SvButton } from '@svgrid/grid'
20
+ import { showValue } from './cluster'
21
+ import { fill, reviewMessages, type ReviewMessages } from './messages'
22
+ import type { Review, ReviewOutcome } from './model.svelte'
23
+ import type { Proposal, ProposalGroup } from './types'
24
+
25
+ let {
26
+ review,
27
+ title,
28
+ confirmOver = 50,
29
+ onDecide,
30
+ messages,
31
+ }: {
32
+ review: Review<any>
33
+ /** The heading; default `messages.title`. */
34
+ title?: string
35
+ /** Wording for this panel, over the review's own `messages`. */
36
+ messages?: Partial<ReviewMessages>
37
+ /** Ask before deciding more than this many proposals at once. */
38
+ confirmOver?: number
39
+ /** Called after each decision with what happened. */
40
+ onDecide?: (outcome: ReviewOutcome) => void
41
+ } = $props()
42
+
43
+ const m = $derived(reviewMessages(review.messages, messages))
44
+ const heading = $derived(title ?? m.title)
45
+ /** A message with `{count}` split out, so the number can be set in bold. */
46
+ const around = (template: string) => {
47
+ const at = template.indexOf('{count}')
48
+ return at < 0 ? [template, ''] : [template.slice(0, at), template.slice(at + 7)]
49
+ }
50
+ const show = (v: unknown) => showValue(v, m.empty)
51
+ /** A proposal's row by name, or "Row 17" when nothing names it. */
52
+ const rowName = (p: Proposal) => review.rowLabel(p) ?? fill(m.rowId, { row: p.rowId })
53
+ const groups = $derived(review.groups())
54
+ const conflicts = $derived(review.conflicts())
55
+ /** Conflicting proposals by cell, one entry per cell. */
56
+ const conflictCells = $derived.by(() => {
57
+ const byCell = new Map<string, Proposal[]>()
58
+ for (const p of conflicts) {
59
+ const k = p.rowId + '\u0000' + p.field
60
+ byCell.set(k, [...(byCell.get(k) ?? []), p])
61
+ }
62
+ return [...byCell.values()]
63
+ })
64
+ const pendingCount = $derived(groups.reduce((s, g) => s + g.count, 0))
65
+ /** Every proposal of the session by status, for the progress bar and the summary. */
66
+ const tally = $derived.by(() => {
67
+ const t = { total: 0, accepted: 0, rejected: 0, stale: 0 }
68
+ for (const p of review.proposals()) {
69
+ t.total++
70
+ if (p.status === 'accepted') t.accepted++
71
+ else if (p.status === 'rejected') t.rejected++
72
+ else if (p.status === 'stale') t.stale++
73
+ }
74
+ return t
75
+ })
76
+ const decidedCount = $derived(tally.accepted + tally.rejected + tally.stale)
77
+ const stale = $derived(review.log().filter((p) => p.status === 'stale'))
78
+
79
+ let openKey = $state<string | null>(null)
80
+ // What the open group held. A decision can move the rest of it under another
81
+ // key (two "AMER" fills less one is a singleton, pooled with the others), and
82
+ // the group the reviewer is working in should stay open as it moves.
83
+ let openIds: ReadonlySet<string> = new Set()
84
+ let showAll = $state(false)
85
+ const openedKey = $derived.by(() => {
86
+ if (openKey === null || groups.some((g) => g.key === openKey)) return openKey
87
+ return groups.find((g) => g.ids.some((id) => openIds.has(id)))?.key ?? null
88
+ })
89
+ function toggle(g: ProposalGroup) {
90
+ if (openedKey === g.key) return void (openKey = null)
91
+ openKey = g.key
92
+ openIds = new Set(g.ids)
93
+ showAll = false
94
+ }
95
+ let focusIndex = $state(0)
96
+ let busy = $state(false)
97
+ let announce = $state('')
98
+ let confirming = $state<{ label: string; count: number; run: () => void } | null>(null)
99
+ let revealed = $state<Record<string, { id: string | null; hidden: number }>>({})
100
+ let listEl = $state<HTMLElement | null>(null)
101
+
102
+ /** What to check in a group of this kind, on hover over its heading. */
103
+ const hintOf = (kind: ProposalGroup['kind']): string => {
104
+ switch (kind) {
105
+ case 'whitespace': case 'case': case 'format': case 'scale': return m.hintRule
106
+ case 'same-mapping': return m.hintMapping
107
+ case 'fill-empty': return m.hintFill
108
+ case 'constant-target': return m.hintConstant
109
+ case 'unpatterned': return m.hintEach
110
+ }
111
+ }
112
+
113
+ function say(out: ReviewOutcome) {
114
+ const parts: string[] = []
115
+ const add = (template: string, count: number) => count && parts.push(fill(template, { count }))
116
+ add(m.announceAccepted, out.accepted.length)
117
+ add(m.announceRejected, out.rejected.length)
118
+ add(m.announceStale, out.stale.length)
119
+ add(m.announceCommittedStale, out.committedStale.length)
120
+ add(m.announceSkipped, out.skipped.length)
121
+ if (out.error) parts.push(fill(m.announceNotSaved, { error: out.error instanceof Error ? out.error.message : String(out.error) }))
122
+ announce = parts.join(', ') || m.announceNothing
123
+ onDecide?.(out)
124
+ }
125
+
126
+ // A failed undo / redo save is announced where the decisions are.
127
+ let announcedError = 0
128
+ $effect(() => {
129
+ const e = review.lastError
130
+ // Once per failure: a change of wording must not repeat an old one.
131
+ if (!e || e.at === announcedError) return
132
+ announcedError = e.at
133
+ const text = e.error instanceof Error ? e.error.message : String(e.error)
134
+ announce = fill(e.during === 'undo' ? m.undoFailed : m.redoFailed, { error: text })
135
+ })
136
+
137
+ function confirmYes() {
138
+ const c = confirming
139
+ confirming = null
140
+ c?.run()
141
+ }
142
+
143
+ function guard(label: string, count: number, run: () => void) {
144
+ if (count > confirmOver) confirming = { label, count, run }
145
+ else run()
146
+ }
147
+
148
+ async function acceptIds(ids: string[]) {
149
+ busy = true
150
+ try {
151
+ say(await review.accept(ids))
152
+ } finally {
153
+ busy = false
154
+ }
155
+ }
156
+
157
+ let headingEl = $state<HTMLElement | null>(null)
158
+ /**
159
+ * A decided group leaves the list, and the focus would fall to the page.
160
+ * When it was inside the panel, it moves to the group that took the decided
161
+ * one's place, or to the heading when none are left.
162
+ */
163
+ /** Whether the focus is in the panel now, judged when a decision is made. */
164
+ const focusInPanel = () => {
165
+ const active = document.activeElement
166
+ return !!active && (listEl?.closest('.sv-review')?.contains(active) ?? false)
167
+ }
168
+ async function keepFocus(index: number, wasHere: boolean) {
169
+ await tick()
170
+ if (!wasHere) return
171
+ const heads = listEl?.querySelectorAll<HTMLElement>('.sv-review-group-head') ?? []
172
+ if (heads.length) {
173
+ focusIndex = Math.min(index, heads.length - 1)
174
+ heads[focusIndex]!.focus()
175
+ } else headingEl?.focus()
176
+ }
177
+
178
+ async function acceptOne(p: Proposal, index: number) {
179
+ const here = focusInPanel()
180
+ busy = true
181
+ try {
182
+ say(await review.accept([p.id]))
183
+ } finally {
184
+ busy = false
185
+ }
186
+ void keepLineFocus(index, 'accept', here)
187
+ }
188
+ function rejectOne(p: Proposal, index: number) {
189
+ const here = focusInPanel()
190
+ say(review.reject([p.id]))
191
+ void keepLineFocus(index, 'reject', here)
192
+ }
193
+ /**
194
+ * A decided line leaves the open group. The focus goes to the same button
195
+ * on the line that took its place (so ✓ ✓ ✓ walks down a group), then to
196
+ * the group's heading, then on as for a decided group.
197
+ */
198
+ async function keepLineFocus(index: number, which: 'accept' | 'reject', wasHere: boolean) {
199
+ const groupIndex = groups.findIndex((g) => g.key === openedKey)
200
+ await tick()
201
+ if (!wasHere) return
202
+ const open = listEl?.querySelector('.sv-review-group.is-open')
203
+ const acts = open?.querySelectorAll<HTMLElement>(which === 'accept' ? '.sv-review-line-act.is-accept' : '.sv-review-line-act:not(.is-accept)') ?? []
204
+ if (acts.length) return acts[Math.min(index, acts.length - 1)]!.focus()
205
+ const head = open?.querySelector<HTMLElement>('.sv-review-group-head')
206
+ if (head) return head.focus()
207
+ void keepFocus(Math.max(0, groupIndex), true)
208
+ }
209
+
210
+ const acceptGroup = (g: ProposalGroup) => {
211
+ const index = groups.indexOf(g)
212
+ guard(fill(m.confirmAcceptGroup, { count: g.count, label: g.label }), g.count, () => {
213
+ const here = focusInPanel()
214
+ void acceptIds(g.ids).then(() => keepFocus(index, here))
215
+ })
216
+ }
217
+ const rejectGroup = (g: ProposalGroup) => {
218
+ const index = groups.indexOf(g)
219
+ guard(fill(m.confirmRejectGroup, { count: g.count, label: g.label }), g.count, () => {
220
+ const here = focusInPanel()
221
+ say(review.reject(g.ids))
222
+ void keepFocus(index, here)
223
+ })
224
+ }
225
+
226
+ /** The groups whose every member is proven to follow its rule. */
227
+ const ruleGroups = $derived(groups.filter((g) => g.rule))
228
+ const ruleCount = $derived(ruleGroups.reduce((n, g) => n + g.count, 0))
229
+ function acceptRules() {
230
+ const ids = ruleGroups.flatMap((g) => g.ids)
231
+ guard(fill(m.confirmAcceptRules, { groups: ruleGroups.length, count: ids.length }), ids.length, () => void acceptIds(ids))
232
+ }
233
+
234
+ function acceptAll() {
235
+ const ids = groups.flatMap((g) => g.ids)
236
+ guard(fill(m.confirmAcceptAll, { count: ids.length }), ids.length, () => void acceptIds(ids))
237
+ }
238
+ function rejectAll() {
239
+ const ids = groups.flatMap((g) => g.ids)
240
+ guard(fill(m.confirmRejectAll, { count: ids.length }), ids.length, () => say(review.reject(ids)))
241
+ }
242
+
243
+ function showInGrid(g: ProposalGroup) {
244
+ revealed = { ...revealed, [g.key]: review.reveal(g.ids, revealed[g.key]?.id ?? undefined) }
245
+ }
246
+
247
+ /** What an open group lists: every member of a no-pattern group (it has no rule to trust), else the sample. */
248
+ function listed(g: ProposalGroup): Proposal[] {
249
+ const ids = g.kind === 'unpatterned' || showAll ? g.ids.slice(0, 200) : g.sample
250
+ return ids.map((id) => review.get(id)).filter((p): p is Proposal => !!p)
251
+ }
252
+
253
+ const pct = (n: number) => `${Math.round(n * 100)}%`
254
+ function confidenceText(g: ProposalGroup): string {
255
+ if (g.minConfidence === undefined) return ''
256
+ return g.minConfidence === g.avgConfidence ? fill(m.sure, { pct: pct(g.minConfidence) }) : fill(m.sureRange, { min: pct(g.minConfidence), avg: pct(g.avgConfidence!) })
257
+ }
258
+ const maxOf = (g: ProposalGroup) => Math.max(1, ...(g.beforeDistribution ?? []).map((d) => d.count))
259
+
260
+ function onKeydown(e: KeyboardEvent) {
261
+ // Keys inside an open group (a line, its accept / reject, Show all) are the buttons' own.
262
+ if (busy || !groups.length || (e.target as HTMLElement).closest('button.sv-review-act, .sv-review-body')) return
263
+ const g = groups[Math.min(focusIndex, groups.length - 1)]!
264
+ const key = e.key.toLowerCase()
265
+ if (key === 'j' || e.key === 'ArrowDown') focusIndex = Math.min(groups.length - 1, focusIndex + 1)
266
+ else if (key === 'k' || e.key === 'ArrowUp') focusIndex = Math.max(0, focusIndex - 1)
267
+ else if (e.key === 'Enter') toggle(g)
268
+ else if (key === 'a') acceptGroup(g)
269
+ else if (key === 'r') rejectGroup(g)
270
+ else return
271
+ e.preventDefault()
272
+ queueMicrotask(() => listEl?.querySelectorAll<HTMLElement>('.sv-review-group-head')[focusIndex]?.focus())
273
+ }
274
+ </script>
275
+
276
+ {#snippet check()}
277
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
278
+ {/snippet}
279
+ {#snippet cross()}
280
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" /></svg>
281
+ {/snippet}
282
+
283
+ <section class="sv-review" aria-label={heading}>
284
+ <header class="sv-review-head">
285
+ <div class="sv-review-titlerow">
286
+ <h2 class="sv-review-title" tabindex="-1" bind:this={headingEl}>{heading}</h2>
287
+ <span class="sv-review-count">
288
+ {pendingCount ? fill(groups.length === 1 ? m.pendingInOneGroup : m.pendingInGroups, { count: pendingCount, groups: groups.length }) : m.nothingPending}{conflictCells.length ? fill(m.inConflict, { count: conflictCells.length }) : ''}
289
+ </span>
290
+ </div>
291
+ {#if tally.total}
292
+ <div
293
+ class="sv-review-progress"
294
+ role="progressbar"
295
+ aria-label={m.decidedLabel}
296
+ aria-valuemin={0}
297
+ aria-valuemax={tally.total}
298
+ aria-valuenow={decidedCount}
299
+ aria-valuetext={fill(m.decided, { decided: decidedCount, total: tally.total })}
300
+ title={fill(m.decided, { decided: decidedCount, total: tally.total })}
301
+ >
302
+ <span class="is-accepted" style:width="{(tally.accepted / tally.total) * 100}%"></span>
303
+ <span class="is-rejected" style:width="{((tally.rejected + tally.stale) / tally.total) * 100}%"></span>
304
+ </div>
305
+ {/if}
306
+ {#if pendingCount}
307
+ <!-- The proven changes are the safe bulk step, so they get the primary
308
+ button; Accept all is primary only when every group is a rule group. -->
309
+ <div class="sv-review-all">
310
+ {#if ruleGroups.length && ruleGroups.length < groups.length}
311
+ <SvButton size="sm" disabled={busy} onclick={acceptRules}>{fill(m.acceptRuleGroups, { count: ruleCount })}</SvButton>
312
+ {/if}
313
+ <SvButton size="sm" variant={ruleGroups.length === groups.length ? 'primary' : 'secondary'} disabled={busy || conflicts.length > 0} onclick={acceptAll}>{m.acceptAll}</SvButton>
314
+ <SvButton size="sm" variant="secondary" disabled={busy} onclick={rejectAll}>{m.rejectAll}</SvButton>
315
+ </div>
316
+ {#if conflicts.length}<p class="sv-review-hint">{m.resolveFirst}</p>{/if}
317
+ {/if}
318
+ {#if pendingCount || conflicts.length || review.pendingOnly}
319
+ <button
320
+ type="button"
321
+ class="sv-review-only"
322
+ aria-pressed={review.pendingOnly}
323
+ onclick={() => review.showPendingOnly(!review.pendingOnly)}
324
+ >
325
+ <span class="sv-review-switch" aria-hidden="true"></span>
326
+ {m.onlyRowsWithChanges}
327
+ </button>
328
+ {/if}
329
+ </header>
330
+
331
+ {#if confirming}
332
+ <div class="sv-review-confirm" role="alertdialog" aria-label={confirming.label}>
333
+ <p>{confirming.label}</p>
334
+ <div class="sv-review-all">
335
+ <SvButton size="sm" onclick={confirmYes}>{fill(m.confirmYes, { count: confirming.count })}</SvButton>
336
+ <SvButton size="sm" variant="ghost" onclick={() => (confirming = null)}>{m.cancel}</SvButton>
337
+ </div>
338
+ </div>
339
+ {/if}
340
+
341
+ {#if conflicts.length}
342
+ <section class="sv-review-conflicts" aria-label={m.conflictsTitle}>
343
+ <h3 class="sv-review-subtitle">{m.conflictsTitle}</h3>
344
+ {#each conflictCells as cell (cell[0].id)}
345
+ <div class="sv-review-conflict">
346
+ <span class="sv-review-cell">{fill(m.conflictCell, { row: rowName(cell[0]), field: review.fieldLabel(cell[0].field), value: show(cell[0].before) })}</span>
347
+ <div class="sv-review-keeps">
348
+ {#each cell as p (p.id)}
349
+ <button type="button" class="sv-review-keep" onclick={() => review.resolveConflict(p.id)}>
350
+ {fill(m.keep, { value: show(p.after) })} <span class="sv-review-src">({p.source.label})</span>
351
+ </button>
352
+ {/each}
353
+ </div>
354
+ </div>
355
+ {/each}
356
+ </section>
357
+ {/if}
358
+
359
+ {#if groups.length}
360
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
361
+ <ul class="sv-review-groups" bind:this={listEl} onkeydown={onKeydown}>
362
+ {#each groups as g, i (g.key)}
363
+ {@const open = openedKey === g.key}
364
+ {@const [pre, post] = around(g.count === 1 ? m.oneChange : m.changes)}
365
+ <li class="sv-review-group" class:is-rule={g.rule} class:is-open={open}>
366
+ <div class="sv-review-row">
367
+ <button
368
+ type="button"
369
+ class="sv-review-group-head"
370
+ aria-expanded={open}
371
+ tabindex={i === Math.min(focusIndex, groups.length - 1) ? 0 : -1}
372
+ onfocus={() => (focusIndex = i)}
373
+ title={hintOf(g.kind)}
374
+ onclick={() => toggle(g)}
375
+ >
376
+ <span class="sv-review-text">
377
+ <span class="sv-review-label">{g.label}</span>
378
+ <span class="sv-review-conf">
379
+ {#if g.rule}<span class="sv-review-badge">{m.badgeRule}</span>{/if}
380
+ <span>{pre}<span class="sv-review-n">{g.count}</span>{post}</span>
381
+ {#if g.minConfidence !== undefined}
382
+ <span class:is-low={g.minConfidence < 0.6}>{confidenceText(g)}</span>
383
+ {/if}
384
+ </span>
385
+ </span>
386
+ </button>
387
+ <div class="sv-review-acts">
388
+ <button type="button" class="sv-review-act is-accept" disabled={busy} aria-label={fill(m.acceptGroupLabel, { count: g.count, label: g.label })} title={fill(m.acceptGroupLabel, { count: g.count, label: g.label })} onclick={() => acceptGroup(g)}>{@render check()}<span>{m.accept}</span></button>
389
+ <button type="button" class="sv-review-act" disabled={busy} aria-label={fill(m.rejectGroupLabel, { count: g.count, label: g.label })} title={fill(m.rejectGroupLabel, { count: g.count, label: g.label })} onclick={() => rejectGroup(g)}>{@render cross()}<span>{m.reject}</span></button>
390
+ </div>
391
+ </div>
392
+ {#if open}
393
+ <div class="sv-review-body">
394
+ {#if g.beforeDistribution}
395
+ <p class="sv-review-subtitle">{m.from}</p>
396
+ <ul class="sv-review-dist">
397
+ {#each g.beforeDistribution as d, j (j)}
398
+ <li>
399
+ <span class="sv-review-dist-label">{d.other ? m.otherValues : show(d.value)}</span>
400
+ <span class="sv-review-dist-bar" aria-hidden="true"><span style:width="{(d.count / maxOf(g)) * 100}%"></span></span>
401
+ <span class="sv-review-n">{d.count}</span>
402
+ </li>
403
+ {/each}
404
+ </ul>
405
+ {/if}
406
+ <p class="sv-review-subtitle">{g.kind === 'unpatterned' || showAll || g.sample.length >= g.count ? (g.count > 200 ? fill(m.firstN, { count: 200 }) : m.everyChange) : fill(m.sampleOf, { count: g.count })}</p>
407
+ <ul class="sv-review-sample">
408
+ {#each listed(g) as p, li (p.id)}
409
+ {@const who = review.rowLabel(p)}
410
+ <li class="sv-review-line">
411
+ <button type="button" class="sv-review-sample-row" title={fill(m.showRow, { row: who ?? fill(m.rowId, { row: p.rowId }) })} onclick={() => review.reveal([p.id])}>
412
+ {#if who}<span class="sv-review-who">{who}</span>{/if}
413
+ <span class="sv-review-pair">
414
+ <span class="sv-review-before">{show(p.before)}</span>
415
+ <span class="sv-review-arrow" aria-hidden="true">→</span>
416
+ <span class="sv-review-after">{show(p.after)}</span>
417
+ </span>
418
+ {#if p.confidence !== undefined || p.reason}
419
+ <span class="sv-review-meta">
420
+ {#if p.confidence !== undefined}<span class:is-low={p.confidence < 0.6}>{pct(p.confidence)}</span>{/if}
421
+ {#if p.reason}<span>{p.reason}</span>{/if}
422
+ </span>
423
+ {/if}
424
+ </button>
425
+ <span class="sv-review-line-acts">
426
+ <button type="button" class="sv-review-line-act is-accept" disabled={busy} aria-label={m.acceptOne} title={m.acceptOne} onclick={() => void acceptOne(p, li)}>{@render check()}</button>
427
+ <button type="button" class="sv-review-line-act" disabled={busy} aria-label={m.rejectOne} title={m.rejectOne} onclick={() => rejectOne(p, li)}>{@render cross()}</button>
428
+ </span>
429
+ </li>
430
+ {/each}
431
+ </ul>
432
+ {#if g.kind !== 'unpatterned' && !showAll && g.count > g.sample.length}
433
+ <button type="button" class="sv-review-more" onclick={() => (showAll = true)}>{fill(m.showAll, { count: g.count })}</button>
434
+ {/if}
435
+ <div class="sv-review-all">
436
+ <SvButton size="sm" variant="ghost" onclick={() => showInGrid(g)}>
437
+ {revealed[g.key]?.id ? m.nextInGrid : m.showInGrid}
438
+ </SvButton>
439
+ {#if revealed[g.key]?.hidden}<span class="sv-review-hint">{fill(m.hiddenRows, { count: revealed[g.key]!.hidden })}</span>{/if}
440
+ </div>
441
+ </div>
442
+ {/if}
443
+ </li>
444
+ {/each}
445
+ </ul>
446
+ {:else if !conflicts.length}
447
+ <div class="sv-review-empty">
448
+ {#if tally.total}
449
+ <p class="sv-review-empty-title">{fill(m.allReviewed, { count: tally.total })}</p>
450
+ <p class="sv-review-hint">
451
+ {fill(m.reviewedSummary, { accepted: tally.accepted, rejected: tally.rejected })}{tally.stale ? fill(m.reviewedStale, { count: tally.stale }) : ''}.
452
+ </p>
453
+ {:else}
454
+ <p class="sv-review-empty-title">{m.noProposals}</p>
455
+ <p class="sv-review-hint">{m.noProposalsHint}</p>
456
+ {/if}
457
+ </div>
458
+ {/if}
459
+
460
+ {#if stale.length}
461
+ <details class="sv-review-stale">
462
+ <summary>{fill(m.notWritten, { count: stale.length })}</summary>
463
+ <ul>
464
+ {#each stale as p (p.id)}
465
+ <li><span class="sv-review-cell">{fill(m.rowField, { row: rowName(p), field: review.fieldLabel(p.field) })}</span> {show(p.after)} <span class="sv-review-hint">{p.note}</span></li>
466
+ {/each}
467
+ </ul>
468
+ </details>
469
+ {/if}
470
+
471
+ {#if groups.length}<p class="sv-review-hint sv-review-keys">{m.keysHint}</p>{/if}
472
+ <p class="sv-review-live" role="status" aria-live="polite">{announce}</p>
473
+ {#if pendingCount || announce}<p class="sv-review-hint">{m.undoHint}</p>{/if}
474
+ </section>
475
+
476
+ <style>
477
+ .sv-review {
478
+ container-type: inline-size;
479
+ display: flex;
480
+ flex-direction: column;
481
+ gap: 10px;
482
+ min-width: 0;
483
+ padding: 0 14px 14px;
484
+ font-family: var(--sg-font, inherit);
485
+ font-size: 13px;
486
+ line-height: 1.4;
487
+ color: var(--sg-fg, #111827);
488
+ background: var(--sg-bg, #fff);
489
+ border: 1px solid var(--sg-border, #e5e7eb);
490
+ border-radius: var(--sg-radius, 8px);
491
+ }
492
+ .sv-review-head {
493
+ position: sticky;
494
+ top: 0;
495
+ z-index: 1;
496
+ display: flex;
497
+ flex-direction: column;
498
+ gap: 6px;
499
+ padding: 14px 0 10px;
500
+ background: var(--sg-bg, #fff);
501
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
502
+ }
503
+ /* The status stays on one line; when the title leaves no room it moves under it. */
504
+ .sv-review-titlerow { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 10px; }
505
+ .sv-review-title { margin: 0; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
506
+ .sv-review-progress {
507
+ display: flex;
508
+ height: 4px;
509
+ border-radius: 999px;
510
+ overflow: hidden;
511
+ background: color-mix(in srgb, var(--sg-fg, #111827) 12%, transparent);
512
+ }
513
+ .sv-review-progress span { transition: width 0.3s ease; }
514
+ /* Neutral: the bar shows how far along the review is, not good and bad. */
515
+ .sv-review-progress .is-accepted { background: color-mix(in srgb, var(--sg-fg, #111827) 62%, transparent); }
516
+ .sv-review-progress .is-rejected { background: color-mix(in srgb, var(--sg-fg, #111827) 28%, transparent); }
517
+ .sv-review-count { margin-left: auto; font-size: 12px; color: var(--sg-muted, #6b7280); font-variant-numeric: tabular-nums; white-space: nowrap; }
518
+ .sv-review-hint { margin: 0; color: var(--sg-muted, #6b7280); }
519
+ .sv-review-hint { font-size: 12px; }
520
+ .sv-review-subtitle { margin: 10px 0 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--sg-muted, #6b7280); }
521
+ .sv-review-all { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
522
+ .sv-review-only {
523
+ display: inline-flex;
524
+ align-items: center;
525
+ gap: 8px;
526
+ align-self: flex-start;
527
+ padding: 2px 0;
528
+ font: inherit;
529
+ font-size: 12.5px;
530
+ color: var(--sg-fg, #111827);
531
+ background: none;
532
+ border: 0;
533
+ cursor: pointer;
534
+ }
535
+ .sv-review-only:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: 2px; border-radius: 4px; }
536
+ .sv-review-switch {
537
+ position: relative;
538
+ width: 26px;
539
+ height: 15px;
540
+ flex: none;
541
+ border-radius: 999px;
542
+ background: color-mix(in srgb, var(--sg-fg, #111827) 12%, transparent);
543
+ box-shadow: inset 0 0 0 1px var(--sg-border, #d1d5db);
544
+ transition: background 0.15s ease;
545
+ }
546
+ .sv-review-switch::after {
547
+ content: '';
548
+ position: absolute;
549
+ top: 2px;
550
+ left: 2px;
551
+ width: 11px;
552
+ height: 11px;
553
+ border-radius: 50%;
554
+ background: var(--sg-bg, #fff);
555
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--sg-fg, #111827) 25%, transparent);
556
+ transition: transform 0.15s ease;
557
+ }
558
+ .sv-review-only[aria-pressed='true'] .sv-review-switch { background: var(--sg-fg, #111827); box-shadow: none; }
559
+ .sv-review-only[aria-pressed='true'] .sv-review-switch::after { transform: translateX(11px); }
560
+
561
+ .sv-review-confirm {
562
+ padding: 10px 12px;
563
+ border: 1px solid color-mix(in srgb, var(--sg-warning, #d97706) 45%, var(--sg-border, #e5e7eb));
564
+ border-radius: var(--sg-radius, 8px);
565
+ background: color-mix(in srgb, var(--sg-warning, #d97706) 8%, var(--sg-bg, #fff));
566
+ }
567
+ .sv-review-confirm p { margin: 0 0 4px; font-weight: 550; }
568
+
569
+ .sv-review-conflicts {
570
+ padding: 10px 12px;
571
+ border-radius: var(--sg-radius, 8px);
572
+ background: color-mix(in srgb, var(--sg-danger, #dc2626) 6%, var(--sg-bg, #fff));
573
+ border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 30%, var(--sg-border, #e5e7eb));
574
+ }
575
+ .sv-review-conflicts .sv-review-subtitle { margin-top: 0; color: var(--sg-danger, #dc2626); }
576
+ .sv-review-conflict { display: flex; flex-direction: column; gap: 4px; padding: 4px 0; }
577
+ .sv-review-keeps { display: flex; flex-wrap: wrap; gap: 6px; }
578
+ .sv-review-cell { font-weight: 550; }
579
+ .sv-review-keep {
580
+ font: inherit;
581
+ font-size: 12.5px;
582
+ padding: 3px 9px;
583
+ border: 1px solid var(--sg-border, #e5e7eb);
584
+ border-radius: 999px;
585
+ background: var(--sg-bg, #fff);
586
+ color: inherit;
587
+ cursor: pointer;
588
+ }
589
+ .sv-review-keep:hover { background: var(--sg-row-hover-bg, #f9fafb); }
590
+ .sv-review-src { color: var(--sg-muted, #6b7280); }
591
+
592
+ .sv-review-groups { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
593
+ .sv-review-group {
594
+ border: 1px solid var(--sg-border, #e5e7eb);
595
+ border-radius: var(--sg-radius, 8px);
596
+ background: var(--sg-bg, #fff);
597
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
598
+ }
599
+ .sv-review-group.is-open { border-color: color-mix(in srgb, var(--sg-fg, #111827) 22%, var(--sg-border, #e5e7eb)); box-shadow: 0 1px 3px color-mix(in srgb, var(--sg-fg, #111827) 8%, transparent); }
600
+ /* The hover tint covers the whole heading row, actions included. */
601
+ .sv-review-row { display: flex; align-items: flex-start; border-radius: calc(var(--sg-radius, 8px) - 1px); }
602
+ .sv-review-row:hover { background: var(--sg-row-hover-bg, #f9fafb); }
603
+ .sv-review-group.is-open .sv-review-row { border-radius: calc(var(--sg-radius, 8px) - 1px) calc(var(--sg-radius, 8px) - 1px) 0 0; }
604
+ .sv-review-group-head {
605
+ flex: 1;
606
+ min-width: 0;
607
+ display: flex;
608
+ align-items: flex-start;
609
+ gap: 9px;
610
+ padding: 9px 10px;
611
+ font: inherit;
612
+ text-align: left;
613
+ color: inherit;
614
+ background: none;
615
+ border: 0;
616
+ border-radius: var(--sg-radius, 8px);
617
+ cursor: pointer;
618
+ }
619
+ .sv-review-group-head:focus-visible, .sv-review-act:focus-visible, .sv-review-keep:focus-visible {
620
+ outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb));
621
+ outline-offset: -2px;
622
+ }
623
+ /* Tracks and quiet fills mix the text colour, so they read in any theme,
624
+ light or dark, without relying on a token the theme may not define. */
625
+ /* The label wraps, at word breaks: it is what a reviewer decides on, so it
626
+ is never cut. Count and confidence go on the line under it. */
627
+ .sv-review-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
628
+ .sv-review-label { overflow-wrap: break-word; font-weight: 550; }
629
+ .sv-review-conf { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 6px; color: var(--sg-muted, #6b7280); font-size: 12px; }
630
+ .sv-review-n { font-variant-numeric: tabular-nums; font-weight: 650; color: var(--sg-fg, #111827); }
631
+ /* A low confidence is the one signal in a heading: bold text and a dot, no colour wash. */
632
+ .sv-review-conf .is-low { color: var(--sg-fg, #111827); font-weight: 600; }
633
+ .sv-review-conf .is-low::before, .sv-review-meta .is-low::before {
634
+ content: '';
635
+ display: inline-block;
636
+ width: 6px;
637
+ height: 6px;
638
+ margin-right: 5px;
639
+ vertical-align: 1px;
640
+ border-radius: 50%;
641
+ background: var(--sg-warning, #d97706);
642
+ }
643
+ /* Rule groups are the only marked ones: their changes are proven by their rule. */
644
+ .sv-review-badge {
645
+ font-size: 10.5px;
646
+ font-weight: 650;
647
+ letter-spacing: 0.02em;
648
+ padding: 0 6px;
649
+ border-radius: 999px;
650
+ color: var(--sg-muted, #6b7280);
651
+ background: color-mix(in srgb, var(--sg-fg, #111827) 8%, transparent);
652
+ }
653
+
654
+ /* Accept and reject sit on the label line as icons, named by aria-label and
655
+ titled on hover, so the label keeps the panel's width. */
656
+ .sv-review-acts { display: flex; flex: none; gap: 2px; padding: 7px 7px 0 0; }
657
+ .sv-review-act {
658
+ position: relative;
659
+ display: grid;
660
+ place-items: center;
661
+ width: 28px;
662
+ height: 28px;
663
+ padding: 0;
664
+ border: 1px solid var(--sg-border, #e5e7eb);
665
+ border-radius: 6px;
666
+ background: var(--sg-bg, #fff);
667
+ color: var(--sg-fg, #111827);
668
+ cursor: pointer;
669
+ }
670
+ .sv-review-act span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
671
+ .sv-review-act svg { fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
672
+ .sv-review-act:not(.is-accept) { color: var(--sg-muted, #6b7280); }
673
+ .sv-review-act.is-accept:hover { color: var(--sg-success, #16a34a); border-color: color-mix(in srgb, var(--sg-success, #16a34a) 45%, var(--sg-border, #e5e7eb)); }
674
+ .sv-review-act:not(.is-accept):hover { color: var(--sg-danger, #dc2626); border-color: color-mix(in srgb, var(--sg-danger, #dc2626) 45%, var(--sg-border, #e5e7eb)); }
675
+ .sv-review-act:disabled { opacity: 0.45; cursor: default; }
676
+
677
+ .sv-review-body { padding: 2px 12px 12px; border-top: 1px solid var(--sg-border, #e5e7eb); }
678
+ .sv-review-dist, .sv-review-sample { list-style: none; margin: 0; padding: 0; }
679
+ .sv-review-dist li { display: grid; grid-template-columns: minmax(0, 1fr) 60px auto; align-items: center; gap: 8px; padding: 2px 0; }
680
+ .sv-review-dist-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
681
+ .sv-review-dist-bar { height: 6px; border-radius: 999px; background: color-mix(in srgb, var(--sg-fg, #111827) 12%, transparent); overflow: hidden; }
682
+ .sv-review-dist-bar span { display: block; height: 100%; background: color-mix(in srgb, var(--sg-fg, #111827) 35%, transparent); }
683
+ .sv-review-sample li { border-top: 1px dashed var(--sg-border, #e5e7eb); }
684
+ /* A line: the change (a click shows its row) and its own accept / reject. */
685
+ .sv-review-line { display: flex; align-items: flex-start; gap: 4px; }
686
+ .sv-review-line .sv-review-sample-row { flex: 1; min-width: 0; margin: 0; }
687
+ .sv-review-line-acts { display: flex; flex: none; gap: 2px; padding-top: 4px; }
688
+ .sv-review-line-act {
689
+ display: grid;
690
+ place-items: center;
691
+ width: 24px;
692
+ height: 24px;
693
+ padding: 0;
694
+ border: 1px solid transparent;
695
+ border-radius: 5px;
696
+ background: none;
697
+ color: var(--sg-muted, #6b7280);
698
+ cursor: pointer;
699
+ }
700
+ .sv-review-line-act svg { fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
701
+ .sv-review-line-act.is-accept:hover { color: var(--sg-success, #16a34a); border-color: color-mix(in srgb, var(--sg-success, #16a34a) 35%, transparent); }
702
+ .sv-review-line-act:not(.is-accept):hover { color: var(--sg-danger, #dc2626); border-color: color-mix(in srgb, var(--sg-danger, #dc2626) 35%, transparent); }
703
+ .sv-review-line-act:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: 1px; }
704
+ .sv-review-line-act:disabled { opacity: 0.45; cursor: default; }
705
+ .sv-review-more {
706
+ margin-top: 4px;
707
+ padding: 2px 0;
708
+ font: inherit;
709
+ font-size: 12px;
710
+ font-weight: 600;
711
+ color: var(--sg-fg, #111827);
712
+ background: none;
713
+ border: 0;
714
+ text-decoration: underline;
715
+ text-underline-offset: 2px;
716
+ cursor: pointer;
717
+ }
718
+ .sv-review-keys { margin-top: 2px; }
719
+ /* A sample line is a button: a click shows that row in the grid. */
720
+ .sv-review-sample-row {
721
+ display: flex;
722
+ flex-direction: column;
723
+ gap: 1px;
724
+ width: 100%;
725
+ padding: 5px 4px;
726
+ margin: 0 -4px;
727
+ font: inherit;
728
+ text-align: left;
729
+ color: inherit;
730
+ background: none;
731
+ border: 0;
732
+ border-radius: 4px;
733
+ cursor: pointer;
734
+ }
735
+ .sv-review-sample-row:hover { background: var(--sg-row-hover-bg, #f9fafb); }
736
+ .sv-review-sample-row:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: -2px; }
737
+ .sv-review-sample li:first-child { border-top: 0; }
738
+ .sv-review-pair { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
739
+ .sv-review-before { color: var(--sg-muted, #6b7280); text-decoration: line-through; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
740
+ .sv-review-arrow { color: var(--sg-muted, #6b7280); flex: none; }
741
+ .sv-review-who { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
742
+ .sv-review-after { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
743
+ .sv-review-meta { display: flex; gap: 8px; color: var(--sg-muted, #6b7280); font-size: 12px; }
744
+ .sv-review-meta .is-low { color: var(--sg-fg, #111827); font-weight: 600; white-space: nowrap; }
745
+
746
+ .sv-review-empty { padding: 18px 4px 6px; text-align: left; }
747
+ .sv-review-empty-title { margin: 0 0 2px; font-weight: 650; }
748
+ .sv-review-stale summary { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--sg-muted, #6b7280); }
749
+ .sv-review-stale ul { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
750
+ .sv-review-live { margin: 0; min-height: 1em; font-size: 12px; font-weight: 550; }
751
+ /* A finger needs more than a pointer does. */
752
+ @media (pointer: coarse) {
753
+ .sv-review-act, .sv-review-line-act { width: 40px; height: 40px; }
754
+ .sv-review-line-acts { gap: 4px; padding-top: 0; }
755
+ }
756
+ @media (prefers-reduced-motion: reduce) {
757
+ .sv-review-progress span, .sv-review-group, .sv-review-switch, .sv-review-switch::after { transition: none; }
758
+ }
759
+ </style>