@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,2112 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* MediaPickerBlock — the Gallery review archetype (docs/review-ux/
|
|
4
|
+
* proposal.md): a grid of media cards where the asset is primary, each
|
|
5
|
+
* decided Approve / Needs work / Reject with an optional note. Ported from
|
|
6
|
+
* scripts/review-kit/lib/page.mjs behaviour (mos-nj3am, mos-vzame), built
|
|
7
|
+
* on the shared jatui review primitives since mos-lsmve.6 (M2):
|
|
8
|
+
* VerdictControl, ReviewNote, RoundHistory, ReviewProgress,
|
|
9
|
+
* ReviewFilterBar, ReviewShortcuts, reviewKeys() and MediaZoomLightbox.
|
|
10
|
+
*
|
|
11
|
+
* Viewer decisions persist through ctx.saveState as
|
|
12
|
+
* `{ [itemId]: { verdict, variant, note, at, round?, history? } }`
|
|
13
|
+
* (./decisions.ts). The flat fields are the CURRENT round; when saveState
|
|
14
|
+
* is undefined (public link, print, tests) the grid stays interactive but
|
|
15
|
+
* nothing persists. A failed save shows in the block with a Retry.
|
|
16
|
+
*
|
|
17
|
+
* Rounds (Joe D1/D2/D7 = A): a round is the version under review
|
|
18
|
+
* (`currentVersion`), or — when the team marks the item ready — that
|
|
19
|
+
* `team.readySince`. The first save in a new round locks the previous
|
|
20
|
+
* verdict + note into history and starts blank (../../review/rounds.ts);
|
|
21
|
+
* older versions are read-only and their locked line shows in RoundHistory.
|
|
22
|
+
*
|
|
23
|
+
* Campaign-blocks Gate A still holds: the asset is shown WHOLE
|
|
24
|
+
* (object-contain), views are one snapping filmstrip, the filter row is
|
|
25
|
+
* not sticky and scrolls sideways on a phone, columns come from a
|
|
26
|
+
* container query, rejects dim + sink (hover/focus restores them).
|
|
27
|
+
*
|
|
28
|
+
* D5 = A: images AND video open MediaZoomLightbox with the verdict control
|
|
29
|
+
* and note in its footer, and ←/→ (past the last view) or j/k page to the
|
|
30
|
+
* next card — decide without closing.
|
|
31
|
+
*
|
|
32
|
+
* mos-bk1k8 (Joe 2026-10-19: "I have to traverse the longest part of the
|
|
33
|
+
* screen to progress"): in the zoom an approve / pick moves on to the next
|
|
34
|
+
* OPEN card after a short confirm beat (instant under reduced motion), with
|
|
35
|
+
* a "Saved · next" cue and an Undo; Needs work / Reject stay put for the
|
|
36
|
+
* note. Previous / Next / Next open sit beside the verdict row, and the
|
|
37
|
+
* footer says "card 5 of 30 · 4 decided". `kind: "text"` cards drop the
|
|
38
|
+
* zoom chrome and render their words on a themed surface.
|
|
39
|
+
*/
|
|
40
|
+
import MediaZoomLightbox from "../../../components/review/MediaZoomLightbox.svelte"
|
|
41
|
+
import type {
|
|
42
|
+
MediaZoomFooterContext,
|
|
43
|
+
MediaZoomView,
|
|
44
|
+
} from "../../../components/review/mediaZoom"
|
|
45
|
+
import ReviewFilterBar from "../../../components/review/ReviewFilterBar.svelte"
|
|
46
|
+
import ReviewProgress from "../../../components/review/ReviewProgress.svelte"
|
|
47
|
+
import ReviewShortcuts from "../../../components/review/ReviewShortcuts.svelte"
|
|
48
|
+
import RoundHistory from "../../../components/review/RoundHistory.svelte"
|
|
49
|
+
import VerdictControl from "../../../components/review/VerdictControl.svelte"
|
|
50
|
+
import { buildReviewFilters } from "../../../components/review/reviewSummary"
|
|
51
|
+
import { isTypingTarget } from "../../../utils/keyboardShortcutRegistry"
|
|
52
|
+
import {
|
|
53
|
+
reviewKeys,
|
|
54
|
+
type NeedsYou,
|
|
55
|
+
type ReviewKeyRow,
|
|
56
|
+
type ReviewKeysOptions,
|
|
57
|
+
type VerdictOption,
|
|
58
|
+
} from "../../../actions/reviewKeys"
|
|
59
|
+
import { showConfirm } from "../../../stores/confirmDialog"
|
|
60
|
+
import ItemNote from "../../review/ItemNote.svelte"
|
|
61
|
+
import type { NoteAttachment } from "../../review/attachments.js"
|
|
62
|
+
import { onDestroy, tick, untrack } from "svelte"
|
|
63
|
+
import type { BlockProps } from "../../types.js"
|
|
64
|
+
import BulkRejectForm from "../../review/BulkRejectForm.svelte"
|
|
65
|
+
import BulkUndoBar from "../../review/BulkUndoBar.svelte"
|
|
66
|
+
import { decideMany, undoMany, type BulkUndo } from "../../review/bulk.js"
|
|
67
|
+
import NoteToggle from "../../review/NoteToggle.svelte"
|
|
68
|
+
import TeamChip from "../../review/TeamChip.svelte"
|
|
69
|
+
import { looksLikeMarkdown, renderBlockMarkdown } from "../markdown/render.js"
|
|
70
|
+
import { historyRounds } from "../../review/roundLines.js"
|
|
71
|
+
import type { RoundView } from "../../review/rounds.js"
|
|
72
|
+
import { createStateSaver } from "../../review/saver.js"
|
|
73
|
+
import { teamRound } from "../../review/team.js"
|
|
74
|
+
import {
|
|
75
|
+
isTextTarget,
|
|
76
|
+
sortRanks,
|
|
77
|
+
visualItems,
|
|
78
|
+
} from "../../review/visualOrder.js"
|
|
79
|
+
import {
|
|
80
|
+
isEmptyDecision,
|
|
81
|
+
mediaRounds,
|
|
82
|
+
parseDecisions,
|
|
83
|
+
type MediaDecision,
|
|
84
|
+
type MediaVerdict,
|
|
85
|
+
} from "./decisions.js"
|
|
86
|
+
import { choiceLabelOf, choicesOf, isPick } from "./choices.js"
|
|
87
|
+
import type { MediaItem, MediaPickerParams, MediaVariant } from "./types.js"
|
|
88
|
+
import {
|
|
89
|
+
captionFor,
|
|
90
|
+
currentVersionOf,
|
|
91
|
+
historyLine,
|
|
92
|
+
versionOfVariant,
|
|
93
|
+
versionsOf,
|
|
94
|
+
viewLabel,
|
|
95
|
+
viewsOf,
|
|
96
|
+
} from "./versions.js"
|
|
97
|
+
import SectionTools from "../../SectionTools.svelte"
|
|
98
|
+
|
|
99
|
+
let { params, ctx }: BlockProps<MediaPickerParams> = $props()
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The intro renders through the shared sanitized markdown renderer when it
|
|
103
|
+
* carries obvious markdown syntax (mos-8vdg9): paragraphs, lists, bold,
|
|
104
|
+
* links. Plain-text intros render unchanged — whitespace and line breaks
|
|
105
|
+
* exactly as authored (the sniff is conservative on purpose; see
|
|
106
|
+
* looksLikeMarkdown).
|
|
107
|
+
*/
|
|
108
|
+
const introIsMarkdown = $derived(
|
|
109
|
+
Boolean(params.intro && looksLikeMarkdown(params.intro)),
|
|
110
|
+
)
|
|
111
|
+
const introHtml = $derived(
|
|
112
|
+
introIsMarkdown ? renderBlockMarkdown(params.intro) : null,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const VERDICTS: VerdictOption[] = [
|
|
116
|
+
{ id: "approve", label: "Approve", tone: "success", key: "a" },
|
|
117
|
+
{
|
|
118
|
+
id: "redo",
|
|
119
|
+
label: "Needs work",
|
|
120
|
+
tone: "warning",
|
|
121
|
+
key: "x",
|
|
122
|
+
opensNote: true,
|
|
123
|
+
},
|
|
124
|
+
{ id: "reject", label: "Reject", tone: "error", key: "r", opensNote: true },
|
|
125
|
+
]
|
|
126
|
+
const VERDICT_WORD: Record<MediaVerdict, string> = {
|
|
127
|
+
approve: "approved",
|
|
128
|
+
redo: "needs work",
|
|
129
|
+
reject: "rejected",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Stored entries — seeded ONCE from the viewer's saved state (the host
|
|
133
|
+
* passes state at render, never streams it). */
|
|
134
|
+
const initialDecisions = untrack(() => parseDecisions(ctx.state))
|
|
135
|
+
let decisions = $state<Record<string, MediaDecision>>(initialDecisions)
|
|
136
|
+
/**
|
|
137
|
+
* mos-guqgs: deciding requires BOTH a session-free read-only flag AND a
|
|
138
|
+
* save channel. `ctx.readonly` alone was the old gate — which made the
|
|
139
|
+
* admin preview pane (readonly=false, saveState=undefined) look fully
|
|
140
|
+
* answerable while every answer evaporated. Same contract as checklist /
|
|
141
|
+
* compare / callout / actions: no saveState, no live controls.
|
|
142
|
+
*/
|
|
143
|
+
const canDecide = $derived(Boolean(ctx.saveState) && !ctx.readonly)
|
|
144
|
+
const saver = createStateSaver(() => ctx.saveState)
|
|
145
|
+
|
|
146
|
+
/** The round an item is in: the team's readySince when it marked the item
|
|
147
|
+
* ready, else the version under review (null = no rounds). */
|
|
148
|
+
function roundKeyOf(item: MediaItem): string | null {
|
|
149
|
+
return teamRound(item.team) ?? currentVersionOf(item)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Each item's current round + locked history (pure read, no write). */
|
|
153
|
+
const views = $derived.by(() => {
|
|
154
|
+
const out: Record<string, RoundView> = {}
|
|
155
|
+
for (const item of params.items)
|
|
156
|
+
out[item.id] = mediaRounds.view(
|
|
157
|
+
decisions[item.id],
|
|
158
|
+
roundKeyOf(item),
|
|
159
|
+
item.history,
|
|
160
|
+
)
|
|
161
|
+
return out
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
function currentOf(id: string): MediaDecision {
|
|
165
|
+
return (views[id]?.current ?? {}) as MediaDecision
|
|
166
|
+
}
|
|
167
|
+
function verdictOf(id: string): MediaVerdict | null {
|
|
168
|
+
return currentOf(id).verdict ?? null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Selected view per item. A re-opened review lands on the pick the viewer
|
|
172
|
+
* approved this round (review-kit behaviour), else the first view of the
|
|
173
|
+
* version under review. */
|
|
174
|
+
const initialSelected: Record<string, string> = {}
|
|
175
|
+
for (const item of untrack(() => params.items)) {
|
|
176
|
+
const v = mediaRounds.view(
|
|
177
|
+
initialDecisions[item.id],
|
|
178
|
+
roundKeyOf(item),
|
|
179
|
+
).current
|
|
180
|
+
const pre = typeof v.variant === "string" ? v.variant : null
|
|
181
|
+
initialSelected[item.id] = item.variants.some((x) => x.id === pre)
|
|
182
|
+
? (pre as string)
|
|
183
|
+
: (viewsOf(item, currentVersionOf(item))[0] ?? item.variants[0]).id
|
|
184
|
+
}
|
|
185
|
+
let selected = $state<Record<string, string>>(initialSelected)
|
|
186
|
+
let filter = $state("all")
|
|
187
|
+
let notesOpen = $state<Record<string, boolean>>({})
|
|
188
|
+
let helpOpen = $state(false)
|
|
189
|
+
let rootEl = $state<HTMLElement | null>(null)
|
|
190
|
+
/** Zoom view: the item shown and its view index. */
|
|
191
|
+
let zoomItem = $state<string | null>(null)
|
|
192
|
+
let zoomIndex = $state(0)
|
|
193
|
+
|
|
194
|
+
const itemsById = $derived(
|
|
195
|
+
new Map<string, MediaItem>(params.items.map((i) => [i.id, i])),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
/** Groups in display order; unlisted/ungrouped items fall into an
|
|
199
|
+
* implicit section — nothing published is ever hidden from the human. */
|
|
200
|
+
const sections = $derived.by(() => {
|
|
201
|
+
const named = (params.groups ?? [])
|
|
202
|
+
.map((g) => ({
|
|
203
|
+
id: g.id,
|
|
204
|
+
title: g.title,
|
|
205
|
+
items: params.items.filter((it) => it.group === g.id),
|
|
206
|
+
}))
|
|
207
|
+
.filter((g) => g.items.length > 0)
|
|
208
|
+
const listedIds = new Set(named.flatMap((g) => g.items.map((i) => i.id)))
|
|
209
|
+
const rest = params.items.filter((it) => !listedIds.has(it.id))
|
|
210
|
+
const other =
|
|
211
|
+
rest.length > 0
|
|
212
|
+
? [
|
|
213
|
+
{
|
|
214
|
+
id: "__other__",
|
|
215
|
+
title: named.length > 0 ? "Other" : "",
|
|
216
|
+
items: rest,
|
|
217
|
+
},
|
|
218
|
+
]
|
|
219
|
+
: []
|
|
220
|
+
return [...named, ...other]
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
/** A new round is open and this viewer has not decided it. Informational
|
|
224
|
+
* items ask no decision — never a retest, never "needs you". */
|
|
225
|
+
function isRetest(item: MediaItem): boolean {
|
|
226
|
+
if (item.informational || verdictOf(item.id)) return false
|
|
227
|
+
return item.team?.status === "ready" || !!views[item.id]?.stale
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const counts = $derived.by(() => {
|
|
231
|
+
const byVerdict: Record<string, number> = {}
|
|
232
|
+
let retest = 0
|
|
233
|
+
// Informational items are not units — the census and the progress
|
|
234
|
+
// sentence count only items a decision is asked on (mos-lwbbt).
|
|
235
|
+
const units = params.items.filter((it) => !it.informational)
|
|
236
|
+
for (const item of units) {
|
|
237
|
+
const v = verdictOf(item.id)
|
|
238
|
+
if (v) byVerdict[v] = (byVerdict[v] ?? 0) + 1
|
|
239
|
+
else if (isRetest(item)) retest++
|
|
240
|
+
}
|
|
241
|
+
return { byVerdict, retest, total: units.length }
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
/** Chips read as states ("Approved 40 / Rejected 12", mos-u8i35), so the
|
|
245
|
+
* reviewer scans one group and flips the outliers. */
|
|
246
|
+
const FILTER_VERDICTS: VerdictOption[] = VERDICTS.map((o) => ({
|
|
247
|
+
...o,
|
|
248
|
+
label:
|
|
249
|
+
o.id === "approve"
|
|
250
|
+
? "Approved"
|
|
251
|
+
: o.id === "reject"
|
|
252
|
+
? "Rejected"
|
|
253
|
+
: o.label,
|
|
254
|
+
}))
|
|
255
|
+
|
|
256
|
+
const filters = $derived(
|
|
257
|
+
buildReviewFilters({
|
|
258
|
+
verdicts: FILTER_VERDICTS,
|
|
259
|
+
counts: counts.byVerdict,
|
|
260
|
+
total: counts.total,
|
|
261
|
+
retest: counts.retest,
|
|
262
|
+
}),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
function visible(id: string): boolean {
|
|
266
|
+
if (filter === "all") return true
|
|
267
|
+
const item = itemsById.get(id)
|
|
268
|
+
if (item?.informational) return false // context is never a filter bucket
|
|
269
|
+
const v = verdictOf(id)
|
|
270
|
+
if (filter === "undecided") return !v
|
|
271
|
+
if (filter === "retest") {
|
|
272
|
+
return !!item && isRetest(item)
|
|
273
|
+
}
|
|
274
|
+
return v === filter
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Rejects sink to the end of their section — from a frozen snapshot
|
|
278
|
+
* (mos-lsmve.12, ../../review/visualOrder) re-taken only on a filter
|
|
279
|
+
* change, so j from a just-rejected card walks the order on screen. */
|
|
280
|
+
const SINK = { reject: 1 } as const
|
|
281
|
+
function snapshotRanks() {
|
|
282
|
+
return sortRanks(
|
|
283
|
+
params.items.map((it) => it.id),
|
|
284
|
+
verdictOf,
|
|
285
|
+
SINK,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
let ranks = $state<Record<string, number>>(untrack(snapshotRanks))
|
|
289
|
+
$effect(() => {
|
|
290
|
+
void filter
|
|
291
|
+
untrack(() => (ranks = snapshotRanks()))
|
|
292
|
+
})
|
|
293
|
+
function sinkOrder(id: string): number {
|
|
294
|
+
return ranks[id] ?? 0
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function needsYou(id: string): NeedsYou {
|
|
298
|
+
const item = itemsById.get(id)
|
|
299
|
+
if (!item || verdictOf(id) || item.informational) return false
|
|
300
|
+
return isRetest(item) ? "retest" : "undecided"
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function selectedVariantOf(item: MediaItem): MediaVariant {
|
|
304
|
+
return (
|
|
305
|
+
item.variants.find((v) => v.id === selected[item.id]) ?? item.variants[0]
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The variants a choice item offers Pick buttons for: every view of the
|
|
310
|
+
* version under review (never a choice item — its options are named
|
|
311
|
+
* choices over one image, and never an informational item — context asks
|
|
312
|
+
* no decision, whatever flags rode along with it). */
|
|
313
|
+
function pickableViews(item: MediaItem): MediaVariant[] {
|
|
314
|
+
if (!isPick(item) || item.informational || choicesOf(item).length > 0)
|
|
315
|
+
return []
|
|
316
|
+
return viewsOf(item, currentVersionOf(item))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Is this variant the one the round's approve is pinned to? */
|
|
320
|
+
function isPickedVariant(item: MediaItem, variantId: string): boolean {
|
|
321
|
+
const cur = currentOf(item.id)
|
|
322
|
+
return cur.verdict === "approve" && cur.variant === variantId
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Human label for a pinned pick id: a named choice's label first, else
|
|
326
|
+
* the variant's. */
|
|
327
|
+
function pickLabel(item: MediaItem, id: string | null | undefined): string {
|
|
328
|
+
if (!id) return ""
|
|
329
|
+
return choiceLabelOf(item, id) ?? variantLabel(item, id)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Does the Approve button NAME the view on screen? Real choices and
|
|
333
|
+
* evidence cards never do (Joe 2026-10-14: an evidence card's images are
|
|
334
|
+
* EXAMPLES of the problem — the verdict approves the card, no image is
|
|
335
|
+
* named). */
|
|
336
|
+
function namesVariant(item: MediaItem): boolean {
|
|
337
|
+
if (!canDecide || item.evidence) return false
|
|
338
|
+
if (choicesOf(item).length > 0 || pickableViews(item).length > 1)
|
|
339
|
+
return false
|
|
340
|
+
return item.variants.length > 1
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The verdict the control should SHOW as active. An approval is pinned to
|
|
344
|
+
* its variant: while the reviewer browses a different one, no radio reads
|
|
345
|
+
* active — the badge names what was approved, and pressing the approve
|
|
346
|
+
* button (which names the view on screen) moves the pin deliberately.
|
|
347
|
+
* mos-yntqm still holds: a repeat press of the SHOWN active verdict never
|
|
348
|
+
* re-sends, so it can never clear. */
|
|
349
|
+
function approveVisibleValue(item: MediaItem): MediaVerdict | null {
|
|
350
|
+
const cur = currentOf(item.id)
|
|
351
|
+
if (
|
|
352
|
+
cur.verdict === "approve" &&
|
|
353
|
+
cur.variant != null &&
|
|
354
|
+
cur.variant !== selected[item.id]
|
|
355
|
+
)
|
|
356
|
+
return null
|
|
357
|
+
return cur.verdict ?? null
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The verdict row's options for one item. When the item offers more than
|
|
361
|
+
* one variant, Approve names the one the reviewer is looking at — "Approve
|
|
362
|
+
* Bamboo", never a bare Approve (Joe 2026-10-14: "where there are two
|
|
363
|
+
* options how am I picking it") — unless the item is an EVIDENCE card,
|
|
364
|
+
* whose images are examples, not choices: the Approve answers the card,
|
|
365
|
+
* so it stays plain. A choice item has no single Approve at all: its Pick
|
|
366
|
+
* buttons ARE the approve. Read-only surfaces show the full vocabulary,
|
|
367
|
+
* disabled (mos-guqgs: the answer must stay visible). */
|
|
368
|
+
function verdictOptions(item: MediaItem): VerdictOption[] {
|
|
369
|
+
if (!canDecide) return VERDICTS
|
|
370
|
+
if (choicesOf(item).length > 0 || pickableViews(item).length > 1)
|
|
371
|
+
return VERDICTS.filter((o) => o.id !== "approve")
|
|
372
|
+
if (namesVariant(item)) {
|
|
373
|
+
const label = viewLabel(item, selectedVariantOf(item))
|
|
374
|
+
return VERDICTS.map((o) =>
|
|
375
|
+
o.id === "approve" ? { ...o, label: `Approve ${label}` } : o,
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
return VERDICTS
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The FULL option labels for the verdict buttons' `title` tooltips — a
|
|
382
|
+
* long approve name truncates with ellipsis (CSS) and the hover says the
|
|
383
|
+
* whole name (Joe 2026-10-14: never hide an option, never lose its name). */
|
|
384
|
+
function verdictFullLabels(item: MediaItem): Record<string, string> {
|
|
385
|
+
const out: Record<string, string> = {}
|
|
386
|
+
for (const o of VERDICTS) out[o.id] = o.label
|
|
387
|
+
if (namesVariant(item))
|
|
388
|
+
out.approve = `Approve ${viewLabel(item, selectedVariantOf(item))}`
|
|
389
|
+
return out
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Stamp the full label as a `title` on each verdict button under `node`.
|
|
393
|
+
* Re-runs whenever the labels change (the approve name follows the view
|
|
394
|
+
* on screen). Deferred once so the child's buttons exist to stamp. */
|
|
395
|
+
function verdictTooltips(node: HTMLElement, labels: Record<string, string>) {
|
|
396
|
+
let latest = labels
|
|
397
|
+
const stamp = () => {
|
|
398
|
+
for (const b of node.querySelectorAll<HTMLButtonElement>(
|
|
399
|
+
"[data-verdict]",
|
|
400
|
+
)) {
|
|
401
|
+
const full = latest[b.dataset.verdict ?? ""]
|
|
402
|
+
if (full) b.setAttribute("title", full)
|
|
403
|
+
else b.removeAttribute("title")
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
queueMicrotask(stamp)
|
|
407
|
+
return {
|
|
408
|
+
update(next: Record<string, string>) {
|
|
409
|
+
latest = next
|
|
410
|
+
stamp()
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The version the card is showing (null for an item without versions). */
|
|
416
|
+
function activeVersion(item: MediaItem): string | null {
|
|
417
|
+
if (versionsOf(item).length === 0) return null
|
|
418
|
+
return versionOfVariant(item, selectedVariantOf(item))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Is the card showing an OLDER version (read-only, no verdicts)? */
|
|
422
|
+
function viewingPrevious(item: MediaItem): boolean {
|
|
423
|
+
const av = activeVersion(item)
|
|
424
|
+
return av != null && av !== currentVersionOf(item)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function switchVersion(id: string, version: string) {
|
|
428
|
+
const item = itemsById.get(id)
|
|
429
|
+
if (!item) return
|
|
430
|
+
const first = viewsOf(item, version)[0]
|
|
431
|
+
if (first) selectVariant(id, first.id)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** `[` (delta -1) older / `]` (+1) newer. The row lists the version under
|
|
435
|
+
* review first, then the publisher's order (newest first). */
|
|
436
|
+
function stepVersion(id: string, delta: -1 | 1) {
|
|
437
|
+
const item = itemsById.get(id)
|
|
438
|
+
if (!item) return
|
|
439
|
+
const versions = versionsOf(item)
|
|
440
|
+
if (versions.length === 0) return
|
|
441
|
+
const at = Math.max(0, versions.indexOf(activeVersion(item) ?? ""))
|
|
442
|
+
const next = Math.min(versions.length - 1, Math.max(0, at - delta))
|
|
443
|
+
if (next !== at) switchVersion(id, versions[next])
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 1-9: pick a view of the version the card is showing. */
|
|
447
|
+
function pickView(id: string, index: number) {
|
|
448
|
+
const item = itemsById.get(id)
|
|
449
|
+
const v = item ? viewsOf(item, activeVersion(item))[index] : undefined
|
|
450
|
+
if (v) selectVariant(id, v.id)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// --- saving -----------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
/** Apply one change to an item's stored entry under its current round
|
|
456
|
+
* (the first save in a new round locks the old one into history). Local
|
|
457
|
+
* only — `flush()` persists. */
|
|
458
|
+
function update(item: MediaItem, patch: Record<string, unknown>) {
|
|
459
|
+
const next = mediaRounds.save(
|
|
460
|
+
decisions[item.id],
|
|
461
|
+
{ ...patch, at: new Date().toISOString() },
|
|
462
|
+
roundKeyOf(item),
|
|
463
|
+
) as MediaDecision
|
|
464
|
+
const updated = { ...decisions }
|
|
465
|
+
if (isEmptyDecision(next)) delete updated[item.id]
|
|
466
|
+
else updated[item.id] = next
|
|
467
|
+
decisions = updated
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function flush(): Promise<void> {
|
|
471
|
+
return saver.save($state.snapshot(decisions) as Record<string, unknown>)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function persist(item: MediaItem, patch: Record<string, unknown>) {
|
|
475
|
+
update(item, patch)
|
|
476
|
+
// A failure shows as the block's alert (saver.failed), not a throw.
|
|
477
|
+
flush().catch(() => {})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** A verdict save that may move the zoom on (mos-bk1k8). `advances` is
|
|
481
|
+
* true for an approve or a pick — a Needs work / Reject keeps the card so
|
|
482
|
+
* the reviewer can write the note. The entry BEFORE the save is kept for
|
|
483
|
+
* Undo, which restores it exactly (round history included). */
|
|
484
|
+
function decide(
|
|
485
|
+
item: MediaItem,
|
|
486
|
+
patch: Record<string, unknown>,
|
|
487
|
+
advances: boolean,
|
|
488
|
+
) {
|
|
489
|
+
const before = decisions[item.id]
|
|
490
|
+
? ($state.snapshot(decisions[item.id]) as MediaDecision)
|
|
491
|
+
: undefined
|
|
492
|
+
cancelAdvance()
|
|
493
|
+
persist(item, patch)
|
|
494
|
+
if (zoomItem !== item.id) return
|
|
495
|
+
lastPick = advances ? { id: item.id, title: item.title, before } : null
|
|
496
|
+
if (advances) scheduleAdvance(item.id)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* a / x / r, or a verdict button. VerdictControl never re-sends the active
|
|
501
|
+
* verdict (mos-yntqm: a repeat press used to CLEAR an approve); a repeat
|
|
502
|
+
* KEY on a note-worthy verdict re-opens the note instead. A new Needs work
|
|
503
|
+
* / Reject opens + focuses the note itself (ReviewNote, mos-calzz).
|
|
504
|
+
* A named-choice item has no single Approve — `a` must not silently pin
|
|
505
|
+
* the comparison sheet as the answer; the choice buttons pick.
|
|
506
|
+
*/
|
|
507
|
+
function setVerdict(id: string, verdict: MediaVerdict) {
|
|
508
|
+
const item = itemsById.get(id)
|
|
509
|
+
if (!item || !canDecide || viewingPrevious(item) || item.informational)
|
|
510
|
+
return
|
|
511
|
+
if (verdict === "approve" && choicesOf(item).length > 0) return
|
|
512
|
+
if (verdictOf(id) === verdict) {
|
|
513
|
+
// Approving again with a DIFFERENT view selected moves the pin to the
|
|
514
|
+
// view on screen (the button names it); the SAME view is a no-op
|
|
515
|
+
// (mos-yntqm: a repeat press must never clear or churn). An evidence
|
|
516
|
+
// card pins no variant — a repeat approve is a plain no-op.
|
|
517
|
+
if (
|
|
518
|
+
verdict === "approve" &&
|
|
519
|
+
!item.evidence &&
|
|
520
|
+
currentOf(id).variant !== selected[id]
|
|
521
|
+
) {
|
|
522
|
+
decide(item, { verdict, variant: selected[id] }, true)
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
if (VERDICTS.find((o) => o.id === verdict)?.opensNote) openNote(id)
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
// An evidence card's approve answers the CARD (the images are examples,
|
|
529
|
+
// not choices) — it saves no variant (mos-9d62g).
|
|
530
|
+
decide(
|
|
531
|
+
item,
|
|
532
|
+
verdict === "approve" && !item.evidence
|
|
533
|
+
? { verdict, variant: selected[id] }
|
|
534
|
+
: { verdict },
|
|
535
|
+
verdict === "approve",
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** u, or Undo: clear this round's verdict deliberately. The note stays (a
|
|
540
|
+
* remark is not a verdict); the pinned variant goes with the approve. */
|
|
541
|
+
function undoVerdict(id: string) {
|
|
542
|
+
const item = itemsById.get(id)
|
|
543
|
+
if (
|
|
544
|
+
!item ||
|
|
545
|
+
!canDecide ||
|
|
546
|
+
!verdictOf(id) ||
|
|
547
|
+
viewingPrevious(item) ||
|
|
548
|
+
item.informational
|
|
549
|
+
)
|
|
550
|
+
return
|
|
551
|
+
persist(item, { verdict: undefined, variant: undefined })
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Pick one variant of a choice item: approve + THAT variant in one save —
|
|
555
|
+
* the action itself names what it approves (mos-lwbbt). Shows the pick. */
|
|
556
|
+
function pickVariant(id: string, variantId: string) {
|
|
557
|
+
const item = itemsById.get(id)
|
|
558
|
+
if (!item || !canDecide || viewingPrevious(item) || item.informational)
|
|
559
|
+
return
|
|
560
|
+
if (isPickedVariant(item, variantId)) return
|
|
561
|
+
selectVariant(id, variantId)
|
|
562
|
+
decide(item, { verdict: "approve", variant: variantId }, true)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Pick a named choice (the "A vs B" comparison sheet): the choice id is
|
|
566
|
+
* saved in `variant`, the same field every approve pins. */
|
|
567
|
+
function pickChoice(id: string, cid: string) {
|
|
568
|
+
const item = itemsById.get(id)
|
|
569
|
+
if (!item || !canDecide || viewingPrevious(item) || item.informational)
|
|
570
|
+
return
|
|
571
|
+
if (isPickedVariant(item, cid)) return
|
|
572
|
+
decide(item, { verdict: "approve", variant: cid }, true)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The items a bulk may decide: undecided, showing the version under
|
|
576
|
+
* review, shown under the active filter chip, and not a choice (a pick
|
|
577
|
+
* must be deliberate) or a retest (a reopened item deserves eyes).
|
|
578
|
+
* Informational items ask no decision. */
|
|
579
|
+
function bulkApprovable(item: MediaItem): boolean {
|
|
580
|
+
if (
|
|
581
|
+
item.informational ||
|
|
582
|
+
isPick(item) ||
|
|
583
|
+
isRetest(item) ||
|
|
584
|
+
verdictOf(item.id) ||
|
|
585
|
+
viewingPrevious(item) ||
|
|
586
|
+
!visible(item.id)
|
|
587
|
+
)
|
|
588
|
+
return false
|
|
589
|
+
return true
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function approvableCount(items: MediaItem[]): number {
|
|
593
|
+
return items.filter((it) => bulkApprovable(it)).length
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function itemCount(n: number): string {
|
|
597
|
+
return n === 1 ? "1 item" : `${n} items`
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** The last bulk, for its Undo (cleared by Undo, dismiss or the next bulk). */
|
|
601
|
+
let lastBulk = $state<{
|
|
602
|
+
undo: BulkUndo<MediaDecision>
|
|
603
|
+
count: number
|
|
604
|
+
} | null>(null)
|
|
605
|
+
/** The open bulk-reject form (mos-u8i35): the items it will reject and
|
|
606
|
+
* the control that opened it (focus returns there on Cancel). */
|
|
607
|
+
let rejectAsk = $state<{ ids: string[]; opener: HTMLElement | null } | null>(
|
|
608
|
+
null,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
/** Decide every eligible item in one go — ONE save, not N. An approve
|
|
612
|
+
* carries each item's currently shown variant, exactly like pressing the
|
|
613
|
+
* (variant-named) Approve button on each card — except on evidence cards,
|
|
614
|
+
* where the approve answers the card and saves no variant (mos-9d62g). A
|
|
615
|
+
* reject carries the one shared note the form asked for. */
|
|
616
|
+
function decideAll(
|
|
617
|
+
items: MediaItem[],
|
|
618
|
+
verdict: "approve" | "reject",
|
|
619
|
+
note?: string,
|
|
620
|
+
) {
|
|
621
|
+
if (!canDecide) return
|
|
622
|
+
const targets = items.filter((it) => bulkApprovable(it))
|
|
623
|
+
if (targets.length === 0) return
|
|
624
|
+
const { decisions: next, undo } = decideMany(
|
|
625
|
+
decisions,
|
|
626
|
+
targets.map((it) => ({
|
|
627
|
+
id: it.id,
|
|
628
|
+
round: roundKeyOf(it),
|
|
629
|
+
patch:
|
|
630
|
+
verdict === "approve" && !it.evidence
|
|
631
|
+
? { variant: selected[it.id] }
|
|
632
|
+
: undefined,
|
|
633
|
+
})),
|
|
634
|
+
{ verdict, note, at: new Date().toISOString(), save: mediaRounds.save },
|
|
635
|
+
)
|
|
636
|
+
decisions = next as Record<string, MediaDecision>
|
|
637
|
+
lastBulk = { undo, count: targets.length }
|
|
638
|
+
flush().catch(() => {})
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** A section's Approve all: one click, Undo-able. */
|
|
642
|
+
function approveAll(items: MediaItem[]) {
|
|
643
|
+
decideAll(items, "approve")
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** The page-level Approve all remaining asks first (mos-u8i35). */
|
|
647
|
+
async function confirmApproveAll() {
|
|
648
|
+
const ids = params.items.filter(bulkApprovable).map((it) => it.id)
|
|
649
|
+
if (!canDecide || ids.length === 0) return
|
|
650
|
+
const ok = await showConfirm({
|
|
651
|
+
title: `Approve ${itemCount(ids.length)}?`,
|
|
652
|
+
body: "You can still change any item after. Items you already decided stay as they are.",
|
|
653
|
+
confirmLabel: "Approve",
|
|
654
|
+
cancelLabel: "Cancel",
|
|
655
|
+
danger: false,
|
|
656
|
+
timeoutMs: 0,
|
|
657
|
+
})
|
|
658
|
+
// Re-check after the dialog: an item decided meanwhile is not overwritten.
|
|
659
|
+
if (ok)
|
|
660
|
+
decideAll(
|
|
661
|
+
params.items.filter((it) => ids.includes(it.id)),
|
|
662
|
+
"approve",
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function askReject(items: MediaItem[]) {
|
|
667
|
+
const ids = items.filter(bulkApprovable).map((it) => it.id)
|
|
668
|
+
if (!canDecide || ids.length === 0) return
|
|
669
|
+
const active = document.activeElement
|
|
670
|
+
rejectAsk = {
|
|
671
|
+
ids,
|
|
672
|
+
opener: active instanceof HTMLElement ? active : null,
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const rejectCount = $derived(
|
|
677
|
+
rejectAsk
|
|
678
|
+
? params.items.filter(
|
|
679
|
+
(it) => rejectAsk!.ids.includes(it.id) && bulkApprovable(it),
|
|
680
|
+
).length
|
|
681
|
+
: 0,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
function confirmReject(note: string) {
|
|
685
|
+
if (!rejectAsk) return
|
|
686
|
+
const ids = rejectAsk.ids
|
|
687
|
+
rejectAsk = null
|
|
688
|
+
decideAll(
|
|
689
|
+
params.items.filter((it) => ids.includes(it.id)),
|
|
690
|
+
"reject",
|
|
691
|
+
note,
|
|
692
|
+
)
|
|
693
|
+
tick().then(() =>
|
|
694
|
+
visualItems(rootEl)
|
|
695
|
+
.find((el) => !el.hidden)
|
|
696
|
+
?.focus(),
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function cancelReject() {
|
|
701
|
+
const opener = rejectAsk?.opener
|
|
702
|
+
rejectAsk = null
|
|
703
|
+
tick().then(() => opener?.isConnected && opener.focus())
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function undoBulk() {
|
|
707
|
+
if (!lastBulk || !canDecide) return
|
|
708
|
+
decisions = undoMany(decisions, lastBulk.undo)
|
|
709
|
+
lastBulk = null
|
|
710
|
+
flush().catch(() => {})
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Shift+A / Shift+R on the block: approve / reject all remaining (both
|
|
714
|
+
* ask first). Never while typing, never with Ctrl / Meta / Alt. */
|
|
715
|
+
function onBulkKey(e: KeyboardEvent) {
|
|
716
|
+
if (!e.shiftKey || (e.key !== "A" && e.key !== "R")) return
|
|
717
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return
|
|
718
|
+
if (e.defaultPrevented || e.isComposing || isTypingTarget(e.target)) return
|
|
719
|
+
if (!canDecide || approvableCount(params.items) === 0) return
|
|
720
|
+
e.preventDefault()
|
|
721
|
+
if (e.key === "A") void confirmApproveAll()
|
|
722
|
+
else askReject(params.items)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
$effect(() => {
|
|
726
|
+
const el = rootEl
|
|
727
|
+
if (!el) return
|
|
728
|
+
el.addEventListener("keydown", onBulkKey)
|
|
729
|
+
return () => el.removeEventListener("keydown", onBulkKey)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
function onNoteInput(id: string, text: string) {
|
|
733
|
+
const item = itemsById.get(id)
|
|
734
|
+
if (item) update(item, { note: text || undefined })
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** The note's uploaded clips (mos-rccb5): each upload adds its descriptor
|
|
738
|
+
* to the item's saved entry — the bytes are already in the artifact's
|
|
739
|
+
* private asset store by the time this fires. */
|
|
740
|
+
function onItemClips(id: string, clips: NoteAttachment[]) {
|
|
741
|
+
const item = itemsById.get(id)
|
|
742
|
+
if (item) persist(item, { attachments: clips })
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** ReviewNote's debounced save: throws so the note shows "Not saved". */
|
|
746
|
+
const noteSave = $derived(ctx.saveState ? () => flush() : undefined)
|
|
747
|
+
|
|
748
|
+
function noteInputId(id: string): string {
|
|
749
|
+
return zoomItem === id ? `zoomnote-${id}` : `noteinput-${id}`
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Reveal the item's note and put the caret in it. */
|
|
753
|
+
function openNote(id: string) {
|
|
754
|
+
cancelAdvance()
|
|
755
|
+
notesOpen = { ...notesOpen, [id]: true }
|
|
756
|
+
tick().then(() => {
|
|
757
|
+
document
|
|
758
|
+
.getElementById(noteInputId(id))
|
|
759
|
+
?.focus({ preventScroll: zoomItem === id })
|
|
760
|
+
})
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function toggleNote(id: string) {
|
|
764
|
+
const item = itemsById.get(id)
|
|
765
|
+
if (!item || !canDecide || viewingPrevious(item)) return
|
|
766
|
+
if (notesOpen[id]) notesOpen = { ...notesOpen, [id]: false }
|
|
767
|
+
else openNote(id)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function selectVariant(id: string, variantId: string) {
|
|
771
|
+
const item = itemsById.get(id)
|
|
772
|
+
if (!item?.variants.some((v) => v.id === variantId)) return
|
|
773
|
+
selected = { ...selected, [id]: variantId }
|
|
774
|
+
// An approval is STICKY (mos-lwbbt): browsing another variant never
|
|
775
|
+
// moves it — the badge keeps naming what was approved and the Approve
|
|
776
|
+
// button, which always names the view you are on now, is how you move
|
|
777
|
+
// it deliberately. (It used to re-point silently on every switch.)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** RoundHistory's selected line per item (a round that is not a version
|
|
781
|
+
* the card can show — a retest round, or the version under review). */
|
|
782
|
+
let historySel = $state<Record<string, string | null>>({})
|
|
783
|
+
|
|
784
|
+
/** RoundHistory ↔ version switcher: the line of the version the card
|
|
785
|
+
* shows is selected; picking an older version's line shows that version,
|
|
786
|
+
* anything else (or deselecting) returns to the one under review. */
|
|
787
|
+
function selectedRound(
|
|
788
|
+
item: MediaItem,
|
|
789
|
+
rounds: { round: string }[],
|
|
790
|
+
): string | null {
|
|
791
|
+
const shown = activeVersion(item)
|
|
792
|
+
if (viewingPrevious(item))
|
|
793
|
+
return rounds.some((r) => r.round === shown) ? shown : null
|
|
794
|
+
const sel = historySel[item.id] ?? null
|
|
795
|
+
return sel && (!versionsOf(item).includes(sel) || sel === shown)
|
|
796
|
+
? sel
|
|
797
|
+
: null
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function pickRound(item: MediaItem, round: string | null) {
|
|
801
|
+
historySel = { ...historySel, [item.id]: round }
|
|
802
|
+
const cur = currentVersionOf(item)
|
|
803
|
+
if (round && round !== cur && versionsOf(item).includes(round))
|
|
804
|
+
switchVersion(item.id, round)
|
|
805
|
+
else if (viewingPrevious(item) && cur) switchVersion(item.id, cur)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// --- keyboard (jatui reviewKeys: one key table, help rendered from it) -----
|
|
809
|
+
|
|
810
|
+
const keysConfig: ReviewKeysOptions = $derived({
|
|
811
|
+
verdicts: VERDICTS,
|
|
812
|
+
onVerdict: canDecide
|
|
813
|
+
? (id: string, v: string) => setVerdict(id, v as MediaVerdict)
|
|
814
|
+
: undefined,
|
|
815
|
+
onUndo: canDecide ? undoVerdict : undefined,
|
|
816
|
+
onNote: canDecide ? toggleNote : undefined,
|
|
817
|
+
onOpen: (id: string) => openZoom(id),
|
|
818
|
+
onRound: (id: string, delta: -1 | 1) => stepVersion(id, delta),
|
|
819
|
+
onView: pickView,
|
|
820
|
+
needsYou: canDecide ? needsYou : undefined,
|
|
821
|
+
onHelp: () => (helpOpen = true),
|
|
822
|
+
getItems: () => visualItems(rootEl),
|
|
823
|
+
global: false,
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
const BULK_HELP: ReviewKeyRow[] = [
|
|
827
|
+
{
|
|
828
|
+
keys: ["Shift", "A"],
|
|
829
|
+
description: "Approve all remaining (asks first)",
|
|
830
|
+
group: "Decide",
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
keys: ["Shift", "R"],
|
|
834
|
+
description: "Reject all remaining (asks for one note)",
|
|
835
|
+
group: "Decide",
|
|
836
|
+
},
|
|
837
|
+
]
|
|
838
|
+
|
|
839
|
+
const EXTRA_HELP: ReviewKeyRow[] = [
|
|
840
|
+
{
|
|
841
|
+
keys: ["←", "→"],
|
|
842
|
+
description: "Full size: page views, then the next card",
|
|
843
|
+
group: "Item",
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
keys: [",", "."],
|
|
847
|
+
description: "Full size: step a video one frame",
|
|
848
|
+
group: "Item",
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
keys: ["+", "−", "0"],
|
|
852
|
+
description: "Full size: zoom in, out, reset",
|
|
853
|
+
group: "Item",
|
|
854
|
+
},
|
|
855
|
+
]
|
|
856
|
+
|
|
857
|
+
// --- zoom view (MediaZoomLightbox, images and video) ----------------------
|
|
858
|
+
|
|
859
|
+
/** The views the zoom pages through: the shown version's image/video. */
|
|
860
|
+
function zoomVariants(id: string): MediaVariant[] {
|
|
861
|
+
const item = itemsById.get(id)
|
|
862
|
+
if (!item) return []
|
|
863
|
+
return viewsOf(item, activeVersion(item)).filter(
|
|
864
|
+
(v) => v.kind === "image" || v.kind === "video",
|
|
865
|
+
)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Full-size label: "v6 · Loop" on a versioned item, else the view label. */
|
|
869
|
+
function fullLabel(item: MediaItem, v: MediaVariant): string {
|
|
870
|
+
const label = viewLabel(item, v)
|
|
871
|
+
const ver = versionsOf(item).length > 0 ? versionOfVariant(item, v) : null
|
|
872
|
+
return ver ? `${ver} · ${label}` : label
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function zoomViews(id: string): MediaZoomView[] {
|
|
876
|
+
const item = itemsById.get(id)
|
|
877
|
+
if (!item) return []
|
|
878
|
+
return zoomVariants(id).map((v) => ({
|
|
879
|
+
src: v.src,
|
|
880
|
+
kind: v.kind as "image" | "video",
|
|
881
|
+
label: fullLabel(item, v),
|
|
882
|
+
poster: v.poster,
|
|
883
|
+
alt: mediaAlt(item, fullLabel(item, v)),
|
|
884
|
+
}))
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Cards the zoom view pages through: visible, with something to zoom, in
|
|
888
|
+
* the grid's visual order (sections in turn, rejects last in each). */
|
|
889
|
+
const zoomOrder = $derived(
|
|
890
|
+
sections.flatMap((s) =>
|
|
891
|
+
s.items
|
|
892
|
+
.filter((it) => visible(it.id) && zoomVariants(it.id).length > 0)
|
|
893
|
+
.sort((a, b) => sinkOrder(a.id) - sinkOrder(b.id))
|
|
894
|
+
.map((it) => it.id),
|
|
895
|
+
),
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
function openZoom(id: string) {
|
|
899
|
+
const views = zoomVariants(id)
|
|
900
|
+
if (views.length === 0) return
|
|
901
|
+
zoomIndex = Math.max(
|
|
902
|
+
0,
|
|
903
|
+
views.findIndex((v) => v.id === selected[id]),
|
|
904
|
+
)
|
|
905
|
+
zoomItem = id
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function closeZoom() {
|
|
909
|
+
const id = zoomItem
|
|
910
|
+
cancelAdvance()
|
|
911
|
+
lastPick = null
|
|
912
|
+
zoomItem = null
|
|
913
|
+
// Focus the card the reviewer ended on (they may have paged away).
|
|
914
|
+
if (id)
|
|
915
|
+
tick().then(() =>
|
|
916
|
+
rootEl
|
|
917
|
+
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(id)}"]`)
|
|
918
|
+
?.focus(),
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function zoomStep(dir: 1 | -1): (() => void) | undefined {
|
|
923
|
+
if (!zoomItem) return undefined
|
|
924
|
+
const at = zoomOrder.indexOf(zoomItem)
|
|
925
|
+
const to = zoomOrder[at + dir]
|
|
926
|
+
if (at < 0 || !to) return undefined
|
|
927
|
+
return () => {
|
|
928
|
+
zoomIndex = dir > 0 ? 0 : Math.max(0, zoomVariants(to).length - 1)
|
|
929
|
+
zoomItem = to
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Header ↑/↓, j/k, ←/→ past the edge, and the footer's Previous / Next:
|
|
934
|
+
* a deliberate move drops the pending auto-advance and the Undo cue. */
|
|
935
|
+
function navStep(dir: 1 | -1): (() => void) | undefined {
|
|
936
|
+
const go = zoomStep(dir)
|
|
937
|
+
if (!go) return undefined
|
|
938
|
+
return () => {
|
|
939
|
+
cancelAdvance()
|
|
940
|
+
lastPick = null
|
|
941
|
+
go()
|
|
942
|
+
announceCard()
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// --- auto-advance after a pick (mos-bk1k8) ---------------------------------
|
|
947
|
+
|
|
948
|
+
/** Every zoomable card in the grid's visual order, ignoring the filter —
|
|
949
|
+
* the card just decided may have left an "undecided" filter. */
|
|
950
|
+
const zoomOrderAll = $derived(
|
|
951
|
+
sections.flatMap((s) =>
|
|
952
|
+
s.items
|
|
953
|
+
.filter((it) => zoomVariants(it.id).length > 0)
|
|
954
|
+
.sort((a, b) => sinkOrder(a.id) - sinkOrder(b.id))
|
|
955
|
+
.map((it) => it.id),
|
|
956
|
+
),
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
/** The next visible card still asking a decision after `from`, wrapping
|
|
960
|
+
* to the top; null when every card is decided. */
|
|
961
|
+
function nextOpenAfter(from: string): string | null {
|
|
962
|
+
const order = zoomOrderAll
|
|
963
|
+
const at = order.indexOf(from)
|
|
964
|
+
for (let k = 1; k < order.length; k++) {
|
|
965
|
+
const id = order[(at + k + order.length) % order.length]
|
|
966
|
+
if (id !== from && visible(id) && needsYou(id)) return id
|
|
967
|
+
}
|
|
968
|
+
return null
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const AUTO_NEXT_KEY = "mos.review.autoNext"
|
|
972
|
+
const ADVANCE_MS = 400
|
|
973
|
+
/** Per-viewer opt-out (browser-local UI pref, like the checklist's view
|
|
974
|
+
* prefs) — default ON. */
|
|
975
|
+
let autoNext = $state(true)
|
|
976
|
+
let advancing = $state(false)
|
|
977
|
+
let advanceTimer: ReturnType<typeof setTimeout> | null = null
|
|
978
|
+
/** The last advancing pick in the zoom: what Undo restores. */
|
|
979
|
+
let lastPick = $state<{
|
|
980
|
+
id: string
|
|
981
|
+
title: string
|
|
982
|
+
before: MediaDecision | undefined
|
|
983
|
+
} | null>(null)
|
|
984
|
+
/** Screen-reader line: "Card 6 of 30" after a move. */
|
|
985
|
+
let zoomAnnounce = $state("")
|
|
986
|
+
|
|
987
|
+
$effect(() => {
|
|
988
|
+
try {
|
|
989
|
+
if (localStorage.getItem(AUTO_NEXT_KEY) === "0") autoNext = false
|
|
990
|
+
} catch {
|
|
991
|
+
/* storage unavailable — keep the default */
|
|
992
|
+
}
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
function setAutoNext(on: boolean) {
|
|
996
|
+
autoNext = on
|
|
997
|
+
if (!on) cancelAdvance()
|
|
998
|
+
try {
|
|
999
|
+
if (on) localStorage.removeItem(AUTO_NEXT_KEY)
|
|
1000
|
+
else localStorage.setItem(AUTO_NEXT_KEY, "0")
|
|
1001
|
+
} catch {
|
|
1002
|
+
/* storage unavailable — the choice lasts this visit */
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function reducedMotion(): boolean {
|
|
1007
|
+
try {
|
|
1008
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
1009
|
+
} catch {
|
|
1010
|
+
return false
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function cancelAdvance() {
|
|
1015
|
+
if (advanceTimer) clearTimeout(advanceTimer)
|
|
1016
|
+
advanceTimer = null
|
|
1017
|
+
advancing = false
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
onDestroy(cancelAdvance)
|
|
1021
|
+
|
|
1022
|
+
function scheduleAdvance(from: string) {
|
|
1023
|
+
if (!autoNext) return
|
|
1024
|
+
const to = nextOpenAfter(from)
|
|
1025
|
+
if (!to) {
|
|
1026
|
+
zoomAnnounce = "Every card is decided"
|
|
1027
|
+
return
|
|
1028
|
+
}
|
|
1029
|
+
const go = () => {
|
|
1030
|
+
advanceTimer = null
|
|
1031
|
+
advancing = false
|
|
1032
|
+
if (zoomItem !== from) return
|
|
1033
|
+
goToCard(to)
|
|
1034
|
+
tick().then(focusVerdictRow)
|
|
1035
|
+
}
|
|
1036
|
+
if (reducedMotion()) go()
|
|
1037
|
+
else {
|
|
1038
|
+
advancing = true
|
|
1039
|
+
advanceTimer = setTimeout(go, ADVANCE_MS)
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Jump the zoom to a card (its selected view), like opening it. */
|
|
1044
|
+
function goToCard(id: string) {
|
|
1045
|
+
const views = zoomVariants(id)
|
|
1046
|
+
if (views.length === 0) return
|
|
1047
|
+
zoomIndex = Math.max(
|
|
1048
|
+
0,
|
|
1049
|
+
views.findIndex((v) => v.id === selected[id]),
|
|
1050
|
+
)
|
|
1051
|
+
zoomItem = id
|
|
1052
|
+
announceCard()
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function announceCard() {
|
|
1056
|
+
const id = zoomItem
|
|
1057
|
+
const at = id ? zoomOrder.indexOf(id) : -1
|
|
1058
|
+
if (at >= 0) zoomAnnounce = `Card ${at + 1} of ${zoomOrder.length}`
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Focus the new card's first decision control (a pick, else a verdict). */
|
|
1062
|
+
function focusVerdictRow() {
|
|
1063
|
+
const footer = document.querySelector("[data-media-zoom-footer]")
|
|
1064
|
+
const first = [
|
|
1065
|
+
...(footer?.querySelectorAll<HTMLButtonElement>(
|
|
1066
|
+
"[data-pick], [data-zoom-pick], [data-verdict]",
|
|
1067
|
+
) ?? []),
|
|
1068
|
+
].find((b) => !b.disabled)
|
|
1069
|
+
first?.focus({ preventScroll: true })
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Undo: restore the decided card's entry exactly and go back to it. */
|
|
1073
|
+
function undoPick() {
|
|
1074
|
+
const p = lastPick
|
|
1075
|
+
if (!p) return
|
|
1076
|
+
cancelAdvance()
|
|
1077
|
+
lastPick = null
|
|
1078
|
+
const updated = { ...decisions }
|
|
1079
|
+
if (p.before) updated[p.id] = p.before
|
|
1080
|
+
else delete updated[p.id]
|
|
1081
|
+
decisions = updated
|
|
1082
|
+
flush().catch(() => {})
|
|
1083
|
+
if (zoomItem && zoomItem !== p.id) {
|
|
1084
|
+
goToCard(p.id)
|
|
1085
|
+
tick().then(focusVerdictRow)
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function goNextOpen() {
|
|
1090
|
+
const to = zoomItem ? nextOpenAfter(zoomItem) : null
|
|
1091
|
+
if (!to) return
|
|
1092
|
+
cancelAdvance()
|
|
1093
|
+
lastPick = null
|
|
1094
|
+
goToCard(to)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** Decided units on the page, for the footer's progress line. */
|
|
1098
|
+
const decidedCount = $derived(
|
|
1099
|
+
Object.values(counts.byVerdict).reduce((n, c) => n + c, 0),
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
/** A text card's full-size view: no zoom − 100% + row (jatui's lightbox
|
|
1103
|
+
* has no prop for it yet, so the host hides it by its data hook); with
|
|
1104
|
+
* words, the image stage goes too and the footer carries the reading
|
|
1105
|
+
* surface. */
|
|
1106
|
+
const TEXT_ZOOM = "[&_[data-media-zoom-controls]]:hidden"
|
|
1107
|
+
const TEXT_ZOOM_WORDS =
|
|
1108
|
+
"[&_[data-media-zoom-controls]]:hidden [&_[data-zoom-stage]]:hidden [&_[data-media-zoom-footer]]:flex-1 [&_[data-media-zoom-footer]]:content-start [&_[data-media-zoom-footer]]:overflow-y-auto"
|
|
1109
|
+
|
|
1110
|
+
/** A text card renders its words on a themed surface (mos-bk1k8). */
|
|
1111
|
+
function textOf(item: MediaItem): string | null {
|
|
1112
|
+
return item.kind === "text" && item.text ? item.text : null
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** The zoom view's index is the card's selected view: approving in there
|
|
1116
|
+
* pins what the reviewer is looking at. */
|
|
1117
|
+
function setZoomIndex(i: number) {
|
|
1118
|
+
zoomIndex = i
|
|
1119
|
+
const id = zoomItem
|
|
1120
|
+
const v = id ? zoomVariants(id)[i] : undefined
|
|
1121
|
+
if (id && v && selected[id] !== v.id) selectVariant(id, v.id)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Keys the lightbox lets bubble (verdicts, u, n, [ ]) act on its item. */
|
|
1125
|
+
function onZoomKeydown(e: KeyboardEvent) {
|
|
1126
|
+
const id = zoomItem
|
|
1127
|
+
if (!id || e.defaultPrevented || e.isComposing) return
|
|
1128
|
+
if (e.metaKey || e.ctrlKey || e.altKey || isTextTarget(e.target)) return
|
|
1129
|
+
const k = e.key
|
|
1130
|
+
const v = VERDICTS.find((o) => o.key === k)
|
|
1131
|
+
if (v && canDecide) setVerdict(id, v.id as MediaVerdict)
|
|
1132
|
+
else if (k === "u" && canDecide) undoVerdict(id)
|
|
1133
|
+
else if (k === "n" && canDecide) toggleNote(id)
|
|
1134
|
+
else if (k === "[" || k === "]") {
|
|
1135
|
+
stepVersion(id, k === "[" ? -1 : 1)
|
|
1136
|
+
zoomIndex = 0
|
|
1137
|
+
} else return
|
|
1138
|
+
e.preventDefault()
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// --- reduced motion: autoplay muted loop videos only when allowed -----------
|
|
1142
|
+
|
|
1143
|
+
$effect(() => {
|
|
1144
|
+
if (!rootEl) return
|
|
1145
|
+
let ok = true
|
|
1146
|
+
try {
|
|
1147
|
+
ok = !window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
1148
|
+
} catch {
|
|
1149
|
+
ok = true
|
|
1150
|
+
}
|
|
1151
|
+
const videos = rootEl.querySelectorAll<HTMLVideoElement>(
|
|
1152
|
+
"[data-item-id] video[data-autoloop]",
|
|
1153
|
+
)
|
|
1154
|
+
videos.forEach((v) => {
|
|
1155
|
+
if (ok) v.play().catch(() => {})
|
|
1156
|
+
else v.pause()
|
|
1157
|
+
})
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
function mediaAlt(item: MediaItem, label: string | undefined): string {
|
|
1161
|
+
return label ? `${item.title}: ${label}` : item.title
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Human label for a variant id (approved badge). */
|
|
1165
|
+
function variantLabel(item: MediaItem, variantId: string | null | undefined) {
|
|
1166
|
+
const v = item.variants.find((x) => x.id === variantId)
|
|
1167
|
+
return v ? fullLabel(item, v) : (variantId ?? "")
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** The locked line for a version (viewer's own, else the publisher's). */
|
|
1171
|
+
function versionLine(id: string, version: string | null) {
|
|
1172
|
+
if (!version) return null
|
|
1173
|
+
return views[id]?.history.find((l) => l.round === version) ?? null
|
|
1174
|
+
}
|
|
1175
|
+
</script>
|
|
1176
|
+
|
|
1177
|
+
{#snippet cardCopy(item: MediaItem)}
|
|
1178
|
+
<!-- The card's plain-English copy (mos-9d62g): two short labeled lines
|
|
1179
|
+
written for the reader (the build lints them), plus "Open on site"
|
|
1180
|
+
chips — where to see the thing being judged, in a new tab. -->
|
|
1181
|
+
{#if item.wrong}
|
|
1182
|
+
<p class="text-xs leading-relaxed text-base-content/90" data-wrong>
|
|
1183
|
+
<span class="font-semibold">What is wrong:</span>
|
|
1184
|
+
{item.wrong}
|
|
1185
|
+
</p>
|
|
1186
|
+
{/if}
|
|
1187
|
+
{#if item.ifApproved}
|
|
1188
|
+
<p class="text-xs leading-relaxed text-base-content/90" data-if-approved>
|
|
1189
|
+
<span class="font-semibold">If you approve:</span>
|
|
1190
|
+
{item.ifApproved}
|
|
1191
|
+
</p>
|
|
1192
|
+
{/if}
|
|
1193
|
+
{#if item.links?.length}
|
|
1194
|
+
<div class="flex flex-wrap gap-1.5" data-links>
|
|
1195
|
+
{#each item.links as l (l.url)}
|
|
1196
|
+
<a
|
|
1197
|
+
href={l.url}
|
|
1198
|
+
target="_blank"
|
|
1199
|
+
rel="noopener noreferrer"
|
|
1200
|
+
class="btn btn-xs rounded-field border-base-content/15 text-base-content/80 hover:text-base-content"
|
|
1201
|
+
title="{l.label} (opens in a new tab)"
|
|
1202
|
+
>
|
|
1203
|
+
{l.label}
|
|
1204
|
+
<svg
|
|
1205
|
+
viewBox="0 0 24 24"
|
|
1206
|
+
class="size-3"
|
|
1207
|
+
fill="none"
|
|
1208
|
+
stroke="currentColor"
|
|
1209
|
+
stroke-width="2"
|
|
1210
|
+
stroke-linecap="round"
|
|
1211
|
+
stroke-linejoin="round"
|
|
1212
|
+
aria-hidden="true"><path d="M7 17 17 7" /><path d="M8 7h9v9" /></svg
|
|
1213
|
+
>
|
|
1214
|
+
</a>
|
|
1215
|
+
{/each}
|
|
1216
|
+
</div>
|
|
1217
|
+
{/if}
|
|
1218
|
+
{/snippet}
|
|
1219
|
+
|
|
1220
|
+
{#snippet zoomNav()}
|
|
1221
|
+
<!-- Beside the verdict row (mos-bk1k8): moving on is next to the choice
|
|
1222
|
+
just made, not the full diagonal away in the header. Same size as the
|
|
1223
|
+
verdict buttons; the header arrows stay too. -->
|
|
1224
|
+
{@const prev = navStep(-1)}
|
|
1225
|
+
{@const next = navStep(1)}
|
|
1226
|
+
{@const open = zoomItem ? nextOpenAfter(zoomItem) : null}
|
|
1227
|
+
<div
|
|
1228
|
+
class="flex flex-wrap items-center gap-1"
|
|
1229
|
+
role="group"
|
|
1230
|
+
aria-label="Move between cards"
|
|
1231
|
+
data-zoom-nav
|
|
1232
|
+
>
|
|
1233
|
+
<button
|
|
1234
|
+
type="button"
|
|
1235
|
+
class="btn btn-sm rounded-field gap-1"
|
|
1236
|
+
aria-keyshortcuts="K"
|
|
1237
|
+
disabled={!prev}
|
|
1238
|
+
onclick={() => prev?.()}
|
|
1239
|
+
data-zoom-prev
|
|
1240
|
+
>
|
|
1241
|
+
Previous <kbd class="kbd kbd-xs opacity-70" aria-hidden="true">k</kbd>
|
|
1242
|
+
</button>
|
|
1243
|
+
<button
|
|
1244
|
+
type="button"
|
|
1245
|
+
class="btn btn-sm rounded-field gap-1"
|
|
1246
|
+
aria-keyshortcuts="J"
|
|
1247
|
+
disabled={!next}
|
|
1248
|
+
onclick={() => next?.()}
|
|
1249
|
+
data-zoom-next
|
|
1250
|
+
>
|
|
1251
|
+
Next <kbd class="kbd kbd-xs opacity-70" aria-hidden="true">j</kbd>
|
|
1252
|
+
</button>
|
|
1253
|
+
{#if canDecide && open}
|
|
1254
|
+
<button
|
|
1255
|
+
type="button"
|
|
1256
|
+
class="btn btn-sm btn-ghost rounded-field"
|
|
1257
|
+
onclick={goNextOpen}
|
|
1258
|
+
data-zoom-next-open
|
|
1259
|
+
>
|
|
1260
|
+
Next open
|
|
1261
|
+
</button>
|
|
1262
|
+
{/if}
|
|
1263
|
+
</div>
|
|
1264
|
+
{/snippet}
|
|
1265
|
+
|
|
1266
|
+
{#snippet zoomStatus()}
|
|
1267
|
+
<!-- Progress + the pick cue (mos-bk1k8). The visible line is not live;
|
|
1268
|
+
the sr-only line announces "Card 6 of 30" after a move. -->
|
|
1269
|
+
{@const at = zoomItem ? zoomOrder.indexOf(zoomItem) : -1}
|
|
1270
|
+
<div
|
|
1271
|
+
class="flex w-full flex-wrap items-center gap-x-3 gap-y-1 text-xs text-base-content/70"
|
|
1272
|
+
data-zoom-status
|
|
1273
|
+
>
|
|
1274
|
+
<span class="tabular-nums" data-zoom-progress>
|
|
1275
|
+
{`${at >= 0 ? `card ${at + 1} of ${zoomOrder.length} · ` : ""}${decidedCount} decided`}
|
|
1276
|
+
</span>
|
|
1277
|
+
{#if lastPick}
|
|
1278
|
+
<span
|
|
1279
|
+
class="flex items-center gap-1.5 font-medium text-success"
|
|
1280
|
+
data-zoom-cue
|
|
1281
|
+
>
|
|
1282
|
+
{advancing
|
|
1283
|
+
? "Saved · next"
|
|
1284
|
+
: lastPick.id === zoomItem
|
|
1285
|
+
? "Saved"
|
|
1286
|
+
: `Saved: ${lastPick.title}`}
|
|
1287
|
+
<button
|
|
1288
|
+
type="button"
|
|
1289
|
+
class="btn btn-ghost btn-xs rounded-field text-base-content/80"
|
|
1290
|
+
onclick={undoPick}
|
|
1291
|
+
data-zoom-undo-pick>↶ Undo</button
|
|
1292
|
+
>
|
|
1293
|
+
</span>
|
|
1294
|
+
{/if}
|
|
1295
|
+
{#if canDecide}
|
|
1296
|
+
<label class="ml-auto flex cursor-pointer items-center gap-1.5">
|
|
1297
|
+
<input
|
|
1298
|
+
type="checkbox"
|
|
1299
|
+
class="toggle toggle-xs"
|
|
1300
|
+
checked={autoNext}
|
|
1301
|
+
onchange={(e) => setAutoNext(e.currentTarget.checked)}
|
|
1302
|
+
data-zoom-auto-next
|
|
1303
|
+
/>
|
|
1304
|
+
Go to the next card after a pick
|
|
1305
|
+
</label>
|
|
1306
|
+
{/if}
|
|
1307
|
+
<span class="sr-only" aria-live="polite" data-zoom-announce
|
|
1308
|
+
>{zoomAnnounce}</span
|
|
1309
|
+
>
|
|
1310
|
+
</div>
|
|
1311
|
+
{/snippet}
|
|
1312
|
+
|
|
1313
|
+
{#snippet decisionRow(item: MediaItem, inZoom: boolean)}
|
|
1314
|
+
{@const cur = currentOf(item.id)}
|
|
1315
|
+
{@const picks = choicesOf(item)}
|
|
1316
|
+
{#if canDecide && !viewingPrevious(item) && picks.length > 0}
|
|
1317
|
+
<!-- A named-choice item (the "A vs B" comparison sheet): a Pick button
|
|
1318
|
+
per option; the pick saves verdict + the choice id, exactly what an
|
|
1319
|
+
approve saves. Needs work / Reject stay for "neither". -->
|
|
1320
|
+
<div
|
|
1321
|
+
class="flex flex-wrap gap-1.5"
|
|
1322
|
+
role="group"
|
|
1323
|
+
aria-label="Pick an option for {item.title}"
|
|
1324
|
+
data-choice-row
|
|
1325
|
+
>
|
|
1326
|
+
{#each picks as c (c.id)}
|
|
1327
|
+
{@const on = cur.verdict === "approve" && cur.variant === c.id}
|
|
1328
|
+
<button
|
|
1329
|
+
type="button"
|
|
1330
|
+
class="btn btn-sm rounded-field {on ? 'btn-success' : 'btn-outline'}"
|
|
1331
|
+
aria-pressed={on}
|
|
1332
|
+
data-pick={c.id}
|
|
1333
|
+
onclick={() => pickChoice(item.id, c.id)}
|
|
1334
|
+
>
|
|
1335
|
+
{on ? `✓ ${c.label}` : `Pick ${c.label}`}
|
|
1336
|
+
</button>
|
|
1337
|
+
{/each}
|
|
1338
|
+
</div>
|
|
1339
|
+
{/if}
|
|
1340
|
+
{#if inZoom && canDecide && !viewingPrevious(item) && pickableViews(item).length > 1}
|
|
1341
|
+
<!-- A per-variant choice item in the zoom: one Pick button for the view
|
|
1342
|
+
being looked at (the strip's buttons are not reachable here). -->
|
|
1343
|
+
{@const shown = zoomVariants(item.id)[zoomIndex] ?? selectedVariantOf(item)}
|
|
1344
|
+
{@const on = isPickedVariant(item, shown.id)}
|
|
1345
|
+
<button
|
|
1346
|
+
type="button"
|
|
1347
|
+
class="btn btn-sm rounded-field self-start {on
|
|
1348
|
+
? 'btn-success'
|
|
1349
|
+
: 'btn-outline'}"
|
|
1350
|
+
aria-pressed={on}
|
|
1351
|
+
aria-label="{on ? 'Picked' : 'Pick this'}: {viewLabel(item, shown)}"
|
|
1352
|
+
data-zoom-pick
|
|
1353
|
+
onclick={() => pickVariant(item.id, shown.id)}
|
|
1354
|
+
>
|
|
1355
|
+
{on ? "✓ Picked" : "Pick this"}
|
|
1356
|
+
</button>
|
|
1357
|
+
{/if}
|
|
1358
|
+
<div
|
|
1359
|
+
class="flex flex-wrap items-center gap-2 [&_.join]:min-w-0 [&_.join]:flex-wrap [&_[data-verdict]]:min-w-0 [&_[data-verdict='approve']]:max-w-full [&_[data-verdict='approve']]:truncate"
|
|
1360
|
+
use:verdictTooltips={verdictFullLabels(item)}
|
|
1361
|
+
>
|
|
1362
|
+
<VerdictControl
|
|
1363
|
+
options={verdictOptions(item)}
|
|
1364
|
+
value={approveVisibleValue(item)}
|
|
1365
|
+
label="Verdict for {item.title}"
|
|
1366
|
+
undoLabel="Undo"
|
|
1367
|
+
showKeys={inZoom}
|
|
1368
|
+
disabled={!canDecide}
|
|
1369
|
+
onVerdict={(v) => setVerdict(item.id, v as MediaVerdict)}
|
|
1370
|
+
onUndo={() => undoVerdict(item.id)}
|
|
1371
|
+
>
|
|
1372
|
+
{#snippet trailing()}
|
|
1373
|
+
<!-- Inside VerdictControl's atomic Undo group: the wrap boundary
|
|
1374
|
+
falls before the pair, so the Note button never strands alone
|
|
1375
|
+
on its own row (mos-lsmve.16 F7). -->
|
|
1376
|
+
<NoteToggle
|
|
1377
|
+
open={!!notesOpen[item.id]}
|
|
1378
|
+
hasNote={!!cur.note}
|
|
1379
|
+
disabled={!canDecide}
|
|
1380
|
+
controls={inZoom ? `zoomnote-${item.id}` : `noteinput-${item.id}`}
|
|
1381
|
+
itemTitle={item.title}
|
|
1382
|
+
onToggle={() => toggleNote(item.id)}
|
|
1383
|
+
/>
|
|
1384
|
+
{/snippet}
|
|
1385
|
+
</VerdictControl>
|
|
1386
|
+
{#if inZoom}
|
|
1387
|
+
{@render zoomNav()}
|
|
1388
|
+
{/if}
|
|
1389
|
+
</div>
|
|
1390
|
+
{#if inZoom && cur.verdict === "approve" && (cur.variant || item.evidence)}
|
|
1391
|
+
<!-- The approval names its variant wherever the decision is made —
|
|
1392
|
+
on the card the header badge carries it, in the zoom this line.
|
|
1393
|
+
An evidence card's approve answers the CARD: no image is named. -->
|
|
1394
|
+
<p class="text-xs font-medium text-success" data-zoom-approved>
|
|
1395
|
+
{item.evidence ? "Approved" : `Approved: ${pickLabel(item, cur.variant)}`}
|
|
1396
|
+
</p>
|
|
1397
|
+
{/if}
|
|
1398
|
+
<!-- The ONE composer in its per-item mode (mos-rccb5): jatui ReviewNote
|
|
1399
|
+
stays the inline text leg (round model, open-on-reject, debounced
|
|
1400
|
+
save — all unchanged); the composer adds the attach lane — voice /
|
|
1401
|
+
video / screen capture, file picker, paste and drag-drop — and the
|
|
1402
|
+
clip chips. Uploads land in the artifact's private asset store and
|
|
1403
|
+
ride the item's saved entry as descriptors. -->
|
|
1404
|
+
<ItemNote
|
|
1405
|
+
artifactId={ctx.artifactId}
|
|
1406
|
+
note={{
|
|
1407
|
+
id: inZoom ? `zoomnote-${item.id}` : `noteinput-${item.id}`,
|
|
1408
|
+
value: cur.note ?? "",
|
|
1409
|
+
verdict: cur.verdict ?? null,
|
|
1410
|
+
options: VERDICTS,
|
|
1411
|
+
round: views[item.id]?.round ?? null,
|
|
1412
|
+
saveFailed: saver.failed,
|
|
1413
|
+
label: `Note for ${item.title}`,
|
|
1414
|
+
placeholder: "What needs to change?",
|
|
1415
|
+
onInput: (t) => onNoteInput(item.id, t),
|
|
1416
|
+
onSave: noteSave,
|
|
1417
|
+
...(inZoom
|
|
1418
|
+
? {
|
|
1419
|
+
returnFocusTo: () =>
|
|
1420
|
+
document.querySelector<HTMLElement>("[data-media-zoom-lightbox]"),
|
|
1421
|
+
}
|
|
1422
|
+
: {}),
|
|
1423
|
+
}}
|
|
1424
|
+
bind:open={() => !!notesOpen[item.id],
|
|
1425
|
+
(o) => (notesOpen = { ...notesOpen, [item.id]: o })}
|
|
1426
|
+
clips={currentOf(item.id).attachments ?? []}
|
|
1427
|
+
onClipsChange={(clips) => onItemClips(item.id, clips)}
|
|
1428
|
+
disabled={!canDecide}
|
|
1429
|
+
/>
|
|
1430
|
+
{/snippet}
|
|
1431
|
+
|
|
1432
|
+
<!-- The cards are the interactive elements; jatui reviewKeys (listening on
|
|
1433
|
+
this block only) acts on the focused card. -->
|
|
1434
|
+
<section
|
|
1435
|
+
bind:this={rootEl}
|
|
1436
|
+
class="@container rounded-box border border-base-content/10 bg-base-100"
|
|
1437
|
+
aria-label={params.title ?? "Media review grid"}
|
|
1438
|
+
use:reviewKeys={keysConfig}
|
|
1439
|
+
>
|
|
1440
|
+
{#if params.title || params.intro}
|
|
1441
|
+
<!-- flow-root contains the Listen control's float; the title and intro
|
|
1442
|
+
wrap around it, so a long title never runs under the pill. -->
|
|
1443
|
+
<header class="flow-root px-4 pt-4 pb-1 sm:px-5">
|
|
1444
|
+
<SectionTools {ctx} listenLabel={params.title?.trim() || "this review"} />
|
|
1445
|
+
{#if params.title}
|
|
1446
|
+
<h2 class="text-lg font-semibold tracking-tight">{params.title}</h2>
|
|
1447
|
+
{/if}
|
|
1448
|
+
{#if params.intro}
|
|
1449
|
+
{#if introHtml}
|
|
1450
|
+
<div
|
|
1451
|
+
class="prose prose-sm mt-0.5 max-w-[70ch] text-base-content/75 prose-p:my-1.5 prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5"
|
|
1452
|
+
data-intro-md
|
|
1453
|
+
>
|
|
1454
|
+
{@html introHtml}
|
|
1455
|
+
</div>
|
|
1456
|
+
{:else}
|
|
1457
|
+
<p
|
|
1458
|
+
class="mt-0.5 max-w-[70ch] text-sm whitespace-pre-line text-base-content/75"
|
|
1459
|
+
data-intro-plain
|
|
1460
|
+
>
|
|
1461
|
+
{params.intro}
|
|
1462
|
+
</p>
|
|
1463
|
+
{/if}
|
|
1464
|
+
{/if}
|
|
1465
|
+
</header>
|
|
1466
|
+
{/if}
|
|
1467
|
+
|
|
1468
|
+
<div
|
|
1469
|
+
class="flex flex-col gap-2 border-b border-base-content/10 px-4 py-2.5 sm:px-5"
|
|
1470
|
+
>
|
|
1471
|
+
<ReviewProgress
|
|
1472
|
+
verdicts={VERDICTS}
|
|
1473
|
+
counts={counts.byVerdict}
|
|
1474
|
+
total={counts.total}
|
|
1475
|
+
retest={counts.retest}
|
|
1476
|
+
size="xs"
|
|
1477
|
+
label="Review progress"
|
|
1478
|
+
/>
|
|
1479
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
1480
|
+
<ReviewFilterBar
|
|
1481
|
+
{filters}
|
|
1482
|
+
bind:active={filter}
|
|
1483
|
+
onHelp={() => (helpOpen = true)}
|
|
1484
|
+
label="Filter cards"
|
|
1485
|
+
/>
|
|
1486
|
+
{#if canDecide && approvableCount(params.items) > 0}
|
|
1487
|
+
<!-- Joe (pe-dusk-sweep): clicking Approve 90 times is bad UX — one
|
|
1488
|
+
bulk approve per page, one save. Joe 10-19 (mos-u8i35): bulk
|
|
1489
|
+
approve OR reject, then flip the outliers. Both ask first. -->
|
|
1490
|
+
<div class="ml-auto flex shrink-0 flex-wrap items-center gap-2">
|
|
1491
|
+
<button
|
|
1492
|
+
type="button"
|
|
1493
|
+
class="btn btn-outline btn-xs rounded-field"
|
|
1494
|
+
aria-label="Approve all {approvableCount(
|
|
1495
|
+
params.items,
|
|
1496
|
+
)} undecided items"
|
|
1497
|
+
aria-keyshortcuts="Shift+A"
|
|
1498
|
+
data-approve-all
|
|
1499
|
+
onclick={confirmApproveAll}
|
|
1500
|
+
>
|
|
1501
|
+
Approve all remaining
|
|
1502
|
+
<span class="tabular-nums opacity-70"
|
|
1503
|
+
>({approvableCount(params.items)})</span
|
|
1504
|
+
>
|
|
1505
|
+
</button>
|
|
1506
|
+
<button
|
|
1507
|
+
type="button"
|
|
1508
|
+
class="btn btn-outline btn-error btn-xs rounded-field"
|
|
1509
|
+
aria-label="Reject all {approvableCount(
|
|
1510
|
+
params.items,
|
|
1511
|
+
)} undecided items"
|
|
1512
|
+
aria-keyshortcuts="Shift+R"
|
|
1513
|
+
aria-expanded={!!rejectAsk}
|
|
1514
|
+
data-reject-all
|
|
1515
|
+
onclick={() => askReject(params.items)}
|
|
1516
|
+
>
|
|
1517
|
+
Reject all remaining
|
|
1518
|
+
<span class="tabular-nums opacity-70"
|
|
1519
|
+
>({approvableCount(params.items)})</span
|
|
1520
|
+
>
|
|
1521
|
+
</button>
|
|
1522
|
+
</div>
|
|
1523
|
+
{/if}
|
|
1524
|
+
</div>
|
|
1525
|
+
</div>
|
|
1526
|
+
|
|
1527
|
+
{#if rejectAsk && canDecide && rejectCount > 0}
|
|
1528
|
+
<BulkRejectForm
|
|
1529
|
+
count={rejectCount}
|
|
1530
|
+
noun="item"
|
|
1531
|
+
onConfirm={confirmReject}
|
|
1532
|
+
onCancel={cancelReject}
|
|
1533
|
+
/>
|
|
1534
|
+
{/if}
|
|
1535
|
+
|
|
1536
|
+
{#if lastBulk && canDecide}
|
|
1537
|
+
{@const rejected = lastBulk.undo.verdict === "reject"}
|
|
1538
|
+
<BulkUndoBar
|
|
1539
|
+
message="{rejected ? 'Rejected' : 'Approved'} {itemCount(
|
|
1540
|
+
lastBulk.count,
|
|
1541
|
+
)}. Flip any card with a or r."
|
|
1542
|
+
tone={rejected ? "error" : "success"}
|
|
1543
|
+
onUndo={undoBulk}
|
|
1544
|
+
onDismiss={() => (lastBulk = null)}
|
|
1545
|
+
/>
|
|
1546
|
+
{/if}
|
|
1547
|
+
|
|
1548
|
+
{#if saver.failed}
|
|
1549
|
+
<div
|
|
1550
|
+
class="mx-4 mt-3 flex items-center gap-2 rounded-field border border-error/40 bg-error/10 px-3 py-2 text-sm sm:mx-5"
|
|
1551
|
+
role="alert"
|
|
1552
|
+
data-save-error
|
|
1553
|
+
>
|
|
1554
|
+
<span>Your last change was not saved.</span>
|
|
1555
|
+
<button
|
|
1556
|
+
type="button"
|
|
1557
|
+
class="btn btn-xs ml-auto rounded-field"
|
|
1558
|
+
onclick={() => saver.retry()}>Retry</button
|
|
1559
|
+
>
|
|
1560
|
+
</div>
|
|
1561
|
+
{/if}
|
|
1562
|
+
|
|
1563
|
+
<div class="px-4 pt-2 pb-5 sm:px-5">
|
|
1564
|
+
{#if sections.length === 0}
|
|
1565
|
+
<p class="py-6 text-sm text-base-content/70">No media to review yet.</p>
|
|
1566
|
+
{/if}
|
|
1567
|
+
{#each sections as section (section.id)}
|
|
1568
|
+
{@const anyVisible = section.items.some((it) => visible(it.id))}
|
|
1569
|
+
<section aria-label={section.title || "Media"} hidden={!anyVisible}>
|
|
1570
|
+
{#if section.title}
|
|
1571
|
+
<div class="mt-4 mb-2 flex flex-wrap items-center gap-2">
|
|
1572
|
+
<h3 class="text-sm font-semibold text-base-content/80">
|
|
1573
|
+
{section.title}
|
|
1574
|
+
</h3>
|
|
1575
|
+
{#if canDecide && approvableCount(section.items) > 0}
|
|
1576
|
+
<button
|
|
1577
|
+
type="button"
|
|
1578
|
+
class="btn btn-ghost btn-xs rounded-field text-base-content/70"
|
|
1579
|
+
aria-label="Approve all {approvableCount(
|
|
1580
|
+
section.items,
|
|
1581
|
+
)} undecided in {section.title}"
|
|
1582
|
+
data-approve-all-section={section.id}
|
|
1583
|
+
onclick={() => approveAll(section.items)}
|
|
1584
|
+
>
|
|
1585
|
+
Approve all
|
|
1586
|
+
<span class="tabular-nums">
|
|
1587
|
+
{approvableCount(section.items)}</span
|
|
1588
|
+
>
|
|
1589
|
+
</button>
|
|
1590
|
+
{/if}
|
|
1591
|
+
</div>
|
|
1592
|
+
{/if}
|
|
1593
|
+
<div class="grid grid-cols-1 gap-3 @xl:grid-cols-2 @5xl:grid-cols-3">
|
|
1594
|
+
{#each section.items as item (item.id)}
|
|
1595
|
+
{@const cur = currentOf(item.id)}
|
|
1596
|
+
{@const v = cur.verdict ?? null}
|
|
1597
|
+
{@const view = views[item.id]}
|
|
1598
|
+
{@const show = visible(item.id)}
|
|
1599
|
+
{@const selVariant = selectedVariantOf(item)}
|
|
1600
|
+
{@const versions = versionsOf(item)}
|
|
1601
|
+
{@const curVersion = currentVersionOf(item)}
|
|
1602
|
+
{@const shownVersion =
|
|
1603
|
+
versions.length > 0 ? versionOfVariant(item, selVariant) : null}
|
|
1604
|
+
{@const previous =
|
|
1605
|
+
shownVersion != null && shownVersion !== curVersion}
|
|
1606
|
+
{@const itemViews = viewsOf(item, shownVersion)}
|
|
1607
|
+
{@const history = historyRounds(
|
|
1608
|
+
view?.history ?? [],
|
|
1609
|
+
mediaRounds.roundLabel,
|
|
1610
|
+
)}
|
|
1611
|
+
{@const retest = isRetest(item)}
|
|
1612
|
+
<!-- The card is the keyboard focus TARGET for reviewKeys — an
|
|
1613
|
+
intentional roving-focus pattern (article tabindex=0). -->
|
|
1614
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
1615
|
+
<article
|
|
1616
|
+
data-item-id={item.id}
|
|
1617
|
+
tabindex="0"
|
|
1618
|
+
class="relative flex flex-col overflow-hidden rounded-box border bg-base-100 transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary motion-reduce:transition-none {v ===
|
|
1619
|
+
'approve'
|
|
1620
|
+
? 'border-success ring-1 ring-success/40'
|
|
1621
|
+
: v === 'redo'
|
|
1622
|
+
? 'border-warning/60'
|
|
1623
|
+
: v === 'reject'
|
|
1624
|
+
? 'border-error/40 opacity-50 focus-within:opacity-100 hover:opacity-100'
|
|
1625
|
+
: 'border-base-content/10'}"
|
|
1626
|
+
style:order={sinkOrder(item.id)}
|
|
1627
|
+
aria-label="{item.title}, {item.informational
|
|
1628
|
+
? 'context only, no decision needed'
|
|
1629
|
+
: v
|
|
1630
|
+
? VERDICT_WORD[v]
|
|
1631
|
+
: retest
|
|
1632
|
+
? 'to retest'
|
|
1633
|
+
: 'undecided'}"
|
|
1634
|
+
hidden={!show}
|
|
1635
|
+
>
|
|
1636
|
+
<!-- Media area: the WHOLE asset, letterboxed (never cropped).
|
|
1637
|
+
An evidence card (mos-9d62g) shows its variants as an
|
|
1638
|
+
EXAMPLES gallery instead — every image visible with its
|
|
1639
|
+
numbered caption; the verdict approves the CARD, so no
|
|
1640
|
+
image is named, nothing is pinned. -->
|
|
1641
|
+
{#if textOf(item)}
|
|
1642
|
+
<!-- A text card's words on a themed surface (mos-bk1k8) —
|
|
1643
|
+
never the image's white slab in dark mode. -->
|
|
1644
|
+
<button
|
|
1645
|
+
type="button"
|
|
1646
|
+
class="block w-full cursor-zoom-in bg-base-200 p-2 text-left"
|
|
1647
|
+
aria-label="Open {item.title} full size"
|
|
1648
|
+
onclick={() => openZoom(item.id)}
|
|
1649
|
+
data-text-card
|
|
1650
|
+
>
|
|
1651
|
+
<p
|
|
1652
|
+
class="line-clamp-[12] rounded-field border border-base-content/10 bg-base-100 p-3 text-sm leading-relaxed whitespace-pre-line text-base-content"
|
|
1653
|
+
>
|
|
1654
|
+
{textOf(item)}
|
|
1655
|
+
</p>
|
|
1656
|
+
</button>
|
|
1657
|
+
{:else if item.evidence}
|
|
1658
|
+
<div class="bg-base-200 p-2" data-evidence>
|
|
1659
|
+
<p
|
|
1660
|
+
class="px-0.5 pb-1 text-2xs font-medium tracking-wide text-base-content/55 uppercase"
|
|
1661
|
+
data-evidence-label
|
|
1662
|
+
>
|
|
1663
|
+
Examples{itemViews.length > 1
|
|
1664
|
+
? ` (${itemViews.length})`
|
|
1665
|
+
: ""}
|
|
1666
|
+
</p>
|
|
1667
|
+
<div
|
|
1668
|
+
class="grid grid-cols-2 gap-1.5"
|
|
1669
|
+
role="group"
|
|
1670
|
+
aria-label="{item.title} examples"
|
|
1671
|
+
data-evidence-gallery
|
|
1672
|
+
>
|
|
1673
|
+
{#each itemViews as vr, i (vr.id)}
|
|
1674
|
+
{@const label = viewLabel(item, vr)}
|
|
1675
|
+
<div
|
|
1676
|
+
class="flex flex-col gap-0.5"
|
|
1677
|
+
data-evidence-item={vr.id}
|
|
1678
|
+
>
|
|
1679
|
+
{#if vr.kind === "audio"}
|
|
1680
|
+
<div class="p-1">
|
|
1681
|
+
<!-- Audio reads as a deliberate row: framed like
|
|
1682
|
+
the other variants (rounded, bordered) — the
|
|
1683
|
+
native control's chrome is the browser's; the
|
|
1684
|
+
frame is ours. -->
|
|
1685
|
+
<audio
|
|
1686
|
+
controls
|
|
1687
|
+
src={vr.src}
|
|
1688
|
+
preload="metadata"
|
|
1689
|
+
aria-label={mediaAlt(item, label)}
|
|
1690
|
+
class="w-full rounded-field border border-base-content/10 bg-base-100"
|
|
1691
|
+
></audio>
|
|
1692
|
+
</div>
|
|
1693
|
+
{:else}
|
|
1694
|
+
<button
|
|
1695
|
+
type="button"
|
|
1696
|
+
class="relative block w-full cursor-zoom-in overflow-hidden rounded-field border border-base-content/10 bg-base-200"
|
|
1697
|
+
aria-label="Open {mediaAlt(item, label)} full size"
|
|
1698
|
+
data-evidence={vr.id}
|
|
1699
|
+
onclick={() => {
|
|
1700
|
+
selectVariant(item.id, vr.id)
|
|
1701
|
+
openZoom(item.id)
|
|
1702
|
+
}}
|
|
1703
|
+
>
|
|
1704
|
+
{#if vr.kind === "image"}
|
|
1705
|
+
<img
|
|
1706
|
+
src={vr.src}
|
|
1707
|
+
alt={mediaAlt(item, label)}
|
|
1708
|
+
loading="lazy"
|
|
1709
|
+
class="aspect-[4/3] w-full object-cover"
|
|
1710
|
+
/>
|
|
1711
|
+
{:else if vr.poster}
|
|
1712
|
+
<img
|
|
1713
|
+
src={vr.poster}
|
|
1714
|
+
alt={mediaAlt(item, label)}
|
|
1715
|
+
loading="lazy"
|
|
1716
|
+
class="aspect-[4/3] w-full object-cover"
|
|
1717
|
+
/>
|
|
1718
|
+
{:else}
|
|
1719
|
+
<span
|
|
1720
|
+
class="grid aspect-[4/3] w-full place-items-center text-2xs text-base-content/70"
|
|
1721
|
+
aria-hidden="true">{vr.kind}</span
|
|
1722
|
+
>
|
|
1723
|
+
{/if}
|
|
1724
|
+
</button>
|
|
1725
|
+
{/if}
|
|
1726
|
+
<span
|
|
1727
|
+
class="block truncate px-0.5 text-2xs leading-4 text-base-content/80"
|
|
1728
|
+
title={label}
|
|
1729
|
+
>
|
|
1730
|
+
{i + 1}. {label}
|
|
1731
|
+
</span>
|
|
1732
|
+
</div>
|
|
1733
|
+
{/each}
|
|
1734
|
+
</div>
|
|
1735
|
+
</div>
|
|
1736
|
+
{:else}
|
|
1737
|
+
<div class="relative bg-base-200">
|
|
1738
|
+
{#if selVariant.kind === "image"}
|
|
1739
|
+
<button
|
|
1740
|
+
type="button"
|
|
1741
|
+
class="block w-full cursor-zoom-in"
|
|
1742
|
+
aria-label="Open {mediaAlt(
|
|
1743
|
+
item,
|
|
1744
|
+
fullLabel(item, selVariant),
|
|
1745
|
+
)} full size"
|
|
1746
|
+
onclick={() => openZoom(item.id)}
|
|
1747
|
+
>
|
|
1748
|
+
<img
|
|
1749
|
+
src={selVariant.src}
|
|
1750
|
+
alt={mediaAlt(item, fullLabel(item, selVariant))}
|
|
1751
|
+
loading="lazy"
|
|
1752
|
+
class="aspect-[4/3] w-full object-contain"
|
|
1753
|
+
/>
|
|
1754
|
+
</button>
|
|
1755
|
+
{:else if selVariant.kind === "video"}
|
|
1756
|
+
<button
|
|
1757
|
+
type="button"
|
|
1758
|
+
class="group relative block w-full cursor-zoom-in"
|
|
1759
|
+
aria-label="Open {mediaAlt(
|
|
1760
|
+
item,
|
|
1761
|
+
fullLabel(item, selVariant),
|
|
1762
|
+
)} full size (zoom, frame-step, speed)"
|
|
1763
|
+
onclick={() => openZoom(item.id)}
|
|
1764
|
+
>
|
|
1765
|
+
<video
|
|
1766
|
+
data-autoloop
|
|
1767
|
+
src={selVariant.src}
|
|
1768
|
+
poster={selVariant.poster}
|
|
1769
|
+
muted
|
|
1770
|
+
loop
|
|
1771
|
+
playsinline
|
|
1772
|
+
preload="metadata"
|
|
1773
|
+
aria-hidden="true"
|
|
1774
|
+
tabindex="-1"
|
|
1775
|
+
class="pointer-events-none aspect-[4/3] w-full object-contain"
|
|
1776
|
+
></video>
|
|
1777
|
+
<span
|
|
1778
|
+
class="absolute right-2 bottom-2 rounded-field bg-base-100/90 px-1.5 py-0.5 text-2xs font-medium text-base-content shadow-sm group-hover:bg-base-100"
|
|
1779
|
+
aria-hidden="true">⤢ zoom</span
|
|
1780
|
+
>
|
|
1781
|
+
</button>
|
|
1782
|
+
{:else if selVariant.kind === "audio"}
|
|
1783
|
+
<div class="p-3">
|
|
1784
|
+
<!-- Audio framed like the other media variants (see
|
|
1785
|
+
the evidence gallery row). -->
|
|
1786
|
+
<audio
|
|
1787
|
+
controls
|
|
1788
|
+
src={selVariant.src}
|
|
1789
|
+
preload="metadata"
|
|
1790
|
+
aria-label={mediaAlt(item, fullLabel(item, selVariant))}
|
|
1791
|
+
class="w-full rounded-field border border-base-content/10 bg-base-100"
|
|
1792
|
+
></audio>
|
|
1793
|
+
</div>
|
|
1794
|
+
{/if}
|
|
1795
|
+
</div>
|
|
1796
|
+
{/if}
|
|
1797
|
+
{#if versions.length > 0}
|
|
1798
|
+
<!-- The version row — the one under review first, marked.
|
|
1799
|
+
[ / ] step through it. -->
|
|
1800
|
+
<div
|
|
1801
|
+
class="flex items-center gap-1.5 overflow-x-auto px-2.5 pt-2.5"
|
|
1802
|
+
role="group"
|
|
1803
|
+
aria-label="{item.title} versions"
|
|
1804
|
+
data-version-row
|
|
1805
|
+
>
|
|
1806
|
+
<span class="shrink-0 text-2xs text-base-content/60"
|
|
1807
|
+
>Version</span
|
|
1808
|
+
>
|
|
1809
|
+
<div class="join shrink-0">
|
|
1810
|
+
{#each versions as ver (ver)}
|
|
1811
|
+
{@const on = shownVersion === ver}
|
|
1812
|
+
<button
|
|
1813
|
+
type="button"
|
|
1814
|
+
class="btn join-item btn-xs {on
|
|
1815
|
+
? 'btn-primary'
|
|
1816
|
+
: 'btn-ghost border-base-content/15'}"
|
|
1817
|
+
aria-pressed={on}
|
|
1818
|
+
data-version={ver}
|
|
1819
|
+
title={ver === curVersion
|
|
1820
|
+
? `${ver}, the version under review`
|
|
1821
|
+
: `${ver}, a previous version (read-only)`}
|
|
1822
|
+
onclick={() => switchVersion(item.id, ver)}
|
|
1823
|
+
>{ver}{#if ver === curVersion}<span
|
|
1824
|
+
class="ml-1 font-normal opacity-80">reviewing</span
|
|
1825
|
+
>{/if}</button
|
|
1826
|
+
>
|
|
1827
|
+
{/each}
|
|
1828
|
+
</div>
|
|
1829
|
+
</div>
|
|
1830
|
+
{/if}
|
|
1831
|
+
{#if itemViews.length > 1 && !item.evidence}
|
|
1832
|
+
<!-- One snapping filmstrip below the media (never wrapped
|
|
1833
|
+
rows): the views of the shown version, labelled. An
|
|
1834
|
+
evidence card has the gallery above instead. -->
|
|
1835
|
+
<div
|
|
1836
|
+
class="flex snap-x gap-1.5 overflow-x-auto px-2.5 pt-2.5 pb-1"
|
|
1837
|
+
role="group"
|
|
1838
|
+
aria-label="{item.title}{shownVersion
|
|
1839
|
+
? ` ${shownVersion}`
|
|
1840
|
+
: ''} views"
|
|
1841
|
+
>
|
|
1842
|
+
{#each itemViews as vr, i (vr.id)}
|
|
1843
|
+
{@const thumb =
|
|
1844
|
+
vr.kind === "image"
|
|
1845
|
+
? vr.src
|
|
1846
|
+
: vr.kind === "video"
|
|
1847
|
+
? vr.poster
|
|
1848
|
+
: undefined}
|
|
1849
|
+
{@const on = selVariant.id === vr.id}
|
|
1850
|
+
{@const vlabel = viewLabel(item, vr)}
|
|
1851
|
+
{@const picked = isPickedVariant(item, vr.id)}
|
|
1852
|
+
<div
|
|
1853
|
+
class="flex w-[4.5rem] shrink-0 snap-start flex-col gap-1"
|
|
1854
|
+
>
|
|
1855
|
+
<button
|
|
1856
|
+
type="button"
|
|
1857
|
+
class="relative w-full overflow-hidden rounded-field border-2 text-left transition-colors motion-reduce:transition-none {on
|
|
1858
|
+
? 'border-primary'
|
|
1859
|
+
: 'border-transparent opacity-80 hover:opacity-100'}"
|
|
1860
|
+
aria-pressed={on}
|
|
1861
|
+
title="{vlabel} (key {i + 1})"
|
|
1862
|
+
onclick={() => selectVariant(item.id, vr.id)}
|
|
1863
|
+
>
|
|
1864
|
+
{#if thumb}
|
|
1865
|
+
<img
|
|
1866
|
+
src={thumb}
|
|
1867
|
+
alt=""
|
|
1868
|
+
loading="lazy"
|
|
1869
|
+
class="aspect-[4/3] w-full bg-base-200 object-cover"
|
|
1870
|
+
/>
|
|
1871
|
+
{:else}
|
|
1872
|
+
<span
|
|
1873
|
+
class="grid aspect-[4/3] w-full place-items-center bg-base-200 text-2xs text-base-content/70"
|
|
1874
|
+
aria-hidden="true">{vr.kind}</span
|
|
1875
|
+
>
|
|
1876
|
+
{/if}
|
|
1877
|
+
{#if i < 9}
|
|
1878
|
+
<span
|
|
1879
|
+
class="absolute top-0.5 left-0.5 rounded-field bg-base-100/90 px-1 text-2xs leading-4 font-semibold tabular-nums text-base-content"
|
|
1880
|
+
aria-hidden="true">{i + 1}</span
|
|
1881
|
+
>
|
|
1882
|
+
{/if}
|
|
1883
|
+
{#if picked}
|
|
1884
|
+
<!-- The approval is pinned to THIS variant — visible
|
|
1885
|
+
even when the reviewer browses another one. -->
|
|
1886
|
+
<span
|
|
1887
|
+
class="absolute top-0.5 right-0.5 rounded-field bg-base-100/90 px-1 text-2xs leading-4 font-semibold text-success"
|
|
1888
|
+
aria-hidden="true">✓</span
|
|
1889
|
+
>
|
|
1890
|
+
{/if}
|
|
1891
|
+
<span
|
|
1892
|
+
class="block truncate bg-base-100 px-1 text-2xs leading-4 text-base-content/80"
|
|
1893
|
+
title={vlabel}>{vlabel}</span
|
|
1894
|
+
>
|
|
1895
|
+
</button>
|
|
1896
|
+
{#if canDecide && !previous && pickableViews(item).length > 1}
|
|
1897
|
+
<!-- A choice item picks per variant: the action
|
|
1898
|
+
itself names what it approves (mos-lwbbt). -->
|
|
1899
|
+
<button
|
|
1900
|
+
type="button"
|
|
1901
|
+
class="btn btn-xs rounded-field {picked
|
|
1902
|
+
? 'btn-success'
|
|
1903
|
+
: 'btn-outline'}"
|
|
1904
|
+
aria-pressed={picked}
|
|
1905
|
+
aria-label="{picked
|
|
1906
|
+
? 'Picked'
|
|
1907
|
+
: 'Pick this'}: {vlabel}"
|
|
1908
|
+
data-pick={vr.id}
|
|
1909
|
+
onclick={() => pickVariant(item.id, vr.id)}
|
|
1910
|
+
>
|
|
1911
|
+
{picked ? "✓ Picked" : "Pick this"}
|
|
1912
|
+
</button>
|
|
1913
|
+
{/if}
|
|
1914
|
+
</div>
|
|
1915
|
+
{/each}
|
|
1916
|
+
</div>
|
|
1917
|
+
{/if}
|
|
1918
|
+
{#if item.variants.length > 1 && !item.evidence}
|
|
1919
|
+
<p
|
|
1920
|
+
class="px-2.5 pt-1 text-xs leading-snug break-words text-base-content/70 tabular-nums"
|
|
1921
|
+
aria-live="polite"
|
|
1922
|
+
data-caption
|
|
1923
|
+
>
|
|
1924
|
+
{captionFor(item, selVariant, shownVersion)}
|
|
1925
|
+
</p>
|
|
1926
|
+
{/if}
|
|
1927
|
+
|
|
1928
|
+
<div class="flex flex-1 flex-col gap-1.5 p-3">
|
|
1929
|
+
<div class="flex flex-wrap items-baseline gap-1.5">
|
|
1930
|
+
<h4 class="text-sm leading-snug font-semibold">
|
|
1931
|
+
{item.title}
|
|
1932
|
+
</h4>
|
|
1933
|
+
{#if v === "approve" && (cur.variant || item.evidence)}
|
|
1934
|
+
<span
|
|
1935
|
+
class="badge badge-sm badge-success font-medium h-auto max-w-full py-0.5 text-left leading-snug whitespace-normal"
|
|
1936
|
+
data-approved-badge
|
|
1937
|
+
>
|
|
1938
|
+
{item.evidence
|
|
1939
|
+
? "Approved"
|
|
1940
|
+
: `Approved: ${pickLabel(item, cur.variant)}`}
|
|
1941
|
+
</span>
|
|
1942
|
+
{/if}
|
|
1943
|
+
{#if item.informational}
|
|
1944
|
+
<span class="badge badge-sm badge-ghost" data-informational>
|
|
1945
|
+
no decision needed
|
|
1946
|
+
</span>
|
|
1947
|
+
{/if}
|
|
1948
|
+
{#each item.tags ?? [] as t (t)}
|
|
1949
|
+
<span class="badge badge-ghost badge-sm">{t}</span>
|
|
1950
|
+
{/each}
|
|
1951
|
+
</div>
|
|
1952
|
+
{#if item.team || (retest && canDecide)}
|
|
1953
|
+
<TeamChip team={item.team} retest={retest && canDecide} />
|
|
1954
|
+
{/if}
|
|
1955
|
+
{#if item.subtitle}
|
|
1956
|
+
<p
|
|
1957
|
+
class="line-clamp-4 text-xs leading-relaxed {v === 'redo'
|
|
1958
|
+
? 'italic text-base-content'
|
|
1959
|
+
: 'text-base-content/75'}"
|
|
1960
|
+
title={item.subtitle}
|
|
1961
|
+
>
|
|
1962
|
+
{item.subtitle}
|
|
1963
|
+
</p>
|
|
1964
|
+
{/if}
|
|
1965
|
+
{@render cardCopy(item)}
|
|
1966
|
+
{#if item.meta && Object.keys(item.meta).length > 0}
|
|
1967
|
+
<p class="text-xs text-base-content/70 tabular-nums">
|
|
1968
|
+
{Object.entries(item.meta)
|
|
1969
|
+
.map(([k, val]) => `${k}: ${val}`)
|
|
1970
|
+
.join(" · ")}
|
|
1971
|
+
</p>
|
|
1972
|
+
{/if}
|
|
1973
|
+
|
|
1974
|
+
<div class="mt-auto flex flex-col gap-1.5 pt-1.5">
|
|
1975
|
+
{#if !item.informational}
|
|
1976
|
+
{#if previous}
|
|
1977
|
+
<!-- An older version is read-only: the verdict left on it
|
|
1978
|
+
then, and the way back. -->
|
|
1979
|
+
<div
|
|
1980
|
+
class="flex flex-col gap-1.5 rounded-field border border-base-content/10 bg-base-200 p-2 text-xs"
|
|
1981
|
+
role="note"
|
|
1982
|
+
data-previous-version
|
|
1983
|
+
>
|
|
1984
|
+
<p>
|
|
1985
|
+
<span class="font-semibold"
|
|
1986
|
+
>Previous version ({shownVersion})</span
|
|
1987
|
+
>
|
|
1988
|
+
is read-only. Verdicts go on {curVersion}.
|
|
1989
|
+
</p>
|
|
1990
|
+
<p class="text-base-content/80">
|
|
1991
|
+
{historyLine(versionLine(item.id, shownVersion))}
|
|
1992
|
+
</p>
|
|
1993
|
+
<button
|
|
1994
|
+
type="button"
|
|
1995
|
+
class="btn btn-xs self-start rounded-field"
|
|
1996
|
+
onclick={() =>
|
|
1997
|
+
curVersion && switchVersion(item.id, curVersion)}
|
|
1998
|
+
>Back to {curVersion} (reviewing)</button
|
|
1999
|
+
>
|
|
2000
|
+
</div>
|
|
2001
|
+
{:else if canDecide && zoomItem !== item.id}
|
|
2002
|
+
{@render decisionRow(item, false)}
|
|
2003
|
+
{:else if canDecide}
|
|
2004
|
+
<p class="text-xs text-base-content/70">
|
|
2005
|
+
Deciding in the full-size view.
|
|
2006
|
+
</p>
|
|
2007
|
+
{:else if verdictOf(item.id) || currentOf(item.id).note}
|
|
2008
|
+
<!-- mos-guqgs: a saveless surface (public share, print)
|
|
2009
|
+
renders the saved decision as an ANSWER — the same
|
|
2010
|
+
control, visibly disabled — never an editable form,
|
|
2011
|
+
never interactive-looking over a dead save. -->
|
|
2012
|
+
{@render decisionRow(item, false)}
|
|
2013
|
+
{/if}
|
|
2014
|
+
{/if}
|
|
2015
|
+
{#if !item.informational}
|
|
2016
|
+
<RoundHistory
|
|
2017
|
+
rounds={history}
|
|
2018
|
+
verdicts={VERDICTS}
|
|
2019
|
+
bind:selected={() => selectedRound(item, history),
|
|
2020
|
+
(r) => pickRound(item, r)}
|
|
2021
|
+
toggleLabel={(n) =>
|
|
2022
|
+
n === 1 ? "1 earlier round" : `${n} earlier rounds`}
|
|
2023
|
+
/>
|
|
2024
|
+
{/if}
|
|
2025
|
+
</div>
|
|
2026
|
+
</div>
|
|
2027
|
+
</article>
|
|
2028
|
+
{/each}
|
|
2029
|
+
</div>
|
|
2030
|
+
</section>
|
|
2031
|
+
{/each}
|
|
2032
|
+
|
|
2033
|
+
{#if params.items.length > 0 && sections.every((s) => !s.items.some( (it) => visible(it.id), ))}
|
|
2034
|
+
<p class="py-6 text-sm text-base-content/70">
|
|
2035
|
+
Nothing in this filter.
|
|
2036
|
+
<button
|
|
2037
|
+
type="button"
|
|
2038
|
+
class="link link-hover ml-1"
|
|
2039
|
+
onclick={() => (filter = "all")}>Show all</button
|
|
2040
|
+
>
|
|
2041
|
+
</p>
|
|
2042
|
+
{/if}
|
|
2043
|
+
</div>
|
|
2044
|
+
</section>
|
|
2045
|
+
|
|
2046
|
+
<ReviewShortcuts
|
|
2047
|
+
bind:open={helpOpen}
|
|
2048
|
+
config={keysConfig}
|
|
2049
|
+
extraRows={canDecide ? [...BULK_HELP, ...EXTRA_HELP] : EXTRA_HELP}
|
|
2050
|
+
footnote="Keys act on the focused card. u (or Undo) clears this round's verdict; the note stays. Enter opens the card full size, where the same verdict keys work: after a bulk, a, x or r flips the card on screen. Shift+A and Shift+R decide every undecided card in the current filter; cards you already decided stay."
|
|
2051
|
+
/>
|
|
2052
|
+
|
|
2053
|
+
{#if zoomItem}
|
|
2054
|
+
{@const zItem = itemsById.get(zoomItem)}
|
|
2055
|
+
{#if zItem}
|
|
2056
|
+
{@const at = zoomOrder.indexOf(zItem.id)}
|
|
2057
|
+
{@const words = textOf(zItem)}
|
|
2058
|
+
{#snippet zoomFooter(_f: MediaZoomFooterContext)}
|
|
2059
|
+
{#if words}
|
|
2060
|
+
<div
|
|
2061
|
+
class="mx-auto w-full max-w-[70ch] rounded-box border border-base-content/10 bg-base-100 p-4 text-base leading-relaxed whitespace-pre-line text-base-content"
|
|
2062
|
+
data-zoom-text
|
|
2063
|
+
>
|
|
2064
|
+
{words}
|
|
2065
|
+
</div>
|
|
2066
|
+
{/if}
|
|
2067
|
+
{@render zoomStatus()}
|
|
2068
|
+
{#if canDecide && viewingPrevious(zItem) && !zItem.informational}
|
|
2069
|
+
<p class="text-xs text-base-content/80">
|
|
2070
|
+
Previous version ({activeVersion(zItem)}) is read-only. Verdicts go on
|
|
2071
|
+
{currentVersionOf(zItem)}.
|
|
2072
|
+
</p>
|
|
2073
|
+
{@render zoomNav()}
|
|
2074
|
+
{:else if canDecide && !zItem.informational}
|
|
2075
|
+
<div class="flex w-full flex-col gap-1.5">
|
|
2076
|
+
{@render cardCopy(zItem)}
|
|
2077
|
+
{@render decisionRow(zItem, true)}
|
|
2078
|
+
</div>
|
|
2079
|
+
{:else if verdictOf(zItem.id) || currentOf(zItem.id).note}
|
|
2080
|
+
<!-- mos-guqgs: the saveless surface's saved decision, disabled. -->
|
|
2081
|
+
<div class="flex w-full flex-col gap-1.5">
|
|
2082
|
+
{@render cardCopy(zItem)}
|
|
2083
|
+
{@render decisionRow(zItem, true)}
|
|
2084
|
+
</div>
|
|
2085
|
+
{:else}
|
|
2086
|
+
{@render zoomNav()}
|
|
2087
|
+
{/if}
|
|
2088
|
+
{/snippet}
|
|
2089
|
+
<!-- Verdict keys the lightbox lets bubble act on the item shown; the
|
|
2090
|
+
lightbox itself is the dialog and owns focus. -->
|
|
2091
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
2092
|
+
<div onkeydown={onZoomKeydown}>
|
|
2093
|
+
<MediaZoomLightbox
|
|
2094
|
+
items={zoomViews(zItem.id)}
|
|
2095
|
+
bind:index={() => zoomIndex, setZoomIndex}
|
|
2096
|
+
title={zItem.title}
|
|
2097
|
+
position={at >= 0 ? `card ${at + 1} of ${zoomOrder.length}` : ""}
|
|
2098
|
+
onClose={closeZoom}
|
|
2099
|
+
onNextItem={navStep(1)}
|
|
2100
|
+
onPrevItem={navStep(-1)}
|
|
2101
|
+
footer={zoomFooter}
|
|
2102
|
+
showHint={zItem.kind !== "text"}
|
|
2103
|
+
zIndex={60}
|
|
2104
|
+
class={zItem.kind === "text"
|
|
2105
|
+
? words
|
|
2106
|
+
? TEXT_ZOOM_WORDS
|
|
2107
|
+
: TEXT_ZOOM
|
|
2108
|
+
: ""}
|
|
2109
|
+
/>
|
|
2110
|
+
</div>
|
|
2111
|
+
{/if}
|
|
2112
|
+
{/if}
|