rvlib-shipkit 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
// sent. The list is read while the page shows, every 2 s, answered 304 when the store did not move
|
|
4
4
|
import { makeAutoObservable, reaction, runInAction } from 'mobx'
|
|
5
5
|
import {
|
|
6
|
+
asOptions,
|
|
7
|
+
isApproved,
|
|
8
|
+
isChoice,
|
|
6
9
|
isGallery,
|
|
10
|
+
isPair,
|
|
7
11
|
laneOf,
|
|
8
12
|
moveOf,
|
|
9
13
|
reworkOf,
|
|
14
|
+
STARS,
|
|
15
|
+
STATUS_LABEL,
|
|
10
16
|
stateOf,
|
|
11
17
|
unreadBy,
|
|
12
18
|
verdictOf,
|
|
@@ -31,6 +37,8 @@ interface Draft {
|
|
|
31
37
|
rects: Rect[]
|
|
32
38
|
comment: string
|
|
33
39
|
reactions?: ReviewReaction[]
|
|
40
|
+
/** the option he picked, on a choice card */
|
|
41
|
+
pick?: number
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
/** a live `sk review watch`: its process, the session or lane it keeps, when it started */
|
|
@@ -71,6 +79,10 @@ export type PhonePane = 'list' | 'card' | 'answer'
|
|
|
71
79
|
export type PairSide = 'before' | 'after' | 'swipe'
|
|
72
80
|
|
|
73
81
|
const POLL_MS = 2000
|
|
82
|
+
/** a sound's clock steps a tenth of a second */
|
|
83
|
+
const SOUND_FPS = 10
|
|
84
|
+
/** ← → on a sound jump this many seconds */
|
|
85
|
+
const SOUND_JUMP = 2
|
|
74
86
|
|
|
75
87
|
const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
|
|
76
88
|
|
|
@@ -97,7 +109,7 @@ const draftOf = (s: string | null): Draft | null => {
|
|
|
97
109
|
try {
|
|
98
110
|
const d = s === null ? null : (JSON.parse(s) as Partial<Draft>)
|
|
99
111
|
if (!d || !Array.isArray(d.rects) || typeof d.comment !== 'string') return null
|
|
100
|
-
return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}) }
|
|
112
|
+
return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}), ...(typeof d.pick === 'number' ? { pick: d.pick } : {}) }
|
|
101
113
|
} catch {
|
|
102
114
|
return null
|
|
103
115
|
}
|
|
@@ -179,6 +191,8 @@ export class PhoneSt {
|
|
|
179
191
|
}
|
|
180
192
|
}
|
|
181
193
|
|
|
194
|
+
const isShownStatus = (s: string): s is ShownStatus => Object.hasOwn(STATUS_LABEL, s)
|
|
195
|
+
|
|
182
196
|
export class ReviewSt {
|
|
183
197
|
list: Listing | null = null
|
|
184
198
|
listError: string | null = null
|
|
@@ -206,6 +220,10 @@ export class ReviewSt {
|
|
|
206
220
|
viewZoom: Record<number, Zoom> = {}
|
|
207
221
|
/** the gallery tile shown large across the column, `<view>/<tile>` */
|
|
208
222
|
bigTile: string | null = null
|
|
223
|
+
/** a card of options shown full, every view stacked, by id: his switch, this visit */
|
|
224
|
+
full: Record<string, boolean> = {}
|
|
225
|
+
/** the option rows unfolded to their full view, by view index */
|
|
226
|
+
openRows: Record<number, boolean> = {}
|
|
209
227
|
replies: Record<string, string> = {}
|
|
210
228
|
private reading = new Set<string>()
|
|
211
229
|
/** « hide handled »: only the active threads in the list */
|
|
@@ -239,6 +257,27 @@ export class ReviewSt {
|
|
|
239
257
|
setPlace(p: CardPlace): void {
|
|
240
258
|
this.place = p
|
|
241
259
|
}
|
|
260
|
+
/** the open card as thin option rows: a choice, or views that each play alone, unless he asked them full */
|
|
261
|
+
get rows(): boolean {
|
|
262
|
+
const x = this.open
|
|
263
|
+
return x !== null && asOptions(x.item) && this.full[x.item.id] !== true
|
|
264
|
+
}
|
|
265
|
+
setFull(on: boolean): void {
|
|
266
|
+
const id = this.open?.item.id
|
|
267
|
+
if (id) this.full = { ...this.full, [id]: on }
|
|
268
|
+
}
|
|
269
|
+
get choice(): boolean {
|
|
270
|
+
const x = this.open
|
|
271
|
+
return x !== null && isChoice(x.item)
|
|
272
|
+
}
|
|
273
|
+
/** the card's header (title, state, what, and in one column the verdicts) sits over its views: one column, the
|
|
274
|
+
* phone's card pane, a choice's options */
|
|
275
|
+
get headInViews(): boolean {
|
|
276
|
+
return this.stacked || this.phone.on || this.choice
|
|
277
|
+
}
|
|
278
|
+
toggleRow(i: number): void {
|
|
279
|
+
this.openRows = { ...this.openRows, [i]: !this.openRows[i] }
|
|
280
|
+
}
|
|
242
281
|
|
|
243
282
|
constructor(private readonly st: RepoSt) {
|
|
244
283
|
this.prefix = `shipkit.review.${st.key}.`
|
|
@@ -470,7 +509,14 @@ export class ReviewSt {
|
|
|
470
509
|
if (!x) return { rects: [], comment: '' }
|
|
471
510
|
const v = verdictOf(x)
|
|
472
511
|
const sent = v?.reactions
|
|
473
|
-
return
|
|
512
|
+
return (
|
|
513
|
+
this.drafts[x.item.id] ?? {
|
|
514
|
+
rects: v?.rects ?? [],
|
|
515
|
+
comment: v?.comment ?? '',
|
|
516
|
+
...(sent && sent.length > 0 ? { reactions: sent } : {}),
|
|
517
|
+
...(v?.pick !== undefined ? { pick: v.pick } : {}),
|
|
518
|
+
}
|
|
519
|
+
)
|
|
474
520
|
}
|
|
475
521
|
|
|
476
522
|
get dirty(): boolean {
|
|
@@ -482,7 +528,7 @@ export class ReviewSt {
|
|
|
482
528
|
const x = this.open
|
|
483
529
|
if (!x) return
|
|
484
530
|
const d = this.draft
|
|
485
|
-
const { reactions, ...rest } = f({ rects: [...d.rects],
|
|
531
|
+
const { reactions, ...rest } = f({ ...d, rects: [...d.rects], reactions: [...(d.reactions ?? [])] })
|
|
486
532
|
const next: Draft = reactions && reactions.length > 0 ? { ...rest, reactions } : rest
|
|
487
533
|
this.drafts = { ...this.drafts, [x.item.id]: next }
|
|
488
534
|
store.put(`${this.prefix}draft.${x.item.id}`, JSON.stringify(next))
|
|
@@ -494,12 +540,43 @@ export class ReviewSt {
|
|
|
494
540
|
if (this.phone.on) this.phone.show('card')
|
|
495
541
|
}
|
|
496
542
|
|
|
543
|
+
/** what the address carries beside the card: its view and the list's filters, `view=1&subject=place`, each only
|
|
544
|
+
* off its default (`handled=1` shows the handled threads, `lanes=1` groups by lane) */
|
|
545
|
+
get address(): string {
|
|
546
|
+
const q = new URLSearchParams()
|
|
547
|
+
if (this.view > 0) q.set('view', String(this.view))
|
|
548
|
+
if (this.subject !== null) q.set('subject', this.subject)
|
|
549
|
+
if (this.whose !== null) q.set('whose', this.whose)
|
|
550
|
+
if (this.status !== null) q.set('status', this.status)
|
|
551
|
+
if (!this.hideHandled) q.set('handled', '1')
|
|
552
|
+
if (this.byLane) q.set('lanes', '1')
|
|
553
|
+
return q.toString()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** an address's view and filters read back: a key it leaves out keeps what the page has; an unknown value is dropped */
|
|
557
|
+
applyAddress(raw: string): void {
|
|
558
|
+
const q = new URLSearchParams(raw)
|
|
559
|
+
const view = Number(q.get('view'))
|
|
560
|
+
if (Number.isInteger(view) && view > 0) this.view = view
|
|
561
|
+
const subject = q.get('subject')
|
|
562
|
+
if (subject) this.subject = subject
|
|
563
|
+
const whose = q.get('whose')
|
|
564
|
+
if (whose === 'owner' || whose === 'agents' || whose === 'none') this.whose = whose
|
|
565
|
+
const status = q.get('status')
|
|
566
|
+
if (status !== null && isShownStatus(status)) this.status = status
|
|
567
|
+
if (q.get('handled') === '1') this.hideHandled = false
|
|
568
|
+
if (q.get('lanes') === '1') this.byLane = true
|
|
569
|
+
}
|
|
570
|
+
|
|
497
571
|
/** the card the address names; its draft is read again from storage, the truth over every window */
|
|
498
572
|
select(id: string | null): void {
|
|
499
573
|
if (id !== this.selectedId) {
|
|
500
574
|
this.view = 0
|
|
501
575
|
this.viewZoom = {}
|
|
502
576
|
this.bigTile = null
|
|
577
|
+
this.openRows = {}
|
|
578
|
+
// a link to a card on the phone opens the card, never the list
|
|
579
|
+
if (id !== null && this.phone.on) this.phone.show('card')
|
|
503
580
|
}
|
|
504
581
|
this.selectedId = id
|
|
505
582
|
if (id !== null) {
|
|
@@ -545,6 +622,13 @@ export class ReviewSt {
|
|
|
545
622
|
this.rect = null
|
|
546
623
|
}
|
|
547
624
|
|
|
625
|
+
/** the playable view an option row or a clip shows: a clip, or a sound shown as a row */
|
|
626
|
+
private playableAt(i: number): MediaView | null {
|
|
627
|
+
const v = this.open?.item.views[i]
|
|
628
|
+
if (!v || isGallery(v)) return null
|
|
629
|
+
return v.clip || (v.audio && this.rows && !isPair(v.files)) ? v : null
|
|
630
|
+
}
|
|
631
|
+
|
|
548
632
|
get openView(): MediaView | null {
|
|
549
633
|
const v = this.open?.item.views[this.view]
|
|
550
634
|
return v && !isGallery(v) ? v : null
|
|
@@ -575,15 +659,28 @@ export class ReviewSt {
|
|
|
575
659
|
/** a reaction's toggle: sets that status, or clears it when it is already the one */
|
|
576
660
|
react(view: number, tile: number | undefined, status: ReviewStatus): void {
|
|
577
661
|
const cur = this.reactionOf(view, tile)
|
|
578
|
-
this.putReaction(view, tile, { status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
|
|
662
|
+
this.putReaction(view, tile, { ...cur, status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
|
|
579
663
|
}
|
|
580
664
|
|
|
581
665
|
setReactionComment(view: number, tile: number | undefined, comment: string): void {
|
|
582
|
-
this.putReaction(view, tile, {
|
|
666
|
+
this.putReaction(view, tile, { ...this.reactionOf(view, tile), comment })
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** an option's stars; the same number again clears them */
|
|
670
|
+
setStars(view: number, n: number): void {
|
|
671
|
+
const cur = this.reactionOf(view)
|
|
672
|
+
this.putReaction(view, undefined, { ...cur, comment: cur?.comment ?? '', stars: cur?.stars === n ? undefined : n })
|
|
583
673
|
}
|
|
584
674
|
|
|
585
675
|
removeReaction(view: number, tile: number | undefined): void {
|
|
586
|
-
this.putReaction(view, tile, {
|
|
676
|
+
this.putReaction(view, tile, { comment: '' })
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** « pick »: that option is his answer, sent at once as an approval; his stars and words go with it */
|
|
680
|
+
async choose(view: number): Promise<void> {
|
|
681
|
+
if (!this.choice) return
|
|
682
|
+
this.edit((d) => ({ ...d, pick: view }))
|
|
683
|
+
await this.submit('approved')
|
|
587
684
|
}
|
|
588
685
|
|
|
589
686
|
/** the reactions that go to the agent with the verdict, in the card's order */
|
|
@@ -591,19 +688,27 @@ export class ReviewSt {
|
|
|
591
688
|
return [...(this.draft.reactions ?? [])].sort((a, b) => a.view - b.view || (a.tile ?? -1) - (b.tile ?? -1))
|
|
592
689
|
}
|
|
593
690
|
|
|
594
|
-
private putReaction(view: number, tile: number | undefined, r: { status
|
|
595
|
-
const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment }
|
|
596
|
-
const empty = !r.status && r.comment === ''
|
|
691
|
+
private putReaction(view: number, tile: number | undefined, r: { status?: ReviewStatus | undefined; comment: string; stars?: number | undefined }): void {
|
|
692
|
+
const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment, ...(r.stars ? { stars: r.stars } : {}) }
|
|
693
|
+
const empty = !r.status && r.comment === '' && !r.stars
|
|
597
694
|
this.edit((d) => {
|
|
598
695
|
const rest = (d.reactions ?? []).filter((q) => !sameSpot(q, view, tile))
|
|
599
696
|
return { ...d, reactions: empty ? rest : [...rest, next] }
|
|
600
697
|
})
|
|
601
698
|
}
|
|
602
699
|
|
|
603
|
-
/** the open view's clip identity, empty when it shows images */
|
|
700
|
+
/** the open view's clip identity (a clip, or a sound on a row), empty when it shows images */
|
|
604
701
|
get clipKey(): string {
|
|
605
702
|
const x = this.open
|
|
606
|
-
return x && this.
|
|
703
|
+
return x && this.playableAt(this.view) ? `${x.item.id}#${this.view}` : ''
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** a row's ▶: that option plays with its sound, the one playing before it stops; again, it pauses */
|
|
707
|
+
listen(i: number): void {
|
|
708
|
+
this.setView(i)
|
|
709
|
+
const c = this.clipOf(i)
|
|
710
|
+
if (c.paused) c.listen()
|
|
711
|
+
else c.pause()
|
|
607
712
|
}
|
|
608
713
|
|
|
609
714
|
/** the clock of a clip view of the open card: every clip view plays and steps on its own */
|
|
@@ -616,9 +721,14 @@ export class ReviewSt {
|
|
|
616
721
|
let c = this.clips.get(view)
|
|
617
722
|
if (!c) {
|
|
618
723
|
c = new ClipSt()
|
|
619
|
-
const
|
|
620
|
-
const
|
|
621
|
-
|
|
724
|
+
const views = this.open?.item.views ?? []
|
|
725
|
+
const v = views[view]
|
|
726
|
+
const media = v && !isGallery(v) ? v : undefined
|
|
727
|
+
// one thing plays at a time: the first clip of a card loops on its own, the others and every option wait
|
|
728
|
+
const first = views.findIndex((w) => !isGallery(w) && w.clip !== undefined)
|
|
729
|
+
const still = this.rows || view !== first
|
|
730
|
+
if (media?.audio) c.load(`${id}#${view}`, media.audio.duration, SOUND_FPS, true)
|
|
731
|
+
else c.load(`${id}#${view}`, media?.clip?.duration ?? 0, media?.clip?.fps ?? 20, still)
|
|
622
732
|
this.clips.set(view, c)
|
|
623
733
|
}
|
|
624
734
|
return c
|
|
@@ -740,7 +850,8 @@ export class ReviewSt {
|
|
|
740
850
|
this.busy = true
|
|
741
851
|
this.error = null
|
|
742
852
|
try {
|
|
743
|
-
const
|
|
853
|
+
const pick = isChoice(x.item) && isApproved(status) && d.pick !== undefined ? { pick: d.pick } : {}
|
|
854
|
+
const m = await postJson<Message>(this.st.url(`/api/review/${id}/feedback`), { status, comment: d.comment, rects: d.rects, reactions: d.reactions ?? [], ...pick })
|
|
744
855
|
runInAction(() => {
|
|
745
856
|
// the verdict shows at once: the list of every card comes back after it
|
|
746
857
|
const r = this.list
|
|
@@ -760,10 +871,22 @@ export class ReviewSt {
|
|
|
760
871
|
}
|
|
761
872
|
|
|
762
873
|
/** the page's keys, as the atelier takes its own: true when it used the key. V approves, F ok with a fix, R rejects,
|
|
763
|
-
* A archives, N the next waiting card, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame
|
|
764
|
-
|
|
874
|
+
* A archives, N the next waiting card, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame
|
|
875
|
+
* (a sound: two seconds); on option rows 1 to 9 play that row, ⇧1 to ⇧5 score the row playing, P picks it. A digit is
|
|
876
|
+
* read from its physical key (`code`), so ⇧ and an AZERTY keyboard read the same */
|
|
877
|
+
key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'> & { code?: string }): boolean {
|
|
765
878
|
const k = e.key.toLowerCase()
|
|
766
|
-
|
|
879
|
+
const digit = Number(/^Digit([1-9])$/.exec(e.code ?? '')?.[1] ?? (/^[1-9]$/.test(e.key) ? e.key : 0))
|
|
880
|
+
const sound = this.openView?.audio !== undefined
|
|
881
|
+
if (this.rows && digit > 0 && !e.shiftKey) {
|
|
882
|
+
if (digit <= (this.open?.item.views.length ?? 0)) this.listen(digit - 1)
|
|
883
|
+
} else if (this.choice && this.rows && digit > 0 && e.shiftKey) {
|
|
884
|
+
if (digit <= STARS) this.setStars(this.view, digit)
|
|
885
|
+
} else if (this.choice && k === 'p') void this.choose(this.view)
|
|
886
|
+
else if (this.clipKey && e.key === ' ') {
|
|
887
|
+
if (this.clip.paused && this.rows) this.clip.listen()
|
|
888
|
+
else this.clip.toggle()
|
|
889
|
+
} else if (this.clipKey && sound && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.jump(this.clip.time + (e.key === 'ArrowRight' ? SOUND_JUMP : -SOUND_JUMP))
|
|
767
890
|
else if (this.clipKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.step(e.key === 'ArrowRight' ? 1 : -1)
|
|
768
891
|
else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') this.move(e.key === 'ArrowDown' ? 1 : -1)
|
|
769
892
|
else if (k === 'v') void this.submit('approved')
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// the review page's middle column: every view of the open card stacked, each under its heading with the owner's reaction and
|
|
2
2
|
// its zoom; a view is an image, a clip, a pair (side by side, the curtain, the onion skin, the after alone) or a
|
|
3
|
-
// gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time
|
|
3
|
+
// gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time. A choice, or
|
|
4
|
+
// views that each play alone, show as thin option rows; the card's header sits on top when the page reads in one column
|
|
4
5
|
import { observer } from 'mobx-react-lite'
|
|
5
6
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
6
|
-
import { isGallery, isPair, onFrame, viewName, type GalleryView, type MediaView, type Rect, type ReviewClip, type ReviewItem, type ReviewMark, type ReviewView, type Side } from '../../../review/model.ts'
|
|
7
|
+
import { isGallery, isPair, onFrame, STARS, thumbFileOf, verdictOf, viewName, type GalleryView, type MediaView, type Rect, type ReviewClip, type ReviewItem, type ReviewMark, type ReviewView, type Side } from '../../../review/model.ts'
|
|
7
8
|
import { blurOnEscape, Empty, FIT_ROOM, keepFocus, PAIR_NAMES, REACTIONS, secs } from './kit.tsx'
|
|
9
|
+
import { CardHead } from './review-answer.tsx'
|
|
8
10
|
import type { ClipSt } from './clip-st.ts'
|
|
9
11
|
import { CARD_PLACES, COMPARE_MODES, drawnSide, ZOOMS, type CompareMode, type CompareSt, type PairSide, type ReviewSt, type Zoom } from './review-st.ts'
|
|
10
12
|
|
|
@@ -16,12 +18,20 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
16
18
|
if (!x) return <Empty text={rv.list ? 'No card. An agent adds one with sk review add.' : 'Reading the cards…'} />
|
|
17
19
|
const r = x.item
|
|
18
20
|
const views = r.views
|
|
19
|
-
const drawable = views.some((v) => !isGallery(v) && !v.clip)
|
|
21
|
+
const drawable = views.some((v) => !isGallery(v) && !v.clip && !v.audio)
|
|
22
|
+
const rows = rv.rows
|
|
20
23
|
return (
|
|
21
24
|
<div className="rv-views">
|
|
25
|
+
{rv.headInViews ? <CardHead rv={rv} x={x} verdicts={rv.stacked && !rv.phone.on} /> : null}
|
|
22
26
|
<div className="rv-viewsbar">
|
|
23
|
-
{views.length > 1 ? <JumpList rv={rv} /> : <span />}
|
|
27
|
+
{views.length > 1 && !rows ? <JumpList rv={rv} /> : <span />}
|
|
24
28
|
<span className="rv-viewsctl">
|
|
29
|
+
{asOptionsCard(rv) ? (
|
|
30
|
+
<span className="seg" title="the options as thin rows, one below the other, or every view full">
|
|
31
|
+
<button className={rows ? 'on' : ''} onMouseDown={keepFocus} onClick={() => rv.setFull(false)}>rows</button>
|
|
32
|
+
<button className={rows ? '' : 'on'} onMouseDown={keepFocus} onClick={() => rv.setFull(true)}>full</button>
|
|
33
|
+
</span>
|
|
34
|
+
) : null}
|
|
25
35
|
{rv.phone.on && drawable ? (
|
|
26
36
|
<button className={rv.phone.draw ? 'primary' : ''} onClick={() => rv.phone.toggleDraw()}>Circle</button>
|
|
27
37
|
) : null}
|
|
@@ -36,12 +46,144 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
36
46
|
</span>
|
|
37
47
|
</div>
|
|
38
48
|
{rv.answered.map((y) => <Raised key={y.item.id} rv={rv} x={y} />)}
|
|
39
|
-
{
|
|
49
|
+
{rows ? (
|
|
50
|
+
<div className="rv-opts">{views.map((v, i) => <OptionRow key={`${r.id}#${i}`} rv={rv} x={x} v={v} i={i} />)}</div>
|
|
51
|
+
) : (
|
|
52
|
+
views.map((v, i) => <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > 1} />)
|
|
53
|
+
)}
|
|
40
54
|
{views.length === 0 ? <TextOnly x={x} /> : null}
|
|
41
55
|
</div>
|
|
42
56
|
)
|
|
43
57
|
})
|
|
44
58
|
|
|
59
|
+
/** the open card may show as rows: a choice, or views that each play alone (the switch shows while it shows full) */
|
|
60
|
+
const asOptionsCard = (rv: ReviewSt): boolean => rv.rows || rv.full[rv.open?.item.id ?? ''] === true
|
|
61
|
+
|
|
62
|
+
/** one option of a choice, or one view of a group, as a thin row: its number, ▶ and the clock, its label, a strip that
|
|
63
|
+
* scrubs it, its stars and « pick » (a choice) or OK, fix, no (a group), ✎ his word, ⤢ the full view under it */
|
|
64
|
+
const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: ReviewView; i: number }) => {
|
|
65
|
+
const [word, setWord] = useState(false)
|
|
66
|
+
const id = x.item.id
|
|
67
|
+
const media = isGallery(v) ? null : v
|
|
68
|
+
const playable = media !== null && !isPair(media.files) && (media.clip !== undefined || media.audio !== undefined) ? media : null
|
|
69
|
+
const c = playable ? rv.clipOf(i) : null
|
|
70
|
+
const reaction = rv.reactionOf(i)
|
|
71
|
+
const choice = rv.choice
|
|
72
|
+
const picked = rv.draft.pick === i
|
|
73
|
+
const sent = verdictOf(x)?.pick === i
|
|
74
|
+
const unfolded = rv.openRows[i] === true
|
|
75
|
+
const file = playable ? ('video' in playable.files ? playable.files.video : 'audio' in playable.files ? playable.files.audio : '') : ''
|
|
76
|
+
const duration = playable?.clip?.duration ?? playable?.audio?.duration ?? 0
|
|
77
|
+
const showWord = word || (reaction?.comment ?? '') !== ''
|
|
78
|
+
return (
|
|
79
|
+
<div id={`review-view-${i}`} className={`rv-opt${rv.view === i ? ' active' : ''}${picked ? ' picked' : ''}${toneClass(reaction?.status)}`} onMouseDownCapture={() => rv.view !== i && rv.setView(i)}>
|
|
80
|
+
<div className="rv-optrow">
|
|
81
|
+
<span className="rv-optn">{i + 1}</span>
|
|
82
|
+
{c ? (
|
|
83
|
+
<button className={`rv-optplay${c.paused ? '' : ' on'}`} title={`${c.paused ? 'play' : 'pause'} (${i + 1})`} onMouseDown={keepFocus} onClick={() => rv.listen(i)}>
|
|
84
|
+
{c.paused ? '▶' : '❚❚'}
|
|
85
|
+
</button>
|
|
86
|
+
) : null}
|
|
87
|
+
<b className="rv-optlabel" title={viewName(v, i)}>{viewName(v, i)}</b>
|
|
88
|
+
{c && playable?.clip && !unfolded ? (
|
|
89
|
+
<span className="rv-optthumb" style={{ aspectRatio: `${playable.size.w} / ${playable.size.h}` }} onClick={() => rv.toggleRow(i)} title="the full view">
|
|
90
|
+
<ClipVideo clip={c} src={rv.fileUrl(id, file)} poster={rv.fileUrl(id, playable.clip.poster)} loop={false} />
|
|
91
|
+
</span>
|
|
92
|
+
) : null}
|
|
93
|
+
{c && playable ? (
|
|
94
|
+
<Strip clip={c} duration={duration} wave={playable.audio ? rv.fileUrl(id, playable.audio.wave) : undefined} />
|
|
95
|
+
) : (
|
|
96
|
+
<span className="rv-optthumb img" onClick={() => rv.toggleRow(i)} title="the full view"><img src={rv.thumbUrl(x, thumbFileOf(v))} alt="" draggable={false} /></span>
|
|
97
|
+
)}
|
|
98
|
+
{c ? <span className="rv-mono rv-opttime">{clock(c.time)} / {clock(duration)}</span> : null}
|
|
99
|
+
<span className="rv-optacts">
|
|
100
|
+
{choice ? <Stars rv={rv} view={i} /> : <ReactionToggles rv={rv} view={i} small />}
|
|
101
|
+
<button className={`rv-optword${showWord ? ' on' : ''}`} title="a word on this one" onMouseDown={keepFocus} onClick={() => setWord(!word)}>✎</button>
|
|
102
|
+
{playable?.clip || !playable ? (
|
|
103
|
+
<button className={unfolded ? 'on' : ''} title={unfolded ? 'fold it back to its row' : 'the full view'} onMouseDown={keepFocus} onClick={() => rv.toggleRow(i)}>⤢</button>
|
|
104
|
+
) : null}
|
|
105
|
+
{playable?.audio ? <a className="rv-dl" href={rv.fileUrl(id, file)} download={`${id}-${file}`} title="download the sound">⤓</a> : null}
|
|
106
|
+
{choice ? (
|
|
107
|
+
<button className={`rv-pick${picked ? ' on' : ''}`} title={`pick this one: it is your answer (P)${sent ? ', sent' : ''}`} disabled={rv.busy} onClick={() => void rv.choose(i)}>
|
|
108
|
+
{picked ? '✓ picked' : 'pick'}
|
|
109
|
+
</button>
|
|
110
|
+
) : null}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
{media?.note ? <div className="rv-optnote">{media.note}</div> : null}
|
|
114
|
+
{showWord ? (
|
|
115
|
+
<textarea className="rv-word" rows={1} placeholder="a word on this one" value={reaction?.comment ?? ''} autoFocus={word && !reaction?.comment}
|
|
116
|
+
onKeyDown={blurOnEscape} onChange={(e) => rv.setReactionComment(i, undefined, e.currentTarget.value)} />
|
|
117
|
+
) : null}
|
|
118
|
+
{c && playable?.audio ? <SoundEl clip={c} src={rv.fileUrl(id, file)} /> : null}
|
|
119
|
+
{unfolded ? (
|
|
120
|
+
<div className="rv-optbody">
|
|
121
|
+
{isGallery(v) ? <Gallery rv={rv} id={id} v={v} view={i} /> : <MediaBody rv={rv} id={id} v={v} i={i} zoom={rv.zoomOf(i)} />}
|
|
122
|
+
</div>
|
|
123
|
+
) : null}
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/** `1:05`: a time on a row, short */
|
|
129
|
+
const clock = (t: number): string => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`
|
|
130
|
+
|
|
131
|
+
/** an option's score: STARS stars, a click on the one given clears it */
|
|
132
|
+
const Stars = observer(({ rv, view }: { rv: ReviewSt; view: number }) => {
|
|
133
|
+
const n = rv.reactionOf(view)?.stars ?? 0
|
|
134
|
+
return (
|
|
135
|
+
<span className="rv-stars" title={`your score (⇧1 to ⇧${STARS} on the row playing)`}>
|
|
136
|
+
{Array.from({ length: STARS }, (_, k) => (
|
|
137
|
+
<button key={k} className={k < n ? 'on' : ''} aria-label={`${k + 1} of ${STARS}`} onMouseDown={keepFocus} onClick={() => rv.setStars(view, k + 1)}>{k < n ? '★' : '☆'}</button>
|
|
138
|
+
))}
|
|
139
|
+
</span>
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
/** a thin track a row scrubs: a sound's waveform under what has played; a click or a drag moves the time */
|
|
144
|
+
const Strip = observer(({ clip, duration, wave }: { clip: ClipSt; duration: number; wave: string | undefined }) => {
|
|
145
|
+
const el = useRef<HTMLDivElement>(null)
|
|
146
|
+
const down = (e: React.PointerEvent): void => {
|
|
147
|
+
const b = el.current?.getBoundingClientRect()
|
|
148
|
+
if (!b || duration <= 0) return
|
|
149
|
+
e.preventDefault()
|
|
150
|
+
const at = (px: number): void => clip.jump(Math.max(0, Math.min(1, (px - b.left) / b.width)) * duration)
|
|
151
|
+
at(e.clientX)
|
|
152
|
+
track((m) => at(m.clientX))
|
|
153
|
+
}
|
|
154
|
+
const played = duration > 0 ? Math.min(1, clip.time / duration) : 0
|
|
155
|
+
return (
|
|
156
|
+
<div ref={el} className={`rv-strip${wave ? ' wave' : ''}`} onPointerDown={down}>
|
|
157
|
+
{wave ? (
|
|
158
|
+
<>
|
|
159
|
+
<Wave src={wave} className="rv-stripwave" />
|
|
160
|
+
<Wave src={wave} className="rv-stripwave done" style={{ clipPath: `inset(0 ${100 - played * 100}% 0 0)` }} />
|
|
161
|
+
</>
|
|
162
|
+
) : (
|
|
163
|
+
<div className="rv-stripdone" style={{ width: `${played * 100}%` }} />
|
|
164
|
+
)}
|
|
165
|
+
<div className="rv-striphead" style={{ left: `${played * 100}%` }} />
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/** a waveform png as a mask the page fills with its own colour, so it reads on either theme (any colour it was drawn in) */
|
|
171
|
+
const Wave = ({ src, className, style }: { src: string; className: string; style?: React.CSSProperties }) => {
|
|
172
|
+
const mask = `url("${src}") center / 100% 100% no-repeat`
|
|
173
|
+
return <div className={className} style={{ ...style, mask, WebkitMask: mask }} />
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** a sound row's player, no controls of its own: the row's clock drives it; at its end it waits at the start */
|
|
177
|
+
const SoundEl = ({ clip, src }: { clip: ClipSt; src: string }) => {
|
|
178
|
+
const ref = useRef<HTMLAudioElement>(null)
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const el = ref.current
|
|
181
|
+
if (!el) return
|
|
182
|
+
return clip.attach(el)
|
|
183
|
+
}, [clip, src])
|
|
184
|
+
return <audio ref={ref} src={src} preload="metadata" onEnded={() => clip.seek(0)} />
|
|
185
|
+
}
|
|
186
|
+
|
|
45
187
|
/** a raised item the open card answers, nested above its views: the owner's words, his still */
|
|
46
188
|
const Raised = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
|
|
47
189
|
const opening = x.thread.find((m) => m.op === undefined && m.text !== undefined)
|
|
@@ -213,7 +355,9 @@ const SoundBody = ({ rv, id, v }: { rv: ReviewSt; id: string; v: MediaView }) =>
|
|
|
213
355
|
{rows.map((r) => (
|
|
214
356
|
<div key={r.file} className="rv-soundrow">
|
|
215
357
|
{r.label ? <div className="rv-dim">{r.label}</div> : null}
|
|
216
|
-
<
|
|
358
|
+
<div role="img" aria-label={`the waveform of ${r.file}`} className="rv-wavebox" style={{ aspectRatio: `${v.size.w} / ${v.size.h}` }}>
|
|
359
|
+
<Wave src={rv.fileUrl(id, r.wave)} className="rv-stripwave full" />
|
|
360
|
+
</div>
|
|
217
361
|
<audio controls preload="metadata" src={rv.fileUrl(id, r.file)} style={{ width: '100%' }} />
|
|
218
362
|
</div>
|
|
219
363
|
))}
|
|
@@ -463,15 +607,16 @@ const RectMark = ({ r, n, on, faint, at, pick }: { r: Rect; n: number; on: boole
|
|
|
463
607
|
|
|
464
608
|
// ── a clip ──
|
|
465
609
|
|
|
466
|
-
/** one video of the open view, driven by the view's one clock; muted until the sound button, looping,
|
|
467
|
-
|
|
610
|
+
/** one video of the open view, driven by the view's one clock; muted until the sound button, inline; looping, or at its
|
|
611
|
+
* end waiting at the start */
|
|
612
|
+
const ClipVideo = ({ clip, src, poster, loop = true }: { clip: ClipSt; src: string; poster?: string; loop?: boolean }) => {
|
|
468
613
|
const ref = useRef<HTMLVideoElement>(null)
|
|
469
614
|
useEffect(() => {
|
|
470
615
|
const el = ref.current
|
|
471
616
|
if (!el) return
|
|
472
617
|
return clip.attach(el)
|
|
473
618
|
}, [clip, src])
|
|
474
|
-
return <video ref={ref} className="rv-video" src={src} muted loop playsInline preload="auto" />
|
|
619
|
+
return <video ref={ref} className="rv-video" src={src} poster={poster} muted loop={loop} playsInline preload="auto" onEnded={loop ? undefined : () => clip.seek(0)} />
|
|
475
620
|
}
|
|
476
621
|
|
|
477
622
|
/** the sound button and the volume: a volume moved turns the sound on */
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
.rv-word{margin-top:var(--s2)}
|
|
119
119
|
.rv-raised{padding:var(--s2) var(--s3);border:1px solid var(--line);border-left:4px solid var(--warn);border-radius:4px;background:var(--panel)}
|
|
120
120
|
.rv-raisedimg{display:block;max-width:100%;max-height:320px;margin-top:var(--s2);border-radius:4px}
|
|
121
|
-
.rv-pairgrid{
|
|
121
|
+
.rv-pairgrid{display:grid;gap:var(--s2)}
|
|
122
122
|
.rv-cell{display:flex;flex-direction:column;align-items:center;gap:var(--s0);min-width:0}
|
|
123
123
|
.rv-frame{position:relative;width:100%;max-width:100%;border-radius:4px;border:1px solid var(--line);overflow:hidden;
|
|
124
124
|
background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/16px 16px}
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
.rv-tile{display:flex;flex-direction:column;gap:var(--s1);padding:var(--s1);border-radius:6px;border:2px solid var(--line)}
|
|
149
149
|
.rv-tile[class*=' re-']{border-color:var(--tone);background:color-mix(in srgb,var(--tone) 8%,transparent)}
|
|
150
150
|
.rv-tile.big{width:100%}
|
|
151
|
-
.rv-tileimg{border-radius:4px;cursor:zoom-in;
|
|
151
|
+
.rv-tileimg{border-radius:4px;cursor:zoom-in;width:100%;background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/12px 12px}
|
|
152
152
|
.rv-tile.big .rv-tileimg{cursor:zoom-out}
|
|
153
153
|
.rv-tileimg img{display:block;width:100%;height:100%;object-fit:contain}
|
|
154
154
|
.rv-tilelabel{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -159,19 +159,62 @@
|
|
|
159
159
|
.rv-sound button{padding:0 var(--s1);line-height:22px}
|
|
160
160
|
.rv-sound input{width:72px}
|
|
161
161
|
.rv-phonepair{display:flex;flex-direction:column;gap:var(--s2)}
|
|
162
|
+
/* options: one thin row a view, one below the other; the row playing or last touched has the line, the picked one its tone */
|
|
163
|
+
.rv-opts{display:flex;flex-direction:column;gap:var(--s1)}
|
|
164
|
+
.rv-opt{scroll-margin-top:8px;padding:var(--s1) var(--s2);border-radius:6px;border:1px solid var(--line);border-left:4px solid transparent;background:var(--panel)}
|
|
165
|
+
.rv-opt.active{border-left-color:var(--accent)}
|
|
166
|
+
.rv-opt[class*=' re-']{border-left-color:var(--tone)}
|
|
167
|
+
.rv-opt.picked{--tone:var(--ok);border-color:var(--tone);border-left-color:var(--tone);background:color-mix(in srgb,var(--tone) 9%,var(--panel))}
|
|
168
|
+
.rv-optrow{display:flex;flex-wrap:wrap;gap:var(--s1) var(--s2);align-items:center;min-height:32px}
|
|
169
|
+
.rv-optn{flex:none;width:16px;text-align:right;font:600 12px ui-monospace,Menlo,monospace;color:var(--dim)}
|
|
170
|
+
.rv-optplay{flex:none;width:32px;height:28px;padding:0;font-size:13px}
|
|
171
|
+
.rv-optplay.on{color:var(--bg);background:var(--accent);border-color:var(--accent)}
|
|
172
|
+
.rv-optlabel{flex:0 1 auto;min-width:48px;max-width:30%;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
173
|
+
.rv-optthumb{flex:none;height:32px;max-width:72px;border-radius:3px;overflow:hidden;cursor:zoom-in;border:1px solid var(--line);background:var(--panel2)}
|
|
174
|
+
.rv-optthumb.img{width:48px}
|
|
175
|
+
.rv-optthumb img{display:block;width:100%;height:100%;object-fit:cover}
|
|
176
|
+
.rv-strip{position:relative;flex:1 1 140px;min-width:120px;height:28px;border-radius:3px;cursor:pointer;touch-action:none;background:var(--panel2);border:1px solid var(--line);overflow:hidden}
|
|
177
|
+
.rv-stripdone{position:absolute;inset:0 auto 0 0;background:var(--wash)}
|
|
178
|
+
/* a waveform is a mask filled with tokens: --dim to play (5.0:1 on --panel2 light, 5.3:1 dark), --accent played
|
|
179
|
+
(4.9:1 both) */
|
|
180
|
+
.rv-stripwave{position:absolute;inset:0;background:var(--dim)}
|
|
181
|
+
.rv-stripwave.done{background:var(--accent)}
|
|
182
|
+
.rv-stripwave.full{background:var(--accent)}
|
|
183
|
+
.rv-wavebox{position:relative;width:100%;border-radius:4px;background:var(--panel2)}
|
|
184
|
+
.rv-striphead{position:absolute;top:0;bottom:0;width:2px;transform:translateX(-1px);background:var(--accent)}
|
|
185
|
+
.rv-opttime{flex:none;font-size:11px;color:var(--dim)}
|
|
186
|
+
.rv-optacts{flex:none;display:inline-flex;gap:var(--s1);align-items:center;margin-left:auto}
|
|
187
|
+
.rv-optacts > button{padding:0 var(--s1);line-height:22px}
|
|
188
|
+
.rv-optacts > button.on{border-color:var(--accent)}
|
|
189
|
+
.rv-stars{display:inline-flex}
|
|
190
|
+
.rv-stars button{border:0;background:none;padding:0;font-size:16px;line-height:22px;color:var(--dim)}
|
|
191
|
+
.rv-stars button.on{color:var(--warn)}
|
|
192
|
+
.rv-pick{font-weight:700;padding:0 var(--s3)!important;color:var(--ok);border-color:color-mix(in srgb,var(--ok) 55%,var(--line))}
|
|
193
|
+
.rv-pick.on{color:var(--bg);background:var(--ok);border-color:var(--ok)}
|
|
194
|
+
.rv-optnote{margin:var(--s0) 0 0 var(--s4);font-size:12.5px;color:var(--ink)}
|
|
195
|
+
.rv-opt .rv-word{margin:var(--s1) 0 0 var(--s4);width:calc(100% - var(--s4))}
|
|
196
|
+
.rv-optbody{margin-top:var(--s2)}
|
|
162
197
|
|
|
163
198
|
/* ── the answer ── */
|
|
164
199
|
.rv-form{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
|
|
165
|
-
.rv-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
.rv-
|
|
169
|
-
.rv-
|
|
170
|
-
.rv-
|
|
200
|
+
.rv-title{margin:0;font-size:16px;line-height:1.3;color:var(--ink)}
|
|
201
|
+
/* the card's header: the title, one tight line of state, then what the agent says at full ink; --dim holds 5.6:1 or
|
|
202
|
+
more on --bg and --panel in both themes */
|
|
203
|
+
.rv-head{display:flex;flex-direction:column;gap:var(--s1)}
|
|
204
|
+
.rv-meta{display:flex;flex-wrap:wrap;gap:var(--s1) var(--s2);align-items:center;font-size:12px;color:var(--dim);line-height:18px}
|
|
205
|
+
.rv-meta .rv-badge{font-size:10px;line-height:18px;max-width:none}
|
|
206
|
+
.rv-copyid{font-size:11.5px;padding:0 var(--s1);line-height:18px;color:var(--dim)}
|
|
207
|
+
.rv-copyid:hover{color:var(--ink)}
|
|
208
|
+
.rv-what{font-size:13.5px;line-height:1.45;color:var(--ink)}
|
|
209
|
+
.rv-what ul{margin:var(--s0) 0;padding-left:var(--s4)}
|
|
210
|
+
.rv-what p{margin:var(--s0) 0}
|
|
211
|
+
.rv-why{color:var(--dim);font-size:12px}
|
|
212
|
+
.rv-headverdicts{display:flex;flex-wrap:wrap;gap:var(--s1);align-items:center;margin-top:var(--s1)}
|
|
213
|
+
.rv-headverdicts .rv-verdict{padding:0 var(--s2);line-height:24px;font-size:12.5px}
|
|
171
214
|
.rv-keeps{color:var(--ok);font-size:13px}
|
|
172
215
|
.rv-facts{display:flex;flex-direction:column}
|
|
173
|
-
.rv-fact{display:flex;gap:var(--s2);align-items:center;min-height:
|
|
174
|
-
.rv-factl{width:64px;flex:none;color:var(--dim);font-size:
|
|
216
|
+
.rv-fact{display:flex;gap:var(--s2);align-items:center;min-height:22px;font-size:12.5px}
|
|
217
|
+
.rv-factl{width:64px;flex:none;color:var(--dim);font-size:12px}
|
|
175
218
|
.rv-factv{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
176
219
|
.rv-linked{padding:var(--s1) var(--s2);border:1px solid var(--line);border-radius:4px;background:var(--panel)}
|
|
177
220
|
.rv-linkedrow{display:flex;gap:var(--s2);align-items:center;font-size:12px}
|
|
@@ -208,6 +251,16 @@
|
|
|
208
251
|
.rv-verdictbar{flex:none;display:flex;gap:var(--s1);padding:var(--s2);border-top:1px solid var(--line);background:var(--panel)}
|
|
209
252
|
.rv-verdictbar .rv-verdict{flex:1;text-align:center;font-size:14px;padding:var(--s2) var(--s1);color:var(--tone)}
|
|
210
253
|
.rv-verdictbar .rv-verdict.chosen{color:var(--bg)}
|
|
254
|
+
.rv-phone .rv-views{gap:var(--s3)}
|
|
255
|
+
/* a row on a phone: ▶, the label and the time on one line, the strip and the acts under them */
|
|
256
|
+
.rv-phone .rv-optrow{flex-wrap:wrap;row-gap:var(--s1)}
|
|
257
|
+
.rv-phone .rv-optlabel{flex:1;max-width:none}
|
|
258
|
+
.rv-phone .rv-strip{order:5;flex:1 1 100%;height:32px}
|
|
259
|
+
.rv-phone .rv-optthumb{order:4}
|
|
260
|
+
.rv-phone .rv-optacts{order:6;flex:1 1 100%;justify-content:space-between}
|
|
261
|
+
.rv-phone .rv-stars button{font-size:22px;padding:0 var(--s1)}
|
|
262
|
+
.rv-phone .rv-optplay{width:40px;height:34px}
|
|
263
|
+
.rv-phone .rv-optnote,.rv-phone .rv-opt .rv-word{margin-left:0;width:100%}
|
|
211
264
|
|
|
212
265
|
/* ── the trailer page ── */
|
|
213
266
|
.tr-cuts{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
|
|
@@ -323,6 +376,13 @@
|
|
|
323
376
|
.rv-boardrow .rv-linkedtitle{display:block}
|
|
324
377
|
.rv-kindview{display:flex;flex-direction:column;gap:var(--s2);margin:var(--s2) 0}
|
|
325
378
|
.rv-kindtext{font:12px/1.5 ui-monospace,Menlo,monospace;background:var(--panel);border:1px solid var(--line);border-radius:3px;padding:var(--s2) var(--s3);margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
379
|
+
.rv-part{border:1px solid var(--line);border-radius:4px;padding:var(--s2) var(--s3);margin-top:var(--s2);background:var(--panel)}
|
|
380
|
+
.rv-parthead{display:flex;align-items:center;gap:var(--s2);margin-bottom:var(--s2)}
|
|
381
|
+
.rv-particon{width:20px;height:20px;border-radius:5px;flex:none}
|
|
382
|
+
.rv-partbadge{margin-left:auto;font-size:11px;font-weight:700;border:1px solid;border-radius:3px;padding:0 var(--s1)}
|
|
383
|
+
.rv-partbody{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--s3);align-items:start}
|
|
384
|
+
.rv-partbody img,.rv-partbody video{width:100%;max-height:320px;object-fit:contain;border-radius:6px;display:block}
|
|
385
|
+
.rv-parttext{font-size:13px;line-height:1.45;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
326
386
|
.rv-anim select{font-size:12px}
|
|
327
387
|
/* a sound view (review-views.tsx SoundBody): its waveform over the player, a row a side on a pair */
|
|
328
388
|
.rv-sound{display:flex;flex-direction:column;gap:var(--s2)}
|