@joewinke/jatui 0.33.0 → 0.34.1
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/CLAUDE.md +15 -0
- package/dist/actions/reviewKeys.d.ts +159 -0
- package/dist/actions/reviewKeys.js +353 -0
- package/dist/components/review/MediaZoomLightbox.svelte +628 -0
- package/dist/components/review/MediaZoomLightbox.svelte.d.ts +56 -0
- package/dist/components/review/ReviewFilterBar.svelte +296 -0
- package/dist/components/review/ReviewFilterBar.svelte.d.ts +45 -0
- package/dist/components/review/ReviewNote.svelte +213 -0
- package/dist/components/review/ReviewNote.svelte.d.ts +37 -0
- package/dist/components/review/ReviewProgress.svelte +99 -0
- package/dist/components/review/ReviewProgress.svelte.d.ts +32 -0
- package/dist/components/review/ReviewShortcuts.svelte +80 -0
- package/dist/components/review/ReviewShortcuts.svelte.d.ts +15 -0
- package/dist/components/review/RoundHistory.svelte +151 -0
- package/dist/components/review/RoundHistory.svelte.d.ts +35 -0
- package/dist/components/review/VerdictControl.svelte +163 -0
- package/dist/components/review/VerdictControl.svelte.d.ts +23 -0
- package/dist/components/review/index.d.ts +24 -0
- package/dist/components/review/index.js +23 -0
- package/dist/components/review/mediaZoom.d.ts +115 -0
- package/dist/components/review/mediaZoom.js +161 -0
- package/dist/components/review/reviewSummary.d.ts +101 -0
- package/dist/components/review/reviewSummary.js +122 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/package.json +6 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReviewProgress (jatui-xmrod) — one horizontal tone bar plus a sentence:
|
|
3
|
+
* "12 of 19 decided · 9 pass · 2 fail · 1 blocked · 4 to retest".
|
|
4
|
+
*
|
|
5
|
+
* Generic over verdicts: pass the block's `{id, label, tone}` list and a
|
|
6
|
+
* count per verdict id. The same component serves one block AND the page
|
|
7
|
+
* Finish bar — for the page, sum the blocks with `mergeReviewCounts()` and
|
|
8
|
+
* pass the summed item total. Purely presentational; the host counts.
|
|
9
|
+
*/
|
|
10
|
+
import { type ReviewCounts, type ReviewVerdictDef } from './reviewSummary';
|
|
11
|
+
interface Props {
|
|
12
|
+
verdicts: ReviewVerdictDef[];
|
|
13
|
+
/** Decided items per verdict id. */
|
|
14
|
+
counts: ReviewCounts;
|
|
15
|
+
/** Items under review (decided + undecided). */
|
|
16
|
+
total: number;
|
|
17
|
+
/** Items in an open retest round — appended as "N to retest". */
|
|
18
|
+
retest?: number;
|
|
19
|
+
/** Word after "N of M" (default "decided"). */
|
|
20
|
+
decidedWord?: string;
|
|
21
|
+
/** Accessible name of the bar. */
|
|
22
|
+
label?: string;
|
|
23
|
+
size?: 'xs' | 'sm' | 'md';
|
|
24
|
+
/** Hide the sentence (bar only; the sentence stays the bar's aria-valuetext). */
|
|
25
|
+
hideSentence?: boolean;
|
|
26
|
+
/** Announce sentence changes politely to screen readers. Default true. */
|
|
27
|
+
live?: boolean;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
declare const ReviewProgress: import("svelte").Component<Props, {}, "">;
|
|
31
|
+
type ReviewProgress = ReturnType<typeof ReviewProgress>;
|
|
32
|
+
export default ReviewProgress;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ReviewShortcuts — the review surfaces' keyboard help (mos-z8vgx; replaces
|
|
3
|
+
MOS ShortcutsModal and the checklist's footer hint).
|
|
4
|
+
|
|
5
|
+
Renders FROM the same key table reviewKeys() dispatches from: pass the
|
|
6
|
+
SAME config object you give `use:reviewKeys` and the rows are
|
|
7
|
+
`reviewKeyRows(config)` — only the actions that surface wired, verdict
|
|
8
|
+
rows from its own `verdicts`. The help can't drift from the behaviour.
|
|
9
|
+
`extraRows` appends surface-specific rows the table doesn't own.
|
|
10
|
+
|
|
11
|
+
jatui Modal owns focus-in, the focus trap, Escape and focus return; while
|
|
12
|
+
open its trapKeys stops the page's reviewKeys from seeing j/k/a/x….
|
|
13
|
+
-->
|
|
14
|
+
<script lang="ts">
|
|
15
|
+
import Modal from '../Modal.svelte';
|
|
16
|
+
import type { ShortcutRegistry } from '../../utils/keyboardShortcutRegistry';
|
|
17
|
+
import { reviewKeyRows, type ReviewKeyRow, type ReviewKeysOptions } from '../../actions/reviewKeys';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
open?: boolean;
|
|
21
|
+
/** The reviewKeys() config of the surface (only its handler presence + verdicts are read). */
|
|
22
|
+
config: ReviewKeysOptions;
|
|
23
|
+
extraRows?: ReviewKeyRow[];
|
|
24
|
+
title?: string;
|
|
25
|
+
footnote?: string;
|
|
26
|
+
shortcutRegistry?: ShortcutRegistry;
|
|
27
|
+
onclose?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
open = $bindable(false),
|
|
32
|
+
config,
|
|
33
|
+
extraRows = [],
|
|
34
|
+
title = 'Keyboard shortcuts',
|
|
35
|
+
footnote,
|
|
36
|
+
shortcutRegistry,
|
|
37
|
+
onclose
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
const rows = $derived([...reviewKeyRows(config), ...extraRows]);
|
|
41
|
+
|
|
42
|
+
const sections = $derived.by(() => {
|
|
43
|
+
const out: { group: string; rows: ReviewKeyRow[] }[] = [];
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
let s = out.find((x) => x.group === row.group);
|
|
46
|
+
if (!s) out.push((s = { group: row.group, rows: [] }));
|
|
47
|
+
s.rows.push(row);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
});
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<Modal bind:open {title} size="sm" {shortcutRegistry} {onclose}>
|
|
54
|
+
<div class="flex flex-col gap-4" data-review-shortcuts>
|
|
55
|
+
{#each sections as section (section.group)}
|
|
56
|
+
<section>
|
|
57
|
+
<h3 class="mb-1 text-xs font-semibold tracking-wide text-base-content/60 uppercase">{section.group}</h3>
|
|
58
|
+
<table class="w-full text-sm">
|
|
59
|
+
<tbody>
|
|
60
|
+
{#each section.rows as row (row.keys.join(' ') + row.description)}
|
|
61
|
+
<tr>
|
|
62
|
+
<td class="w-24 py-1 align-top whitespace-nowrap">
|
|
63
|
+
{#each row.keys as k, i (k)}
|
|
64
|
+
{#if i > 0}<span class="mx-0.5 text-base-content/50" aria-hidden="true">/</span>{/if}<kbd
|
|
65
|
+
class="kbd kbd-sm">{k}</kbd
|
|
66
|
+
>
|
|
67
|
+
{/each}
|
|
68
|
+
</td>
|
|
69
|
+
<td class="py-1 text-base-content/80">{row.description}</td>
|
|
70
|
+
</tr>
|
|
71
|
+
{/each}
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
</section>
|
|
75
|
+
{/each}
|
|
76
|
+
{#if footnote}
|
|
77
|
+
<p class="text-xs text-base-content/70">{footnote}</p>
|
|
78
|
+
{/if}
|
|
79
|
+
</div>
|
|
80
|
+
</Modal>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ShortcutRegistry } from '../../utils/keyboardShortcutRegistry';
|
|
2
|
+
import { type ReviewKeyRow, type ReviewKeysOptions } from '../../actions/reviewKeys';
|
|
3
|
+
interface Props {
|
|
4
|
+
open?: boolean;
|
|
5
|
+
/** The reviewKeys() config of the surface (only its handler presence + verdicts are read). */
|
|
6
|
+
config: ReviewKeysOptions;
|
|
7
|
+
extraRows?: ReviewKeyRow[];
|
|
8
|
+
title?: string;
|
|
9
|
+
footnote?: string;
|
|
10
|
+
shortcutRegistry?: ShortcutRegistry;
|
|
11
|
+
onclose?: () => void;
|
|
12
|
+
}
|
|
13
|
+
declare const ReviewShortcuts: import("svelte").Component<Props, {}, "open">;
|
|
14
|
+
type ReviewShortcuts = ReturnType<typeof ReviewShortcuts>;
|
|
15
|
+
export default ReviewShortcuts;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* RoundHistory (jatui-xmrod) — the locked, read-only rounds of one review
|
|
4
|
+
* item: "▸ Round 2 · Oct 10 · Fail · "drain cleaning still doubling up"".
|
|
5
|
+
*
|
|
6
|
+
* Collapsed by default behind a "▸ N earlier" toggle; open, lines are newest
|
|
7
|
+
* first. Each line is one button: pressing it selects that round (and shows
|
|
8
|
+
* its full note); pressing the selected line again deselects it. `selected`
|
|
9
|
+
* is bindable so the list pairs with a version switcher — set it from the
|
|
10
|
+
* switcher and the list opens and expands that line.
|
|
11
|
+
*
|
|
12
|
+
* The current round is NOT part of `rounds` — pass history only.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
formatRoundDate,
|
|
16
|
+
orderRounds,
|
|
17
|
+
TONE_BG,
|
|
18
|
+
TONE_TEXT,
|
|
19
|
+
type ReviewRound,
|
|
20
|
+
type ReviewVerdictDef
|
|
21
|
+
} from './reviewSummary';
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
/** Locked earlier rounds (any order — see orderRounds). */
|
|
25
|
+
rounds: ReviewRound[];
|
|
26
|
+
/** Verdict definitions, to label and colour each line's verdict id. */
|
|
27
|
+
verdicts?: ReviewVerdictDef[];
|
|
28
|
+
/** Selected round key (bindable). Pairs with a version switcher. */
|
|
29
|
+
selected?: string | null;
|
|
30
|
+
/** Whether the list is expanded (bindable). Default false (collapsed). */
|
|
31
|
+
open?: boolean;
|
|
32
|
+
/** Called when a line is (de)selected — null when deselected. */
|
|
33
|
+
onSelect?: (round: string | null) => void;
|
|
34
|
+
/** Label for a line without an explicit `label`. Default `Round ${n}`. */
|
|
35
|
+
roundLabel?: (entry: ReviewRound, n: number) => string;
|
|
36
|
+
/** Toggle text. Default "N earlier". */
|
|
37
|
+
toggleLabel?: (count: number) => string;
|
|
38
|
+
/** Text for a round that closed with no verdict. */
|
|
39
|
+
undecidedLabel?: string;
|
|
40
|
+
class?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let {
|
|
44
|
+
rounds,
|
|
45
|
+
verdicts = [],
|
|
46
|
+
selected = $bindable(null),
|
|
47
|
+
open = $bindable(false),
|
|
48
|
+
onSelect,
|
|
49
|
+
roundLabel = (_e: ReviewRound, n: number) => `Round ${n}`,
|
|
50
|
+
toggleLabel = (count: number) => `${count} earlier`,
|
|
51
|
+
undecidedLabel = 'Undecided',
|
|
52
|
+
class: className = ''
|
|
53
|
+
}: Props = $props();
|
|
54
|
+
|
|
55
|
+
const ordered = $derived(orderRounds(rounds));
|
|
56
|
+
const verdictById = $derived(new Map(verdicts.map((v) => [v.id, v])));
|
|
57
|
+
const uid = $props.id();
|
|
58
|
+
const listId = `round-history-${uid}`;
|
|
59
|
+
|
|
60
|
+
// A selection made from outside (the version switcher) opens the list.
|
|
61
|
+
$effect(() => {
|
|
62
|
+
if (selected != null && ordered.some((r) => r.entry.round === selected)) open = true;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function pick(round: string) {
|
|
66
|
+
selected = selected === round ? null : round;
|
|
67
|
+
onSelect?.(selected);
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
{#if ordered.length}
|
|
72
|
+
<div class="round-history min-w-0 text-xs {className}">
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
class="round-history-toggle text-base-content/70 hover:text-base-content inline-flex items-center gap-1 font-medium"
|
|
76
|
+
aria-expanded={open}
|
|
77
|
+
aria-controls={listId}
|
|
78
|
+
onclick={() => (open = !open)}
|
|
79
|
+
>
|
|
80
|
+
<svg
|
|
81
|
+
class="round-history-chevron size-3 shrink-0"
|
|
82
|
+
class:rotate-90={open}
|
|
83
|
+
viewBox="0 0 16 16"
|
|
84
|
+
fill="currentColor"
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
>
|
|
87
|
+
<path d="M6 3.5 10.5 8 6 12.5z" />
|
|
88
|
+
</svg>
|
|
89
|
+
{toggleLabel(ordered.length)}
|
|
90
|
+
</button>
|
|
91
|
+
|
|
92
|
+
{#if open}
|
|
93
|
+
<ol id={listId} class="mt-1 flex flex-col gap-0.5" aria-label="Earlier rounds, newest first">
|
|
94
|
+
{#each ordered as { entry, n } (entry.round)}
|
|
95
|
+
{@const v = entry.verdict ? verdictById.get(entry.verdict) : undefined}
|
|
96
|
+
{@const isSel = selected === entry.round}
|
|
97
|
+
{@const date = formatRoundDate(entry.at)}
|
|
98
|
+
<li>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
class="round-history-line text-base-content/75 hover:bg-base-content/5 flex w-full min-w-0 items-start gap-1.5 px-1.5 py-1 text-left"
|
|
102
|
+
class:round-history-line-selected={isSel}
|
|
103
|
+
aria-pressed={isSel}
|
|
104
|
+
data-round={entry.round}
|
|
105
|
+
onclick={() => pick(entry.round)}
|
|
106
|
+
>
|
|
107
|
+
<span
|
|
108
|
+
class="mt-1.5 size-1.5 shrink-0 rounded-full {v ? TONE_BG[v.tone] : 'bg-base-content/30'}"
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
></span>
|
|
111
|
+
<span class="min-w-0 flex-1" class:truncate={!isSel}>
|
|
112
|
+
<span class="text-base-content font-medium">{entry.label ?? roundLabel(entry, n)}</span
|
|
113
|
+
>{date ? ` · ${date}` : ''}{' · '}<span class={v ? TONE_TEXT[v.tone] : ''}
|
|
114
|
+
>{v?.label ?? entry.verdict ?? undecidedLabel}</span
|
|
115
|
+
>{#if entry.note}<span class:whitespace-pre-wrap={isSel}>{` · “${entry.note}”`}</span>{/if}
|
|
116
|
+
</span>
|
|
117
|
+
<svg
|
|
118
|
+
class="text-base-content/40 mt-0.5 size-3 shrink-0"
|
|
119
|
+
viewBox="0 0 16 16"
|
|
120
|
+
fill="currentColor"
|
|
121
|
+
role="img"
|
|
122
|
+
aria-label="locked"
|
|
123
|
+
>
|
|
124
|
+
<path
|
|
125
|
+
d="M5 7V5a3 3 0 1 1 6 0v2h.5A1.5 1.5 0 0 1 13 8.5v4a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 12.5v-4A1.5 1.5 0 0 1 4.5 7zm1.5 0h3V5a1.5 1.5 0 0 0-3 0z"
|
|
126
|
+
/>
|
|
127
|
+
</svg>
|
|
128
|
+
</button>
|
|
129
|
+
</li>
|
|
130
|
+
{/each}
|
|
131
|
+
</ol>
|
|
132
|
+
{/if}
|
|
133
|
+
</div>
|
|
134
|
+
{/if}
|
|
135
|
+
|
|
136
|
+
<style>
|
|
137
|
+
.round-history-chevron {
|
|
138
|
+
transition: transform var(--motion-fast, 150ms) var(--motion-standard, ease);
|
|
139
|
+
}
|
|
140
|
+
.round-history-line {
|
|
141
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
142
|
+
}
|
|
143
|
+
.round-history-line-selected {
|
|
144
|
+
background: color-mix(in oklab, var(--color-base-content) 8%, transparent);
|
|
145
|
+
}
|
|
146
|
+
@media (prefers-reduced-motion: reduce) {
|
|
147
|
+
.round-history-chevron {
|
|
148
|
+
transition: none;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoundHistory (jatui-xmrod) — the locked, read-only rounds of one review
|
|
3
|
+
* item: "▸ Round 2 · Oct 10 · Fail · "drain cleaning still doubling up"".
|
|
4
|
+
*
|
|
5
|
+
* Collapsed by default behind a "▸ N earlier" toggle; open, lines are newest
|
|
6
|
+
* first. Each line is one button: pressing it selects that round (and shows
|
|
7
|
+
* its full note); pressing the selected line again deselects it. `selected`
|
|
8
|
+
* is bindable so the list pairs with a version switcher — set it from the
|
|
9
|
+
* switcher and the list opens and expands that line.
|
|
10
|
+
*
|
|
11
|
+
* The current round is NOT part of `rounds` — pass history only.
|
|
12
|
+
*/
|
|
13
|
+
import { type ReviewRound, type ReviewVerdictDef } from './reviewSummary';
|
|
14
|
+
interface Props {
|
|
15
|
+
/** Locked earlier rounds (any order — see orderRounds). */
|
|
16
|
+
rounds: ReviewRound[];
|
|
17
|
+
/** Verdict definitions, to label and colour each line's verdict id. */
|
|
18
|
+
verdicts?: ReviewVerdictDef[];
|
|
19
|
+
/** Selected round key (bindable). Pairs with a version switcher. */
|
|
20
|
+
selected?: string | null;
|
|
21
|
+
/** Whether the list is expanded (bindable). Default false (collapsed). */
|
|
22
|
+
open?: boolean;
|
|
23
|
+
/** Called when a line is (de)selected — null when deselected. */
|
|
24
|
+
onSelect?: (round: string | null) => void;
|
|
25
|
+
/** Label for a line without an explicit `label`. Default `Round ${n}`. */
|
|
26
|
+
roundLabel?: (entry: ReviewRound, n: number) => string;
|
|
27
|
+
/** Toggle text. Default "N earlier". */
|
|
28
|
+
toggleLabel?: (count: number) => string;
|
|
29
|
+
/** Text for a round that closed with no verdict. */
|
|
30
|
+
undecidedLabel?: string;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
declare const RoundHistory: import("svelte").Component<Props, {}, "open" | "selected">;
|
|
34
|
+
type RoundHistory = ReturnType<typeof RoundHistory>;
|
|
35
|
+
export default RoundHistory;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
VerdictControl — one review item's verdict: a joined row of always-labelled
|
|
3
|
+
verdict buttons (the active one filled in its tone) plus an explicit Undo
|
|
4
|
+
(mos-z8vgx "Shared primitives"; replaces MOS VerdictBar, the checklist radio
|
|
5
|
+
segment and BlockVerdict's buttons).
|
|
6
|
+
|
|
7
|
+
- A repeat press of the active verdict is a NO-OP (mos-yntqm: a second
|
|
8
|
+
press used to silently clear an approve). Clearing lives on Undo only.
|
|
9
|
+
- Radio semantics: role=radiogroup, roving tabindex, ←/→/↑/↓/Home/End move
|
|
10
|
+
focus between options. Arrows do NOT cast a verdict — passing through
|
|
11
|
+
"Needs work" on the way to "Reject" would save it and pop its note open —
|
|
12
|
+
Space/Enter or the option's hotkey decides.
|
|
13
|
+
- One-key hotkeys from `options[].key` (and `u` = Undo) while focus is in
|
|
14
|
+
the control. Handled keys are preventDefault-ed so a page-level
|
|
15
|
+
reviewKeys() handler skips them.
|
|
16
|
+
- Sizes: `sm` (cards, default) and `xs` (inline strips).
|
|
17
|
+
|
|
18
|
+
The `options` array is the same one ReviewNote (`opensNote`) and
|
|
19
|
+
reviewKeys (`key`) take.
|
|
20
|
+
-->
|
|
21
|
+
<script lang="ts">
|
|
22
|
+
import type { VerdictOption, VerdictTone } from '../../actions/reviewKeys';
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
options: readonly VerdictOption[];
|
|
26
|
+
/** Current-round verdict id, or null when undecided. */
|
|
27
|
+
value?: string | null;
|
|
28
|
+
/** Called with a NEW verdict only — never for the active one. */
|
|
29
|
+
onVerdict?: (id: string) => void;
|
|
30
|
+
/** Called by the Undo button / `u`. Undo shows only when a verdict is set. */
|
|
31
|
+
onUndo?: () => void;
|
|
32
|
+
size?: 'sm' | 'xs';
|
|
33
|
+
/** Accessible name of the group, e.g. "Verdict for Drain cleaning page". */
|
|
34
|
+
label?: string;
|
|
35
|
+
undoLabel?: string;
|
|
36
|
+
/** Show each option's hotkey as a keycap inside its button. */
|
|
37
|
+
showKeys?: boolean;
|
|
38
|
+
/** Local hotkeys while focus is inside the control. Default true. */
|
|
39
|
+
hotkeys?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
class?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
options,
|
|
46
|
+
value = null,
|
|
47
|
+
onVerdict,
|
|
48
|
+
onUndo,
|
|
49
|
+
size = 'sm',
|
|
50
|
+
label = 'Verdict',
|
|
51
|
+
undoLabel = 'Undo',
|
|
52
|
+
showKeys = false,
|
|
53
|
+
hotkeys = true,
|
|
54
|
+
disabled = false,
|
|
55
|
+
class: className = ''
|
|
56
|
+
}: Props = $props();
|
|
57
|
+
|
|
58
|
+
/** Literal class names so Tailwind sees every tone. */
|
|
59
|
+
const FILLED: Record<VerdictTone, string> = {
|
|
60
|
+
success: 'btn-success',
|
|
61
|
+
warning: 'btn-warning',
|
|
62
|
+
error: 'btn-error',
|
|
63
|
+
info: 'btn-info',
|
|
64
|
+
neutral: 'btn-neutral'
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const sizeClass = $derived(size === 'xs' ? 'btn-xs' : 'btn-sm');
|
|
68
|
+
|
|
69
|
+
let group: HTMLDivElement | undefined = $state();
|
|
70
|
+
|
|
71
|
+
/** The option that holds the roving tab stop: the active one, else the first. */
|
|
72
|
+
const tabStop = $derived(options.some((o) => o.id === value) ? value : (options[0]?.id ?? null));
|
|
73
|
+
|
|
74
|
+
function choose(id: string) {
|
|
75
|
+
if (disabled || id === value) return;
|
|
76
|
+
onVerdict?.(id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function radios(): HTMLButtonElement[] {
|
|
80
|
+
return group ? Array.from(group.querySelectorAll<HTMLButtonElement>('[role="radio"]')) : [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function onKeydown(e: KeyboardEvent) {
|
|
84
|
+
if (disabled || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
85
|
+
const list = radios();
|
|
86
|
+
const at = list.indexOf(document.activeElement as HTMLButtonElement);
|
|
87
|
+
|
|
88
|
+
let next = -1;
|
|
89
|
+
if (at >= 0) {
|
|
90
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (at + 1) % list.length;
|
|
91
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (at - 1 + list.length) % list.length;
|
|
92
|
+
else if (e.key === 'Home') next = 0;
|
|
93
|
+
else if (e.key === 'End') next = list.length - 1;
|
|
94
|
+
}
|
|
95
|
+
if (next >= 0) {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
list[next]?.focus();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!hotkeys) return;
|
|
102
|
+
const hit = options.find((o) => o.key && o.key === e.key);
|
|
103
|
+
if (hit) {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
choose(hit.id);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (e.key === 'u' && value && onUndo) {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
onUndo();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<div class="flex flex-wrap items-center gap-2 {className}">
|
|
116
|
+
<!-- Roving tabindex: focus lives on the radios, never on the group itself. -->
|
|
117
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
118
|
+
<div
|
|
119
|
+
bind:this={group}
|
|
120
|
+
class="join"
|
|
121
|
+
role="radiogroup"
|
|
122
|
+
aria-label={label}
|
|
123
|
+
aria-disabled={disabled || undefined}
|
|
124
|
+
onkeydown={onKeydown}
|
|
125
|
+
>
|
|
126
|
+
{#each options as o (o.id)}
|
|
127
|
+
{@const active = value === o.id}
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
role="radio"
|
|
131
|
+
aria-checked={active}
|
|
132
|
+
tabindex={o.id === tabStop ? 0 : -1}
|
|
133
|
+
aria-keyshortcuts={o.key || undefined}
|
|
134
|
+
data-verdict={o.id}
|
|
135
|
+
{disabled}
|
|
136
|
+
class="btn join-item {sizeClass} {active
|
|
137
|
+
? FILLED[o.tone]
|
|
138
|
+
: 'border-base-content/15 bg-base-100 text-base-content/80 hover:text-base-content'}"
|
|
139
|
+
onclick={() => choose(o.id)}
|
|
140
|
+
>
|
|
141
|
+
{o.label}
|
|
142
|
+
{#if showKeys && o.key}
|
|
143
|
+
<kbd class="kbd kbd-xs opacity-70" aria-hidden="true">{o.key}</kbd>
|
|
144
|
+
{/if}
|
|
145
|
+
</button>
|
|
146
|
+
{/each}
|
|
147
|
+
</div>
|
|
148
|
+
{#if value && onUndo}
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
class="btn btn-ghost {sizeClass} gap-1 px-2 text-base-content/75 hover:text-base-content"
|
|
152
|
+
aria-label="{undoLabel} — {label}"
|
|
153
|
+
aria-keyshortcuts="u"
|
|
154
|
+
{disabled}
|
|
155
|
+
onclick={() => onUndo?.()}
|
|
156
|
+
>
|
|
157
|
+
<svg viewBox="0 0 24 24" class="size-3.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
158
|
+
<path d="M9 14 4 9l5-5" /><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11" />
|
|
159
|
+
</svg>
|
|
160
|
+
{undoLabel}
|
|
161
|
+
</button>
|
|
162
|
+
{/if}
|
|
163
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { VerdictOption } from '../../actions/reviewKeys';
|
|
2
|
+
interface Props {
|
|
3
|
+
options: readonly VerdictOption[];
|
|
4
|
+
/** Current-round verdict id, or null when undecided. */
|
|
5
|
+
value?: string | null;
|
|
6
|
+
/** Called with a NEW verdict only — never for the active one. */
|
|
7
|
+
onVerdict?: (id: string) => void;
|
|
8
|
+
/** Called by the Undo button / `u`. Undo shows only when a verdict is set. */
|
|
9
|
+
onUndo?: () => void;
|
|
10
|
+
size?: 'sm' | 'xs';
|
|
11
|
+
/** Accessible name of the group, e.g. "Verdict for Drain cleaning page". */
|
|
12
|
+
label?: string;
|
|
13
|
+
undoLabel?: string;
|
|
14
|
+
/** Show each option's hotkey as a keycap inside its button. */
|
|
15
|
+
showKeys?: boolean;
|
|
16
|
+
/** Local hotkeys while focus is inside the control. Default true. */
|
|
17
|
+
hotkeys?: boolean;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
declare const VerdictControl: import("svelte").Component<Props, {}, "">;
|
|
22
|
+
type VerdictControl = ReturnType<typeof VerdictControl>;
|
|
23
|
+
export default VerdictControl;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review primitives (MOS epic mos-lsmve, design mos-z8vgx) — the building
|
|
3
|
+
* blocks of an item-by-item review surface: a verdict per item, an optional
|
|
4
|
+
* note, a keyboard table, round history, progress, filters and a zoomable
|
|
5
|
+
* media lightbox with a verdict slot.
|
|
6
|
+
*
|
|
7
|
+
* Importable as `jatui/review` (everything below) or from the `jatui` root
|
|
8
|
+
* (components, the keyboard layer, the summary helpers and every type; the
|
|
9
|
+
* zoom/pan math and tone class maps stay on this subpath only).
|
|
10
|
+
*/
|
|
11
|
+
export { default as VerdictControl } from './VerdictControl.svelte';
|
|
12
|
+
export { default as ReviewNote, NOTE_HINT } from './ReviewNote.svelte';
|
|
13
|
+
export type { ReviewNoteSaveState } from './ReviewNote.svelte';
|
|
14
|
+
export { default as ReviewShortcuts } from './ReviewShortcuts.svelte';
|
|
15
|
+
export { reviewKeys, createReviewKeys, reviewKeyRows, resolveReviewKey, REVIEW_KEY_TABLE } from '../../actions/reviewKeys';
|
|
16
|
+
export type { VerdictTone, VerdictOption, NeedsYou, ReviewKeyAction, ReviewKeyEntry, ReviewKeysOptions, ReviewKeyRow, ReviewKeysController } from '../../actions/reviewKeys';
|
|
17
|
+
export { default as RoundHistory } from './RoundHistory.svelte';
|
|
18
|
+
export { default as ReviewProgress } from './ReviewProgress.svelte';
|
|
19
|
+
export { default as ReviewFilterBar } from './ReviewFilterBar.svelte';
|
|
20
|
+
export { mergeReviewCounts, summarizeReview, reviewSentence, buildReviewFilters, orderRounds, formatRoundDate, TONE_BG, TONE_TEXT } from './reviewSummary';
|
|
21
|
+
export type { ReviewTone, ReviewVerdictDef, ReviewCounts, ReviewRound, ReviewFilterChip, ReviewSummary, BuildFiltersInput } from './reviewSummary';
|
|
22
|
+
export { default as MediaZoomLightbox } from './MediaZoomLightbox.svelte';
|
|
23
|
+
export { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP, ZOOM_IDENTITY, DEFAULT_FPS, DEFAULT_SPEEDS, MEDIA_ZOOM_KEYS, clampScale, clampPan, zoomAt, panBy, wheelFactor, zoomTransform, formatMediaTime, stepFrameTime, clampIndex, stepView, stepSpeed, resolveMediaZoomKey, mediaZoomKeys, mediaZoomHint } from './mediaZoom';
|
|
24
|
+
export type { MediaZoomState, MediaZoomView, MediaZoomFooterContext, ViewStep, MediaZoomAction, MediaZoomKeyDef, MediaZoomKeyContext } from './mediaZoom';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review primitives (MOS epic mos-lsmve, design mos-z8vgx) — the building
|
|
3
|
+
* blocks of an item-by-item review surface: a verdict per item, an optional
|
|
4
|
+
* note, a keyboard table, round history, progress, filters and a zoomable
|
|
5
|
+
* media lightbox with a verdict slot.
|
|
6
|
+
*
|
|
7
|
+
* Importable as `jatui/review` (everything below) or from the `jatui` root
|
|
8
|
+
* (components, the keyboard layer, the summary helpers and every type; the
|
|
9
|
+
* zoom/pan math and tone class maps stay on this subpath only).
|
|
10
|
+
*/
|
|
11
|
+
// J1 — verdicts, notes and the review keyboard layer (jatui-et7pm)
|
|
12
|
+
export { default as VerdictControl } from './VerdictControl.svelte';
|
|
13
|
+
export { default as ReviewNote, NOTE_HINT } from './ReviewNote.svelte';
|
|
14
|
+
export { default as ReviewShortcuts } from './ReviewShortcuts.svelte';
|
|
15
|
+
export { reviewKeys, createReviewKeys, reviewKeyRows, resolveReviewKey, REVIEW_KEY_TABLE } from '../../actions/reviewKeys';
|
|
16
|
+
// J2 — round history, progress and filters (jatui-xmrod)
|
|
17
|
+
export { default as RoundHistory } from './RoundHistory.svelte';
|
|
18
|
+
export { default as ReviewProgress } from './ReviewProgress.svelte';
|
|
19
|
+
export { default as ReviewFilterBar } from './ReviewFilterBar.svelte';
|
|
20
|
+
export { mergeReviewCounts, summarizeReview, reviewSentence, buildReviewFilters, orderRounds, formatRoundDate, TONE_BG, TONE_TEXT } from './reviewSummary';
|
|
21
|
+
// J3 — zoomable image/video lightbox with a verdict footer slot (jatui-c79nt)
|
|
22
|
+
export { default as MediaZoomLightbox } from './MediaZoomLightbox.svelte';
|
|
23
|
+
export { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP, ZOOM_IDENTITY, DEFAULT_FPS, DEFAULT_SPEEDS, MEDIA_ZOOM_KEYS, clampScale, clampPan, zoomAt, panBy, wheelFactor, zoomTransform, formatMediaTime, stepFrameTime, clampIndex, stepView, stepSpeed, resolveMediaZoomKey, mediaZoomKeys, mediaZoomHint } from './mediaZoom';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mediaZoom — the pure core of MediaZoomLightbox (jatui-c79nt, ported from
|
|
3
|
+
* mardukos media-picker/zoom.ts, mos-0wvol).
|
|
4
|
+
*
|
|
5
|
+
* Zoom/pan math, frame stepping, view/item paging and the key table. The
|
|
6
|
+
* transform is `translate(x, y) scale(s)` on a wrapper whose origin is the
|
|
7
|
+
* stage centre, so every geometry helper works in stage-centred pixels.
|
|
8
|
+
* No DOM — every helper here is unit-tested in isolation.
|
|
9
|
+
*
|
|
10
|
+
* The key table (`MEDIA_ZOOM_KEYS`) is the single source for BOTH the
|
|
11
|
+
* keydown handler (`resolveMediaZoomKey`) and the on-screen hint
|
|
12
|
+
* (`mediaZoomHint`), so the documented keys can't drift from the behaviour
|
|
13
|
+
* (docs/review-ux/proposal.md → "One keyboard map").
|
|
14
|
+
*/
|
|
15
|
+
export type MediaZoomState = {
|
|
16
|
+
scale: number;
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
};
|
|
20
|
+
/** One page of the lightbox: a view of the item being judged. */
|
|
21
|
+
export type MediaZoomView = {
|
|
22
|
+
src: string;
|
|
23
|
+
kind: 'image' | 'video';
|
|
24
|
+
/** Accessible name / caption ("Loop", "Still", "v8 · front"). */
|
|
25
|
+
label: string;
|
|
26
|
+
poster?: string | undefined;
|
|
27
|
+
/** Image alt text; defaults to `label`. */
|
|
28
|
+
alt?: string | undefined;
|
|
29
|
+
};
|
|
30
|
+
/** What the lightbox hands its `footer` snippet. */
|
|
31
|
+
export type MediaZoomFooterContext = {
|
|
32
|
+
view: MediaZoomView | undefined;
|
|
33
|
+
index: number;
|
|
34
|
+
isVideo: boolean;
|
|
35
|
+
};
|
|
36
|
+
export declare const MIN_ZOOM = 1;
|
|
37
|
+
export declare const MAX_ZOOM = 8;
|
|
38
|
+
export declare const ZOOM_STEP = 1.25;
|
|
39
|
+
export declare const ZOOM_IDENTITY: Readonly<MediaZoomState>;
|
|
40
|
+
/** Frame step: the asset's fps is unknown to the page; review loops are 30fps. */
|
|
41
|
+
export declare const DEFAULT_FPS = 30;
|
|
42
|
+
export declare const DEFAULT_SPEEDS: readonly number[];
|
|
43
|
+
export declare function clampScale(s: number): number;
|
|
44
|
+
/** Keep the content covering the stage: at scale s the slack is (s-1)·w/2. */
|
|
45
|
+
export declare function clampPan(z: MediaZoomState, w: number, h: number): MediaZoomState;
|
|
46
|
+
/**
|
|
47
|
+
* Zoom by `factor` keeping the point (cx, cy) — stage-centred px — fixed
|
|
48
|
+
* under the pointer. Back at scale 1 the pan resets.
|
|
49
|
+
*/
|
|
50
|
+
export declare function zoomAt(z: MediaZoomState, factor: number, cx: number, cy: number, w: number, h: number): MediaZoomState;
|
|
51
|
+
export declare function panBy(z: MediaZoomState, dx: number, dy: number, w: number, h: number): MediaZoomState;
|
|
52
|
+
/** Wheel delta → multiplicative zoom factor (smooth on trackpads too). */
|
|
53
|
+
export declare function wheelFactor(deltaY: number): number;
|
|
54
|
+
export declare function zoomTransform(z: MediaZoomState): string;
|
|
55
|
+
/** `m:ss.cc` — sub-second precision because frame-stepping is the point. */
|
|
56
|
+
export declare function formatMediaTime(sec: number): string;
|
|
57
|
+
/** Move `frames` frames from `time`, clamped to [0, duration]. */
|
|
58
|
+
export declare function stepFrameTime(time: number, frames: number, duration: number, fps?: number): number;
|
|
59
|
+
export declare function clampIndex(i: number, length: number): number;
|
|
60
|
+
/**
|
|
61
|
+
* Where a ←/→ press goes. Inside the item's views it moves one view. At an
|
|
62
|
+
* edge it hands off to the host's item paging when the host offers it
|
|
63
|
+
* (`hasNextItem` / `hasPrevItem` — the legacy review-kit flow: decide, →,
|
|
64
|
+
* next item), and otherwise wraps around the views.
|
|
65
|
+
*/
|
|
66
|
+
export type ViewStep = {
|
|
67
|
+
kind: 'view';
|
|
68
|
+
index: number;
|
|
69
|
+
} | {
|
|
70
|
+
kind: 'item';
|
|
71
|
+
dir: 1 | -1;
|
|
72
|
+
} | {
|
|
73
|
+
kind: 'none';
|
|
74
|
+
};
|
|
75
|
+
export declare function stepView(index: number, delta: 1 | -1, length: number, opts?: {
|
|
76
|
+
hasNextItem?: boolean;
|
|
77
|
+
hasPrevItem?: boolean;
|
|
78
|
+
}): ViewStep;
|
|
79
|
+
export type MediaZoomAction = 'close' | 'prev-view' | 'next-view' | 'prev-item' | 'next-item' | 'pick-view' | 'zoom-in' | 'zoom-out' | 'zoom-reset' | 'play-pause' | 'frame-back' | 'frame-forward' | 'loop' | 'slower' | 'faster';
|
|
80
|
+
export type MediaZoomKeyDef = {
|
|
81
|
+
action: MediaZoomAction;
|
|
82
|
+
/** `KeyboardEvent.key` values that trigger it. */
|
|
83
|
+
keys: readonly string[];
|
|
84
|
+
/** Keycap text for the hint. */
|
|
85
|
+
label: string;
|
|
86
|
+
/** Hint text. */
|
|
87
|
+
description: string;
|
|
88
|
+
/** Only meaningful while a video view is showing. */
|
|
89
|
+
video?: boolean;
|
|
90
|
+
/** Only meaningful when the host pages items (onNextItem / onPrevItem). */
|
|
91
|
+
items?: boolean;
|
|
92
|
+
/** Only meaningful with more than one view. */
|
|
93
|
+
views?: boolean;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* j/k and ↓/↑ page ITEMS (the review-wide "next / previous item" keys), ←/→
|
|
97
|
+
* page VIEWS and spill into items at an edge, 1–9 pick a view. Verdict keys
|
|
98
|
+
* (a/x/r/b/u/n), rounds ([ / ]) and `?` are deliberately absent: they bubble
|
|
99
|
+
* out to the host, which owns them (the footer snippet renders VerdictControl).
|
|
100
|
+
* `k` is NOT play/pause here (it was in the MOS port) — it is "previous item".
|
|
101
|
+
*/
|
|
102
|
+
export declare const MEDIA_ZOOM_KEYS: readonly MediaZoomKeyDef[];
|
|
103
|
+
export type MediaZoomKeyContext = {
|
|
104
|
+
isVideo: boolean;
|
|
105
|
+
hasItems: boolean;
|
|
106
|
+
viewCount: number;
|
|
107
|
+
};
|
|
108
|
+
/** keydown `key` → the action it triggers in this context, or null (let it bubble). */
|
|
109
|
+
export declare function resolveMediaZoomKey(key: string, ctx: MediaZoomKeyContext): MediaZoomAction | null;
|
|
110
|
+
/** The keys that work right now, for the on-screen hint. */
|
|
111
|
+
export declare function mediaZoomKeys(ctx: MediaZoomKeyContext): MediaZoomKeyDef[];
|
|
112
|
+
/** One-line hint built from the key table: "Space play/pause · , frame back · …". */
|
|
113
|
+
export declare function mediaZoomHint(ctx: MediaZoomKeyContext): string;
|
|
114
|
+
/** The next/previous entry in `speeds` from `current` (clamped at the ends). */
|
|
115
|
+
export declare function stepSpeed(current: number, dir: 1 | -1, speeds?: readonly number[]): number;
|