rvlib-shipkit 2.8.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 2.9.0
2
+
3
+ - **`sk fleet --update` commits and pushes each bump.** Every repo the update moved gets one commit of package.json and its lock alone, by pathspec, through the repo's own hooks: `chore(deps): rvlib-shipkit <range>`, then the plain push of `sk fleet push`. A repo whose manifest held changes before the update, whose package.json moved more than the bump, mid-merge, mid-rebase, on a detached HEAD or nested in another repo is skipped, its bump left in the tree and the command to run printed. A refused hook, a failed push or a branch behind its upstream is a FAIL line, and the command exits 1. `--no-commit` leaves every bump in its tree, as before.
4
+ - **`sk fleet --update` moves a published package's devDependencies.** A repo that declares `release.npm` was only told its bump (« published to npm, never updated by a check »), so comfy-ts stayed behind on every release. Under the fleet, SK005 now moves its devDependencies; a `dependencies` entry stays named, and a plain gate still never writes either.
5
+ - **The review card: a rectangle's note opens beside it, every image starts full, every text box grows.** A rectangle drawn on an image opens its note box right there, under it (over it when it sits low), numbered and focused, to type in at once, Enter or Escape closes it, a click on the rectangle opens it again, ✕ deletes it; the answer column still lists every note. Each image, pair and clip of a choice or a group shows full under its row from the start (⤢ folds one); a sound stays a row. Every text box of the page (the overall word, a view's or a tile's word, a note, the reply, a trailer note) grows with its text, no inner scrollbar and no drag handle: `AutoText` (`hub-app/auto-text.tsx`), which the atelier's prompt now uses too. On a phone the card pane holds the answer under the views (no third « answer » pane), a field's text is 16 px so the page no longer zooms into it, and the views' labels take the whole width. Another card opens at its top. A draft card's history cycles in place over its live frame again (a slider), and its files carry the frame, so a new frame shows. A clip's sound button and volume sit on one line with its time (a sound view's class had taken the same name).
6
+
1
7
  ## 2.8.0
2
8
 
3
9
  - **A brand's `icon` draws the favicon family at each size.** `icon: canvas(draw)` in `.shipkit/identity/brand.ts` draws `favicon.png` (512), `favicon-32.png`, `favicon-16.png` and `apple-touch-icon.png` (180, flattened on `palette.bg`), each at its own size, `asset.id` naming the file, so a 16 px tab reads a shape made for it, never a shrunk logo. Without one the favicon is the logo master resized, as before.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -40,7 +40,8 @@
40
40
  // exists for: package.json moves, package-lock.json does not, and
41
41
  // the `npm ci` a deploy runs refuses that pair
42
42
  // registry unreachable → loud skip, never red (offline is not a defect)
43
- // Committing the bump stays with the working agent — shipkit never commits.
43
+ // Committing the bump stays with the working agent: a check never commits
44
+ // (`sk fleet --update` commits the bump it asked for, fleet/commit-bump.ts).
44
45
  // Clean passes cache 24h (rule ttl), so the registry is fetched at most
45
46
  // daily per repo; skip/WARN/FAIL runs are never cached. The key carries the
46
47
  // INSTALLED versions, so a manual `bun add -d rvlib-x@N` busts it: keyed on
@@ -51,7 +52,7 @@
51
52
  import { existsSync, readFileSync } from 'fs'
52
53
  import { basename, join } from 'path'
53
54
  import type { CheckCtx } from '../core/check-ctx.ts'
54
- import { allowMajor, breakingAdvice, isBreaking, isLinkedInstall, pinFloor } from '../core/update-policy.ts'
55
+ import { allowMajor, breakingAdvice, fleetAsked, isBreaking, isLinkedInstall, pinFloor } from '../core/update-policy.ts'
55
56
  import { isRunningCopy, rememberLatest } from '../core/self-update.ts'
56
57
  import { rootPublishesNpm } from '../core/repo-facts.ts'
57
58
  import { mutateLease, withMutateLease } from '../core/locked-write.ts'
@@ -153,23 +154,28 @@ export const rvlibVersions = check({
153
154
  }
154
155
  if (stale.length === 0) return
155
156
 
156
- const drift = stale.map((s) => `${s.name}${s.linked ? ' pin' : ''} ${s.installed} → ${s.latest}`).join(', ')
157
- const names = stale.map((s) => s.name)
157
+ const driftOf = (xs: typeof stale): string => xs.map((s) => `${s.name}${s.linked ? ' pin' : ''} ${s.installed} → ${s.latest}`).join(', ')
158
158
  if (isRefusal(pm)) {
159
- c.warn(`newer rvlib live: ${drift}, not updating: ${pm.refused}`)
159
+ c.warn(`newer rvlib live: ${driftOf(stale)}, not updating: ${pm.refused}`)
160
160
  return
161
161
  }
162
- const cmd = pm.update(names)
163
162
  if (process.env['SHIPKIT_FROZEN'] === '1') {
164
- c.warn(`newer rvlib live: ${drift} (frozen — apply with: ${cmd.join(' ')})`)
163
+ c.warn(`newer rvlib live: ${driftOf(stale)} (frozen — apply with: ${pm.update(stale.map((s) => s.name)).join(' ')})`)
165
164
  return
166
165
  }
167
166
  // a package published to npm: its package.json is its public
168
167
  // contract, so the bump is a release decision, named and never made
168
+ // by a check. `sk fleet --update` is the operator naming it, and a
169
+ // devDependency is read by no one who installs the package: it moves
170
+ let moving = stale
169
171
  if (rootPublishesNpm(c.config)) {
170
- c.warn(`newer rvlib live: ${drift} (published to npm, never updated by a check — apply with: ${cmd.join(' ')})`)
171
- return
172
+ const held = fleetAsked() ? stale.filter((s) => pkg.dependencies?.[s.name] != null) : stale
173
+ if (held.length > 0) c.warn(`newer rvlib live: ${driftOf(held)} (published to npm, never updated by a check${fleetAsked() ? ' nor the fleet: a dependency its installers get' : ''} — apply with: ${pm.update(held.map((s) => s.name)).join(' ')})`)
174
+ moving = stale.filter((s) => !held.includes(s))
175
+ if (moving.length === 0) return
172
176
  }
177
+ const drift = driftOf(moving)
178
+ const cmd = pm.update(moving.map((s) => s.name))
173
179
  const dirty = await c.exec(['git', 'status', '--porcelain', '--', ...manifest], { cwd: c.root })
174
180
  if (dirty.code !== 0 || dirty.out.trim() !== '') {
175
181
  c.warn(`newer rvlib live: ${drift} — self-update deferred (${dirty.code !== 0 ? `git says: ${dirty.out.trim().split('\n')[0] ?? `exit ${dirty.code}`}` : 'package.json/lockfile dirty'}); commit first, then re-run check`)
@@ -188,7 +194,7 @@ export const rvlibVersions = check({
188
194
  const up = await c.exec(cmd, { cwd: c.root, timeoutMs: 120_000 })
189
195
  // the update may swap a link for the tarball: put every link back,
190
196
  // whatever the update did, inside the same lease
191
- for (const s of stale) if (s.linked) await c.exec(['bun', 'link', s.name], { cwd: c.root, timeoutMs: 60_000 })
197
+ for (const s of moving) if (s.linked) await c.exec(['bun', 'link', s.name], { cwd: c.root, timeoutMs: 60_000 })
192
198
  return up
193
199
  })
194
200
  if (!leased.ok) {
@@ -212,7 +218,7 @@ export const rvlibVersions = check({
212
218
  else c.warn(`self-update failed (${why}) and nothing was written — the registry or the network, not this repo. Retry with: ${cmd.join(' ')}: ${tail}`)
213
219
  return
214
220
  }
215
- for (const s of stale) {
221
+ for (const s of moving) {
216
222
  if (s.linked) {
217
223
  const now = pinFloor(readPin(c.root, s.name) ?? '')
218
224
  if (!isLinkedInstall(c.root, s.name)) c.fail(`${s.name} pin ${s.installed} → ${now ?? 'unreadable'} but node_modules/${s.name} is no longer linked to its source — run: bun link ${s.name}`)
@@ -1,7 +1,8 @@
1
1
  // shipkit fleet — every repo on this disk that installs the toolkit, and how
2
2
  // far behind each one is. Reports by default. `--update` hands the work to
3
3
  // each repo's OWN shipkit, so rule SK005 performs the update with the lease,
4
- // the dirty-tree guard and the frozen guard it already owns.
4
+ // the dirty-tree guard and the frozen guard it already owns, then commits and
5
+ // pushes each repo's bump unless `--no-commit`.
5
6
  import { Command, Option } from 'clipanion'
6
7
  import { join } from 'path'
7
8
  import { npmLatest, resolveLatest, scanFleet, type FleetRepo } from '../fleet/scan-fleet.ts'
@@ -17,7 +18,8 @@ export class FleetCommand extends Command {
17
18
  examples: [
18
19
  ['what is stale out there', 'sk fleet'],
19
20
  ['filesystem only, no registry', 'sk fleet --offline'],
20
- ['bring every behind repo up, through its own gate', 'sk fleet --update'],
21
+ ['bring every behind repo up through its own gate, commit and push each bump', 'sk fleet --update'],
22
+ ['update, leave the bumps in the trees', 'sk fleet --update --no-commit'],
21
23
  ['one repo only', 'sk fleet --update --only my-app'],
22
24
  ['machine-readable', 'sk fleet --json'],
23
25
  ],
@@ -25,6 +27,7 @@ export class FleetCommand extends Command {
25
27
 
26
28
  root = Option.String('--root', { required: false, description: 'directory holding the repos (default: the parent of this repo)' })
27
29
  update = Option.Boolean('--update', false, { description: "run each behind repo's own `shipkit check`, which is where rule SK005 performs the guarded update" })
30
+ noCommit = Option.Boolean('--no-commit', false, { description: 'with --update: leave each bump in its tree. By default each moved repo commits package.json and its lock alone, by pathspec, as `chore(deps): <dep> <range>`, then pushes; a repo whose manifest was dirty before, whose package.json moved more than the bump, mid-merge, mid-rebase, on a detached HEAD or nested in another repo is skipped with the command to run' })
28
31
  only = Option.Array('--only', { description: 'limit to these repo directory names (repeatable)' })
29
32
  offline = Option.Boolean('--offline', false, { description: 'skip the registry: report what is installed, never what is current' })
30
33
  major = Option.Boolean('--major', false, { description: 'let --update cross a breaking boundary too. Default off: a major (or a 0.x minor, as npm caret reads it) is a porting job, not a bump, and no automatic update path takes one' })
@@ -58,11 +61,11 @@ export class FleetCommand extends Command {
58
61
  return 0
59
62
  }
60
63
  if (!this.update) {
61
- w(`WARN ${behind.length}/${repos.length} repo(s) behind — rerun with --update, which runs each repo's own gate so SK005 does the write`)
64
+ w(`WARN ${behind.length}/${repos.length} repo(s) behind — rerun with --update, which runs each repo's own gate so SK005 does the write, then commits and pushes each bump`)
62
65
  return 0
63
66
  }
64
- await updateFleet(behind, { major: this.major, print: w })
65
- return 0
67
+ const res = await updateFleet(behind, { major: this.major, commit: !this.noCommit, print: w })
68
+ return res.failed > 0 ? 1 : 0
66
69
  }
67
70
 
68
71
  private row(r: FleetRepo): string {
@@ -10,6 +10,12 @@ export const ALLOW_MAJOR_ENV = 'SHIPKIT_ALLOW_MAJOR'
10
10
 
11
11
  export const allowMajor = (): boolean => process.env[ALLOW_MAJOR_ENV] === '1'
12
12
 
13
+ /** set by `shipkit fleet --update` on every repo run it spawns: the operator
14
+ * named the bump, so a published package's devDependencies may move */
15
+ export const FLEET_UPDATE_ENV = 'SHIPKIT_FLEET_UPDATE'
16
+
17
+ export const fleetAsked = (): boolean => process.env[FLEET_UPDATE_ENV] === '1'
18
+
13
19
  /** a jump across the boundary npm's caret draws: a changed major, or on a
14
20
  * `0.x` package a changed minor (`^0.2.0` means `<0.3.0`). A porting job
15
21
  * rather than a bump, so no automatic path takes it. */
@@ -0,0 +1,123 @@
1
+ // the commit `fleet --update` makes once a repo moved: package.json and its lock
2
+ // only, by pathspec (never a private index, never `add -A`, never a stash or a
3
+ // branch switch), then the plain push of `sk fleet push`. It commits nothing
4
+ // that is not the bump: a manifest dirty before the update, a package.json diff
5
+ // beyond the moved deps, a merge or rebase under way and a detached HEAD each
6
+ // skip, the files left updated and the line saying what to run.
7
+ import { existsSync, readFileSync } from 'fs'
8
+ import { isAbsolute, join } from 'path'
9
+ import { detectPackageManager, isRefusal, LOCK_FILES } from '../core/package-manager.ts'
10
+ import { git, pushRepo } from './push-fleet.ts'
11
+
12
+ export type BumpResult = { status: 'pushed' | 'skip' | 'fail' | 'none'; line: string }
13
+
14
+ type Change = { path: string; tracked: boolean }
15
+
16
+ /** what the repo held before the update: its manifest (package.json and the
17
+ * locks of the manager it installs with) and which of those files were
18
+ * already changed, someone else's work. Null outside a git repo */
19
+ export type Baseline = { manifest: string[]; dirty: Change[] } | null
20
+
21
+ async function changes(dir: string, manifest: string[]): Promise<Change[] | null> {
22
+ const r = await git(dir, ['status', '--porcelain', '--', ...manifest])
23
+ if (r.code !== 0) return null
24
+ return r.out.split('\n').filter((l) => l !== '').map((l) => ({ path: l.slice(3), tracked: !l.startsWith('??') }))
25
+ }
26
+
27
+ export async function bumpBaseline(dir: string): Promise<Baseline> {
28
+ const pm = detectPackageManager(dir)
29
+ const manifest = ['package.json', ...(isRefusal(pm) ? LOCK_FILES : pm.locks)]
30
+ const dirty = await changes(dir, manifest)
31
+ return dirty == null ? null : { manifest, dirty }
32
+ }
33
+
34
+ const OPERATIONS: Array<[string, string]> = [['MERGE_HEAD', 'a merge'], ['rebase-merge', 'a rebase'], ['rebase-apply', 'a rebase'], ['CHERRY_PICK_HEAD', 'a cherry-pick'], ['REVERT_HEAD', 'a revert']]
35
+
36
+ /** why this repo takes no commit of ours right now, or null. One git call:
37
+ * the dir's place in its work tree (a nested repo is a folder of another
38
+ * repo's history, never committed on), the ref HEAD names (`HEAD` itself
39
+ * when detached), then where each operation in progress leaves its state */
40
+ async function headRefusal(dir: string): Promise<string | null> {
41
+ const r = await git(dir, ['rev-parse', '--show-prefix', '--symbolic-full-name', 'HEAD', ...OPERATIONS.flatMap(([p]) => ['--git-path', p])])
42
+ if (r.code !== 0) return `git rev-parse failed: ${r.err.trim().split('\n')[0]}`
43
+ const [prefix, ref, ...paths] = r.out.split('\n').map((l) => l.trim())
44
+ if (prefix != null && prefix !== '') return `it is the folder ${prefix} of a larger repo, whose history is not its own`
45
+ for (const [i, [, label]] of OPERATIONS.entries()) {
46
+ const p = paths[i]
47
+ if (p != null && p !== '' && existsSync(isAbsolute(p) ? p : join(dir, p))) return `${label} is under way`
48
+ }
49
+ return ref === 'HEAD' ? 'HEAD is detached' : null
50
+ }
51
+
52
+ /** a value with its object keys sorted, so key order is never a change */
53
+ const canonical = (v: unknown): unknown =>
54
+ Array.isArray(v) ? v.map(canonical) : v != null && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical((v as Record<string, unknown>)[k])])) : v
55
+
56
+ const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
57
+
58
+ /** every field of package.json the update changed besides the moved deps,
59
+ * as `devDependencies.left-pad` or `scripts` */
60
+ export function extraChanges(before: Record<string, unknown>, after: Record<string, unknown>, moved: string[]): string[] {
61
+ const out: string[] = []
62
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
63
+ const [b, a] = [before[key], after[key]]
64
+ if (DEP_FIELDS.includes(key) && (b == null || typeof b === 'object') && (a == null || typeof a === 'object')) {
65
+ const [bs, as] = [(b ?? {}) as Record<string, unknown>, (a ?? {}) as Record<string, unknown>]
66
+ for (const dep of new Set([...Object.keys(bs), ...Object.keys(as)])) {
67
+ if (moved.includes(dep) && (key === 'dependencies' || key === 'devDependencies')) continue
68
+ if (bs[dep] !== as[dep]) out.push(`${key}.${dep}`)
69
+ }
70
+ continue
71
+ }
72
+ if (JSON.stringify(canonical(b)) !== JSON.stringify(canonical(a))) out.push(key)
73
+ }
74
+ return out
75
+ }
76
+
77
+ const lastLine = (text: string): string | null => text.split('\n').map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('hint:')).at(-1) ?? null
78
+
79
+ /** commit and push the bump of `moved` in `dir`, against the baseline read
80
+ * before the update ran */
81
+ export async function commitBump(repo: { name: string; dir: string }, moved: string[], base: Baseline): Promise<BumpResult> {
82
+ const { name, dir } = repo
83
+ const skip = (why: string, then: string): BumpResult => ({ status: 'skip', line: `skip: ${name}: ${why}, so the bump is not committed. It stays in the tree: cd ${dir} && ${then}` })
84
+ if (base == null) return skip('git status failed before the update', 'git status')
85
+ if (base.dirty.length > 0) {
86
+ const held = base.dirty.map((c) => c.path)
87
+ return skip(`${held.join(', ')} held changes before the update`, `git diff -- ${held.join(' ')}, then commit the bump with them by hand`)
88
+ }
89
+ const refused = await headRefusal(dir)
90
+ if (refused != null) return skip(refused, 'commit the bump by hand once it can take one')
91
+ const after = await changes(dir, base.manifest)
92
+ if (after == null) return { status: 'fail', line: `FAIL: ${name}: git status failed after the update: cd ${dir} && git status` }
93
+ const paths = after.filter((c) => c.tracked).map((c) => c.path).sort((a, b) => Number(b === 'package.json') - Number(a === 'package.json'))
94
+ if (paths.length === 0) return { status: 'none', line: `ok: ${name}: package.json and its lock unchanged, nothing to commit` }
95
+
96
+ const head = await git(dir, ['show', 'HEAD:./package.json'])
97
+ let was: Record<string, unknown>
98
+ let now: Record<string, unknown>
99
+ try {
100
+ was = JSON.parse(head.out) as Record<string, unknown>
101
+ now = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Record<string, unknown>
102
+ } catch {
103
+ return skip('package.json is unreadable in HEAD or on disk', 'git diff -- package.json')
104
+ }
105
+ const extra = extraChanges(was, now, moved)
106
+ if (extra.length > 0) return skip(`the update changed ${extra.join(', ')} in package.json too`, 'git diff -- package.json')
107
+
108
+ const deps = now as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
109
+ const message = `chore(deps): ${[...moved].sort().map((d) => `${d} ${deps.dependencies?.[d] ?? deps.devDependencies?.[d] ?? '?'}`).join(', ')}`
110
+ const by = `git commit -m '${message}' -- ${paths.join(' ')}`
111
+
112
+ // its first line names the commit: `[main 1a2b3c4] chore(deps): …`
113
+ const done = await git(dir, ['commit', '-m', message, '--', ...paths], 120_000)
114
+ if (done.code !== 0) return { status: 'fail', line: `FAIL: ${name}: the commit was refused: ${lastLine(`${done.out}\n${done.err}`) ?? `git commit exited ${done.code}`}. The bump stays in the tree: cd ${dir} && ${by}` }
115
+ const sha = done.out.match(/^\[\S+ ([0-9a-f]+)\]/m)?.[1] ?? 'HEAD'
116
+ const push = await pushRepo({ name, path: dir })
117
+ const committed = `committed ${sha} ${message}`
118
+ if (push.ok && push.action !== 'none') return { status: 'pushed', line: `ok: ${name}: ${committed}, pushed (${push.branch})` }
119
+ // never a force: a branch behind its upstream waits for a pull, and the
120
+ // operator is told as loudly as a failed push
121
+ if (push.action === 'skip') return { status: 'fail', line: `FAIL: ${name}: ${committed}, not pushed: ${push.why}` }
122
+ return { status: 'fail', line: `FAIL: ${name}: ${committed}, the push failed: ${push.line.replace(/^FAIL: [^:]+: /, '')}. Run: cd ${dir} && git push` }
123
+ }
@@ -15,9 +15,12 @@ export type PushResult = PushPlan & { ok: boolean; line: string }
15
15
  const PARALLEL = 4
16
16
  const PUSH_MS = 60_000
17
17
 
18
- async function git(cwd: string, args: string[], ms = 15_000): Promise<{ code: number; out: string; err: string }> {
19
- // a push that would ask for a password fails instead of hanging the page
20
- const p = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
18
+ /** git in another repo: a push that would ask for a password fails instead of
19
+ * hanging, and a GIT_DIR or GIT_INDEX_FILE this process inherited (a hook) is
20
+ * never carried into it */
21
+ export async function git(cwd: string, args: string[], ms = 15_000): Promise<{ code: number; out: string; err: string }> {
22
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'GIT_DIR' && k !== 'GIT_INDEX_FILE' && k !== 'GIT_WORK_TREE'))
23
+ const p = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...env, GIT_TERMINAL_PROMPT: '0' } })
21
24
  const timer = setTimeout(() => p.kill(), ms)
22
25
  const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
23
26
  clearTimeout(timer)
@@ -63,6 +66,9 @@ async function pushOne(p: PushPlan): Promise<PushResult> {
63
66
  return { ...p, ok: false, line: `FAIL: ${p.name}: git ${args.join(' ')}: ${why}` }
64
67
  }
65
68
 
69
+ /** one repo planned and pushed: what `fleet --update` runs after its commit */
70
+ export const pushRepo = async (repo: { name: string; path: string }): Promise<PushResult> => pushOne(await pushPlan(repo))
71
+
66
72
  /** every repo planned, the ones that need it pushed, a few at a time; `dry`
67
73
  * plans only. Results come back in the repos' order */
68
74
  export async function pushFleet(repos: { name: string; path: string }[], o: { dry?: boolean } = {}): Promise<PushResult[]> {
@@ -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 {
@@ -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. 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
3
+ // gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time; the picked
4
+ // rectangle's note box opens beside it. A choice, or views that each play alone, show as option rows, each image and
5
+ // clip shown full under its row; the card's header sits on top when the page reads in one column
5
6
  import { observer } from 'mobx-react-lite'
6
7
  import { useEffect, useRef, useState, type ReactNode } from 'react'
7
8
  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'
9
+ import { AutoText } from '../auto-text.tsx'
8
10
  import { blurOnEscape, Empty, FIT_ROOM, keepFocus, PAIR_NAMES, REACTIONS, secs } from './kit.tsx'
9
11
  import { CardHead } from './review-answer.tsx'
10
12
  import type { ClipSt } from './clip-st.ts'
@@ -20,11 +22,13 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
20
22
  const views = r.views
21
23
  const drawable = views.some((v) => !isGallery(v) && !v.clip && !v.audio)
22
24
  const rows = rv.rows
25
+ // a draft card's history is no view of its own: its frames cycle in place over the live one
26
+ const history = r.wip ? views.find(isGallery) : undefined
23
27
  return (
24
28
  <div className="rv-views">
25
29
  {rv.headInViews ? <CardHead rv={rv} x={x} /> : null}
26
30
  <div className="rv-viewsbar">
27
- {views.length > 1 && !rows ? <JumpList rv={rv} /> : <span />}
31
+ {views.length > 1 && !rows && !history ? <JumpList rv={rv} /> : <span />}
28
32
  <span className="rv-viewsctl">
29
33
  {asOptionsCard(rv) ? (
30
34
  <span className="seg" title="the options as thin rows, one below the other, or every view full">
@@ -49,7 +53,9 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
49
53
  {rows ? (
50
54
  <div className="rv-opts">{views.map((v, i) => <OptionRow key={`${r.id}#${i}`} rv={rv} x={x} v={v} i={i} />)}</div>
51
55
  ) : (
52
- views.map((v, i) => <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > 1} />)
56
+ views.map((v, i) => (v === history ? null : (
57
+ <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > (history ? 2 : 1)} {...(i === 0 && history && isGallery(history) ? { frames: history } : {})} />
58
+ )))
53
59
  )}
54
60
  {views.length === 0 ? <TextOnly x={x} /> : null}
55
61
  </div>
@@ -59,8 +65,9 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
59
65
  /** the open card may show as rows: a choice, or views that each play alone (the switch shows while it shows full) */
60
66
  const asOptionsCard = (rv: ReviewSt): boolean => rv.rows || rv.full[rv.open?.item.id ?? ''] === true
61
67
 
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 */
68
+ /** one option of a choice, or one view of a group, as a row: its number, ▶ and the clock, its label, a strip that
69
+ * scrubs it, its stars and « pick » (a choice) or OK, fix, no (a group), ✎ his word, ⤢ folds or unfolds the full view
70
+ * under it (an image or a clip shows it from the start) */
64
71
  const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: ReviewView; i: number }) => {
65
72
  const [word, setWord] = useState(false)
66
73
  const id = x.item.id
@@ -71,7 +78,7 @@ const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: R
71
78
  const choice = rv.choice
72
79
  const picked = rv.draft.pick === i
73
80
  const sent = verdictOf(x)?.pick === i
74
- const unfolded = rv.openRows[i] === true
81
+ const unfolded = rv.rowOpen(i)
75
82
  const file = playable ? ('video' in playable.files ? playable.files.video : 'audio' in playable.files ? playable.files.audio : '') : ''
76
83
  const duration = playable?.clip?.duration ?? playable?.audio?.duration ?? 0
77
84
  const showWord = word || (reaction?.comment ?? '') !== ''
@@ -92,7 +99,7 @@ const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: R
92
99
  ) : null}
93
100
  {c && playable ? (
94
101
  <Strip clip={c} duration={duration} wave={playable.audio ? rv.fileUrl(id, playable.audio.wave) : undefined} />
95
- ) : (
102
+ ) : unfolded ? null : (
96
103
  <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
104
  )}
98
105
  {c ? <span className="rv-mono rv-opttime">{clock(c.time)} / {clock(duration)}</span> : null}
@@ -112,8 +119,8 @@ const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: R
112
119
  </div>
113
120
  {media?.note ? <div className="rv-optnote">{media.note}</div> : null}
114
121
  {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)} />
122
+ <AutoText className="rv-word" placeholder="a word on this one" value={reaction?.comment ?? ''} autoFocus={word && !reaction?.comment}
123
+ onKeyDown={blurOnEscape} onText={(t) => rv.setReactionComment(i, undefined, t)} />
117
124
  ) : null}
118
125
  {c && playable?.audio ? <SoundEl clip={c} src={rv.fileUrl(id, file)} /> : null}
119
126
  {unfolded ? (
@@ -223,8 +230,11 @@ const JumpList = observer(({ rv }: { rv: ReviewSt }) => {
223
230
  )
224
231
  })
225
232
 
226
- /** one view: its label, its reaction and zoom, its image, pair, clip or gallery, then his word on it */
227
- const ViewBlock = observer(({ rv, id, v, i, many }: { rv: ReviewSt; id: string; v: ReviewView; i: number; many: boolean }) => {
233
+ /** one view: its label, its reaction and zoom, its image, pair, clip or gallery, then his word on it; a draft card's
234
+ * live view carries its history's frames, one shown in its place */
235
+ const ViewBlock = observer(({ rv, id, v: live, i, many, frames }: { rv: ReviewSt; id: string; v: ReviewView; i: number; many: boolean; frames?: GalleryView }) => {
236
+ const past = frames && rv.frame !== null ? frames.gallery[rv.frame] : undefined
237
+ const v: ReviewView = past && !isGallery(live) ? { ...live, files: { image: past.file }, size: past.size } : live
228
238
  const reaction = rv.reactionOf(i)
229
239
  const media = isGallery(v) ? null : v
230
240
  const zoom = rv.zoomOf(i)
@@ -243,9 +253,25 @@ const ViewBlock = observer(({ rv, id, v, i, many }: { rv: ReviewSt; id: string;
243
253
  onClick={() => rv.setViewZoom(i, zoom === 'fit' ? '1' : 'fit')}>{zoom === 'fit' ? '100 %' : 'Fit'}</button>
244
254
  ) : null}
245
255
  </div>
256
+ {frames ? <FrameSlider rv={rv} frames={frames} /> : null}
246
257
  {isGallery(v) ? <Gallery rv={rv} id={id} v={v} view={i} /> : <MediaBody rv={rv} id={id} v={v} i={i} zoom={zoom} />}
247
- <textarea className="rv-word" rows={1} placeholder={isGallery(v) ? 'a word on these images' : 'a word on this image'} value={reaction?.comment ?? ''}
248
- onKeyDown={blurOnEscape} onChange={(e) => rv.setReactionComment(i, undefined, e.currentTarget.value)} />
258
+ <AutoText className="rv-word" placeholder={isGallery(v) ? 'a word on these images' : 'a word on this image'} value={reaction?.comment ?? ''}
259
+ onKeyDown={blurOnEscape} onText={(t) => rv.setReactionComment(i, undefined, t)} />
260
+ </div>
261
+ )
262
+ })
263
+
264
+ /** a draft card's frames over its live view: a slider and two arrows cycle them in place, the live one at the right end */
265
+ const FrameSlider = observer(({ rv, frames }: { rv: ReviewSt; frames: GalleryView }) => {
266
+ const last = frames.gallery.length
267
+ const at = rv.frame ?? last
268
+ const go = (f: number): void => rv.setFrame(f >= last ? null : Math.max(0, f))
269
+ return (
270
+ <div className="rv-frames">
271
+ <button title="the frame before" disabled={at === 0} onMouseDown={keepFocus} onClick={() => go(at - 1)}>‹</button>
272
+ <input type="range" min={0} max={last} step={1} value={at} aria-label="the draft's frames" onChange={(e) => go(Number(e.currentTarget.value))} onMouseUp={(e) => e.currentTarget.blur()} />
273
+ <button title="the frame after" disabled={at === last} onMouseDown={keepFocus} onClick={() => go(at + 1)}>›</button>
274
+ <span className="rv-dim rv-small">{at === last ? 'now' : (frames.gallery[at]?.label ?? `frame ${at + 1}`)}</span>
249
275
  </div>
250
276
  )
251
277
  })
@@ -285,8 +311,8 @@ const Gallery = observer(({ rv, id, v, view }: { rv: ReviewSt; id: string; v: Ga
285
311
  </div>
286
312
  <div className="rv-tilelabel" title={t.label}>{t.label}</div>
287
313
  <ReactionToggles rv={rv} view={view} tile={k} small />
288
- <textarea className="rv-word" rows={1} placeholder="a word" value={r?.comment ?? ''} onKeyDown={blurOnEscape}
289
- onChange={(e) => rv.setReactionComment(view, k, e.currentTarget.value)} />
314
+ <AutoText className="rv-word" placeholder="a word" value={r?.comment ?? ''} onKeyDown={blurOnEscape}
315
+ onText={(t) => rv.setReactionComment(view, k, t)} />
290
316
  </div>
291
317
  )
292
318
  })}
@@ -309,7 +335,8 @@ const MediaBody = observer(({ rv, id, v, i, zoom }: { rv: ReviewSt; id: string;
309
335
  return (
310
336
  <div style={scroll}>
311
337
  <div style={{ width: scale ? `${v.size.w * scale}px` : `min(100%, calc((100vh - ${room}px) * ${ratio}))`, margin: '0 auto' }}>
312
- <Compare c={rv.compare} base={rv.fileUrl(id, name)} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={undefined} view={i} />} />
338
+ <Compare c={rv.compare} base={rv.fileUrl(id, name)} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={undefined} view={i} />}
339
+ over={() => <RectNote rv={rv} view={i} sides={[undefined]} />} />
313
340
  {bar}
314
341
  <Downloads rv={rv} id={id} files={[{ file: name }]} />
315
342
  </div>
@@ -334,7 +361,8 @@ const MediaBody = observer(({ rv, id, v, i, zoom }: { rv: ReviewSt; id: string;
334
361
  <div style={scroll}>
335
362
  <div className="rv-pairgrid" style={{ width: scale ? `${v.size.w * scale * cols + 8 * (cols - 1)}px` : fitW, gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
336
363
  <Compare c={c} base={before} mod={after} names={PAIR_NAMES} aspect={aspect}
337
- draw={(s) => <Marks rv={rv} src={s} side={drawnSide(c.mode, s === before ? 'before' : 'after')} view={i} />} />
364
+ draw={(s) => <Marks rv={rv} src={s} side={drawnSide(c.mode, s === before ? 'before' : 'after')} view={i} />}
365
+ over={(srcs) => <RectNote rv={rv} view={i} sides={srcs.map((s) => drawnSide(c.mode, s === before ? 'before' : 'after'))} />} />
338
366
  </div>
339
367
  </div>
340
368
  {bar ? <div style={{ width: fitW, margin: '0 auto' }}>{bar}</div> : null}
@@ -351,7 +379,7 @@ const SoundBody = ({ rv, id, v }: { rv: ReviewSt; id: string; v: MediaView }) =>
351
379
  if (!a) return null
352
380
  const rows = isPair(f) ? [{ label: 'before', file: f.before, wave: a.waveBefore ?? a.wave }, { label: 'after', file: f.after, wave: a.wave }] : [{ label: '', file: 'audio' in f ? f.audio : '', wave: a.wave }]
353
381
  return (
354
- <div className="rv-sound">
382
+ <div className="rv-soundview">
355
383
  {rows.map((r) => (
356
384
  <div key={r.file} className="rv-soundrow">
357
385
  {r.label ? <div className="rv-dim">{r.label}</div> : null}
@@ -412,9 +440,11 @@ const PhonePair = observer(({ rv, view, bar, before, after, aspect, size, scale
412
440
  <div style={scale ? { overflow: 'auto', maxHeight: '70vh' } : undefined}>
413
441
  <div style={{ width: scale ? `${size.w * scale}px` : '100%' }}>
414
442
  {side === 'swipe' ? (
415
- <Compare c={c} base={before} mod={after} names={PAIR_NAMES} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={s === before ? 'before' : 'after'} view={view} />} />
443
+ <Compare c={c} base={before} mod={after} names={PAIR_NAMES} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={s === before ? 'before' : 'after'} view={view} />}
444
+ over={(srcs) => <RectNote rv={rv} view={view} sides={srcs.map((s) => (s === before ? 'before' : 'after'))} />} />
416
445
  ) : (
417
- <Compare key={side} c={c} base={one} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={side} view={view} />} />
446
+ <Compare key={side} c={c} base={one} mod={undefined} aspect={aspect} draw={(s) => <Marks rv={rv} src={s} side={side} view={view} />}
447
+ over={() => <RectNote rv={rv} view={view} sides={[side]} />} />
418
448
  )}
419
449
  {bar}
420
450
  </div>
@@ -458,27 +488,28 @@ function track(move: (e: PointerEvent) => void): void {
458
488
  }
459
489
 
460
490
  /** THE pair: side by side, the curtain clipping both images, the after over the before as an onion skin, or the
461
- * after alone (the before while the pointer is over it). One image alone when `mod` is undefined */
462
- const Compare = observer(({ c, base, mod, names, aspect, draw }: { c: CompareSt; base: string; mod: string | undefined; names?: { base: string; mod: string }; aspect: string; draw: (src: string) => ReactNode }) => {
491
+ * after alone (the before while the pointer is over it). One image alone when `mod` is undefined. `over` draws what
492
+ * sits over a frame unclipped (the picked rectangle's note box), given the images that frame shows */
493
+ const Compare = observer(({ c, base, mod, names, aspect, draw, over }: { c: CompareSt; base: string; mod: string | undefined; names?: { base: string; mod: string }; aspect: string; draw: (src: string) => ReactNode; over?: (srcs: string[]) => ReactNode }) => {
463
494
  const [peek, setPeek] = useState(false)
464
- if (mod === undefined) return <Cell aspect={aspect} caption={null}><div className="rv-fill">{draw(base)}</div></Cell>
495
+ if (mod === undefined) return <Cell aspect={aspect} caption={null} over={over?.([base])}><div className="rv-fill">{draw(base)}</div></Cell>
465
496
  const n = names ?? PAIR_NAMES
466
497
  if (c.mode === 'side')
467
498
  return (
468
499
  <>
469
- <Cell aspect={aspect} caption={n.base}><div className="rv-fill">{draw(base)}</div></Cell>
470
- <Cell aspect={aspect} caption={n.mod} modCap><div className="rv-fill">{draw(mod)}</div></Cell>
500
+ <Cell aspect={aspect} caption={n.base} over={over?.([base])}><div className="rv-fill">{draw(base)}</div></Cell>
501
+ <Cell aspect={aspect} caption={n.mod} modCap over={over?.([mod])}><div className="rv-fill">{draw(mod)}</div></Cell>
471
502
  </>
472
503
  )
473
504
  if (c.mode === 'mod')
474
505
  return (
475
- <Cell aspect={aspect} caption={peek ? n.base : n.mod} modCap={!peek} onPeek={setPeek}>
506
+ <Cell aspect={aspect} caption={peek ? n.base : n.mod} modCap={!peek} onPeek={setPeek} over={over?.([peek ? base : mod])}>
476
507
  <div className="rv-fill">{draw(peek ? base : mod)}</div>
477
508
  </Cell>
478
509
  )
479
510
  const pct = Math.round(c.split * 100)
480
511
  return (
481
- <Cell aspect={aspect} caption={<>{n.base} {c.mode === 'swipe' ? '◀ ▶' : '+'} <span className="rv-modcap">{n.mod}</span></>} swipe={c.mode === 'swipe' ? c : undefined}>
512
+ <Cell aspect={aspect} caption={<>{n.base} {c.mode === 'swipe' ? '◀ ▶' : '+'} <span className="rv-modcap">{n.mod}</span></>} swipe={c.mode === 'swipe' ? c : undefined} over={over?.([base, mod])}>
482
513
  <div className="rv-fill" style={c.mode === 'swipe' ? { clipPath: `inset(0 ${100 - pct}% 0 0)` } : undefined}>{draw(base)}</div>
483
514
  <div className="rv-fill" style={c.mode === 'swipe' ? { clipPath: `inset(0 0 0 ${pct}%)` } : { opacity: c.opacity }}>{draw(mod)}</div>
484
515
  {c.mode === 'swipe' ? <div className="rv-curtain" style={{ left: `${pct}%` }} /> : null}
@@ -486,8 +517,8 @@ const Compare = observer(({ c, base, mod, names, aspect, draw }: { c: CompareSt;
486
517
  )
487
518
  })
488
519
 
489
- /** one frame and its caption; the curtain's line follows the pointer */
490
- const Cell = ({ aspect, caption, modCap, swipe, onPeek, children }: { aspect: string; caption: ReactNode; modCap?: boolean; swipe?: CompareSt | undefined; onPeek?: (on: boolean) => void; children: ReactNode }) => {
520
+ /** one frame and its caption; the curtain's line follows the pointer; `over` sits on the frame, free of its clip */
521
+ const Cell = ({ aspect, caption, modCap, swipe, onPeek, over, children }: { aspect: string; caption: ReactNode; modCap?: boolean; swipe?: CompareSt | undefined; onPeek?: (on: boolean) => void; over?: ReactNode; children: ReactNode }) => {
491
522
  const frame = useRef<HTMLDivElement>(null)
492
523
  const down = (e: React.PointerEvent): void => {
493
524
  const el = frame.current
@@ -502,9 +533,12 @@ const Cell = ({ aspect, caption, modCap, swipe, onPeek, children }: { aspect: st
502
533
  }
503
534
  return (
504
535
  <div className="rv-cell">
505
- <div ref={frame} className={`rv-frame${swipe ? ' swipe' : ''}`} style={{ aspectRatio: aspect }} onPointerDown={down}
506
- onMouseEnter={() => onPeek?.(true)} onMouseLeave={() => onPeek?.(false)}>
507
- {children}
536
+ <div className="rv-framebox">
537
+ <div ref={frame} className={`rv-frame${swipe ? ' swipe' : ''}`} style={{ aspectRatio: aspect }} onPointerDown={down}
538
+ onMouseEnter={() => onPeek?.(true)} onMouseLeave={() => onPeek?.(false)}>
539
+ {children}
540
+ </div>
541
+ {over ? <div className="rv-over">{over}</div> : null}
508
542
  </div>
509
543
  {caption != null ? <div className={`rv-caption${modCap ? ' mod' : ''}`}>{caption}</div> : null}
510
544
  </div>
@@ -578,7 +612,7 @@ const Marks = observer(({ rv, src, side, view }: { rv: ReviewSt; src: string; si
578
612
  {clip ? <ClipVideo key={src} clip={c} src={src} /> : <img src={src} alt="" draggable={false} className="rv-markimg" />}
579
613
  {shown && rings.length > 0 ? <Rings marks={rings} w={shown.size.w} h={shown.size.h} /> : null}
580
614
  {rects.map(([r, i]) => (
581
- <RectMark key={i} r={r} n={i + 1} on={rv.rect === i} faint={!onIts(r)} at={clip && r.t !== undefined ? secs(r.t) : undefined} pick={() => rv.pickRect(i)} />
615
+ <RectMark key={i} r={r} n={i + 1} on={rv.rect === i} faint={!onIts(r)} at={clip && r.t !== undefined ? secs(r.t) : undefined} pick={() => rv.pickRect(i, true)} />
582
616
  ))}
583
617
  {drag ? <div className="rv-drag" style={pct(box(drag))} /> : null}
584
618
  </div>
@@ -600,11 +634,46 @@ const Rings = ({ marks, w, h }: { marks: readonly ReviewMark[]; w: number; h: nu
600
634
  /** a rectangle of his; on a clip, faint and dashed away from its frame, its time on its number */
601
635
  const RectMark = ({ r, n, on, faint, at, pick }: { r: Rect; n: number; on: boolean; faint: boolean; at: string | undefined; pick: () => void }) => (
602
636
  <div className={`rv-rect${on ? ' on' : ''}${faint ? ' faint' : ''}`} style={pct(r)} title={r.comment || `rectangle ${n}`}
603
- onPointerDown={(e) => { e.stopPropagation(); pick() }}>
637
+ onPointerDown={(e) => { e.stopPropagation(); pick() }} onMouseDown={keepFocus}>
604
638
  <span className="rv-rectn">{n}{at ? ` · ${at}` : ''}</span>
605
639
  </div>
606
640
  )
607
641
 
642
+ /** the picked rectangle's note box, beside it on the image: under it, or over it when it sits low, focused once drawn
643
+ * or clicked. Enter or Escape closes it (the rectangle stays), Shift Enter is a new line; `sides` are the sides the
644
+ * frame shows */
645
+ const RectNote = observer(({ rv, view, sides }: { rv: ReviewSt; view: number; sides: readonly (Side | undefined)[] }) => {
646
+ const box = useRef<HTMLTextAreaElement | null>(null)
647
+ const i = rv.rect
648
+ const r = i === null ? undefined : rv.draft.rects[i]
649
+ const here = i !== null && r !== undefined && (r.view ?? 0) === view && sides.includes(r.side)
650
+ const focus = here && rv.noteFocus === i
651
+ useEffect(() => {
652
+ if (!focus || box.current === null) return
653
+ box.current.focus()
654
+ rv.noteFocused()
655
+ }, [focus, rv])
656
+ if (!here) return null
657
+ const below = r.y + r.h <= NOTE_LOW || r.y < 1 - NOTE_LOW
658
+ const place = below ? { top: `${(r.y + r.h) * 100}%` } : { bottom: `${(1 - r.y) * 100}%` }
659
+ return (
660
+ <div className={`rv-note${below ? ' below' : ' above'}`} style={{ ...place, ['--nx' as string]: `${r.x * 100}%` }} onPointerDown={(e) => e.stopPropagation()}>
661
+ <span className="rv-noten">{i + 1}{r.t !== undefined ? ` · ${secs(r.t)}` : ''}</span>
662
+ <AutoText el={(t) => (box.current = t)} placeholder="what is wrong here · Enter closes" value={r.comment}
663
+ onText={(t) => rv.setRectComment(i, t)}
664
+ onKeyDown={(e) => {
665
+ if ((e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) || e.key === 'Escape') {
666
+ e.preventDefault()
667
+ rv.pickRect(null)
668
+ }
669
+ }} />
670
+ <button className="ghost rv-x" title="delete this rectangle" aria-label="delete this rectangle" onMouseDown={keepFocus} onClick={() => rv.removeRect(i)}>✕</button>
671
+ </div>
672
+ )
673
+ })
674
+ /** a rectangle whose bottom is lower than this share of the image opens its note over it, when there is room above */
675
+ const NOTE_LOW = 0.78
676
+
608
677
  // ── a clip ──
609
678
 
610
679
  /** one video of the open view, driven by the view's one clock; muted until the sound button, inline; looping, or at its
@@ -4,6 +4,7 @@
4
4
  import { observer } from 'mobx-react-lite'
5
5
  import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
6
6
  import { Group, Panel, Separator } from 'react-resizable-panels'
7
+ import { AutoText } from '../auto-text.tsx'
7
8
  import { clock, cutsByRecent, shotAt, type CutInfo, type TrailerNote } from '../../../review/trailer-model.ts'
8
9
  import { editorHref } from '../check-words.ts'
9
10
  import { renderMd } from '../md.ts'
@@ -271,7 +272,7 @@ const DraftBox = observer(({ tr }: { tr: TrailerSt }) => {
271
272
  <span className="tr-dim tr-small">click the image to pin a point</span>
272
273
  )}
273
274
  </div>
274
- <textarea autoFocus rows={3} placeholder="What is wrong, what you want instead…" value={d.text} onChange={(e) => tr.setDraftText(e.currentTarget.value)}
275
+ <AutoText autoFocus rows={3} placeholder="What is wrong, what you want instead…" value={d.text} onText={(t) => tr.setDraftText(t)}
275
276
  onKeyDown={(e) => {
276
277
  if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
277
278
  e.preventDefault()
@@ -362,7 +363,7 @@ const NoteCard = observer(({ tr, n: p }: { tr: TrailerSt; n: PlacedNote }) => {
362
363
  {on ? <Still tr={tr} n={n} w="100%" /> : null}
363
364
  {editing ? (
364
365
  <div className="tr-edit">
365
- <textarea autoFocus rows={3} value={editing.text} onChange={(e) => tr.setEditText(e.currentTarget.value)} onKeyDown={enterSaves(() => void tr.saveEdit(), () => tr.cancelEdit())} />
366
+ <AutoText autoFocus rows={3} value={editing.text} onText={(t) => tr.setEditText(t)} onKeyDown={enterSaves(() => void tr.saveEdit(), () => tr.cancelEdit())} />
366
367
  <div className="tr-dim tr-small">Enter saves · Escape drops it · on its frame, a click moves the pin</div>
367
368
  </div>
368
369
  ) : null}
@@ -374,8 +375,8 @@ const NoteCard = observer(({ tr, n: p }: { tr: TrailerSt; n: PlacedNote }) => {
374
375
  ) : null}
375
376
  {closing ? (
376
377
  <div className="tr-edit">
377
- <textarea autoFocus rows={2} placeholder={closing.state === 'done' ? 'The commit, or what was done (optional)' : 'Why (optional)'} value={closing.reply}
378
- onChange={(e) => tr.setReply(e.currentTarget.value)} onKeyDown={enterSaves(() => void tr.confirmClose(), () => tr.cancelClose())} />
378
+ <AutoText autoFocus rows={2} placeholder={closing.state === 'done' ? 'The commit, or what was done (optional)' : 'Why (optional)'} value={closing.reply}
379
+ onText={(t) => tr.setReply(t)} onKeyDown={enterSaves(() => void tr.confirmClose(), () => tr.cancelClose())} />
379
380
  <div className="tr-draftbtns">
380
381
  <button onClick={() => tr.cancelClose()}>Cancel</button>
381
382
  <button className="primary" onClick={() => void tr.confirmClose()}>{closing.state === 'done' ? 'Mark done' : 'Not doing it'}</button>
@@ -120,6 +120,9 @@
120
120
  .rv-raisedimg{display:block;max-width:100%;max-height:320px;margin-top:var(--s2);border-radius:4px}
121
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
+ /* the frame's box: what sits over the frame free of its clip (the note box) shares its size */
124
+ .rv-framebox{position:relative;width:100%}
125
+ .rv-over{position:absolute;inset:0;z-index:4;pointer-events:none}
123
126
  .rv-frame{position:relative;width:100%;max-width:100%;border-radius:4px;border:1px solid var(--line);overflow:hidden;
124
127
  background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/16px 16px}
125
128
  .rv-frame.swipe{cursor:ew-resize;touch-action:none}
@@ -142,6 +145,13 @@
142
145
  .rv-rectn{position:absolute;left:-2px;top:-2px;transform:translateY(-100%);padding:0 var(--s1);border-radius:3px;background:var(--warn);color:var(--bg);font-size:11px;font-weight:800;line-height:16px;white-space:nowrap}
143
146
  .rv-rect.on .rv-rectn{background:var(--blue)}
144
147
  .rv-drag{position:absolute;border:2px dashed var(--warn)}
148
+ /* the picked rectangle's note box, in the picked rectangle's blue: under it, or over it (clearing its number) */
149
+ .rv-note{position:absolute;left:clamp(0px,var(--nx),calc(100% - min(300px,100%)));width:min(300px,100%);pointer-events:auto;display:flex;gap:var(--s1);align-items:flex-start;
150
+ padding:var(--s1);border-radius:6px;border:2px solid var(--blue);background:var(--panel);box-shadow:0 2px 10px var(--shadow)}
151
+ .rv-note.below{margin-top:var(--s1)}
152
+ .rv-note.above{margin-bottom:var(--s4)}
153
+ .rv-noten{flex:none;padding:0 var(--s1);border-radius:3px;background:var(--blue);color:var(--bg);font-size:11px;font-weight:800;line-height:18px;white-space:nowrap}
154
+ .rv-note textarea{flex:1;min-width:0}
145
155
  .rv-downloads{display:flex;gap:var(--s2);justify-content:flex-end;margin-top:var(--s1)}
146
156
  .rv-dl{font-size:11.5px;text-decoration:none}
147
157
  .rv-gallery{display:flex;flex-wrap:wrap;gap:var(--s2);align-items:flex-start}
@@ -153,8 +163,12 @@
153
163
  .rv-tileimg img{display:block;width:100%;height:100%;object-fit:contain}
154
164
  .rv-tilelabel{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
155
165
  .rv-clipbar{display:flex;gap:var(--s1);align-items:center;margin-top:var(--s2)}
166
+ .rv-frames{display:flex;gap:var(--s1);align-items:center;margin-bottom:var(--s2)}
167
+ .rv-frames input{flex:1;min-width:60px}
168
+ .rv-frames button{padding:0 var(--s2);line-height:22px}
156
169
  .rv-clipbar button{padding:0 var(--s2);line-height:22px}
157
170
  .rv-scrub{flex:1;min-width:60px}
171
+ .rv-clipbar .rv-mono{flex:none;white-space:nowrap}
158
172
  .rv-sound{display:inline-flex;align-items:center;gap:var(--s1)}
159
173
  .rv-sound button{padding:0 var(--s1);line-height:22px}
160
174
  .rv-sound input{width:72px}
@@ -223,7 +237,7 @@
223
237
  .rv-linkedtitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
224
238
  .rv-outgoing,.rv-messages{display:flex;flex-direction:column;gap:var(--s1)}
225
239
  .rv-outrow{display:flex;gap:var(--s1);align-items:flex-start}
226
- .rv-outrow textarea{flex:1;min-height:44px}
240
+ .rv-outrow textarea{flex:1}
227
241
  .rv-outbadge{flex:none;max-width:170px;margin-top:var(--s0);font-size:11px;padding:0 var(--s2);line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
228
242
  color:var(--tone,var(--ink));border-color:color-mix(in srgb,var(--tone,var(--line)) 50%,var(--line))}
229
243
  .rv-outbadge.rect{--tone:var(--warn)}
@@ -251,6 +265,8 @@
251
265
  .rv-verdictbar .rv-verdict{flex:1;text-align:center;font-size:14px;padding:var(--s2) var(--s1);color:var(--tone)}
252
266
  .rv-verdictbar .rv-verdict.chosen{color:var(--bg)}
253
267
  .rv-phone .rv-views{gap:var(--s3)}
268
+ /* the views' labels take the phone's whole width, the zoom under them */
269
+ .rv-phone .rv-jumps{flex:1 1 100%}
254
270
  .rv-phone .rv-toast{top:var(--s4);bottom:auto}
255
271
  /* a row on a phone: ▶, the label and the time on one line, the strip and the acts under them */
256
272
  .rv-phone .rv-optrow{flex-wrap:wrap;row-gap:var(--s1)}
@@ -261,6 +277,8 @@
261
277
  .rv-phone .rv-stars button{font-size:22px;padding:0 var(--s1)}
262
278
  .rv-phone .rv-optplay{width:40px;height:34px}
263
279
  .rv-phone .rv-optnote,.rv-phone .rv-opt .rv-word{margin-left:0;width:100%}
280
+ /* a phone zooms the page into a field whose text is under 16px: the field takes 16px instead */
281
+ .rv-phone textarea,.tr-phone textarea{font-size:16px}
264
282
 
265
283
  /* ── the trailer page ── */
266
284
  .tr-cuts{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
@@ -385,5 +403,5 @@
385
403
  .rv-parttext{font-size:13px;line-height:1.45;white-space:pre-wrap;overflow-wrap:anywhere}
386
404
  .rv-anim select{font-size:12px}
387
405
  /* a sound view (review-views.tsx SoundBody): its waveform over the player, a row a side on a pair */
388
- .rv-sound{display:flex;flex-direction:column;gap:var(--s2)}
406
+ .rv-soundview{display:flex;flex-direction:column;gap:var(--s2)}
389
407
  .rv-soundrow{display:flex;flex-direction:column;gap:var(--s1);background:var(--panel);border:1px solid var(--line);border-radius:4px;padding:var(--s2)}