rvlib-shipkit 2.2.0 → 2.3.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 +38 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// the review page's left column: the search and the filters, one card per thread, its older versions nested
|
|
2
|
+
import { observer } from 'mobx-react-lite'
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { aboutText, firstView, isGallery, isPair, laneOf, MOVE_LABEL, STATUS_LABEL, subjectLabel, verdictOf, type Move, type ReviewItem, type ShownStatus } from '../../../review/model.ts'
|
|
5
|
+
import type { Thread } from '../../../review/threads.ts'
|
|
6
|
+
import { AutoTag, LaneTag, laneColor, Listeners, Rework, ThumbOf, toneOf } from './kit.tsx'
|
|
7
|
+
import { ReviewBoard } from './review-board.tsx'
|
|
8
|
+
import type { ReviewSt } from './review-st.ts'
|
|
9
|
+
import { Caret } from '../tree-row.tsx'
|
|
10
|
+
|
|
11
|
+
const ALL = ''
|
|
12
|
+
|
|
13
|
+
export const ReviewList = observer(({ rv }: { rv: ReviewSt }) => {
|
|
14
|
+
const shown = rv.visible
|
|
15
|
+
const open = rv.open?.item.id
|
|
16
|
+
return (
|
|
17
|
+
<div className="rv-listcol">
|
|
18
|
+
<div className="rv-filters">
|
|
19
|
+
<input className="rv-search" placeholder="search" aria-label="search the cards" value={rv.query} onChange={(e) => rv.setQuery(e.currentTarget.value)}
|
|
20
|
+
onKeyDown={(e) => { if (e.key === 'Escape') { rv.setQuery(''); e.currentTarget.blur() } }} />
|
|
21
|
+
<div className="rv-selects">
|
|
22
|
+
<select aria-label="subject" value={rv.subject ?? ALL} onChange={(e) => rv.setFilter({ subject: e.currentTarget.value === ALL ? null : e.currentTarget.value })}>
|
|
23
|
+
<option value={ALL}>every subject</option>
|
|
24
|
+
{rv.subjects.map((k) => <option key={k} value={k}>{subjectLabel(k, rv.spec ?? undefined)}</option>)}
|
|
25
|
+
</select>
|
|
26
|
+
<select aria-label="state" value={rv.status ?? ALL} onChange={(e) => rv.setFilter({ status: e.currentTarget.value === ALL ? null : (e.currentTarget.value as ShownStatus) })}>
|
|
27
|
+
<option value={ALL}>every state</option>
|
|
28
|
+
{(Object.keys(STATUS_LABEL) as ShownStatus[]).map((s) => <option key={s} value={s}>{STATUS_LABEL[s]}</option>)}
|
|
29
|
+
</select>
|
|
30
|
+
</div>
|
|
31
|
+
<span className="seg rv-whose" aria-label="whose move">
|
|
32
|
+
<button className={rv.whose === null ? 'on' : ''} onClick={() => rv.setFilter({ whose: null })}>all</button>
|
|
33
|
+
{(['owner', 'agents', 'none'] as const).map((m: Move) => (
|
|
34
|
+
<button key={m} className={rv.whose === m ? 'on' : ''} onClick={() => rv.setFilter({ whose: m })}>{MOVE_LABEL[m]}</button>
|
|
35
|
+
))}
|
|
36
|
+
</span>
|
|
37
|
+
<div className="rv-switches">
|
|
38
|
+
<label className="rv-switch"><input type="checkbox" checked={rv.hideHandled} onChange={(e) => rv.setHideHandled(e.currentTarget.checked)} />hide handled</label>
|
|
39
|
+
<label className="rv-switch"><input type="checkbox" checked={rv.byLane} onChange={(e) => rv.setByLane(e.currentTarget.checked)} />by lane</label>
|
|
40
|
+
{rv.spec?.dispatcher ? <label className="rv-switch"><input type="checkbox" checked={rv.board} onChange={(e) => rv.setBoard(e.currentTarget.checked)} />board</label> : null}
|
|
41
|
+
</div>
|
|
42
|
+
<div className="rv-count">
|
|
43
|
+
{rv.activeCount} active · {rv.pendingCount} waiting{rv.hideHandled ? '' : ` · ${shown.length} / ${rv.threads.length} threads`}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
{rv.board && rv.spec?.dispatcher ? <ReviewBoard rv={rv} /> : <div className="rv-listscroll">
|
|
47
|
+
{rv.byLane
|
|
48
|
+
? rv.laneGroups.map((g) => (
|
|
49
|
+
<div key={g.lane ?? ''}>
|
|
50
|
+
<LaneHeader rv={rv} lane={g.lane} threads={g.threads} />
|
|
51
|
+
{g.threads.map((t) => <ThreadRow key={t.head.item.id} rv={rv} t={t} openId={open} />)}
|
|
52
|
+
</div>
|
|
53
|
+
))
|
|
54
|
+
: shown.map((t) => <ThreadRow key={t.head.item.id} rv={rv} t={t} openId={open} />)}
|
|
55
|
+
{shown.length === 0 && rv.list != null ? (
|
|
56
|
+
<div className="rv-empty">{rv.items.length === 0 ? 'No card yet. An agent sends one with sk review add.' : rv.hideHandled ? 'Nothing active: « hide handled » hides the handled threads.' : 'No card matches.'}</div>
|
|
57
|
+
) : null}
|
|
58
|
+
{rv.list == null && rv.listError == null ? <div className="rv-empty"><span className="spin" /> reading the cards…</div> : null}
|
|
59
|
+
{rv.listError != null ? <div className="rv-err">{rv.listError}</div> : null}
|
|
60
|
+
{rv.broken.map((b) => <div key={b.id} className="rv-err">unreadable: {b.id} ({b.error})</div>)}
|
|
61
|
+
</div>}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const LaneHeader = observer(({ rv, lane, threads }: { rv: ReviewSt; lane: string | null; threads: readonly Thread[] }) => (
|
|
67
|
+
<div className="rv-lanehead" style={lane ? { ['--lane' as string]: laneColor(lane) } : undefined}>
|
|
68
|
+
<b>{lane ?? 'no lane'}</b>
|
|
69
|
+
<span className="rv-dim">{threads.length}</span>
|
|
70
|
+
<span className="sp" />
|
|
71
|
+
<Listeners rv={rv} cards={threads.map((t) => t.head)} />
|
|
72
|
+
</div>
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
const ThreadRow = observer(({ rv, t, openId }: { rv: ReviewSt; t: Thread; openId: string | undefined }) => {
|
|
76
|
+
const older = t.versions.slice(1)
|
|
77
|
+
const unfolded = older.length > 0 && rv.showsVersions(t)
|
|
78
|
+
const n = t.versions.length
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<ReviewRow rv={rv} x={t.head} on={t.head.item.id === openId} unread={rv.unread(t)} version={n}
|
|
82
|
+
fold={older.length > 0 ? { open: unfolded, toggle: () => rv.fold(t.head.item.id) } : undefined} />
|
|
83
|
+
{unfolded ? older.map((x, i) => <VersionRow key={x.item.id} rv={rv} x={x} n={n - 1 - i} on={x.item.id === openId} />) : null}
|
|
84
|
+
</>
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const laneStyle = (x: ReviewItem): React.CSSProperties | undefined => {
|
|
89
|
+
const lane = laneOf(x)
|
|
90
|
+
return lane ? { ['--lane' as string]: laneColor(lane) } : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** an older version under its thread's head: small, its own status, a click opens it */
|
|
94
|
+
const VersionRow = observer(({ rv, x, n, on }: { rv: ReviewSt; x: ReviewItem; n: number; on: boolean }) => {
|
|
95
|
+
const status = rv.statusOf(x)
|
|
96
|
+
const lane = laneOf(x)
|
|
97
|
+
return (
|
|
98
|
+
<div className={`rv-vrow ${toneOf(status)}${on ? ' on' : ''}${lane ? ' laned' : ''}`} style={laneStyle(x)} onClick={() => rv.pick(x.item.id)}>
|
|
99
|
+
<div className="rv-thumb sm"><ThumbOf rv={rv} x={x} /></div>
|
|
100
|
+
<b className="rv-vn">v{n}</b>
|
|
101
|
+
<span className="rv-badge">{STATUS_LABEL[status]}</span>
|
|
102
|
+
{lane ? <LaneTag lane={lane} /> : null}
|
|
103
|
+
{x.item.origin.by === 'auto' ? <AutoTag rule={x.item.origin.rule} /> : null}
|
|
104
|
+
<span className="rv-vtitle">{x.item.title}</span>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const ReviewRow = observer(({ rv, x, on, version, unread, fold }: {
|
|
110
|
+
rv: ReviewSt
|
|
111
|
+
x: ReviewItem
|
|
112
|
+
on: boolean
|
|
113
|
+
version: number
|
|
114
|
+
unread: boolean
|
|
115
|
+
fold: { open: boolean; toggle: () => void } | undefined
|
|
116
|
+
}) => {
|
|
117
|
+
const r = x.item
|
|
118
|
+
const status = rv.statusOf(x)
|
|
119
|
+
const rework = rv.reworkOf(x)
|
|
120
|
+
// handled by the agent: its answer marked seen, or a newer version sent; never while a lane reworks it
|
|
121
|
+
const muted = !on && !rework && (verdictOf(x)?.seen === true || status === 'superseded' || status === 'withdrawn')
|
|
122
|
+
const lane = laneOf(x)
|
|
123
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (on) ref.current?.scrollIntoView({ block: 'nearest' })
|
|
126
|
+
}, [on])
|
|
127
|
+
const first = firstView(r)
|
|
128
|
+
return (
|
|
129
|
+
<div ref={ref} className={`rv-row ${toneOf(status)}${on ? ' on' : ''}${muted ? ' muted' : ''}${lane ? ' laned' : ''}`} style={laneStyle(x)} onClick={() => rv.pick(r.id)}>
|
|
130
|
+
<div className="rv-thumb"><ThumbOf rv={rv} x={x} /></div>
|
|
131
|
+
<div className="rv-rowbody">
|
|
132
|
+
<div className={`rv-rowtitle${on || status === 'pending' ? ' strong' : ''}`}>
|
|
133
|
+
{unread ? <span className="rv-unread" title="a message of the agent to read" /> : null}
|
|
134
|
+
{r.title}
|
|
135
|
+
</div>
|
|
136
|
+
<div className="rv-rowmeta">
|
|
137
|
+
<span className="rv-badge">{STATUS_LABEL[status]}</span>
|
|
138
|
+
{version > 1 ? <span className="rv-badge outline">v{version}</span> : null}
|
|
139
|
+
{lane ? <LaneTag lane={lane} /> : null}
|
|
140
|
+
{r.origin.by === 'auto' ? <AutoTag rule={r.origin.rule} /> : null}
|
|
141
|
+
<span className="rv-about">
|
|
142
|
+
{aboutText(r, rv.spec ?? undefined)}
|
|
143
|
+
{r.views.some((v) => !isGallery(v) && v.clip) ? ' · clip' : ''}
|
|
144
|
+
{first && isPair(first.files) ? ' · before/after' : ''}
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
<Rework rv={rv} x={x} />
|
|
148
|
+
</div>
|
|
149
|
+
{fold ? (
|
|
150
|
+
<button className="rv-fold" aria-label={fold.open ? 'fold the versions' : 'unfold the versions'} onClick={(e) => { e.stopPropagation(); fold.toggle() }}>
|
|
151
|
+
<Caret open={fold.open} />
|
|
152
|
+
</button>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// a repo's review page (`#page:review`, wiki build/shipkit/review.md, The review page): the cards on the left, the open
|
|
2
|
+
// one in the middle with every view stacked, the answer on the right; narrower than STACK_PX, the card over the answer
|
|
3
|
+
// in one column. 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
|
|
5
|
+
import { observer } from 'mobx-react-lite'
|
|
6
|
+
import { useEffect, useRef } from 'react'
|
|
7
|
+
import { Group, Panel, Separator } from 'react-resizable-panels'
|
|
8
|
+
import type { RepoSt } from '../repo-st.ts'
|
|
9
|
+
import { ReviewForm, VerdictBar } from './review-answer.tsx'
|
|
10
|
+
import { ReviewList } from './review-list.tsx'
|
|
11
|
+
import type { PhonePane } from './review-st.ts'
|
|
12
|
+
import { ReviewImage } from './review-views.tsx'
|
|
13
|
+
|
|
14
|
+
const PANE_LABEL: Record<PhonePane, string> = { list: 'cards', card: 'card', answer: 'answer' }
|
|
15
|
+
|
|
16
|
+
export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
|
|
17
|
+
const rv = app.review
|
|
18
|
+
const el = useRef<HTMLDivElement | null>(null)
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
rv.start()
|
|
21
|
+
return () => rv.stop()
|
|
22
|
+
}, [rv])
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const node = el.current
|
|
25
|
+
if (node == null || typeof ResizeObserver === 'undefined') return
|
|
26
|
+
// the pick lands on the next frame: a relayout inside the callback is the observer's loop error
|
|
27
|
+
let raf = 0
|
|
28
|
+
const ro = new ResizeObserver(([e]) => {
|
|
29
|
+
const w = e!.contentRect.width
|
|
30
|
+
cancelAnimationFrame(raf)
|
|
31
|
+
raf = requestAnimationFrame(() => rv.fit(w))
|
|
32
|
+
})
|
|
33
|
+
ro.observe(node)
|
|
34
|
+
return () => {
|
|
35
|
+
cancelAnimationFrame(raf)
|
|
36
|
+
ro.disconnect()
|
|
37
|
+
}
|
|
38
|
+
}, [rv, rv.phone.on])
|
|
39
|
+
if (rv.phone.on)
|
|
40
|
+
return (
|
|
41
|
+
<div className="rv-phone">
|
|
42
|
+
<PhoneTop app={app} />
|
|
43
|
+
<div className="rv-phonetabs seg">
|
|
44
|
+
{(['list', 'card', 'answer'] as const).map((p) => (
|
|
45
|
+
<button key={p} className={rv.phone.pane === p ? 'on' : ''} onClick={() => rv.phone.show(p)}>
|
|
46
|
+
{PANE_LABEL[p]}{p === 'list' && rv.pendingCount > 0 ? ` · ${rv.pendingCount}` : ''}
|
|
47
|
+
</button>
|
|
48
|
+
))}
|
|
49
|
+
</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>
|
|
53
|
+
{rv.phone.pane === 'list' ? null : <VerdictBar rv={rv} />}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
return (
|
|
57
|
+
<Group className="rv-cols" orientation="horizontal" elementRef={el}>
|
|
58
|
+
<Panel id="review-list" defaultSize={rv.stacked ? '32%' : '20%'} minSize={160} className="rv-pane" style={PANEL}>
|
|
59
|
+
<ReviewList rv={rv} />
|
|
60
|
+
</Panel>
|
|
61
|
+
<Separator className="rsep" />
|
|
62
|
+
{rv.stacked ? (
|
|
63
|
+
// one column: the card over its answer, the boundary dragged
|
|
64
|
+
<Panel id="review-stack" defaultSize="68%" minSize={300} className="rv-pane" style={PANEL}>
|
|
65
|
+
<Group className="rv-stack" orientation="vertical">
|
|
66
|
+
<Panel id="review-image" defaultSize="62%" minSize={160} className="rv-pane" style={PANEL}>
|
|
67
|
+
<div className="rv-scroll"><ReviewImage rv={rv} /></div>
|
|
68
|
+
</Panel>
|
|
69
|
+
<Separator className="rsep hsep" />
|
|
70
|
+
<Panel id="review-form" defaultSize="38%" minSize={140} className="rv-pane" style={PANEL}>
|
|
71
|
+
<div className="rv-scroll"><ReviewForm rv={rv} /></div>
|
|
72
|
+
</Panel>
|
|
73
|
+
</Group>
|
|
74
|
+
</Panel>
|
|
75
|
+
) : (
|
|
76
|
+
<>
|
|
77
|
+
<Panel id="review-image" defaultSize="54%" minSize={300} className="rv-pane" style={PANEL}>
|
|
78
|
+
<div className="rv-scroll"><ReviewImage rv={rv} /></div>
|
|
79
|
+
</Panel>
|
|
80
|
+
<Separator className="rsep" />
|
|
81
|
+
<Panel id="review-form" defaultSize="26%" minSize={260} className="rv-pane" style={PANEL}>
|
|
82
|
+
<div className="rv-scroll"><ReviewForm rv={rv} /></div>
|
|
83
|
+
</Panel>
|
|
84
|
+
</>
|
|
85
|
+
)}
|
|
86
|
+
</Group>
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/** the phone's top line: the way back to the hub, and the repo the page is of. On a phone the page covers the hub's
|
|
91
|
+
* panes, which leave it no room */
|
|
92
|
+
export const PhoneTop = observer(({ app }: { app: RepoSt }) => (
|
|
93
|
+
<div className="rv-phonetop">
|
|
94
|
+
<button className="ghost" onClick={() => app.showReadme()}>◂ hub</button>
|
|
95
|
+
<b>{app.name}</b>
|
|
96
|
+
<span className="rv-dim">{app.sel?.t === 'trailer' ? 'trailer' : 'review'}</span>
|
|
97
|
+
</div>
|
|
98
|
+
))
|
|
99
|
+
|
|
100
|
+
/** a panel never scrolls itself: the column inside it does (hub-app.tsx, PANEL) */
|
|
101
|
+
export const PANEL = { overflow: 'hidden' } as const
|