@joewinke/jatui 0.37.0 → 0.38.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.
- package/dist/artifact-blocks/AttachmentChip.svelte +66 -0
- package/dist/artifact-blocks/AttachmentChip.svelte.d.ts +7 -0
- package/dist/artifact-blocks/BlockComments.svelte +309 -0
- package/dist/artifact-blocks/BlockComments.svelte.d.ts +33 -0
- package/dist/artifact-blocks/BlockRenderer.svelte +674 -0
- package/dist/artifact-blocks/BlockRenderer.svelte.d.ts +55 -0
- package/dist/artifact-blocks/BlockVerdict.svelte +151 -0
- package/dist/artifact-blocks/BlockVerdict.svelte.d.ts +13 -0
- package/dist/artifact-blocks/BlocksArtifactView.svelte +1616 -0
- package/dist/artifact-blocks/BlocksArtifactView.svelte.d.ts +113 -0
- package/dist/artifact-blocks/CONTRACT.md +225 -0
- package/dist/artifact-blocks/CommentPill.svelte +49 -0
- package/dist/artifact-blocks/CommentPill.svelte.d.ts +16 -0
- package/dist/artifact-blocks/FoldedRounds.svelte +95 -0
- package/dist/artifact-blocks/FoldedRounds.svelte.d.ts +9 -0
- package/dist/artifact-blocks/Glyph.svelte +54 -0
- package/dist/artifact-blocks/Glyph.svelte.d.ts +10 -0
- package/dist/artifact-blocks/HistoryScrubber.svelte +500 -0
- package/dist/artifact-blocks/HistoryScrubber.svelte.d.ts +15 -0
- package/dist/artifact-blocks/Markdown.svelte +186 -0
- package/dist/artifact-blocks/Markdown.svelte.d.ts +30 -0
- package/dist/artifact-blocks/NoteComposer.svelte +31 -0
- package/dist/artifact-blocks/NoteComposer.svelte.d.ts +8 -0
- package/dist/artifact-blocks/PlainComposer.svelte +200 -0
- package/dist/artifact-blocks/PlainComposer.svelte.d.ts +15 -0
- package/dist/artifact-blocks/SectionTools.svelte +102 -0
- package/dist/artifact-blocks/SectionTools.svelte.d.ts +19 -0
- package/dist/artifact-blocks/adapter.d.ts +13 -0
- package/dist/artifact-blocks/adapter.js +31 -0
- package/dist/artifact-blocks/autoSend.d.ts +49 -0
- package/dist/artifact-blocks/autoSend.js +103 -0
- package/dist/artifact-blocks/blocks/actions/ActionsBlock.svelte +557 -0
- package/dist/artifact-blocks/blocks/actions/ActionsBlock.svelte.d.ts +48 -0
- package/dist/artifact-blocks/blocks/actions/block.d.ts +8 -0
- package/dist/artifact-blocks/blocks/actions/block.js +74 -0
- package/dist/artifact-blocks/blocks/actions/requests.d.ts +62 -0
- package/dist/artifact-blocks/blocks/actions/requests.js +26 -0
- package/dist/artifact-blocks/blocks/actions/schema.d.ts +168 -0
- package/dist/artifact-blocks/blocks/actions/schema.js +253 -0
- package/dist/artifact-blocks/blocks/callout/CalloutBlock.svelte +779 -0
- package/dist/artifact-blocks/blocks/callout/CalloutBlock.svelte.d.ts +40 -0
- package/dist/artifact-blocks/blocks/callout/block.d.ts +7 -0
- package/dist/artifact-blocks/blocks/callout/block.js +39 -0
- package/dist/artifact-blocks/blocks/callout/schema.d.ts +72 -0
- package/dist/artifact-blocks/blocks/callout/schema.js +115 -0
- package/dist/artifact-blocks/blocks/chart/ChartBlock.svelte +875 -0
- package/dist/artifact-blocks/blocks/chart/ChartBlock.svelte.d.ts +59 -0
- package/dist/artifact-blocks/blocks/chart/block.d.ts +9 -0
- package/dist/artifact-blocks/blocks/chart/block.js +70 -0
- package/dist/artifact-blocks/blocks/chart/schema.d.ts +171 -0
- package/dist/artifact-blocks/blocks/chart/schema.js +498 -0
- package/dist/artifact-blocks/blocks/chart/thumb.d.ts +2 -0
- package/dist/artifact-blocks/blocks/chart/thumb.js +201 -0
- package/dist/artifact-blocks/blocks/checklist/ChecklistBlock.svelte +1048 -0
- package/dist/artifact-blocks/blocks/checklist/ChecklistBlock.svelte.d.ts +16 -0
- package/dist/artifact-blocks/blocks/checklist/Icon.svelte +105 -0
- package/dist/artifact-blocks/blocks/checklist/Icon.svelte.d.ts +17 -0
- package/dist/artifact-blocks/blocks/checklist/block.d.ts +10 -0
- package/dist/artifact-blocks/blocks/checklist/block.js +63 -0
- package/dist/artifact-blocks/blocks/checklist/itemReview.d.ts +16 -0
- package/dist/artifact-blocks/blocks/checklist/itemReview.js +63 -0
- package/dist/artifact-blocks/blocks/checklist/labels.d.ts +62 -0
- package/dist/artifact-blocks/blocks/checklist/labels.js +96 -0
- package/dist/artifact-blocks/blocks/checklist/retest.d.ts +43 -0
- package/dist/artifact-blocks/blocks/checklist/retest.js +68 -0
- package/dist/artifact-blocks/blocks/checklist/schema.d.ts +84 -0
- package/dist/artifact-blocks/blocks/checklist/schema.js +91 -0
- package/dist/artifact-blocks/blocks/code/CodeBlock.svelte +128 -0
- package/dist/artifact-blocks/blocks/code/CodeBlock.svelte.d.ts +12 -0
- package/dist/artifact-blocks/blocks/code/block.d.ts +8 -0
- package/dist/artifact-blocks/blocks/code/block.js +31 -0
- package/dist/artifact-blocks/blocks/code/highlight.d.ts +34 -0
- package/dist/artifact-blocks/blocks/code/highlight.js +280 -0
- package/dist/artifact-blocks/blocks/code/schema.d.ts +32 -0
- package/dist/artifact-blocks/blocks/code/schema.js +39 -0
- package/dist/artifact-blocks/blocks/collage/CollageBlock.svelte +290 -0
- package/dist/artifact-blocks/blocks/collage/CollageBlock.svelte.d.ts +29 -0
- package/dist/artifact-blocks/blocks/collage/block.d.ts +9 -0
- package/dist/artifact-blocks/blocks/collage/block.js +29 -0
- package/dist/artifact-blocks/blocks/collage/schema.d.ts +52 -0
- package/dist/artifact-blocks/blocks/collage/schema.js +86 -0
- package/dist/artifact-blocks/blocks/compare/CompareBlock.svelte +484 -0
- package/dist/artifact-blocks/blocks/compare/CompareBlock.svelte.d.ts +25 -0
- package/dist/artifact-blocks/blocks/compare/block.d.ts +9 -0
- package/dist/artifact-blocks/blocks/compare/block.js +32 -0
- package/dist/artifact-blocks/blocks/compare/schema.d.ts +74 -0
- package/dist/artifact-blocks/blocks/compare/schema.js +148 -0
- package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte +392 -0
- package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +30 -0
- package/dist/artifact-blocks/blocks/diagram/block.d.ts +9 -0
- package/dist/artifact-blocks/blocks/diagram/block.js +50 -0
- package/dist/artifact-blocks/blocks/diagram/constants.d.ts +48 -0
- package/dist/artifact-blocks/blocks/diagram/constants.js +53 -0
- package/dist/artifact-blocks/blocks/diagram/schema.d.ts +161 -0
- package/dist/artifact-blocks/blocks/diagram/schema.js +621 -0
- package/dist/artifact-blocks/blocks/diagram/thumb.d.ts +2 -0
- package/dist/artifact-blocks/blocks/diagram/thumb.js +157 -0
- package/dist/artifact-blocks/blocks/diagram/types.d.ts +74 -0
- package/dist/artifact-blocks/blocks/diagram/types.js +1 -0
- package/dist/artifact-blocks/blocks/diff/DiffBlock.svelte +81 -0
- package/dist/artifact-blocks/blocks/diff/DiffBlock.svelte.d.ts +10 -0
- package/dist/artifact-blocks/blocks/diff/block.d.ts +7 -0
- package/dist/artifact-blocks/blocks/diff/block.js +36 -0
- package/dist/artifact-blocks/blocks/diff/schema.d.ts +18 -0
- package/dist/artifact-blocks/blocks/diff/schema.js +35 -0
- package/dist/artifact-blocks/blocks/diff-review/DiffReviewBlock.svelte +1055 -0
- package/dist/artifact-blocks/blocks/diff-review/DiffReviewBlock.svelte.d.ts +5 -0
- package/dist/artifact-blocks/blocks/diff-review/block.d.ts +15 -0
- package/dist/artifact-blocks/blocks/diff-review/block.js +64 -0
- package/dist/artifact-blocks/blocks/diff-review/decisions.d.ts +57 -0
- package/dist/artifact-blocks/blocks/diff-review/decisions.js +80 -0
- package/dist/artifact-blocks/blocks/diff-review/schema.d.ts +13 -0
- package/dist/artifact-blocks/blocks/diff-review/schema.js +84 -0
- package/dist/artifact-blocks/blocks/diff-review/types.d.ts +35 -0
- package/dist/artifact-blocks/blocks/diff-review/types.js +1 -0
- package/dist/artifact-blocks/blocks/explainer/ExplainerBlock.svelte +145 -0
- package/dist/artifact-blocks/blocks/explainer/ExplainerBlock.svelte.d.ts +5 -0
- package/dist/artifact-blocks/blocks/explainer/block.d.ts +8 -0
- package/dist/artifact-blocks/blocks/explainer/block.js +21 -0
- package/dist/artifact-blocks/blocks/explainer/schema.d.ts +26 -0
- package/dist/artifact-blocks/blocks/explainer/schema.js +69 -0
- package/dist/artifact-blocks/blocks/group-content.d.ts +10 -0
- package/dist/artifact-blocks/blocks/group-content.js +16 -0
- package/dist/artifact-blocks/blocks/group-data.d.ts +10 -0
- package/dist/artifact-blocks/blocks/group-data.js +7 -0
- package/dist/artifact-blocks/blocks/group-review.d.ts +10 -0
- package/dist/artifact-blocks/blocks/group-review.js +12 -0
- package/dist/artifact-blocks/blocks/group-visual.d.ts +17 -0
- package/dist/artifact-blocks/blocks/group-visual.js +14 -0
- package/dist/artifact-blocks/blocks/map/MapBlock.svelte +413 -0
- package/dist/artifact-blocks/blocks/map/MapBlock.svelte.d.ts +53 -0
- package/dist/artifact-blocks/blocks/map/block.d.ts +9 -0
- package/dist/artifact-blocks/blocks/map/block.js +62 -0
- package/dist/artifact-blocks/blocks/map/schema.d.ts +123 -0
- package/dist/artifact-blocks/blocks/map/schema.js +113 -0
- package/dist/artifact-blocks/blocks/map/thumb.d.ts +2 -0
- package/dist/artifact-blocks/blocks/map/thumb.js +69 -0
- package/dist/artifact-blocks/blocks/markdown/MarkdownBlock.svelte +33 -0
- package/dist/artifact-blocks/blocks/markdown/MarkdownBlock.svelte.d.ts +15 -0
- package/dist/artifact-blocks/blocks/markdown/block.d.ts +7 -0
- package/dist/artifact-blocks/blocks/markdown/block.js +28 -0
- package/dist/artifact-blocks/blocks/markdown/render.d.ts +17 -0
- package/dist/artifact-blocks/blocks/markdown/render.js +115 -0
- package/dist/artifact-blocks/blocks/markdown/schema.d.ts +10 -0
- package/dist/artifact-blocks/blocks/markdown/schema.js +23 -0
- package/dist/artifact-blocks/blocks/media-picker/MediaPickerBlock.svelte +2112 -0
- package/dist/artifact-blocks/blocks/media-picker/MediaPickerBlock.svelte.d.ts +5 -0
- package/dist/artifact-blocks/blocks/media-picker/block.d.ts +24 -0
- package/dist/artifact-blocks/blocks/media-picker/block.js +137 -0
- package/dist/artifact-blocks/blocks/media-picker/choices.d.ts +28 -0
- package/dist/artifact-blocks/blocks/media-picker/choices.js +35 -0
- package/dist/artifact-blocks/blocks/media-picker/decisions.d.ts +51 -0
- package/dist/artifact-blocks/blocks/media-picker/decisions.js +75 -0
- package/dist/artifact-blocks/blocks/media-picker/schema.d.ts +7 -0
- package/dist/artifact-blocks/blocks/media-picker/schema.js +199 -0
- package/dist/artifact-blocks/blocks/media-picker/types.d.ts +92 -0
- package/dist/artifact-blocks/blocks/media-picker/types.js +1 -0
- package/dist/artifact-blocks/blocks/media-picker/versions.d.ts +51 -0
- package/dist/artifact-blocks/blocks/media-picker/versions.js +118 -0
- package/dist/artifact-blocks/blocks/mockup/MockupBlock.svelte +213 -0
- package/dist/artifact-blocks/blocks/mockup/MockupBlock.svelte.d.ts +5 -0
- package/dist/artifact-blocks/blocks/mockup/block.d.ts +26 -0
- package/dist/artifact-blocks/blocks/mockup/block.js +52 -0
- package/dist/artifact-blocks/blocks/mockup/doc.d.ts +24 -0
- package/dist/artifact-blocks/blocks/mockup/doc.js +44 -0
- package/dist/artifact-blocks/blocks/mockup/sandbox.d.ts +21 -0
- package/dist/artifact-blocks/blocks/mockup/sandbox.js +27 -0
- package/dist/artifact-blocks/blocks/mockup/schema.d.ts +7 -0
- package/dist/artifact-blocks/blocks/mockup/schema.js +90 -0
- package/dist/artifact-blocks/blocks/mockup/types.d.ts +36 -0
- package/dist/artifact-blocks/blocks/mockup/types.js +17 -0
- package/dist/artifact-blocks/blocks/program/ProgramBlock.svelte +359 -0
- package/dist/artifact-blocks/blocks/program/ProgramBlock.svelte.d.ts +32 -0
- package/dist/artifact-blocks/blocks/program/block.d.ts +35 -0
- package/dist/artifact-blocks/blocks/program/block.js +68 -0
- package/dist/artifact-blocks/blocks/program/schema.d.ts +132 -0
- package/dist/artifact-blocks/blocks/program/schema.js +268 -0
- package/dist/artifact-blocks/blocks/stats/StatsBlock.svelte +315 -0
- package/dist/artifact-blocks/blocks/stats/StatsBlock.svelte.d.ts +24 -0
- package/dist/artifact-blocks/blocks/stats/block.d.ts +7 -0
- package/dist/artifact-blocks/blocks/stats/block.js +49 -0
- package/dist/artifact-blocks/blocks/stats/schema.d.ts +82 -0
- package/dist/artifact-blocks/blocks/stats/schema.js +43 -0
- package/dist/artifact-blocks/blocks/stats/thumb.d.ts +2 -0
- package/dist/artifact-blocks/blocks/stats/thumb.js +102 -0
- package/dist/artifact-blocks/blocks/table/TableBlock.svelte +490 -0
- package/dist/artifact-blocks/blocks/table/TableBlock.svelte.d.ts +28 -0
- package/dist/artifact-blocks/blocks/table/block.d.ts +9 -0
- package/dist/artifact-blocks/blocks/table/block.js +100 -0
- package/dist/artifact-blocks/blocks/table/schema.d.ts +143 -0
- package/dist/artifact-blocks/blocks/table/schema.js +278 -0
- package/dist/artifact-blocks/blocks/timeline/TimelineBlock.svelte +347 -0
- package/dist/artifact-blocks/blocks/timeline/TimelineBlock.svelte.d.ts +42 -0
- package/dist/artifact-blocks/blocks/timeline/block.d.ts +9 -0
- package/dist/artifact-blocks/blocks/timeline/block.js +96 -0
- package/dist/artifact-blocks/blocks/timeline/constants.d.ts +26 -0
- package/dist/artifact-blocks/blocks/timeline/constants.js +27 -0
- package/dist/artifact-blocks/blocks/timeline/schema.d.ts +102 -0
- package/dist/artifact-blocks/blocks/timeline/schema.js +405 -0
- package/dist/artifact-blocks/blocks/timeline/thumb.d.ts +2 -0
- package/dist/artifact-blocks/blocks/timeline/thumb.js +117 -0
- package/dist/artifact-blocks/blocks/timeline/types.d.ts +66 -0
- package/dist/artifact-blocks/blocks/timeline/types.js +1 -0
- package/dist/artifact-blocks/blocks/transcript/TranscriptBlock.svelte +169 -0
- package/dist/artifact-blocks/blocks/transcript/TranscriptBlock.svelte.d.ts +26 -0
- package/dist/artifact-blocks/blocks/transcript/block.d.ts +9 -0
- package/dist/artifact-blocks/blocks/transcript/block.js +86 -0
- package/dist/artifact-blocks/blocks/transcript/schema.d.ts +114 -0
- package/dist/artifact-blocks/blocks/transcript/schema.js +126 -0
- package/dist/artifact-blocks/builtins.d.ts +40 -0
- package/dist/artifact-blocks/builtins.js +22 -0
- package/dist/artifact-blocks/contractBlocks.d.ts +92 -0
- package/dist/artifact-blocks/contractBlocks.js +279 -0
- package/dist/artifact-blocks/history.d.ts +140 -0
- package/dist/artifact-blocks/history.js +363 -0
- package/dist/artifact-blocks/index.d.ts +40 -0
- package/dist/artifact-blocks/index.js +46 -0
- package/dist/artifact-blocks/listen/context.d.ts +30 -0
- package/dist/artifact-blocks/listen/context.js +30 -0
- package/dist/artifact-blocks/liveData.d.ts +78 -0
- package/dist/artifact-blocks/liveData.js +137 -0
- package/dist/artifact-blocks/liveDataPoller.d.ts +41 -0
- package/dist/artifact-blocks/liveDataPoller.js +140 -0
- package/dist/artifact-blocks/openNav.d.ts +46 -0
- package/dist/artifact-blocks/openNav.js +106 -0
- package/dist/artifact-blocks/pageReviewKeys.d.ts +17 -0
- package/dist/artifact-blocks/pageReviewKeys.js +115 -0
- package/dist/artifact-blocks/params.d.ts +98 -0
- package/dist/artifact-blocks/params.js +159 -0
- package/dist/artifact-blocks/registry.d.ts +27 -0
- package/dist/artifact-blocks/registry.js +83 -0
- package/dist/artifact-blocks/review/BlockIcon.svelte +99 -0
- package/dist/artifact-blocks/review/BlockIcon.svelte.d.ts +17 -0
- package/dist/artifact-blocks/review/BulkRejectForm.svelte +94 -0
- package/dist/artifact-blocks/review/BulkRejectForm.svelte.d.ts +11 -0
- package/dist/artifact-blocks/review/BulkUndoBar.svelte +40 -0
- package/dist/artifact-blocks/review/BulkUndoBar.svelte.d.ts +9 -0
- package/dist/artifact-blocks/review/ItemNote.svelte +193 -0
- package/dist/artifact-blocks/review/ItemNote.svelte.d.ts +33 -0
- package/dist/artifact-blocks/review/NoteToggle.svelte +52 -0
- package/dist/artifact-blocks/review/NoteToggle.svelte.d.ts +12 -0
- package/dist/artifact-blocks/review/TeamChip.svelte +71 -0
- package/dist/artifact-blocks/review/TeamChip.svelte.d.ts +8 -0
- package/dist/artifact-blocks/review/attachments.d.ts +10 -0
- package/dist/artifact-blocks/review/attachments.js +37 -0
- package/dist/artifact-blocks/review/bulk.d.ts +52 -0
- package/dist/artifact-blocks/review/bulk.js +61 -0
- package/dist/artifact-blocks/review/itemReview.d.ts +33 -0
- package/dist/artifact-blocks/review/itemReview.js +112 -0
- package/dist/artifact-blocks/review/roundLines.d.ts +14 -0
- package/dist/artifact-blocks/review/roundLines.js +20 -0
- package/dist/artifact-blocks/review/rounds.d.ts +86 -0
- package/dist/artifact-blocks/review/rounds.js +302 -0
- package/dist/artifact-blocks/review/saver.d.ts +23 -0
- package/dist/artifact-blocks/review/saver.js +48 -0
- package/dist/artifact-blocks/review/team.d.ts +31 -0
- package/dist/artifact-blocks/review/team.js +42 -0
- package/dist/artifact-blocks/review/visualOrder.d.ts +29 -0
- package/dist/artifact-blocks/review/visualOrder.js +66 -0
- package/dist/artifact-blocks/review.d.ts +180 -0
- package/dist/artifact-blocks/review.js +716 -0
- package/dist/artifact-blocks/thumb-kit.d.ts +80 -0
- package/dist/artifact-blocks/thumb-kit.js +149 -0
- package/dist/artifact-blocks/types.d.ts +513 -0
- package/dist/artifact-blocks/types.js +1 -0
- package/package.json +11 -2
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* checklist block — the Runbook archetype of the one review experience
|
|
4
|
+
* (docs/review-ux/proposal.md, mos-lsmve.7): rows with a title, an optional
|
|
5
|
+
* test URL, an owner, a verdict and a note. Viewers decide each row; that
|
|
6
|
+
* per-viewer state persists via ctx.saveState (keyed per row id). With
|
|
7
|
+
* teamMode:true the AUTHORED status in params renders as the shared "Team"
|
|
8
|
+
* status — the agents' view, updated by republishing the spec — beside the
|
|
9
|
+
* viewer's own private verdict ("You").
|
|
10
|
+
*
|
|
11
|
+
* Built from the shared jatui review primitives so what a reviewer learns
|
|
12
|
+
* here carries to every other review page:
|
|
13
|
+
* - Rows are focusable cards walked by reviewKeys (j/k, ↓/↑, gg/G). On the
|
|
14
|
+
* focused card: a pass, x fail, b blocked, u undo (back to Untested),
|
|
15
|
+
* n note, Enter opens the row's test link, [ / ] walk earlier rounds,
|
|
16
|
+
* f next row that needs you (retest, then undecided), ? shortcuts. The
|
|
17
|
+
* old p letter still works for one release; the shortcuts modal says so.
|
|
18
|
+
* - VerdictControl: Pass / Fail / Blocked, labels always visible, a second
|
|
19
|
+
* press never clears, Undo is explicit.
|
|
20
|
+
* - ReviewNote: the note of the CURRENT round, opening itself on Fail or
|
|
21
|
+
* Blocked, with its own Saving / Saved ✓ / Not saved state.
|
|
22
|
+
* - RoundHistory: earlier rounds, locked (Joe D1 = A).
|
|
23
|
+
* - ReviewFilterBar (chips with counts + one Sort/Group menu) and
|
|
24
|
+
* ReviewProgress (one tone bar + a sentence) on top.
|
|
25
|
+
*
|
|
26
|
+
* Rounds (Joe D2 = A): a row's round is its `retestSince`. When the team
|
|
27
|
+
* marks a row ready with a new retestSince, the viewer's previous verdict
|
|
28
|
+
* and note show as a locked history line and the current round starts
|
|
29
|
+
* blank; the first save in the round writes that move (retest.ts
|
|
30
|
+
* CHECKLIST_ROUNDS, on the viewer's own save — never a migration).
|
|
31
|
+
*
|
|
32
|
+
* Team progress (mos-mssz0): in teamMode the Team column also speaks two
|
|
33
|
+
* team-only states — "In progress" and "Ready to retest". A ready row the
|
|
34
|
+
* viewer has not re-verdicted in its round carries a "Retest" chip and
|
|
35
|
+
* counts in the progress line; the same rule (retest.ts) is what puts the
|
|
36
|
+
* page under Needs you on /admin/artifacts.
|
|
37
|
+
*
|
|
38
|
+
* Read-only surfaces (public share, print, tests without saveState) render
|
|
39
|
+
* verdicts as badges with no controls, plus the locked history — they must
|
|
40
|
+
* look correct without ever calling saveState.
|
|
41
|
+
*
|
|
42
|
+
* Sort + group (mos-iwmjz) live in the filter bar's menu. They are
|
|
43
|
+
* viewer-LOCAL layout choices — persisted in the viewer's browser
|
|
44
|
+
* (localStorage, try/catch), never in block state — and render server-side
|
|
45
|
+
* with the defaults so hydration never disagrees with a saved row order.
|
|
46
|
+
* The DOM order always equals the visual order, so j/k and Tab follow what
|
|
47
|
+
* the viewer sees.
|
|
48
|
+
*/
|
|
49
|
+
import Icon from "./Icon.svelte"
|
|
50
|
+
import { tick } from "svelte"
|
|
51
|
+
import ReviewFilterBar from "../../../components/review/ReviewFilterBar.svelte"
|
|
52
|
+
import ReviewNote from "../../../components/review/ReviewNote.svelte"
|
|
53
|
+
import ReviewProgress from "../../../components/review/ReviewProgress.svelte"
|
|
54
|
+
import ReviewShortcuts from "../../../components/review/ReviewShortcuts.svelte"
|
|
55
|
+
import RoundHistory from "../../../components/review/RoundHistory.svelte"
|
|
56
|
+
import VerdictControl from "../../../components/review/VerdictControl.svelte"
|
|
57
|
+
import {
|
|
58
|
+
buildReviewFilters,
|
|
59
|
+
orderRounds,
|
|
60
|
+
type ReviewRound,
|
|
61
|
+
} from "../../../components/review/reviewSummary.js"
|
|
62
|
+
import {
|
|
63
|
+
createReviewKeys,
|
|
64
|
+
type NeedsYou,
|
|
65
|
+
type ReviewKeysOptions,
|
|
66
|
+
} from "../../../actions/reviewKeys.js"
|
|
67
|
+
import { isTypingTarget } from "../../../utils/keyboardShortcutRegistry.js"
|
|
68
|
+
import type { BlockProps } from "../../types.js"
|
|
69
|
+
import {
|
|
70
|
+
CHECKLIST_STATUSES,
|
|
71
|
+
type ChecklistParams,
|
|
72
|
+
type ChecklistRow,
|
|
73
|
+
type ChecklistRowState,
|
|
74
|
+
type ChecklistState,
|
|
75
|
+
type ChecklistStatus,
|
|
76
|
+
} from "./schema.js"
|
|
77
|
+
import {
|
|
78
|
+
CHECKLIST_ROUNDS,
|
|
79
|
+
rowAwaitsViewer,
|
|
80
|
+
rowRound,
|
|
81
|
+
seedStatus,
|
|
82
|
+
} from "./retest.js"
|
|
83
|
+
import {
|
|
84
|
+
CHECKLIST_GROUPS,
|
|
85
|
+
CHECKLIST_SORTS,
|
|
86
|
+
GROUP_OPTIONS,
|
|
87
|
+
ICON_TONE,
|
|
88
|
+
LABEL,
|
|
89
|
+
LEGACY_KEYS,
|
|
90
|
+
SORT_OPTIONS,
|
|
91
|
+
STATUS_BADGE,
|
|
92
|
+
STATUS_RANK,
|
|
93
|
+
TEAM_ICON_TONE,
|
|
94
|
+
TEAM_LABEL,
|
|
95
|
+
TITLE_LINK,
|
|
96
|
+
VERDICT_OPTIONS,
|
|
97
|
+
type ChecklistGroup,
|
|
98
|
+
type ChecklistSort,
|
|
99
|
+
} from "./labels.js"
|
|
100
|
+
import SectionTools from "../../SectionTools.svelte"
|
|
101
|
+
|
|
102
|
+
let { params, ctx }: BlockProps<ChecklistParams> = $props()
|
|
103
|
+
|
|
104
|
+
const rows = $derived(params?.rows ?? [])
|
|
105
|
+
const teamMode = $derived(params?.teamMode === true)
|
|
106
|
+
/** Interactive = the viewer can set verdicts here (private to them). */
|
|
107
|
+
const interactive = $derived(Boolean(ctx?.saveState) && !ctx?.readonly)
|
|
108
|
+
/** This viewer's saved state for this block (null = none yet). */
|
|
109
|
+
const saved = $derived((ctx?.state ?? null) as ChecklistState | null)
|
|
110
|
+
const groupName = $derived(`chk-${ctx?.blockId ?? "checklist"}`)
|
|
111
|
+
|
|
112
|
+
// ---- viewer view prefs: sort + group (mos-iwmjz) ------------------------
|
|
113
|
+
// Layout choices are UI preference, not verdict data: they persist in THIS
|
|
114
|
+
// BROWSER under a per-(artifact, block) key and never through ctx.saveState
|
|
115
|
+
// (state is shared per-viewer verdict data keyed per artifact version — a
|
|
116
|
+
// sort must not write it). Every access is try/catch'd and guarded, so a
|
|
117
|
+
// missing/full/blocked storage renders the defaults and works fine.
|
|
118
|
+
const VIEW_KEY = `jat-checklist-view:${ctx?.artifactId ?? "artifact"}:${ctx?.blockId ?? "checklist"}`
|
|
119
|
+
|
|
120
|
+
// Raw (string) values are what the filter bar's menu binds; the typed
|
|
121
|
+
// views below coerce, so corrupted storage can never produce an unknown
|
|
122
|
+
// mode.
|
|
123
|
+
let sortByRaw = $state<string>("authored")
|
|
124
|
+
let groupByRaw = $state<string>("none")
|
|
125
|
+
const sortBy = $derived(isSort(sortByRaw) ? sortByRaw : "authored")
|
|
126
|
+
const groupBy = $derived(isGroup(groupByRaw) ? groupByRaw : "none")
|
|
127
|
+
|
|
128
|
+
function isSort(v: unknown): v is ChecklistSort {
|
|
129
|
+
return (
|
|
130
|
+
typeof v === "string" &&
|
|
131
|
+
(CHECKLIST_SORTS as readonly string[]).includes(v)
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
function isGroup(v: unknown): v is ChecklistGroup {
|
|
135
|
+
return (
|
|
136
|
+
typeof v === "string" &&
|
|
137
|
+
(CHECKLIST_GROUPS as readonly string[]).includes(v)
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadViewPrefs(): {
|
|
142
|
+
sortBy?: ChecklistSort
|
|
143
|
+
groupBy?: ChecklistGroup
|
|
144
|
+
} {
|
|
145
|
+
try {
|
|
146
|
+
if (typeof localStorage === "undefined") return {}
|
|
147
|
+
const raw = localStorage.getItem(VIEW_KEY)
|
|
148
|
+
if (!raw) return {}
|
|
149
|
+
const parsed: unknown = JSON.parse(raw)
|
|
150
|
+
if (!parsed || typeof parsed !== "object") return {}
|
|
151
|
+
const o = parsed as Record<string, unknown>
|
|
152
|
+
return {
|
|
153
|
+
...(isSort(o.sortBy) ? { sortBy: o.sortBy } : {}),
|
|
154
|
+
...(isGroup(o.groupBy) ? { groupBy: o.groupBy } : {}),
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
return {}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function saveViewPrefs(next: { sortBy: string; groupBy: string }) {
|
|
162
|
+
try {
|
|
163
|
+
if (typeof localStorage === "undefined") return
|
|
164
|
+
localStorage.setItem(VIEW_KEY, JSON.stringify(next))
|
|
165
|
+
} catch {
|
|
166
|
+
/* storage unavailable or full — prefs just stay session-local */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Set once the saved prefs (or their absence) landed on mount. */
|
|
171
|
+
let viewHydrated = $state(false)
|
|
172
|
+
|
|
173
|
+
// Restore once on mount. Effects never run on SSR, so the server render
|
|
174
|
+
// always paints the defaults and hydration never disagrees with a
|
|
175
|
+
// saved-prefs row order; the restore is a post-hydration update.
|
|
176
|
+
$effect(() => {
|
|
177
|
+
const prefs = loadViewPrefs()
|
|
178
|
+
if (prefs.sortBy) sortByRaw = prefs.sortBy
|
|
179
|
+
if (prefs.groupBy) groupByRaw = prefs.groupBy
|
|
180
|
+
viewHydrated = true
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Persist on change — only after the restore above ran, so a default can
|
|
184
|
+
// never overwrite what the viewer saved.
|
|
185
|
+
$effect(() => {
|
|
186
|
+
if (!viewHydrated) return
|
|
187
|
+
saveViewPrefs({ sortBy: sortByRaw, groupBy: groupByRaw })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const STATUS_ICON = {
|
|
191
|
+
pass: "circle-check",
|
|
192
|
+
fail: "circle-x",
|
|
193
|
+
blocked: "circle-pause",
|
|
194
|
+
untested: "circle-dashed",
|
|
195
|
+
} as const
|
|
196
|
+
const TEAM_ICON = {
|
|
197
|
+
...STATUS_ICON,
|
|
198
|
+
ready: "rotate-ccw",
|
|
199
|
+
in_progress: "circle-dot-dashed",
|
|
200
|
+
} as const
|
|
201
|
+
|
|
202
|
+
// ---- the viewer's stored entries, one per row ---------------------------
|
|
203
|
+
// State is keyed per (artifact, version, user, blockId) and does not change
|
|
204
|
+
// while mounted — one mount-time read, like the callout block. Each entry
|
|
205
|
+
// is the row's saved shape: the CURRENT round flat ({status, note, at,
|
|
206
|
+
// statusAt, round}) plus the locked `history[]`. Stored JSON is untrusted,
|
|
207
|
+
// so only known fields of the right type survive the read.
|
|
208
|
+
let entries = $state<Record<string, Record<string, unknown>>>({})
|
|
209
|
+
{
|
|
210
|
+
const s = (ctx?.state as ChecklistState | null)?.rows ?? {}
|
|
211
|
+
for (const [id, r] of Object.entries(s)) {
|
|
212
|
+
if (!r || typeof r !== "object") continue
|
|
213
|
+
const raw = r as Record<string, unknown>
|
|
214
|
+
const e: Record<string, unknown> = {}
|
|
215
|
+
if (isStatus(raw.status)) e.status = raw.status
|
|
216
|
+
for (const k of ["note", "at", "statusAt", "round"] as const)
|
|
217
|
+
if (typeof raw[k] === "string") e[k] = raw[k]
|
|
218
|
+
const history = CHECKLIST_ROUNDS.parseHistory(raw.history)
|
|
219
|
+
if (history.length > 0) e.history = history
|
|
220
|
+
entries[id] = e
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isStatus(v: unknown): v is ChecklistStatus {
|
|
225
|
+
return (
|
|
226
|
+
typeof v === "string" &&
|
|
227
|
+
(CHECKLIST_STATUSES as readonly string[]).includes(v)
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Each row's current round + locked history, as the viewer sees it now. */
|
|
232
|
+
const views = $derived.by(() => {
|
|
233
|
+
const out: Record<string, ReturnType<typeof CHECKLIST_ROUNDS.view>> = {}
|
|
234
|
+
for (const row of rows)
|
|
235
|
+
out[row.id] = CHECKLIST_ROUNDS.view(entries[row.id], rowRound(row))
|
|
236
|
+
return out
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The viewer's verdict for a row in its current round. Their save wins;
|
|
241
|
+
* a stale round starts blank. What fills a row with NO save differs by
|
|
242
|
+
* mode (mos-lsmve.16 F8): in teamMode the viewer starts UNTETED — the
|
|
243
|
+
* Team column already shows the team's verdict, and seeding "You" from
|
|
244
|
+
* it double-counted the team's answer as the viewer's (untouched rows
|
|
245
|
+
* carried an Undo, a Blocked row's note sat open, they counted as
|
|
246
|
+
* decided). Outside teamMode the authored status still seeds: a solo
|
|
247
|
+
* checklist's statuses ARE the statuses being confirmed.
|
|
248
|
+
*/
|
|
249
|
+
function viewerStatus(row: ChecklistRow): ChecklistStatus {
|
|
250
|
+
const v = views[row.id]
|
|
251
|
+
const st = v?.current.status
|
|
252
|
+
if (isStatus(st)) return st
|
|
253
|
+
if (v?.stale) return "untested"
|
|
254
|
+
if (teamMode) return "untested"
|
|
255
|
+
return seedStatus(row)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The current round's note ("" when none). */
|
|
259
|
+
function currentNote(row: ChecklistRow): string {
|
|
260
|
+
const n = views[row.id]?.current.note
|
|
261
|
+
return typeof n === "string" ? n : ""
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A `ready` row the viewer has not re-verdicted in its round. */
|
|
265
|
+
function awaitsRetest(row: ChecklistRow): boolean {
|
|
266
|
+
return rowAwaitsViewer(row, entries[row.id])
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Earlier rounds, oldest first (how RoundHistory numbers them). Keys are
|
|
270
|
+
* made unique per line, so two lines of one round never collide. */
|
|
271
|
+
function historyOf(row: ChecklistRow): ReviewRound[] {
|
|
272
|
+
const lines = views[row.id]?.history ?? []
|
|
273
|
+
return [...lines].reverse().map((l, i) => ({
|
|
274
|
+
round: `${i}:${l.round ?? "legacy"}`,
|
|
275
|
+
verdict: l.verdict ?? null,
|
|
276
|
+
note: l.note ?? null,
|
|
277
|
+
at: l.at ?? null,
|
|
278
|
+
}))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- saving (coalesced locally; the host may debounce further) ----------
|
|
282
|
+
let saving = $state(false)
|
|
283
|
+
let saveError = $state<string | null>(null)
|
|
284
|
+
let justSavedAt = $state<string | null>(null)
|
|
285
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
|
286
|
+
|
|
287
|
+
function queueSave() {
|
|
288
|
+
if (!interactive || !ctx?.saveState) return
|
|
289
|
+
if (saveTimer) clearTimeout(saveTimer)
|
|
290
|
+
saving = true
|
|
291
|
+
saveTimer = setTimeout(() => void flushSave(), 350)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The payload carries every row the viewer touched, each in its saved
|
|
296
|
+
* shape — status even when it equals the authored seed (a note-only save
|
|
297
|
+
* must not drop a saved status — the state replaces as a whole), an
|
|
298
|
+
* empty-string note to persist an intentional clear, and the row's own
|
|
299
|
+
* `round` + `history`. Each row keeps its own `at` / `statusAt` — stamped
|
|
300
|
+
* when THAT row changed, never by a save elsewhere on the page.
|
|
301
|
+
*/
|
|
302
|
+
function statePayload(): ChecklistState {
|
|
303
|
+
const out: NonNullable<ChecklistState["rows"]> = {}
|
|
304
|
+
for (const row of rows) {
|
|
305
|
+
const e = entries[row.id]
|
|
306
|
+
if (e && Object.keys(e).length > 0)
|
|
307
|
+
out[row.id] = { ...e } as ChecklistRowState
|
|
308
|
+
}
|
|
309
|
+
return { rows: out, at: new Date().toISOString() }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Save now. Resolves false (and shows the error) when the host refused. */
|
|
313
|
+
async function flushSave(): Promise<boolean> {
|
|
314
|
+
if (saveTimer) clearTimeout(saveTimer)
|
|
315
|
+
saveTimer = null
|
|
316
|
+
if (!ctx?.saveState) return false
|
|
317
|
+
saveError = null
|
|
318
|
+
saving = true
|
|
319
|
+
try {
|
|
320
|
+
await ctx.saveState(statePayload())
|
|
321
|
+
justSavedAt = new Date().toISOString()
|
|
322
|
+
return true
|
|
323
|
+
} catch (e) {
|
|
324
|
+
saveError = e instanceof Error ? e.message : "Saving failed"
|
|
325
|
+
return false
|
|
326
|
+
} finally {
|
|
327
|
+
saving = false
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Apply one change to a row through the round model (the first save in
|
|
332
|
+
* a new round locks the old verdict + note into history first). */
|
|
333
|
+
function patchRow(row: ChecklistRow, patch: Record<string, unknown>) {
|
|
334
|
+
entries[row.id] = CHECKLIST_ROUNDS.save(
|
|
335
|
+
entries[row.id],
|
|
336
|
+
patch,
|
|
337
|
+
rowRound(row),
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function setStatus(row: ChecklistRow, status: string) {
|
|
342
|
+
if (!interactive || !isStatus(status)) return
|
|
343
|
+
const now = new Date().toISOString()
|
|
344
|
+
const opensNote = VERDICT_OPTIONS.some(
|
|
345
|
+
(o) => o.id === status && o.opensNote,
|
|
346
|
+
)
|
|
347
|
+
patchRow(row, { status, at: now, statusAt: now })
|
|
348
|
+
queueSave()
|
|
349
|
+
// ReviewNote opens itself on a verdict CHANGE only while it is mounted
|
|
350
|
+
// open; a closed note is not, so open + focus it here.
|
|
351
|
+
if (opensNote && !openNotes[row.id]) void toggleNote(row)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Undo: the current round back to Untested. The note stays. */
|
|
355
|
+
function undo(row: ChecklistRow) {
|
|
356
|
+
if (viewerStatus(row) === "untested") return
|
|
357
|
+
setStatus(row, "untested")
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Every keystroke lands in the round (locally); ReviewNote's debounced
|
|
361
|
+
* onSave is what sends it. */
|
|
362
|
+
function noteInput(row: ChecklistRow, text: string) {
|
|
363
|
+
if (!interactive) return
|
|
364
|
+
patchRow(row, { note: text, at: new Date().toISOString() })
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function noteSave(row: ChecklistRow, text: string) {
|
|
368
|
+
noteInput(row, text)
|
|
369
|
+
if (!(await flushSave())) throw new Error(saveError ?? "Saving failed")
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Rows whose note field is open. Seeded open where a note matters: the
|
|
373
|
+
* viewer's own note exists on the row, or their verdict stands at Fail /
|
|
374
|
+
* Blocked. In teamMode an UNDECIDED row never seeds its note open — a
|
|
375
|
+
* Team-blocked row is the team's problem statement, not this viewer's
|
|
376
|
+
* open field, and a seeded-open field IS a note-only save waiting to
|
|
377
|
+
* happen (mos-lsmve.16 F8). A row the viewer HAS saved keeps the old
|
|
378
|
+
* rule (their own fail/blocked opens the field). */
|
|
379
|
+
let openNotes = $state<Record<string, boolean>>({})
|
|
380
|
+
for (const row of params?.rows ?? []) {
|
|
381
|
+
if (teamMode && !entries[row.id]) continue
|
|
382
|
+
const st = viewerStatus(row)
|
|
383
|
+
if (currentNote(row) !== "" || st === "fail" || st === "blocked")
|
|
384
|
+
openNotes[row.id] = true
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function noteInputId(row: ChecklistRow): string {
|
|
388
|
+
return `${groupName}-note-${row.id}`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function toggleNote(row: ChecklistRow) {
|
|
392
|
+
if (!interactive) return
|
|
393
|
+
const open = !openNotes[row.id]
|
|
394
|
+
openNotes[row.id] = open
|
|
395
|
+
if (!open) return
|
|
396
|
+
await tick()
|
|
397
|
+
document.getElementById(noteInputId(row))?.focus()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---- earlier rounds -----------------------------------------------------
|
|
401
|
+
let historyOpen = $state<Record<string, boolean>>({})
|
|
402
|
+
let historySelected = $state<Record<string, string | null>>({})
|
|
403
|
+
|
|
404
|
+
/** `[` older / `]` newer: walk the locked lines; past the newest is back
|
|
405
|
+
* to the current round (nothing selected). */
|
|
406
|
+
function stepRound(row: ChecklistRow, delta: -1 | 1) {
|
|
407
|
+
const order = orderRounds(historyOf(row)).map((r) => r.entry.round)
|
|
408
|
+
if (order.length === 0) return
|
|
409
|
+
const cur = historySelected[row.id] ?? null
|
|
410
|
+
const at = cur === null ? -1 : order.indexOf(cur)
|
|
411
|
+
const next = delta === -1 ? Math.min(at + 1, order.length - 1) : at - 1
|
|
412
|
+
historySelected[row.id] = next < 0 ? null : order[next]
|
|
413
|
+
historyOpen[row.id] = next >= 0 || Boolean(historyOpen[row.id])
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---- summary ------------------------------------------------------------
|
|
417
|
+
const counts = $derived.by(() => {
|
|
418
|
+
const c: Record<ChecklistStatus, number> = {
|
|
419
|
+
pass: 0,
|
|
420
|
+
fail: 0,
|
|
421
|
+
blocked: 0,
|
|
422
|
+
untested: 0,
|
|
423
|
+
}
|
|
424
|
+
for (const row of rows) c[viewerStatus(row)]++
|
|
425
|
+
return c
|
|
426
|
+
})
|
|
427
|
+
const total = $derived(rows.length)
|
|
428
|
+
/** Ready rows awaiting this viewer's retest (teamMode only). */
|
|
429
|
+
const retestCount = $derived(
|
|
430
|
+
teamMode ? rows.filter((r) => awaitsRetest(r)).length : 0,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
// ---- filter (session-local) ---------------------------------------------
|
|
434
|
+
/** Does this row wait on the viewer — a retest, or no verdict yet? */
|
|
435
|
+
function needsViewer(row: ChecklistRow): boolean {
|
|
436
|
+
return (teamMode && awaitsRetest(row)) || viewerStatus(row) === "untested"
|
|
437
|
+
}
|
|
438
|
+
/** `f` asks this: retest wins over undecided, like the Needs-you chip. */
|
|
439
|
+
function needsYou(id: string): NeedsYou {
|
|
440
|
+
const row = rowById(id)
|
|
441
|
+
if (!row) return false
|
|
442
|
+
if (teamMode && awaitsRetest(row)) return "retest"
|
|
443
|
+
return viewerStatus(row) === "untested" ? "undecided" : false
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const filters = $derived(
|
|
447
|
+
buildReviewFilters({
|
|
448
|
+
verdicts: VERDICT_OPTIONS,
|
|
449
|
+
counts,
|
|
450
|
+
total,
|
|
451
|
+
needsYou: teamMode ? rows.filter(needsViewer).length : undefined,
|
|
452
|
+
retest: teamMode ? retestCount : undefined,
|
|
453
|
+
}),
|
|
454
|
+
)
|
|
455
|
+
let filterRaw = $state("all")
|
|
456
|
+
/** A filter whose chip emptied out falls back to All, never a blank list. */
|
|
457
|
+
const filter = $derived(
|
|
458
|
+
filters.some((f) => f.id === filterRaw) ? filterRaw : "all",
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
function passesFilter(row: ChecklistRow): boolean {
|
|
462
|
+
switch (filter) {
|
|
463
|
+
case "all":
|
|
464
|
+
return true
|
|
465
|
+
case "needs-you":
|
|
466
|
+
return needsViewer(row)
|
|
467
|
+
case "undecided":
|
|
468
|
+
return viewerStatus(row) === "untested"
|
|
469
|
+
case "retest":
|
|
470
|
+
return awaitsRetest(row)
|
|
471
|
+
default:
|
|
472
|
+
return viewerStatus(row) === filter
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---- sort + group (viewer-local choices, persisted in the browser) ------
|
|
477
|
+
/** One group of rows: stable key, DOM-safe slug, label, order, rows. */
|
|
478
|
+
interface ChecklistBucket {
|
|
479
|
+
key: string
|
|
480
|
+
slug: string
|
|
481
|
+
label: string
|
|
482
|
+
order: number
|
|
483
|
+
rows: ChecklistRow[]
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Bucket keys are data (owner names etc.); ids must be DOM-safe. */
|
|
487
|
+
function slugify(key: string): string {
|
|
488
|
+
return key.toLowerCase().replace(/[^a-z0-9_-]+/g, "-")
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Sorted rows. `sort` is stable, so equal keys keep the authored order —
|
|
493
|
+
* the status sort is a re-prioritisation (fail → blocked → untested →
|
|
494
|
+
* pass), never a reshuffle of peers. Status sorts the VIEWER's status
|
|
495
|
+
* (what they are working through), owner/title are alphabetical with
|
|
496
|
+
* ownerless rows last.
|
|
497
|
+
*/
|
|
498
|
+
const sortedRows = $derived.by(() => {
|
|
499
|
+
const shown = rows.filter(passesFilter)
|
|
500
|
+
if (sortBy === "authored") return shown
|
|
501
|
+
const copy = [...shown]
|
|
502
|
+
if (sortBy === "status")
|
|
503
|
+
copy.sort(
|
|
504
|
+
(a, b) => STATUS_RANK[viewerStatus(a)] - STATUS_RANK[viewerStatus(b)],
|
|
505
|
+
)
|
|
506
|
+
else if (sortBy === "owner")
|
|
507
|
+
copy.sort((a, b) => {
|
|
508
|
+
if (a.owner && b.owner) return a.owner.localeCompare(b.owner)
|
|
509
|
+
if (a.owner) return -1
|
|
510
|
+
if (b.owner) return 1
|
|
511
|
+
return 0
|
|
512
|
+
})
|
|
513
|
+
else copy.sort((a, b) => a.title.localeCompare(b.title))
|
|
514
|
+
return copy
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
/** Which bucket a row belongs to under the current grouping. */
|
|
518
|
+
function bucketOf(row: ChecklistRow): {
|
|
519
|
+
key: string
|
|
520
|
+
label: string
|
|
521
|
+
order: number
|
|
522
|
+
} {
|
|
523
|
+
if (groupBy === "status") {
|
|
524
|
+
const st = viewerStatus(row)
|
|
525
|
+
return { key: `status-${st}`, label: LABEL[st], order: STATUS_RANK[st] }
|
|
526
|
+
}
|
|
527
|
+
if (groupBy === "owner") {
|
|
528
|
+
if (!row.owner) return { key: "owner-none", label: "No owner", order: 1 }
|
|
529
|
+
return { key: `owner-${row.owner}`, label: row.owner, order: 0 }
|
|
530
|
+
}
|
|
531
|
+
// mismatch — teamMode: an UNdecided row is neither a match nor a
|
|
532
|
+
// divergence (the viewer has not answered it — F8), so only decided
|
|
533
|
+
// rows group by divergence; undecided rows bucket under their own
|
|
534
|
+
// group. Solo mode: the viewer's verdict vs the authored seed.
|
|
535
|
+
if (teamMode && viewerStatus(row) === "untested")
|
|
536
|
+
return { key: "mismatch-undecided", label: "Not decided yet", order: 1 }
|
|
537
|
+
const diverged =
|
|
538
|
+
viewerStatus(row) !==
|
|
539
|
+
(teamMode ? (row.status ?? "untested") : seedStatus(row))
|
|
540
|
+
if (teamMode)
|
|
541
|
+
return diverged
|
|
542
|
+
? { key: "mismatch-yes", label: "Mismatch", order: 0 }
|
|
543
|
+
: { key: "mismatch-no", label: "Match", order: 2 }
|
|
544
|
+
return diverged
|
|
545
|
+
? { key: "mismatch-yes", label: "Changed", order: 0 }
|
|
546
|
+
: { key: "mismatch-no", label: "Unchanged", order: 1 }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Contiguous groups in display order (empty ones never appear). With
|
|
551
|
+
* grouping off this is one headerless bucket carrying the sorted rows.
|
|
552
|
+
*/
|
|
553
|
+
const groupedRows = $derived.by(() => {
|
|
554
|
+
if (groupBy === "none")
|
|
555
|
+
return [
|
|
556
|
+
{ key: "all", slug: "all", label: "", order: 0, rows: sortedRows },
|
|
557
|
+
] satisfies ChecklistBucket[]
|
|
558
|
+
const map = new Map<string, ChecklistBucket>()
|
|
559
|
+
for (const row of sortedRows) {
|
|
560
|
+
const meta = bucketOf(row)
|
|
561
|
+
let bucket = map.get(meta.key)
|
|
562
|
+
if (!bucket) {
|
|
563
|
+
bucket = { ...meta, slug: slugify(meta.key), rows: [] }
|
|
564
|
+
map.set(meta.key, bucket)
|
|
565
|
+
}
|
|
566
|
+
bucket.rows.push(row)
|
|
567
|
+
}
|
|
568
|
+
return [...map.values()].sort(
|
|
569
|
+
(a, b) => a.order - b.order || a.label.localeCompare(b.label),
|
|
570
|
+
)
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
/** Collapsed groups are session-local UI state, not persisted. */
|
|
574
|
+
let collapsedGroups = $state<Record<string, boolean>>({})
|
|
575
|
+
|
|
576
|
+
function toggleGroup(key: string) {
|
|
577
|
+
collapsedGroups[key] = !collapsedGroups[key]
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---- keyboard: the shared review map ------------------------------------
|
|
581
|
+
let listEl: HTMLUListElement | undefined = $state()
|
|
582
|
+
let helpOpen = $state(false)
|
|
583
|
+
|
|
584
|
+
function rowById(id: string): ChecklistRow | undefined {
|
|
585
|
+
return rows.find((r) => r.id === id)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Enter on a card follows its test link (the same anchor a click hits). */
|
|
589
|
+
function openRow(el: HTMLElement | null) {
|
|
590
|
+
el?.querySelector<HTMLAnchorElement>("a[data-row-link]")?.click()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const keyConfig: ReviewKeysOptions = $derived({
|
|
594
|
+
// Collapsed groups hide their cards; j/k skips what is not on screen.
|
|
595
|
+
getItems: () =>
|
|
596
|
+
listEl
|
|
597
|
+
? [...listEl.querySelectorAll<HTMLElement>("[data-item-id]")].filter(
|
|
598
|
+
(el) => !el.closest("[hidden]"),
|
|
599
|
+
)
|
|
600
|
+
: [],
|
|
601
|
+
onOpen: (_id, el) => openRow(el),
|
|
602
|
+
onRound: (id, delta) => {
|
|
603
|
+
const row = rowById(id)
|
|
604
|
+
if (row) stepRound(row, delta)
|
|
605
|
+
},
|
|
606
|
+
onHelp: () => (helpOpen = true),
|
|
607
|
+
...(interactive
|
|
608
|
+
? {
|
|
609
|
+
verdicts: VERDICT_OPTIONS,
|
|
610
|
+
onVerdict: (id: string, v: string) => {
|
|
611
|
+
const row = rowById(id)
|
|
612
|
+
if (row) setStatus(row, v)
|
|
613
|
+
},
|
|
614
|
+
onUndo: (id: string) => {
|
|
615
|
+
const row = rowById(id)
|
|
616
|
+
if (row) undo(row)
|
|
617
|
+
},
|
|
618
|
+
onNote: (id: string) => {
|
|
619
|
+
const row = rowById(id)
|
|
620
|
+
if (row) void toggleNote(row)
|
|
621
|
+
},
|
|
622
|
+
needsYou,
|
|
623
|
+
}
|
|
624
|
+
: {}),
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
const keys = createReviewKeys({})
|
|
628
|
+
$effect(() => {
|
|
629
|
+
keys.update(keyConfig)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Listens on the block, not the window: a page can carry several review
|
|
634
|
+
* blocks, and each one's keys act on its own focused card. The old p letter
|
|
635
|
+
* is honoured after the shared map passes on a key (f is the shared
|
|
636
|
+
* "next item that needs you", so it never reaches this fallback).
|
|
637
|
+
*/
|
|
638
|
+
function onKeydown(e: KeyboardEvent) {
|
|
639
|
+
if (keys.handleKeydown(e)) return
|
|
640
|
+
if (!interactive || e.defaultPrevented || e.isComposing) return
|
|
641
|
+
if (e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return
|
|
642
|
+
const legacy = LEGACY_KEYS[e.key.toLowerCase()]
|
|
643
|
+
const id = legacy ? keys.currentId() : null
|
|
644
|
+
const row = id ? rowById(id) : undefined
|
|
645
|
+
if (!legacy || !row) return
|
|
646
|
+
e.preventDefault()
|
|
647
|
+
setStatus(row, legacy)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const SHORTCUTS_FOOTNOTE =
|
|
651
|
+
"The old P still passes for one more release (now A). F no longer fails (that is X): it jumps to the next item that needs you, as on every review page. U is Undo now, not Untested."
|
|
652
|
+
|
|
653
|
+
const timeFmt = new Intl.DateTimeFormat("en-US", {
|
|
654
|
+
hour: "numeric",
|
|
655
|
+
minute: "2-digit",
|
|
656
|
+
})
|
|
657
|
+
const dateTimeFmt = new Intl.DateTimeFormat("en-US", {
|
|
658
|
+
month: "short",
|
|
659
|
+
day: "numeric",
|
|
660
|
+
hour: "numeric",
|
|
661
|
+
minute: "2-digit",
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
const savedLine = $derived.by(() => {
|
|
665
|
+
if (saveError) return saveError
|
|
666
|
+
if (saving) return "Saving…"
|
|
667
|
+
if (justSavedAt) return `Saved ${timeFmt.format(new Date(justSavedAt))}`
|
|
668
|
+
return ""
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
function notePlaceholder(st: ChecklistStatus): string {
|
|
672
|
+
if (st === "fail") return "What failed?"
|
|
673
|
+
if (st === "blocked") return "What is it waiting on?"
|
|
674
|
+
return "Add a note"
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The external-link marker rides with the title's LAST word (nowrap), so a
|
|
679
|
+
* wrapping title can never strand the icon alone on its own line.
|
|
680
|
+
*/
|
|
681
|
+
function titleHead(title: string): string {
|
|
682
|
+
const i = title.trimEnd().lastIndexOf(" ")
|
|
683
|
+
return i < 0 ? "" : title.slice(0, i + 1)
|
|
684
|
+
}
|
|
685
|
+
function titleTail(title: string): string {
|
|
686
|
+
const i = title.trimEnd().lastIndexOf(" ")
|
|
687
|
+
return i < 0 ? title : title.slice(i + 1)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** https:// links leave the app (new tab + marker); root-relative stay. */
|
|
691
|
+
function isExternal(url: string): boolean {
|
|
692
|
+
return /^https:\/\//i.test(url.trim())
|
|
693
|
+
}
|
|
694
|
+
</script>
|
|
695
|
+
|
|
696
|
+
<!-- The cards are the interactive elements; the section only hosts the
|
|
697
|
+
review keys (they bail unless a card or its controls has focus). -->
|
|
698
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
699
|
+
<section
|
|
700
|
+
class="artifact-checklist @container w-full rounded-box border border-base-content/10 bg-base-100"
|
|
701
|
+
onkeydown={onKeydown}
|
|
702
|
+
>
|
|
703
|
+
<header class="flex flex-col gap-2 px-4 pt-3.5 pb-3">
|
|
704
|
+
{#if params?.title}
|
|
705
|
+
<div class="flex items-center gap-2">
|
|
706
|
+
<h2 class="min-w-0 text-sm font-semibold text-base-content">
|
|
707
|
+
{params.title}
|
|
708
|
+
</h2>
|
|
709
|
+
<SectionTools {ctx} inline />
|
|
710
|
+
</div>
|
|
711
|
+
{/if}
|
|
712
|
+
<ReviewProgress
|
|
713
|
+
verdicts={VERDICT_OPTIONS}
|
|
714
|
+
{counts}
|
|
715
|
+
{total}
|
|
716
|
+
retest={retestCount}
|
|
717
|
+
label="{params?.title ?? 'Checklist'} progress"
|
|
718
|
+
size="sm"
|
|
719
|
+
/>
|
|
720
|
+
<ReviewFilterBar
|
|
721
|
+
{filters}
|
|
722
|
+
bind:active={filterRaw}
|
|
723
|
+
sortOptions={rows.length > 1 ? SORT_OPTIONS : undefined}
|
|
724
|
+
bind:sort={sortByRaw}
|
|
725
|
+
groupOptions={rows.length > 1 ? GROUP_OPTIONS : undefined}
|
|
726
|
+
bind:group={groupByRaw}
|
|
727
|
+
onHelp={() => (helpOpen = true)}
|
|
728
|
+
label="Filter {params?.title ?? 'checklist'}"
|
|
729
|
+
/>
|
|
730
|
+
</header>
|
|
731
|
+
|
|
732
|
+
<ul
|
|
733
|
+
bind:this={listEl}
|
|
734
|
+
class="divide-y divide-base-content/10 border-t border-base-content/10"
|
|
735
|
+
aria-label={params?.title ?? "Checklist"}
|
|
736
|
+
>
|
|
737
|
+
{#snippet rowItem(row: ChecklistRow)}
|
|
738
|
+
{@const vStatus = viewerStatus(row)}
|
|
739
|
+
{@const iconName = STATUS_ICON[vStatus]}
|
|
740
|
+
{@const team = row.status ?? "untested"}
|
|
741
|
+
{@const teamIconName = TEAM_ICON[team]}
|
|
742
|
+
{@const retest = teamMode && awaitsRetest(row)}
|
|
743
|
+
{@const history = historyOf(row)}
|
|
744
|
+
{@const roundN = history.length + 1}
|
|
745
|
+
{@const verdict = vStatus === "untested" ? null : vStatus}
|
|
746
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
747
|
+
<li
|
|
748
|
+
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-2 px-4 py-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
|
749
|
+
data-row-id={row.id}
|
|
750
|
+
data-item-id={row.id}
|
|
751
|
+
tabindex="0"
|
|
752
|
+
aria-labelledby="{groupName}-title-{row.id}"
|
|
753
|
+
data-status={vStatus}
|
|
754
|
+
data-awaits-retest={retest ? "" : undefined}
|
|
755
|
+
>
|
|
756
|
+
<span class="mt-0.5 {ICON_TONE[vStatus]}" aria-hidden="true">
|
|
757
|
+
<Icon name={iconName} size={18} strokeWidth={2} />
|
|
758
|
+
</span>
|
|
759
|
+
|
|
760
|
+
<div class="min-w-0">
|
|
761
|
+
{#if row.url}
|
|
762
|
+
{#if isExternal(row.url)}
|
|
763
|
+
<a
|
|
764
|
+
id="{groupName}-title-{row.id}"
|
|
765
|
+
href={row.url}
|
|
766
|
+
target="_blank"
|
|
767
|
+
rel="noopener noreferrer"
|
|
768
|
+
data-row-link
|
|
769
|
+
class="{TITLE_LINK} font-medium text-base-content"
|
|
770
|
+
>{titleHead(row.title)}<span class="whitespace-nowrap"
|
|
771
|
+
>{titleTail(row.title)}<Icon
|
|
772
|
+
name="external-link"
|
|
773
|
+
size={12}
|
|
774
|
+
class="ml-1 inline align-baseline text-base-content/50"
|
|
775
|
+
aria-hidden="true"
|
|
776
|
+
/></span
|
|
777
|
+
><span class="sr-only"> (opens in a new tab)</span></a
|
|
778
|
+
>
|
|
779
|
+
{:else}
|
|
780
|
+
<a
|
|
781
|
+
id="{groupName}-title-{row.id}"
|
|
782
|
+
href={row.url}
|
|
783
|
+
data-row-link
|
|
784
|
+
class="{TITLE_LINK} font-medium text-base-content"
|
|
785
|
+
>{row.title}</a
|
|
786
|
+
>
|
|
787
|
+
{/if}
|
|
788
|
+
{:else}
|
|
789
|
+
<span
|
|
790
|
+
id="{groupName}-title-{row.id}"
|
|
791
|
+
class="font-medium text-base-content">{row.title}</span
|
|
792
|
+
>
|
|
793
|
+
{/if}
|
|
794
|
+
{#if row.note}
|
|
795
|
+
<p
|
|
796
|
+
class="mt-0.5 max-w-[75ch] text-sm leading-snug whitespace-pre-wrap text-base-content/75"
|
|
797
|
+
>
|
|
798
|
+
{row.note}
|
|
799
|
+
</p>
|
|
800
|
+
{/if}
|
|
801
|
+
</div>
|
|
802
|
+
|
|
803
|
+
<!-- Meta line: owner, then the Team status + Retest chip. -->
|
|
804
|
+
{#if row.owner || teamMode}
|
|
805
|
+
<div
|
|
806
|
+
class="col-start-2 flex flex-wrap items-center gap-x-3 gap-y-1.5"
|
|
807
|
+
>
|
|
808
|
+
{#if row.owner}
|
|
809
|
+
<p class="text-xs text-base-content/70">
|
|
810
|
+
<span class="sr-only">{"Owner: "}</span><span
|
|
811
|
+
class="rounded-full bg-base-content/10 px-2 py-0.5"
|
|
812
|
+
>{row.owner}</span
|
|
813
|
+
>
|
|
814
|
+
</p>
|
|
815
|
+
{/if}
|
|
816
|
+
{#if teamMode}
|
|
817
|
+
<span
|
|
818
|
+
class="inline-flex items-center gap-1 text-xs"
|
|
819
|
+
data-team-status={team}
|
|
820
|
+
title="Team status: what the last publish recorded (update by republishing)"
|
|
821
|
+
>
|
|
822
|
+
<span class="text-base-content/70"
|
|
823
|
+
>Team<span class="sr-only">{": "}</span></span
|
|
824
|
+
>
|
|
825
|
+
<Icon
|
|
826
|
+
name={teamIconName}
|
|
827
|
+
size={16}
|
|
828
|
+
strokeWidth={2.25}
|
|
829
|
+
class={TEAM_ICON_TONE[team]}
|
|
830
|
+
aria-hidden="true"
|
|
831
|
+
/>
|
|
832
|
+
<span class="font-medium text-base-content"
|
|
833
|
+
>{TEAM_LABEL[team]}</span
|
|
834
|
+
>
|
|
835
|
+
</span>
|
|
836
|
+
{#if retest}
|
|
837
|
+
<span
|
|
838
|
+
class="badge badge-sm badge-info"
|
|
839
|
+
data-retest-chip={row.id}
|
|
840
|
+
title={row.retestSince
|
|
841
|
+
? `Fixed since your last verdict (${dateTimeFmt.format(new Date(row.retestSince))}) — test it again`
|
|
842
|
+
: "The team marked this ready — test it again"}>Retest</span
|
|
843
|
+
>
|
|
844
|
+
{/if}
|
|
845
|
+
{/if}
|
|
846
|
+
</div>
|
|
847
|
+
{/if}
|
|
848
|
+
|
|
849
|
+
<!-- Decision line: the current round's verdict (+ note toggle). -->
|
|
850
|
+
<div
|
|
851
|
+
class="col-start-2 flex flex-wrap items-center gap-x-3 gap-y-2"
|
|
852
|
+
data-decision={row.id}
|
|
853
|
+
>
|
|
854
|
+
{#if history.length > 0}
|
|
855
|
+
<span
|
|
856
|
+
class="text-xs font-medium text-base-content/70"
|
|
857
|
+
data-current-round={row.id}
|
|
858
|
+
title={row.retestSince
|
|
859
|
+
? `Opened when the team marked it ready (${dateTimeFmt.format(new Date(row.retestSince))})`
|
|
860
|
+
: undefined}>Round {roundN}</span
|
|
861
|
+
>
|
|
862
|
+
{/if}
|
|
863
|
+
{#if interactive}
|
|
864
|
+
{#if teamMode}
|
|
865
|
+
<span class="text-xs text-base-content/70" aria-hidden="true"
|
|
866
|
+
>You</span
|
|
867
|
+
>
|
|
868
|
+
{/if}
|
|
869
|
+
<VerdictControl
|
|
870
|
+
options={VERDICT_OPTIONS}
|
|
871
|
+
value={verdict}
|
|
872
|
+
onVerdict={(v) => setStatus(row, v)}
|
|
873
|
+
onUndo={() => undo(row)}
|
|
874
|
+
label="{teamMode ? 'Your verdict' : 'Verdict'} for {row.title}"
|
|
875
|
+
/>
|
|
876
|
+
{#if !openNotes[row.id]}
|
|
877
|
+
<button
|
|
878
|
+
type="button"
|
|
879
|
+
class="btn btn-ghost btn-xs gap-1 font-normal text-base-content/70 hover:text-base-content"
|
|
880
|
+
aria-label="Add a note to {row.title}"
|
|
881
|
+
aria-keyshortcuts="n"
|
|
882
|
+
data-add-note={row.id}
|
|
883
|
+
onclick={() => void toggleNote(row)}
|
|
884
|
+
>
|
|
885
|
+
<Icon name="message-square-plus" size={13} aria-hidden="true" />
|
|
886
|
+
Add note
|
|
887
|
+
</button>
|
|
888
|
+
{/if}
|
|
889
|
+
{:else}
|
|
890
|
+
<span class="flex items-center gap-1.5">
|
|
891
|
+
{#if teamMode}
|
|
892
|
+
<span class="text-xs text-base-content/70" aria-hidden="true"
|
|
893
|
+
>You</span
|
|
894
|
+
>
|
|
895
|
+
{/if}
|
|
896
|
+
<span
|
|
897
|
+
class="badge badge-sm {STATUS_BADGE[vStatus]}"
|
|
898
|
+
title={teamMode ? "Your saved status" : "Status"}
|
|
899
|
+
>
|
|
900
|
+
{#if teamMode}<span class="sr-only">{"Your status: "}</span
|
|
901
|
+
>{/if}{LABEL[vStatus]}
|
|
902
|
+
</span>
|
|
903
|
+
</span>
|
|
904
|
+
{/if}
|
|
905
|
+
</div>
|
|
906
|
+
|
|
907
|
+
{#if interactive}
|
|
908
|
+
<ReviewNote
|
|
909
|
+
class="checklist-note col-start-2"
|
|
910
|
+
id={noteInputId(row)}
|
|
911
|
+
label={history.length > 0 ? `Round ${roundN} note` : "Note"}
|
|
912
|
+
placeholder={notePlaceholder(vStatus)}
|
|
913
|
+
bind:open={() => openNotes[row.id] ?? false,
|
|
914
|
+
(v) => (openNotes[row.id] = v)}
|
|
915
|
+
value={currentNote(row)}
|
|
916
|
+
{verdict}
|
|
917
|
+
options={VERDICT_OPTIONS}
|
|
918
|
+
round={rowRound(row)}
|
|
919
|
+
rows={1}
|
|
920
|
+
onInput={(t) => noteInput(row, t)}
|
|
921
|
+
onSave={(t) => noteSave(row, t)}
|
|
922
|
+
/>
|
|
923
|
+
{:else if currentNote(row)}
|
|
924
|
+
<p
|
|
925
|
+
class="col-start-2 text-sm whitespace-pre-wrap text-base-content/75"
|
|
926
|
+
>
|
|
927
|
+
Note: {currentNote(row)}
|
|
928
|
+
</p>
|
|
929
|
+
{/if}
|
|
930
|
+
|
|
931
|
+
{#if history.length > 0}
|
|
932
|
+
<div class="col-start-2" data-round-history={row.id}>
|
|
933
|
+
<RoundHistory
|
|
934
|
+
rounds={history}
|
|
935
|
+
verdicts={VERDICT_OPTIONS}
|
|
936
|
+
bind:open={() => historyOpen[row.id] ?? false,
|
|
937
|
+
(v) => (historyOpen[row.id] = v)}
|
|
938
|
+
bind:selected={() => historySelected[row.id] ?? null,
|
|
939
|
+
(v) => (historySelected[row.id] = v)}
|
|
940
|
+
toggleLabel={(n) =>
|
|
941
|
+
`${n} earlier ${n === 1 ? "round" : "rounds"}`}
|
|
942
|
+
/>
|
|
943
|
+
</div>
|
|
944
|
+
{/if}
|
|
945
|
+
</li>
|
|
946
|
+
{/snippet}
|
|
947
|
+
{#each groupedRows as g (g.key)}
|
|
948
|
+
{#if groupBy === "none"}
|
|
949
|
+
{#each g.rows as row (row.id)}
|
|
950
|
+
{@render rowItem(row)}
|
|
951
|
+
{/each}
|
|
952
|
+
{:else}
|
|
953
|
+
<!-- Grouped: one header per non-empty group, count included,
|
|
954
|
+
collapsible via `hidden` so hidden rows also leave the tab
|
|
955
|
+
order — keyboard order always matches the visual order. -->
|
|
956
|
+
<li data-group={g.slug}>
|
|
957
|
+
<h3 class="m-0">
|
|
958
|
+
<button
|
|
959
|
+
type="button"
|
|
960
|
+
class="flex w-full items-center gap-2 px-4 py-2 text-xs font-semibold text-base-content/70 hover:bg-base-content/5 hover:text-base-content"
|
|
961
|
+
aria-expanded={!collapsedGroups[g.key]}
|
|
962
|
+
aria-controls="{groupName}-group-{g.slug}"
|
|
963
|
+
data-group-toggle={g.slug}
|
|
964
|
+
onclick={() => toggleGroup(g.key)}
|
|
965
|
+
>
|
|
966
|
+
<Icon
|
|
967
|
+
name="chevron-down"
|
|
968
|
+
size={14}
|
|
969
|
+
class="transition-transform {collapsedGroups[g.key]
|
|
970
|
+
? '-rotate-90'
|
|
971
|
+
: ''}"
|
|
972
|
+
aria-hidden="true"
|
|
973
|
+
/>
|
|
974
|
+
<span>{g.label}</span>
|
|
975
|
+
<span
|
|
976
|
+
class="rounded-full bg-base-content/10 px-1.5 py-0.5 text-2xs font-medium tabular-nums"
|
|
977
|
+
data-group-count={g.slug}
|
|
978
|
+
>
|
|
979
|
+
{g.rows.length}
|
|
980
|
+
</span>
|
|
981
|
+
</button>
|
|
982
|
+
</h3>
|
|
983
|
+
<ul
|
|
984
|
+
class="divide-y divide-base-content/10"
|
|
985
|
+
id="{groupName}-group-{g.slug}"
|
|
986
|
+
hidden={collapsedGroups[g.key]}
|
|
987
|
+
>
|
|
988
|
+
{#each g.rows as row (row.id)}
|
|
989
|
+
{@render rowItem(row)}
|
|
990
|
+
{/each}
|
|
991
|
+
</ul>
|
|
992
|
+
</li>
|
|
993
|
+
{/if}
|
|
994
|
+
{/each}
|
|
995
|
+
</ul>
|
|
996
|
+
|
|
997
|
+
{#if interactive}
|
|
998
|
+
<footer
|
|
999
|
+
class="flex flex-wrap items-center justify-between gap-2 border-t border-base-content/10 px-4 py-2.5"
|
|
1000
|
+
>
|
|
1001
|
+
<p class="text-xs text-base-content/70">
|
|
1002
|
+
{teamMode
|
|
1003
|
+
? "Your verdicts are private to you. Team is what the last publish recorded."
|
|
1004
|
+
: "Verdicts and notes save privately for you on this page."}
|
|
1005
|
+
<!-- Keyboard hint hidden on touch-first (coarse pointer) devices. -->
|
|
1006
|
+
<span class="whitespace-nowrap [@media(pointer:coarse)]:hidden"
|
|
1007
|
+
>Keys: <kbd class="kbd kbd-xs">a</kbd> pass,
|
|
1008
|
+
<kbd class="kbd kbd-xs">x</kbd> fail,
|
|
1009
|
+
<kbd class="kbd kbd-xs">b</kbd> blocked,
|
|
1010
|
+
<kbd class="kbd kbd-xs">?</kbd> all shortcuts.</span
|
|
1011
|
+
>
|
|
1012
|
+
</p>
|
|
1013
|
+
<p
|
|
1014
|
+
aria-live="polite"
|
|
1015
|
+
class="text-xs tabular-nums {saveError
|
|
1016
|
+
? 'text-error'
|
|
1017
|
+
: 'text-base-content/70'}"
|
|
1018
|
+
>
|
|
1019
|
+
{savedLine}
|
|
1020
|
+
</p>
|
|
1021
|
+
</footer>
|
|
1022
|
+
{:else if saved?.at}
|
|
1023
|
+
<footer
|
|
1024
|
+
class="border-t border-base-content/10 px-4 py-2.5 text-xs tabular-nums text-base-content/70"
|
|
1025
|
+
>
|
|
1026
|
+
Last updated {dateTimeFmt.format(new Date(saved.at))}
|
|
1027
|
+
</footer>
|
|
1028
|
+
{/if}
|
|
1029
|
+
</section>
|
|
1030
|
+
|
|
1031
|
+
{#if helpOpen}
|
|
1032
|
+
<ReviewShortcuts
|
|
1033
|
+
bind:open={helpOpen}
|
|
1034
|
+
config={keyConfig}
|
|
1035
|
+
title="Checklist shortcuts"
|
|
1036
|
+
footnote={interactive ? SHORTCUTS_FOOTNOTE : undefined}
|
|
1037
|
+
/>
|
|
1038
|
+
{/if}
|
|
1039
|
+
|
|
1040
|
+
<style>
|
|
1041
|
+
/* ReviewNote's textarea is DaisyUI .textarea (min-height 5rem): a resting
|
|
1042
|
+
note is ONE line here, grows with its content and scrolls past ~6. */
|
|
1043
|
+
.artifact-checklist :global(.checklist-note textarea) {
|
|
1044
|
+
min-height: 0;
|
|
1045
|
+
max-height: 7.5rem;
|
|
1046
|
+
overflow-y: auto;
|
|
1047
|
+
}
|
|
1048
|
+
</style>
|