rvlib-shipkit 2.7.0 → 2.9.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.
@@ -1,13 +1,15 @@
1
1
  // `shipkit fleet --update`: hand each behind repo its own update. The update
2
2
  // is NOT performed here. Each repo runs its own binary, which is the only copy
3
3
  // whose SK005 matches the rules that repo is pinned to, and the only one
4
- // holding that repo's mutate lease.
4
+ // holding that repo's mutate lease. What it does write is the commit of the
5
+ // bump each repo made, and its push (commit-bump.ts).
5
6
  import { existsSync } from 'fs'
6
7
  import { join } from 'path'
7
8
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
8
9
  import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
9
- import { ALLOW_MAJOR_ENV, breakingAdvice } from '../core/update-policy.ts'
10
+ import { ALLOW_MAJOR_ENV, breakingAdvice, FLEET_UPDATE_ENV } from '../core/update-policy.ts'
10
11
  import { isBehind, judgedVersion, updateRefusal, type FleetRepo } from './scan-fleet.ts'
12
+ import { bumpBaseline, commitBump } from './commit-bump.ts'
11
13
 
12
14
 
13
15
  export const short = (name: string): string => name.replace(/^rvlib-/, '')
@@ -23,26 +25,36 @@ export type FleetUpdateOpts = {
23
25
  /** cross a breaking boundary too (`--major`) */
24
26
  major: boolean
25
27
  print: (line: string) => void
28
+ /** commit and push each repo's bump (fleet/commit-bump.ts); `--no-commit` leaves it in the tree */
29
+ commit?: boolean
30
+ /** runs the repo's own binary (tests pass a fake) */
31
+ spawn?: typeof spawnWithTimeout
26
32
  /** per repo run (default 10 minutes: a fresh health pass probes the world) */
27
33
  timeoutMs?: number
28
34
  }
29
35
 
30
- /** returns how many repos did not update. A repo whose gate refused (dirty
31
- * tree, frozen, a lease held elsewhere) is a REPORT, not a failure of the
32
- * command: SK005 defers on purpose */
33
36
  /** the env of one repo's guarded run. It stays in its own process
34
37
  * (SHIPKIT_NO_HOME): a check the home daemon answers never sees this env, so
35
38
  * --major would be dropped on the way */
36
39
  export function fleetEnv(base: Record<string, string | undefined>, major: boolean): Record<string, string> {
37
40
  const env: Record<string, string> = Object.fromEntries(Object.entries(base).filter((e): e is [string, string] => e[1] != null && e[0] !== 'SHIPKIT_SELF_UPDATED' && e[0] !== ALLOW_MAJOR_ENV))
38
41
  env['SHIPKIT_NO_HOME'] = '1'
42
+ env[FLEET_UPDATE_ENV] = '1'
39
43
  if (major) env[ALLOW_MAJOR_ENV] = '1'
40
44
  return env
41
45
  }
42
46
 
43
- export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Promise<number> {
47
+ /** `notMoved`: repos that did not update. A repo whose gate refused (dirty
48
+ * tree, frozen, a lease held elsewhere) is a REPORT, not a failure of the
49
+ * command: SK005 defers on purpose. `failed`: a commit a hook refused or a
50
+ * push that failed, the lines the operator must act on */
51
+ export type FleetUpdateResult = { notMoved: number; committed: number; failed: number }
52
+
53
+ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Promise<FleetUpdateResult> {
44
54
  const w = o.print
45
55
  let failed = 0
56
+ let committed = 0
57
+ let broke = 0
46
58
  for (const r of behind) {
47
59
  // the same policy SK005 applies inside the repo: a breaking jump is reported,
48
60
  // never taken, and a safe jump beside it still moves
@@ -71,8 +83,9 @@ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Prom
71
83
  // way around it: SK005 has no autofix, so the binary refuses the id.
72
84
  // The re-exec guard is dropped so the repo's own startup self-update
73
85
  // can still fire, and --major travels as the one env the policy reads.
86
+ const before = o.commit === true ? await bumpBaseline(r.dir) : null
74
87
  const env = fleetEnv(process.env, o.major === true)
75
- const res = await spawnWithTimeout(['bun', cli, 'check', '--fresh'], { cwd: r.dir, env, timeoutMs: o.timeoutMs ?? 600_000 })
88
+ const res = await (o.spawn ?? spawnWithTimeout)(['bun', cli, 'check', '--fresh'], { cwd: r.dir, env, timeoutMs: o.timeoutMs ?? 600_000 })
76
89
  // judge by what node_modules carries now (the pin, for a linked dep),
77
90
  // never by the exit code or a printed line: the code covers every rule
78
91
  // in that repo, and the line format belongs to whatever version that repo runs
@@ -82,13 +95,29 @@ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Prom
82
95
  })
83
96
  if (stuck.length === 0) {
84
97
  w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)}${d.linkedInstall ? ' pin' : ''} ${d.at} → ${judgedVersion(r.dir, d.name)}`).join(', ')}`)
98
+ if (o.commit === true) {
99
+ const c = await commitBump({ name: r.name, dir: r.dir }, wanted.map((d) => d.name), before)
100
+ w(c.line)
101
+ if (c.status === 'pushed') committed++
102
+ if (c.status === 'fail') broke++
103
+ }
85
104
  continue
86
105
  }
87
106
  failed++
107
+ // what did move is committed: left in the tree, the next run reads it
108
+ // as someone else's work
109
+ const moved = wanted.filter((d) => !stuck.includes(d))
110
+ if (o.commit === true && moved.length > 0) {
111
+ const c = await commitBump({ name: r.name, dir: r.dir }, moved.map((d) => d.name), before)
112
+ w(c.line)
113
+ if (c.status === 'pushed') committed++
114
+ if (c.status === 'fail') broke++
115
+ }
88
116
  const said = res.out.split('\n').find((l) => /\brvlib-/.test(l) && (l.startsWith('WARN') || l.startsWith('FAIL')))
89
117
  w(`WARN ${r.name} did not move ${stuck.map((d) => short(d.name)).join(', ')} — ${said ?? `check exited ${res.code}${res.timedOut ? ', timed out' : ''} with no rvlib line`}`)
90
118
  w(` fix: cd ${r.dir} && ${manualUpdate(r)}`)
91
119
  }
92
- w(failed === 0 ? `ok: ${behind.length} repo(s) updated through their own gate` : `WARN ${failed}/${behind.length} repo(s) did not update — each line above says why`)
93
- return failed
120
+ const commits = o.commit === true ? `, ${committed} committed${broke > 0 ? `, ${broke} FAILED to commit or push` : ''}` : ''
121
+ w(failed === 0 ? `ok: ${behind.length} repo(s) updated through their own gate${commits}` : `WARN ${failed}/${behind.length} repo(s) did not update — each line above says why${commits}`)
122
+ return { notMoved: failed, committed, failed: broke }
94
123
  }
@@ -0,0 +1,52 @@
1
+ // the hub's one text box that grows with its text: never an inner scrollbar, unless a sheet caps its height
2
+ // (max-height), past which it scrolls. Every multi line field of the hub is this one
3
+ import { useCallback, useLayoutEffect, useRef, type TextareaHTMLAttributes } from 'react'
4
+
5
+ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange' | 'rows'> & {
6
+ value: string
7
+ onText: (text: string) => void
8
+ /** the height it never shrinks under, in lines */
9
+ rows?: number
10
+ /** the element, for a caller that focuses it */
11
+ el?: (t: HTMLTextAreaElement | null) => void
12
+ }
13
+
14
+ /** a textarea at the height of its text: its value, a new width, a font that loads all size it again */
15
+ export function AutoText({ value, onText, rows = 1, el, className, ...rest }: Props) {
16
+ const ref = useRef<HTMLTextAreaElement | null>(null)
17
+ const size = useCallback((): void => {
18
+ const t = ref.current
19
+ if (t == null) return
20
+ t.style.height = 'auto'
21
+ const edge = t.offsetHeight - t.clientHeight
22
+ t.style.height = `${t.scrollHeight + edge}px`
23
+ // a sheet's max-height stops it: only then may it scroll
24
+ t.style.overflowY = t.scrollHeight > t.clientHeight + 1 ? 'auto' : 'hidden'
25
+ }, [])
26
+ useLayoutEffect(size, [value, size])
27
+ useLayoutEffect(() => {
28
+ const t = ref.current
29
+ if (t == null || typeof ResizeObserver === 'undefined') return
30
+ let w = t.clientWidth
31
+ const ro = new ResizeObserver(() => {
32
+ if (t.clientWidth === w) return
33
+ w = t.clientWidth
34
+ size()
35
+ })
36
+ ro.observe(t)
37
+ return () => ro.disconnect()
38
+ }, [size])
39
+ return (
40
+ <textarea
41
+ {...rest}
42
+ ref={(t) => {
43
+ ref.current = t
44
+ el?.(t)
45
+ }}
46
+ className={className ? `autotext ${className}` : 'autotext'}
47
+ rows={rows}
48
+ value={value}
49
+ onChange={(e) => onText(e.currentTarget.value)}
50
+ />
51
+ )
52
+ }
@@ -225,6 +225,8 @@ main{display:flex;flex-direction:column;min-height:0;overflow:hidden}
225
225
  .bar{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s2) var(--s4);border-bottom:1px solid var(--line);background:var(--panel)}
226
226
  textarea{width:100%;min-height:64px;background:var(--bg);color:var(--ink);border:1px solid var(--line);
227
227
  border-radius:3px;padding:var(--s2) var(--s3);font:12px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
228
+ /* auto-text.tsx: the box is as tall as its text, so it never drags nor scrolls (a sheet's max-height may cap it) */
229
+ textarea.autotext{box-sizing:border-box;min-height:0;resize:none;overflow-y:hidden}
228
230
  .promptrow{display:flex;gap:var(--s1);align-items:flex-start}
229
231
  .promptrow .fold{flex:none;background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-radius:3px;
230
232
  padding:var(--s1) var(--s2);font-size:11px;line-height:1.5}
@@ -6,6 +6,7 @@ import { runInAction } from 'mobx'
6
6
  import { observer } from 'mobx-react-lite'
7
7
  import { useEffect, useRef, useState, type DragEvent, type ReactNode } from 'react'
8
8
  import type { RepoSt } from './repo-st.ts'
9
+ import { AutoText } from './auto-text.tsx'
9
10
  import { isAud, type MediaSvc } from './media-svc.ts'
10
11
  import { GeometryLayer, Rectbar, measureBox, type Box } from './geometry-view.tsx'
11
12
  import type { CatRow, CustomPanelRef } from './hub-types.ts'
@@ -17,17 +18,8 @@ const MIX_GROUND = 44
17
18
  export const MediaTopbar = observer(({ app }: { app: RepoSt }) => {
18
19
  const m = app.media
19
20
  const fileRef = useRef<HTMLInputElement>(null)
20
- const taRef = useRef<HTMLTextAreaElement>(null)
21
21
  const manual = m.cur?.manual === true
22
22
  const det = m.deterministic
23
- // auto-grow on EVERY prompt change, programmatic (selection) included —
24
- // the css max-height caps it, overflow scrolls only past ~14 lines
25
- useEffect(() => {
26
- const t = taRef.current
27
- if (t == null) return
28
- t.style.height = 'auto'
29
- t.style.height = `${t.scrollHeight + 2}px`
30
- }, [m.prompt, m.promptOpen])
31
23
  return (
32
24
  <div className="bar">
33
25
  {/* the prompt AUTOSAVES (debounced), no save button, no fear.
@@ -36,8 +28,8 @@ export const MediaTopbar = observer(({ app }: { app: RepoSt }) => {
36
28
  <button className="fold" title={m.promptOpen ? 'fold the prompt' : 'expand the prompt'}
37
29
  onClick={() => m.togglePrompt()}>{m.promptOpen ? '▾' : '▸'}</button>
38
30
  {m.promptOpen
39
- ? <textarea className="prompt" ref={taRef} value={m.prompt} placeholder="select an asset ←"
40
- onChange={(e) => m.setPrompt(e.target.value)} />
31
+ // it grows on every prompt change, a selection's included; the css max-height caps it near 14 lines
32
+ ? <AutoText className="prompt" value={m.prompt} placeholder="select an asset ←" onText={(t) => m.setPrompt(t)} />
41
33
  : <div className="prompt folded" onClick={() => m.togglePrompt()}>
42
34
  {/* placeholder only when NOTHING is selected, a selected
43
35
  asset with an empty prompt shows an empty line */}
@@ -2,6 +2,7 @@
2
2
  // goes to the agent, the verdicts (the phone's in a bar under the thumb), then the thread's conversation and his reply
3
3
  // box. When the page reads in one column the header sits over the views instead (review-views.tsx)
4
4
  import { observer } from 'mobx-react-lite'
5
+ import { AutoText } from '../auto-text.tsx'
5
6
  import { Icon, ICON } from '../icons.tsx'
6
7
  import { laneOf, placeText, STATUS_LABEL, stateOf, verdictOf, viewName, withdrawnOf, type ReviewItem, type ReviewStatus } from '../../../review/model.ts'
7
8
  import { renderMd } from '../md.ts'
@@ -98,7 +99,7 @@ export const CardHead = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) =>
98
99
  <Listeners rv={rv} cards={[x]} />
99
100
  </div>
100
101
  ) : null}
101
- <textarea className="rv-comment" rows={2} placeholder="your overall word (a verdict sends it)" value={draft.comment} onKeyDown={blurOnEscape} onChange={(e) => rv.setComment(e.currentTarget.value)} />
102
+ <AutoText className="rv-comment" rows={2} placeholder="your overall word (a verdict sends it)" value={draft.comment} onKeyDown={blurOnEscape} onText={(t) => rv.setComment(t)} />
102
103
  </div>
103
104
  )
104
105
  })
@@ -175,7 +176,7 @@ const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
175
176
  ? 'React to a view (OK, fix, no, a word), or drag on the clip to circle a detail: it pauses, the rectangle keeps its moment.'
176
177
  : rv.phone.on
177
178
  ? 'React to a view (OK, fix, no, a word), or turn « Circle » on above the image, then drag to circle a detail.'
178
- : 'React to a view (OK, fix, no, a word), or drag on the image to circle a detail.'}
179
+ : 'React to a view (OK, fix, no, a word), or drag on the image to circle a detail, then write beside it.'}
179
180
  </div>
180
181
  ) : null}
181
182
  {draft.pick !== undefined ? (
@@ -192,8 +193,8 @@ const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
192
193
  <button className={`rv-outbadge${q.status ? ` re-${q.status} on` : ''}`} title="see this image" onClick={() => { rv.setView(q.view); jumpTo(q.view, q.tile) }}>
193
194
  {o?.glyph ?? (q.stars ? '' : '·')}{q.stars ? `★${q.stars}` : ''} {placeText(q, views).slice(1, -1)}
194
195
  </button>
195
- <textarea rows={1} placeholder={o ? `${o.label}, with no word` : 'a word'} value={q.comment} onKeyDown={blurOnEscape}
196
- onChange={(e) => rv.setReactionComment(q.view, q.tile, e.currentTarget.value)} />
196
+ <AutoText placeholder={o ? `${o.label}, with no word` : 'a word'} value={q.comment} onKeyDown={blurOnEscape}
197
+ onText={(t) => rv.setReactionComment(q.view, q.tile, t)} />
197
198
  <button className="ghost rv-x" aria-label="remove this reaction" onClick={() => rv.removeReaction(q.view, q.tile)}>✕</button>
198
199
  </div>
199
200
  )
@@ -203,8 +204,8 @@ const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
203
204
  <button className={`rv-outbadge rect${rv.rect === i ? ' on' : ''}`} title="see this rectangle" onClick={() => { rv.pickRect(i); jumpTo(q.view ?? 0) }}>
204
205
  ⬚ {i + 1}{views.length > 1 ? ` · ${viewName(views[q.view ?? 0], q.view ?? 0)}` : ''}{q.side ? ` ${SIDE_LABEL[q.side]}` : ''}{q.t !== undefined ? ` · ${secs(q.t)}` : ''}
205
206
  </button>
206
- <textarea rows={1} placeholder="what is wrong here" value={q.comment} onFocus={() => rv.pickRect(i)} onKeyDown={blurOnEscape}
207
- onChange={(e) => rv.setRectComment(i, e.currentTarget.value)} />
207
+ <AutoText placeholder="what is wrong here" value={q.comment} onFocus={() => rv.pickRect(i)} onKeyDown={blurOnEscape}
208
+ onText={(t) => rv.setRectComment(i, t)} />
208
209
  <button className="ghost rv-x" aria-label="delete this rectangle" onClick={() => rv.removeRect(i)}>✕</button>
209
210
  </div>
210
211
  ))}
@@ -235,7 +236,7 @@ const Messages = observer(({ rv }: { rv: ReviewSt }) => {
235
236
  </div>
236
237
  )
237
238
  })}
238
- <textarea className="rv-reply" rows={2} placeholder="Answer the agent (⌘Enter sends)" value={rv.reply} onChange={(e) => rv.setReply(e.currentTarget.value)}
239
+ <AutoText className="rv-reply" rows={2} placeholder="Answer the agent (⌘Enter sends)" value={rv.reply} onText={(t) => rv.setReply(t)}
239
240
  onKeyDown={(e) => {
240
241
  if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
241
242
  e.preventDefault()
@@ -1,9 +1,10 @@
1
1
  // a repo's review page (`#page:review`, wiki build/shipkit/review.md, The review page): the cards on the left, the open
2
2
  // one in the middle with every view stacked, the answer on the right; `top` (or `auto` narrower than STACK_PX) reads in
3
- // one column from the top: the header and the verdicts, the views, the answer. On a phone one pane at a time, the verdicts in a bar under the thumb. The list is read while the page
4
- // shows
3
+ // one column from the top: the header and the verdicts, the views, the answer. On a phone the cards or the card (its
4
+ // views, then its answer), the verdicts in a bar under the thumb. Another card opens at its top. The list is read while
5
+ // the page shows
5
6
  import { observer } from 'mobx-react-lite'
6
- import { useEffect, useRef } from 'react'
7
+ import { useEffect, useRef, type ReactNode } from 'react'
7
8
  import { Group, Panel, Separator } from 'react-resizable-panels'
8
9
  import type { RepoSt } from '../repo-st.ts'
9
10
  import { ReviewForm, VerdictBar } from './review-answer.tsx'
@@ -11,7 +12,7 @@ import { ReviewList } from './review-list.tsx'
11
12
  import type { PhonePane, ReviewSt } from './review-st.ts'
12
13
  import { ReviewImage } from './review-views.tsx'
13
14
 
14
- const PANE_LABEL: Record<PhonePane, string> = { list: 'cards', card: 'card', answer: 'answer' }
15
+ const PANE_LABEL: Record<PhonePane, string> = { list: 'cards', card: 'card' }
15
16
 
16
17
  export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
17
18
  const rv = app.review
@@ -41,15 +42,20 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
41
42
  <div className="rv-phone">
42
43
  <PhoneTop app={app} />
43
44
  <div className="rv-phonetabs seg">
44
- {(['list', 'card', 'answer'] as const).map((p) => (
45
+ {(['list', 'card'] as const).map((p) => (
45
46
  <button key={p} className={rv.phone.pane === p ? 'on' : ''} onClick={() => rv.phone.show(p)}>
46
47
  {PANE_LABEL[p]}{p === 'list' && rv.pendingCount > 0 ? ` · ${rv.pendingCount}` : ''}
47
48
  </button>
48
49
  ))}
49
50
  </div>
50
- <div className="rv-phonepane">
51
- {rv.phone.pane === 'list' ? <ReviewList rv={rv} /> : rv.phone.pane === 'card' ? <ReviewImage rv={rv} /> : <ReviewForm rv={rv} />}
52
- </div>
51
+ {rv.phone.pane === 'list' ? (
52
+ <div className="rv-phonepane"><ReviewList rv={rv} /></div>
53
+ ) : (
54
+ <CardScroll rv={rv} className="rv-phonepane">
55
+ <ReviewImage rv={rv} />
56
+ <ReviewForm rv={rv} />
57
+ </CardScroll>
58
+ )}
53
59
  {rv.phone.pane === 'list' ? null : <VerdictBar rv={rv} />}
54
60
  <Toast rv={rv} />
55
61
  </div>
@@ -65,19 +71,19 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
65
71
  {rv.stacked ? (
66
72
  // one column read from the top: the header and the verdicts, the views, then the answer
67
73
  <Panel id="review-stack" defaultSize="68%" minSize={300} className="rv-pane" style={PANEL}>
68
- <div className="rv-scroll">
74
+ <CardScroll rv={rv} className="rv-scroll">
69
75
  <ReviewImage rv={rv} />
70
76
  <ReviewForm rv={rv} />
71
- </div>
77
+ </CardScroll>
72
78
  </Panel>
73
79
  ) : (
74
80
  <>
75
81
  <Panel id="review-image" defaultSize="54%" minSize={300} className="rv-pane" style={PANEL}>
76
- <div className="rv-scroll"><ReviewImage rv={rv} /></div>
82
+ <CardScroll rv={rv} className="rv-scroll"><ReviewImage rv={rv} /></CardScroll>
77
83
  </Panel>
78
84
  <Separator className="rsep" />
79
85
  <Panel id="review-form" defaultSize="26%" minSize={260} className="rv-pane" style={PANEL}>
80
- <div className="rv-scroll"><ReviewForm rv={rv} /></div>
86
+ <CardScroll rv={rv} className="rv-scroll"><ReviewForm rv={rv} /></CardScroll>
81
87
  </Panel>
82
88
  </>
83
89
  )}
@@ -96,6 +102,16 @@ export const PhoneTop = observer(({ app }: { app: RepoSt }) => (
96
102
  </div>
97
103
  ))
98
104
 
105
+ /** a column of the open card: another card opens at its top, never where the last one was scrolled */
106
+ const CardScroll = observer(({ rv, className, children }: { rv: ReviewSt; className: string; children: ReactNode }) => {
107
+ const el = useRef<HTMLDivElement | null>(null)
108
+ const id = rv.open?.item.id
109
+ useEffect(() => {
110
+ if (el.current) el.current.scrollTop = 0
111
+ }, [id])
112
+ return <div ref={el} className={className}>{children}</div>
113
+ })
114
+
99
115
  /** what the last copy put on the clipboard, a moment over the page */
100
116
  const Toast = observer(({ rv }: { rv: ReviewSt }) => (rv.toast ? <div className="rv-toast" role="status">{rv.toast}</div> : null))
101
117
 
@@ -77,7 +77,7 @@ export const STACK_PX = 900
77
77
  /** where the card sits against its answer: on top of it, left of it, or by the page's width */
78
78
  export const CARD_PLACES = ['top', 'left', 'auto'] as const
79
79
  export type CardPlace = (typeof CARD_PLACES)[number]
80
- export type PhonePane = 'list' | 'card' | 'answer'
80
+ export type PhonePane = 'list' | 'card'
81
81
  export type PairSide = 'before' | 'after' | 'swipe'
82
82
 
83
83
  const POLL_MS = 2000
@@ -87,6 +87,8 @@ const SOUND_FPS = 10
87
87
  const SOUND_JUMP = 2
88
88
 
89
89
  const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
90
+ /** a draft card's frame on its file urls: a new frame is a new url, past the browser's cache of the old one */
91
+ const frameTag = (x: ReviewItem | null | undefined): string => (x?.item.wip ? `?f=${x.item.wip.frame}` : '')
90
92
 
91
93
  /** what a reload must not lose, under keys of this repo: a blocked storage only loses persistence */
92
94
  const store = {
@@ -232,8 +234,10 @@ export class ReviewSt {
232
234
  whose: Move | null = null
233
235
  status: ShownStatus | null = null
234
236
  drafts: Record<string, Draft> = {}
235
- /** the rectangle of the open card picked on the image, its index */
237
+ /** the rectangle of the open card picked, its index: its note box opens beside it */
236
238
  rect: number | null = null
239
+ /** the rectangle whose note box takes the focus once it shows: one just drawn, or picked on the image */
240
+ noteFocus: number | null = null
237
241
  /** the subject rings over the image (M) */
238
242
  marksOn = true
239
243
  /** the open card's active view, its index: the one last drawn on or picked, whose clip the keys drive */
@@ -242,9 +246,11 @@ export class ReviewSt {
242
246
  viewZoom: Record<number, Zoom> = {}
243
247
  /** the gallery tile shown large across the column, `<view>/<tile>` */
244
248
  bigTile: string | null = null
249
+ /** a draft card's past frame shown in place of its live one, an index of its history; null is now */
250
+ frame: number | null = null
245
251
  /** a card of options shown full, every view stacked, by id: his switch, this visit */
246
252
  full: Record<string, boolean> = {}
247
- /** the option rows unfolded to their full view, by view index */
253
+ /** an option row he folded or unfolded, by view index; a row he never touched follows `rowOpen` */
248
254
  openRows: Record<number, boolean> = {}
249
255
  replies: Record<string, string> = {}
250
256
  private reading = new Set<string>()
@@ -300,8 +306,16 @@ export class ReviewSt {
300
306
  get headInViews(): boolean {
301
307
  return this.stacked || this.phone.on || this.choice
302
308
  }
309
+ /** an option row shows its full view under it: from the start for an image, a pair or a clip, never for a sound
310
+ * (its strip is its view), else as he folded it */
311
+ rowOpen(i: number): boolean {
312
+ const own = this.openRows[i]
313
+ if (own !== undefined) return own
314
+ const v = this.open?.item.views[i]
315
+ return v !== undefined && (isGallery(v) || v.audio === undefined)
316
+ }
303
317
  toggleRow(i: number): void {
304
- this.openRows = { ...this.openRows, [i]: !this.openRows[i] }
318
+ this.openRows = { ...this.openRows, [i]: !this.rowOpen(i) }
305
319
  }
306
320
 
307
321
  constructor(private readonly st: RepoSt) {
@@ -407,12 +421,14 @@ export class ReviewSt {
407
421
  this.board = on
408
422
  }
409
423
 
424
+ /** a card's file; a draft card's carries its frame, so each new frame is a new url the browser loads again */
410
425
  fileUrl(id: string, name: string): string {
411
- return this.st.url(`/api/review/${id}/${name}`)
426
+ const x = this.open?.item.id === id ? this.open : this.list?.items.find((y) => y.item.id === id)
427
+ return `${this.st.url(`/api/review/${id}/${name}`)}${frameTag(x)}`
412
428
  }
413
429
 
414
430
  thumbUrl(x: ReviewItem, name: string): string {
415
- return /\.(png|jpe?g|webp)$/.test(name) ? this.st.url(`/api/review/${x.item.id}/thumb/${name}`) : this.fileUrl(x.item.id, name)
431
+ return /\.(png|jpe?g|webp)$/.test(name) ? `${this.st.url(`/api/review/${x.item.id}/thumb/${name}`)}${frameTag(x)}` : this.fileUrl(x.item.id, name)
416
432
  }
417
433
 
418
434
  threadImageUrl(id: string, name: string): string {
@@ -621,6 +637,7 @@ export class ReviewSt {
621
637
  this.view = 0
622
638
  this.viewZoom = {}
623
639
  this.bigTile = null
640
+ this.frame = null
624
641
  this.openRows = {}
625
642
  // a link to a card on the phone opens the card, never the list
626
643
  if (id !== null && this.phone.on) this.phone.show('card')
@@ -635,6 +652,7 @@ export class ReviewSt {
635
652
  }
636
653
  }
637
654
  this.rect = null
655
+ this.noteFocus = null
638
656
  this.error = null
639
657
  }
640
658
 
@@ -662,6 +680,7 @@ export class ReviewSt {
662
680
  const t = this.clipKey ? { t: Math.round(this.clip.time * 1000) / 1000 } : {}
663
681
  this.edit((d) => ({ ...d, rects: [...d.rects, { ...r, comment: '', ...view, ...(side ? { side } : {}), ...t }] }))
664
682
  this.rect = this.draft.rects.length - 1
683
+ this.noteFocus = this.rect
665
684
  }
666
685
 
667
686
  setView(i: number): void {
@@ -694,6 +713,10 @@ export class ReviewSt {
694
713
  this.viewZoom = { ...this.viewZoom, [i]: z }
695
714
  }
696
715
 
716
+ setFrame(f: number | null): void {
717
+ this.frame = f
718
+ }
719
+
697
720
  toggleTile(view: number, tile: number): void {
698
721
  const k = `${view}/${tile}`
699
722
  this.bigTile = this.bigTile === k ? null : k
@@ -847,9 +870,11 @@ export class ReviewSt {
847
870
  this.marksOn = !this.marksOn
848
871
  }
849
872
 
850
- /** a rectangle picked: its view comes on screen, and on a clip its frame, paused */
851
- pickRect(i: number | null): void {
873
+ /** a rectangle picked: its view comes on screen, and on a clip its frame, paused; `focus` (a click on it on the
874
+ * image) puts the cursor in its note box */
875
+ pickRect(i: number | null, focus = false): void {
852
876
  this.rect = i
877
+ this.noteFocus = focus ? i : null
853
878
  if (i === null) return
854
879
  const r = this.draft.rects[i]
855
880
  this.view = r?.view ?? 0
@@ -857,6 +882,11 @@ export class ReviewSt {
857
882
  this.clip.seek(r.t)
858
883
  }
859
884
 
885
+ /** the note box took the focus it was asked */
886
+ noteFocused(): void {
887
+ this.noteFocus = null
888
+ }
889
+
860
890
  setRectComment(i: number, comment: string): void {
861
891
  this.edit((d) => ({ ...d, rects: d.rects.map((r, j) => (j === i ? { ...r, comment } : r)) }))
862
892
  }
@@ -864,6 +894,7 @@ export class ReviewSt {
864
894
  removeRect(i: number): void {
865
895
  this.edit((d) => ({ ...d, rects: d.rects.filter((_, j) => j !== i) }))
866
896
  this.rect = null
897
+ this.noteFocus = null
867
898
  }
868
899
 
869
900
  setComment(comment: string): void {