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 +6 -0
- package/package.json +1 -1
- package/src/checks/SK005-rvlib-versions.check.ts +17 -11
- package/src/cli/cmd_Fleet.ts +8 -5
- package/src/core/update-policy.ts +6 -0
- package/src/fleet/commit-bump.ts +123 -0
- package/src/fleet/push-fleet.ts +9 -3
- package/src/fleet/update-fleet.ts +38 -9
- package/src/serve/hub-app/auto-text.tsx +52 -0
- package/src/serve/hub-app/hub.css +2 -0
- package/src/serve/hub-app/media-view.tsx +3 -11
- package/src/serve/hub-app/review/review-answer.tsx +8 -7
- package/src/serve/hub-app/review/review-page.tsx +28 -12
- package/src/serve/hub-app/review/review-st.ts +39 -8
- package/src/serve/hub-app/review/review-views.tsx +104 -35
- package/src/serve/hub-app/review/trailer-page.tsx +5 -4
- package/src/serve/hub-app/review.css +20 -2
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.
|
|
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
|
|
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
|
|
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: ${
|
|
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: ${
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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}`)
|
package/src/cli/cmd_Fleet.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/fleet/push-fleet.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
196
|
-
|
|
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
|
-
<
|
|
207
|
-
|
|
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
|
-
<
|
|
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
|
|
4
|
-
//
|
|
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'
|
|
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'
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
<
|
|
74
|
+
<CardScroll rv={rv} className="rv-scroll">
|
|
69
75
|
<ReviewImage rv={rv} />
|
|
70
76
|
<ReviewForm rv={rv} />
|
|
71
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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'
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
// views that each play alone, show as
|
|
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) =>
|
|
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
|
|
63
|
-
* scrubs it, its stars and « pick » (a choice) or OK, fix, no (a group), ✎ his word, ⤢ the full view
|
|
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.
|
|
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
|
-
<
|
|
116
|
-
onKeyDown={blurOnEscape}
|
|
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
|
-
|
|
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
|
-
<
|
|
248
|
-
onKeyDown={blurOnEscape}
|
|
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
|
-
<
|
|
289
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
378
|
-
|
|
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
|
|
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-
|
|
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)}
|