rvlib-shipkit 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
@@ -124,6 +124,8 @@ export interface MediaView {
124
124
  clip?: ReviewClip
125
125
  audio?: ReviewAudio
126
126
  marks?: ReviewMark[]
127
+ /** a line on this view alone (an option's description on a choice card) */
128
+ note?: string
127
129
  }
128
130
 
129
131
  /** one image of a gallery view, a file of the folder (`v<n>-g<k>.<ext>`) */
@@ -168,8 +170,22 @@ export interface Item {
168
170
  light?: 'review'
169
171
  /** a draft card of a repo that shoots live: no verdict until it is ready */
170
172
  wip?: { live: string; frame: number }
173
+ /** a choice: every view is an option, he scores them and picks one */
174
+ choice?: true
171
175
  }
172
176
 
177
+ /** a choice card: he picks one of its views */
178
+ export const isChoice = (i: Item): boolean => i.choice === true
179
+
180
+ /** a view that plays on its own: one clip or one sound, never a pair */
181
+ export const isPlayable = (v: ReviewView | undefined): v is MediaView => v !== undefined && !isGallery(v) && !isPair(v.files) && (v.clip !== undefined || v.audio !== undefined)
182
+
183
+ /** a card shown as options, one thin row a view: a choice, or two views or more that each play on their own */
184
+ export const asOptions = (i: Item): boolean => isChoice(i) || (i.views.length >= 2 && i.views.every(isPlayable))
185
+
186
+ /** the most stars a reaction gives */
187
+ export const STARS = 5
188
+
173
189
  /** a view's name as the page and the CLI show it: its label, or « view n » */
174
190
  export const viewName = (v: ReviewView | undefined, i: number): string => v?.label || `view ${i + 1}`
175
191
 
@@ -219,6 +235,8 @@ export interface ReviewReaction {
219
235
  tile?: number
220
236
  status?: ReviewStatus
221
237
  comment: string
238
+ /** his score of the view, 1 to STARS */
239
+ stars?: number
222
240
  }
223
241
 
224
242
  /** a state change, or a read mark (`seen`, with the ids it read in `of`) */
@@ -238,6 +256,8 @@ export interface Message {
238
256
  rects?: Rect[]
239
257
  reactions?: ReviewReaction[]
240
258
  verdict?: ReviewStatus
259
+ /** the view he picked on a choice card, its index */
260
+ pick?: number
241
261
  /** a repo's own mark on a subject (stars, a song's mark): kept as written */
242
262
  rate?: { stars?: number; mark?: string; part?: string }
243
263
  op?: Op
@@ -313,6 +333,8 @@ export interface Verdict {
313
333
  comment: string
314
334
  rects: Rect[]
315
335
  reactions?: ReviewReaction[]
336
+ /** the view he picked, on a choice card */
337
+ pick?: number
316
338
  at: string
317
339
  seen: boolean
318
340
  }
@@ -338,6 +360,7 @@ export function verdictOf(x: ReviewItem): Verdict | null {
338
360
  comment: text ?? '',
339
361
  rects: rects ?? [],
340
362
  ...(m.reactions && m.reactions.length > 0 ? { reactions: m.reactions } : {}),
363
+ ...(m.pick !== undefined ? { pick: m.pick } : {}),
341
364
  at: m.at,
342
365
  seen: readBy(x, 'agent').has(m.id),
343
366
  }
@@ -677,6 +700,7 @@ export function itemProblems(v: unknown): string[] {
677
700
  for (const k of ['what', 'commit', 'supersedes', 'why', 'keeps']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
678
701
  if (v.light !== undefined && v.light !== 'review') out.push('light: review, or absent')
679
702
  if (v.wip !== undefined && !(obj(v.wip) && typeof v.wip.live === 'string' && v.wip.live !== '' && Number.isInteger(v.wip.frame))) out.push('wip: { live, frame }, or absent')
703
+ if (v.choice !== undefined && !(v.choice === true && Array.isArray(v.views) && v.views.length >= 2)) out.push('choice: true on a card of two views or more, or absent')
680
704
  return out
681
705
  }
682
706
 
@@ -690,6 +714,7 @@ export function messageProblems(v: unknown, name: string): string[] {
690
714
  if (v.op !== undefined && !OPS.includes(v.op as Op)) out.push(`op: one of ${OPS.join(', ')}`)
691
715
  if (v.verdict !== undefined && !STATUSES.includes(v.verdict as ReviewStatus)) out.push(`verdict: one of ${STATUSES.join(', ')}`)
692
716
  for (const k of ['text', 'image', 'note']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
717
+ if (v.pick !== undefined && !(Number.isInteger(v.pick) && (v.pick as number) >= 0 && v.verdict !== undefined)) out.push('pick: a view index, on a verdict')
693
718
  if (v.op === 'seen' && !(Array.isArray(v.of) && v.of.every((x) => typeof x === 'string'))) out.push('of: the ids a seen message read')
694
719
  if (v.op === undefined && v.verdict === undefined && v.rate === undefined && typeof v.text !== 'string' && !Array.isArray(v.rects))
695
720
  out.push('a message says something: text, rects, verdict, rate or op')
@@ -713,6 +738,7 @@ function galleryProblems(v: Record<string, unknown>, at: string): string[] {
713
738
  function viewProblems(v: Record<string, unknown>, at: string): string[] {
714
739
  const out: string[] = []
715
740
  if (typeof v.label !== 'string') out.push(`${at}label: a text`)
741
+ if (v.note !== undefined && typeof v.note !== 'string') out.push(`${at}note: a text`)
716
742
  const f = v.files
717
743
  const okImage = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && !isClipFile(n) && !isAudioFile(n)
718
744
  const okClip = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && isClipFile(n)
@@ -757,7 +783,7 @@ function viewProblems(v: Record<string, unknown>, at: string): string[] {
757
783
  }
758
784
 
759
785
  /** the verdict the page sends for an item, checked: every rectangle inside its view, a side exactly on a pair */
760
- export function verdictMessageOf(body: unknown, item: Item): { ok: true; message: Pick<Message, 'verdict' | 'text' | 'rects' | 'reactions'> } | { ok: false; errors: string[] } {
786
+ export function verdictMessageOf(body: unknown, item: Item): { ok: true; message: Pick<Message, 'verdict' | 'text' | 'rects' | 'reactions' | 'pick'> } | { ok: false; errors: string[] } {
761
787
  if (!obj(body)) return { ok: false, errors: ['not an object'] }
762
788
  const errors: string[] = []
763
789
  if (!STATUSES.includes(body.status as ReviewStatus)) errors.push(`status: one of ${STATUSES.join(', ')}`)
@@ -800,7 +826,13 @@ export function verdictMessageOf(body: unknown, item: Item): { ok: true; message
800
826
  if (clip && typeof t === 'number') rect.t = t
801
827
  out.push(rect)
802
828
  }
803
- const reactions = reactionsOf(body.reactions, views, errors)
829
+ const reactions = reactionsOf(body.reactions, views, errors, isChoice(item))
830
+ const pick = body.pick
831
+ if (pick !== undefined) {
832
+ if (!isChoice(item)) errors.push('pick: only on a choice card')
833
+ else if (!(typeof pick === 'number' && Number.isInteger(pick) && pick >= 0 && pick < views.length)) errors.push(`pick: the index of one of the card's ${views.length} views`)
834
+ else if (!isApproved(body.status as ReviewStatus)) errors.push('pick: goes with approved or approved-with-fix; rejecting every option is a plain rejected')
835
+ }
804
836
  if (errors.length > 0) return { ok: false, errors }
805
837
  const comment = typeof body.comment === 'string' ? body.comment : ''
806
838
  return {
@@ -810,13 +842,14 @@ export function verdictMessageOf(body: unknown, item: Item): { ok: true; message
810
842
  ...(comment ? { text: comment } : {}),
811
843
  ...(out.length > 0 ? { rects: out } : {}),
812
844
  ...(reactions.length > 0 ? { reactions } : {}),
845
+ ...(typeof pick === 'number' ? { pick } : {}),
813
846
  },
814
847
  }
815
848
  }
816
849
 
817
850
  /** the reactions the page sends, checked against the card's views: each on a view, a tile only on a gallery, one per view
818
851
  * or tile; an empty one is dropped */
819
- function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[]): ReviewReaction[] {
852
+ function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[], choice: boolean): ReviewReaction[] {
820
853
  if (v === undefined) return []
821
854
  if (!Array.isArray(v)) {
822
855
  errors.push('reactions: a list')
@@ -835,12 +868,21 @@ function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[])
835
868
  if (tile !== undefined && !(typeof tile === 'number' && Number.isInteger(tile) && tile >= 0 && tile < tiles)) errors.push(`reactions[${i}].tile: the index of one tile of a gallery view`)
836
869
  if (r.status !== undefined && !STATUSES.includes(r.status as ReviewStatus)) errors.push(`reactions[${i}].status: one of ${STATUSES.join(', ')}`)
837
870
  if (r.comment !== undefined && typeof r.comment !== 'string') errors.push(`reactions[${i}].comment: a text`)
871
+ const stars = r.stars
872
+ if (stars !== undefined && !(choice && typeof stars === 'number' && Number.isInteger(stars) && stars >= 1 && stars <= STARS))
873
+ errors.push(choice ? `reactions[${i}].stars: 1 to ${STARS}` : `reactions[${i}].stars: only on a choice card`)
838
874
  const key = `${String(r.view)}/${String(tile ?? '')}`
839
875
  if (seen.has(key)) errors.push(`reactions[${i}]: a second reaction on the same view or tile`)
840
876
  seen.add(key)
841
877
  const comment = typeof r.comment === 'string' ? r.comment.trim() : ''
842
- if (r.status === undefined && comment === '') continue
843
- out.push({ view: r.view as number, ...(typeof tile === 'number' ? { tile } : {}), ...(r.status !== undefined ? { status: r.status as ReviewStatus } : {}), comment })
878
+ if (r.status === undefined && comment === '' && stars === undefined) continue
879
+ out.push({
880
+ view: r.view as number,
881
+ ...(typeof tile === 'number' ? { tile } : {}),
882
+ ...(r.status !== undefined ? { status: r.status as ReviewStatus } : {}),
883
+ comment,
884
+ ...(typeof stars === 'number' ? { stars } : {}),
885
+ })
844
886
  }
845
887
  return out
846
888
  }
@@ -2,12 +2,12 @@
2
2
  // walked dirs' times and its text, imported, its spec bound to the store; its sources pulled, its kinds' facts and data
3
3
  // view computed on the server, so the hub never bundles project code
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
5
- import { join } from 'node:path'
5
+ import { isAbsolute, join, relative, resolve } from 'node:path'
6
6
  import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
7
7
  import { LAYOUT } from '../core/layout.ts'
8
8
  import type { ReviewItem } from './model.ts'
9
9
  import { bindSpec, readAll, storeDir } from './store.ts'
10
- import { specOfUnit, type Fact, type Link, type ReviewUnit, type ViewText } from './unit.ts'
10
+ import { specOfUnit, type Fact, type Link, type ReviewUnit, type ViewPart, type ViewText } from './unit.ts'
11
11
 
12
12
  /** the walk's answer kept in the cache: the unit files and every dir it read, each with its time; a dir whose time moved
13
13
  * means a file came or went, so the walk runs again */
@@ -90,7 +90,12 @@ export function factLines(unit: ReviewUnit | null, x: ReviewItem): string[] {
90
90
  }
91
91
 
92
92
  /** a kind's data view of one item, what the hub draws: its facts, links, live url and text; a function that throws says so as a fact */
93
- export function viewOfItem(unit: ReviewUnit | null, x: ReviewItem): { facts: Fact[]; links: Link[]; live: string | null; text: ViewText | null } {
93
+ /** a part as the page reads it: its media a file of the item's `part-<n>` route, with its kind */
94
+ export type PartData = Omit<ViewPart, 'media'> & { media?: 'image' | 'video' }
95
+
96
+ const VIDEO = /\.(mp4|webm|mov|m4v)$/i
97
+
98
+ export function viewOfItem(unit: ReviewUnit | null, x: ReviewItem): { facts: Fact[]; links: Link[]; live: string | null; text: ViewText | null; parts: PartData[] } {
94
99
  const k = unit?.kinds?.[x.item.subject.kind]
95
100
  const tryOr = <T>(what: string, f: (() => T) | undefined, none: T, errors: Fact[]): T => {
96
101
  try {
@@ -106,5 +111,16 @@ export function viewOfItem(unit: ReviewUnit | null, x: ReviewItem): { facts: Fac
106
111
  const links = tryOr('links', k?.links && (() => k.links?.(x) ?? []), [] as Link[], errors)
107
112
  const live = tryOr<string | null>('live', k?.live && (() => k.live?.(x) ?? null), null, errors)
108
113
  const text = tryOr<ViewText | null>('text', k?.text && (() => k.text?.(x) ?? null), null, errors)
109
- return { facts: [...facts, ...source, ...errors], links, live, text }
114
+ const parts = tryOr('parts', k?.parts && (() => k.parts?.(x) ?? []), [] as ViewPart[], errors).map(({ media, ...p }): PartData => (media ? { ...p, media: VIDEO.test(media) ? 'video' : 'image' } : p))
115
+ return { facts: [...facts, ...source, ...errors], links, live, text, parts }
116
+ }
117
+
118
+ /** the repo file of part `n` of item `x`'s subject, inside the repo; null when the part names none or it is not there */
119
+ export function partFile(root: string, unit: ReviewUnit | null, x: ReviewItem, n: number): string | null {
120
+ const media = unit?.kinds?.[x.item.subject.kind]?.parts?.(x)[n]?.media
121
+ if (!media) return null
122
+ const abs = resolve(root, media)
123
+ const rel = relative(root, abs)
124
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return null
125
+ return existsSync(abs) ? abs : null
110
126
  }
@@ -8,7 +8,7 @@ import { isAnimFormat, isAnimLabels, isAnimMode, pairAnimation } from './anim.ts
8
8
  import { startAuto } from './auto.ts'
9
9
  import { bad, KEEP, NO_STORE, ranged, TYPES } from './http.ts'
10
10
  import type { Rect, Subject } from './model.ts'
11
- import { openReview, viewOfItem } from './project.ts'
11
+ import { openReview, partFile, viewOfItem } from './project.ts'
12
12
  import { roleOf } from './role.ts'
13
13
  import { addMessage, imageFile, markRead, raiseItem, rateSubject, readAllCached, readItem, specOf, storeDir, storeStamp, threadImage, writeVerdict } from './store.ts'
14
14
  import { trailerRoutes } from './trailer-routes.ts'
@@ -196,6 +196,17 @@ async function reviewRoute(req: Request, path: string, dir: string, unit: Review
196
196
  return bad(e)
197
197
  }
198
198
  }
199
+ // a part's media (a draft's clip beside its text): the file the unit names for that part, never a path the page sends
200
+ const part = /^part-(\d+)$/.exec(name)
201
+ if (part) {
202
+ try {
203
+ const x = readItem(dir, id)
204
+ const f = x ? partFile(join(dir, '..', '..'), unit, x, Number(part[1])) : null
205
+ return f ? ranged(req, f) : bad(`no media for part ${part[1]} of ${id}`, 404)
206
+ } catch (e) {
207
+ return bad(e)
208
+ }
209
+ }
199
210
  if (name === 'anim') {
200
211
  const q = new URL(req.url).searchParams
201
212
  const mode = q.get('mode') ?? 'fade'
@@ -90,6 +90,8 @@ export interface ViewInput {
90
90
  after?: string
91
91
  /** a list of small images shown as a wrapping grid, each with its label; never the first view */
92
92
  gallery?: { file: string; label: string }[]
93
+ /** a line on this view alone: an option's description on a choice card */
94
+ note?: string
93
95
  }
94
96
 
95
97
  export interface AddInput {
@@ -123,6 +125,8 @@ export interface AddInput {
123
125
  light?: 'review'
124
126
  /** a draft card on a live shot: its image a live frame, no verdict until it is ready */
125
127
  wip?: { live: string }
128
+ /** every view is an option he picks one of */
129
+ choice?: boolean
126
130
  now?: Date
127
131
  }
128
132
 
@@ -228,12 +232,13 @@ function soundLength(file: string): number {
228
232
  /** the waveform a sound view shows, one size for every sound so a pair reads side by side */
229
233
  const WAVE_SIZE = { w: 1600, h: 240 }
230
234
 
231
- /** view `n`'s waveforms, drawn into `folder` beside their place and renamed */
235
+ /** view `n`'s waveforms, drawn into `folder` beside their place and renamed: each column's peak on a square root scale,
236
+ * so a voice's quiet syllables show; drawn white on transparent, the page fills it with its own colour (a mask) */
232
237
  async function writeWaves(v: Loaded, n: number, folder: string): Promise<void> {
233
238
  if (!v.audio) return
234
239
  const draw = async (from: string, name: string): Promise<void> => {
235
240
  const to = join(folder, `${prefix(n)}${name}.png`)
236
- await ffmpeg(['-i', from, '-filter_complex', `showwavespic=s=${WAVE_SIZE.w}x${WAVE_SIZE.h}:split_channels=0:colors=0x8fb3ff`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', `${to}.tmp`])
241
+ await ffmpeg(['-i', from, '-filter_complex', `showwavespic=s=${WAVE_SIZE.w}x${WAVE_SIZE.h}:split_channels=0:scale=sqrt:filter=peak:colors=white`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', `${to}.tmp`])
237
242
  renameSync(`${to}.tmp`, to)
238
243
  }
239
244
  await draw(v.audio.from, 'wave')
@@ -245,10 +250,10 @@ const prefix = (n: number): string => (n > 0 ? `v${n}-` : '')
245
250
  /** the file name of tile `k` of gallery view `n` */
246
251
  const tileName = (n: number, k: number, path: string): string => `v${n}-g${k}${extname(path).toLowerCase()}`
247
252
 
248
- function viewOf(v: Loaded, n: number, marks: readonly ReviewMark[] = []): ReviewView {
253
+ function viewOf(v: Loaded, n: number, marks: readonly ReviewMark[] = [], note?: string): ReviewView {
249
254
  if (v.tiles) return { label: v.label, gallery: v.tiles.map((t, k): GalleryTile => ({ file: tileName(n, k, t.path), label: t.label, size: t.size })) }
250
255
  const audio = v.audio ? { audio: { duration: v.audio.duration, wave: `${prefix(n)}wave.png`, ...(v.audio.before ? { waveBefore: `${prefix(n)}before-wave.png` } : {}) } } : {}
251
- return { label: v.label, files: filesOf(v, n), size: v.size, ...clipOf(v, n), ...audio, ...(marks.length > 0 ? { marks: [...marks] } : {}) }
256
+ return { label: v.label, files: filesOf(v, n), size: v.size, ...clipOf(v, n), ...audio, ...(marks.length > 0 ? { marks: [...marks] } : {}), ...(note?.trim() ? { note: note.trim() } : {}) }
252
257
  }
253
258
 
254
259
  /** the file names of view `n` in the folder: `image.png` on the first, `v2-before.png` on a later one */
@@ -295,6 +300,8 @@ export async function addItem(dir: string, input: AddInput): Promise<Item> {
295
300
  if (given.length === 0) throw new Error('give --image, --video or --audio, or --before and --after, or --view "<label>" <image>')
296
301
  for (const [i, v] of given.entries()) if (i > 0 && !v.label.trim()) throw new Error('--view: every view after the first needs its label')
297
302
  if (given[0]?.gallery) throw new Error('a gallery is never the first view: give the card an --image (or a pair, or a clip) first')
303
+ if (input.choice && given.length < 2) throw new Error('--choice: a choice has two options or more, one --view each')
304
+ for (const v of given) if (v.note !== undefined && v.gallery) throw new Error(`--view "${v.label}": a note goes on an image, a clip or a sound, never a gallery`)
298
305
  const loaded = await Promise.all(given.map((v, i) => loadView(v, i === 0 ? 'the first view' : `--view "${v.label}"`)))
299
306
  if (input.supersedes !== undefined && !existsSync(join(dir, input.supersedes, 'item.json'))) throw new Error(`--supersedes ${input.supersedes}: no such card in ${dir}`)
300
307
  if (input.supersedes !== undefined && readItem(dir, input.supersedes)?.item.origin.by !== 'agent')
@@ -347,13 +354,14 @@ export async function addItem(dir: string, input: AddInput): Promise<Item> {
347
354
  what: input.what.trim(),
348
355
  ...(topic ? { topic } : {}),
349
356
  ...(input.commit ? { commit: input.commit } : {}),
350
- views: loaded.map((v, n) => viewOf(v, n, input.marks?.[n])),
357
+ views: loaded.map((v, n) => viewOf(v, n, input.marks?.[n], given[n]?.note)),
351
358
  ...(input.supersedes ? { supersedes: input.supersedes } : {}),
352
359
  ...(answers.length > 0 ? { answers } : {}),
353
360
  ...(input.fresh && input.why ? { why: input.why.trim() } : {}),
354
361
  ...(input.supersedes && input.keeps?.trim() ? { keeps: input.keeps.trim() } : {}),
355
362
  ...(input.light ? { light: input.light } : {}),
356
363
  ...(input.wip ? { wip: { live: input.wip.live, frame: 1 } } : {}),
364
+ ...(input.choice ? { choice: true as const } : {}),
357
365
  }
358
366
  const problems = itemProblems(item)
359
367
  if (problems.length > 0) throw new Error(problems.join('; '))
@@ -383,6 +391,7 @@ export async function addView(dir: string, id: string, v: ViewInput, o: { marks?
383
391
  const x = readItem(dir, id)
384
392
  if (!x) throw new Error(`no card ${id}`)
385
393
  if (verdictOf(x)) throw new Error(`${id} has his answer already: a new version passes --supersedes ${id}`)
394
+ if (v.note !== undefined && v.gallery) throw new Error(`"${v.label}": a note goes on an image, a clip or a sound, never a gallery`)
386
395
  if (!v.label.trim()) throw new Error('add-view: the view needs its label')
387
396
  const loaded = await loadView(v, `the view "${v.label}"`)
388
397
  const n = x.item.views.length
@@ -391,7 +400,7 @@ export async function addView(dir: string, id: string, v: ViewInput, o: { marks?
391
400
  copyFiles(loaded, n, folder)
392
401
  await writePoster(loaded, n, folder)
393
402
  await writeWaves(loaded, n, folder)
394
- const item: Item = { ...x.item, views: [...x.item.views, viewOf(loaded, n, o.marks)], ...(x.item.light || o.light ? { light: 'review' as const } : {}) }
403
+ const item: Item = { ...x.item, views: [...x.item.views, viewOf(loaded, n, o.marks, v.note)], ...(x.item.light || o.light ? { light: 'review' as const } : {}) }
395
404
  writeJson(join(folder, 'item.json'), item)
396
405
  return item
397
406
  }
@@ -11,10 +11,26 @@ export interface Fact {
11
11
  value: string
12
12
  }
13
13
 
14
- /** a link the hub opens */
14
+ /** a link the hub opens. `host`: a page of the tool that frames the hub (pkm-multi's admin), its url relative to it:
15
+ * in a frame it is posted to the host (`{ shipkit: 'open', url }`), alone it opens in a new tab */
15
16
  export interface Link {
16
17
  label: string
17
18
  url: string
19
+ host?: boolean
20
+ }
21
+
22
+ /** how a part's change reads: new, changed, gone or the same */
23
+ export type PartTone = 'new' | 'changed' | 'gone' | 'same'
24
+
25
+ /** one part of a subject the hub draws as a card of its own: a message of a post, a page of a script. `icon` an image
26
+ * url (a data url carries a logo), `media` a repo relative file the hub serves beside the text, `badge` its change */
27
+ export interface ViewPart {
28
+ label: string
29
+ detail?: string
30
+ icon?: string
31
+ badge?: { text: string; tone: PartTone }
32
+ media?: string
33
+ text: ViewText
18
34
  }
19
35
 
20
36
  /** a text the hub shows, or a change of one shown as a diff */
@@ -30,6 +46,8 @@ export interface KindDef<S extends Subject = Subject> extends SpecKind {
30
46
  /** a url the hub opens in a frame: the subject live */
31
47
  live?(x: ReviewItem): string | null
32
48
  text?(x: ReviewItem): ViewText | null
49
+ /** the subject in parts, each a card: a post's messages, with their logo, media and change */
50
+ parts?(x: ReviewItem): ViewPart[]
33
51
  /** the `sk review add` flags this kind reads, each with its help line: `--where <zone>:<x>,<y>` */
34
52
  flags?: Record<string, string>
35
53
  /** the subject (and its topic) a card of this kind gets from those flags, every flag as the list of its values;
@@ -16,7 +16,7 @@ export class AdminSvc {
16
16
  /** the colours the preview keeps while a picker moves, so a drag draws a
17
17
  * few icons, not one per frame; null follows the repo's colours */
18
18
  held: Identity | null = null
19
- /** the repo has no png mark, or it would not draw: the app wears the default icon */
19
+ /** the repo has no mark, or it would not draw: the app wears the default icon */
20
20
  noIcon = false
21
21
  private rest: ReturnType<typeof setTimeout> | null = null
22
22
 
@@ -27,7 +27,7 @@ const IdentityBox = observer(({ app }: { app: RepoSt }) => {
27
27
  <div className="adicon">
28
28
  <img src={a.previewSrc} alt={`${name} app icon`} width={128} height={128} hidden={a.noIcon}
29
29
  onLoad={() => a.previewLoaded()} onError={() => a.previewFailed()} />
30
- {a.noIcon ? <span className="dim">no png mark (.shipkit/identity/icon.png, public/logo.png or public/favicon.png): its app wears the default icon</span> : null}
30
+ {a.noIcon ? <span className="dim">no mark (.shipkit/identity/icon.png or icon.svg, public/logo.png or public/favicon.png): its app wears the default icon</span> : null}
31
31
  </div>
32
32
  <div className="adtext">
33
33
  <b>identity</b>
@@ -36,7 +36,7 @@ export type HashSel =
36
36
  /** the checks one owner ships: shipkit's, or the repo's own */
37
37
  | { t: 'checks'; from: CheckOwner }
38
38
  /** the review cards, `id` the open one, null for the first the list shows */
39
- | { t: 'review'; id: string | null }
39
+ | { t: 'review'; id: string | null; q?: string }
40
40
  /** a trailer's cuts, played with their notes: a trailer folder and one of its cuts, null for the first; the note
41
41
  * open on it, and the notes filter when it is not the page's default (all) */
42
42
  | { t: 'trailer'; trailer: string | null; cut: string | null; note?: string | null; filter?: 'open' | 'all' | null }
@@ -55,6 +55,8 @@ export type ParsedHash = {
55
55
  /** a trailer address only: the note open, the notes filter */
56
56
  note?: string
57
57
  filter?: 'open' | 'all'
58
+ /** a review address only: its view and list filters, as `view=1&subject=place` */
59
+ q?: string
58
60
  }
59
61
 
60
62
  const ITEM = 'item:'
@@ -91,7 +93,8 @@ export function hashFor(sel: HashSel | null): string | null {
91
93
  case 'git': return GIT
92
94
  case 'when': return WHEN
93
95
  case 'checks': return `${CHECKS}${sel.from}`
94
- case 'review': return sel.id == null ? REVIEW : `${REVIEW}:${sel.id}`
96
+ // page:review:<id>?view=1&subject=place, the query only when it says something
97
+ case 'review': return `${sel.id == null ? REVIEW : `${REVIEW}:${sel.id}`}${sel.q ? `?${sel.q}` : ''}`
95
98
  case 'trailer': {
96
99
  // page:trailer:<trailer>/<cut>/<note>?filter=all, each part only when it says something
97
100
  const note = sel.trailer != null && sel.note ? `/${sel.cut ?? ''}/${sel.note}` : sel.cut == null ? '' : `/${sel.cut}`
@@ -111,7 +114,12 @@ export function parseHash(raw: string): ParsedHash {
111
114
  if (raw === PLAN) return { kind: 'plan', col: null, id: '', tools: null }
112
115
  if (raw === GIT) return { kind: 'git', col: null, id: '', tools: null }
113
116
  if (raw === WHEN) return { kind: 'when', col: null, id: '', tools: null }
114
- if (raw === REVIEW || raw.startsWith(`${REVIEW}:`)) return { kind: 'review', col: null, id: raw.slice(REVIEW.length + 1), tools: null }
117
+ if (raw === REVIEW || raw.startsWith(`${REVIEW}:`) || raw.startsWith(`${REVIEW}?`)) {
118
+ const qi = raw.indexOf('?')
119
+ const path = qi === -1 ? raw : raw.slice(0, qi)
120
+ const q = qi === -1 ? '' : raw.slice(qi + 1)
121
+ return { kind: 'review', col: null, id: path.slice(REVIEW.length + 1), tools: null, ...(q ? { q } : {}) }
122
+ }
115
123
  // the trailer folder rides in `col`, its cut in `id`
116
124
  if (raw === TRAILER || raw.startsWith(`${TRAILER}:`) || raw.startsWith(`${TRAILER}?`)) {
117
125
  const qi = raw.indexOf('?')
@@ -173,7 +181,7 @@ export function selOf(p: ParsedHash): HashSel | null {
173
181
  case 'git': return { t: 'git' }
174
182
  case 'when': return { t: 'when' }
175
183
  case 'checks': return { t: 'checks', from: p.id as CheckOwner }
176
- case 'review': return { t: 'review', id: p.id === '' ? null : p.id }
184
+ case 'review': return { t: 'review', id: p.id === '' ? null : p.id, ...(p.q ? { q: p.q } : {}) }
177
185
  case 'trailer': return { t: 'trailer', trailer: p.col, cut: p.id === '' ? null : p.id, ...(p.note ? { note: p.note } : {}), ...(p.filter ? { filter: p.filter } : {}) }
178
186
  }
179
187
  }
@@ -159,9 +159,15 @@ export class HomeSt {
159
159
  return r
160
160
  }
161
161
 
162
+ /** the page is the embed page: one repo's page in another tool's frame (embed.ts) */
163
+ get embedded(): boolean {
164
+ return this.base !== undefined
165
+ }
166
+
162
167
  boot(): void {
163
168
  this.machine?.boot()
164
- this.dock.boot()
169
+ // the embed page shows one repo's page alone: no dock, so no request for the house's runs
170
+ if (this.base === undefined) this.dock.boot()
165
171
  this.disposers.push(reaction(() => this.title, (t) => { document.title = t }, { fireImmediately: true }))
166
172
  // back, forward, a pasted link and a hand edited hash all arrive
167
173
  // here; fromUrl is idempotent, so the two events firing together
@@ -20,6 +20,8 @@
20
20
  --k1:#8ca8c7;--k2:#a892c9;--k3:#6fb0b8;--k4:#c792b4;--k5:#9198a1;--k6:#8f95cf;
21
21
  /* the review section's own hue: the one row of the tree meant to be seen first */
22
22
  --review:#e879d4;
23
+ /* a wiki page's refs (wikis-view.tsx): read on demand, loaded with the page */
24
+ --ref-lazy:#e8984a;--ref-eager:#6fa8f0;
23
25
  --wash:rgba(107,143,179,.15);--wash2:rgba(107,143,179,.11);--hair:rgba(107,143,179,.5);
24
26
  --errbg:#2a1a1a;--errline:#4a2c2c;--errink:#e3bebe;
25
27
  /* a running thing's wash, the green twin of errbg and errline */
@@ -39,6 +41,7 @@
39
41
  --ground:#e3ede6;--groundline:#c6d6cb;--tag:#eef2f6;
40
42
  --k1:#3f6690;--k2:#7055a3;--k3:#2b7c86;--k4:#a34e80;--k5:#5d646d;--k6:#4f56a8;
41
43
  --review:#c0279d;
44
+ --ref-lazy:#b8600c;--ref-eager:#1f5fc8;
42
45
  --wash:rgba(59,104,150,.12);--wash2:rgba(59,104,150,.08);--hair:rgba(59,104,150,.45);
43
46
  --errbg:#fbe9e8;--errline:#efc4c2;--errink:#8a2520;
44
47
  --okbg:#e6f3eb;--okline:#9ccdaf}
@@ -271,7 +274,8 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
271
274
  background:conic-gradient(var(--chk1) 90deg,var(--chk2) 0 180deg,var(--chk1) 0 270deg,var(--chk2) 0) 0/34px 34px}
272
275
  .stage img.roll{max-width:100%;max-height:100%;object-fit:contain;filter:drop-shadow(0 6px 20px var(--shadow));z-index:5;position:relative}
273
276
  .stage .ph,.stagewrap .ph{color:var(--dim)}
274
- .stagewrap{flex:1;min-height:0;overflow-y:auto;display:flex;align-items:flex-start;justify-content:center;padding:var(--s4)}
277
+ /* every page card of a repo sits in this column, read from the left, never centred */
278
+ .stagewrap{flex:1;min-height:0;overflow-y:auto;display:flex;align-items:flex-start;justify-content:flex-start;padding:var(--s4)}
275
279
  .stageaud{display:flex;flex-direction:column;align-items:center;gap:var(--s3);font-size:48px;padding:var(--s4)}
276
280
  .stageaud audio{width:min(480px,80vw)}
277
281
 
@@ -703,6 +707,39 @@ a.tok{color:var(--accent)}
703
707
  .gvframe{flex:1;min-height:0;position:relative;display:flex;border:1px solid var(--line);border-radius:3px;background:var(--bg)}
704
708
  .gvwait{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:var(--s2);font-size:12px;color:var(--dim)}
705
709
  .graphview iframe{position:relative;flex:1;min-height:0;width:100%;border:0;background:transparent}
710
+ /* the graph and its preview panel (wikis-view.tsx) */
711
+ .gvbody{flex:1;min-height:0}
712
+ .gvbody .gvframe{height:100%}
713
+ /* the wiki's search bar, its hits dropped under it over the graph */
714
+ .wsearch{position:relative;flex:0 1 360px;min-width:160px;display:flex}
715
+ .wsearch input{flex:1;min-width:0;height:22px;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:0 var(--s3);font:inherit;font-size:12px}
716
+ .wsearch input:focus{outline:none;border-color:var(--accent)}
717
+ .wslist{position:absolute;top:calc(100% + 4px);left:0;z-index:30;width:min(560px,80vw);max-height:60vh;overflow-y:auto;padding:var(--s1) 0;background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 20px var(--shadow)}
718
+ .wshit{padding:var(--s1) var(--s3);cursor:pointer}
719
+ .wshit.on,.wshit:hover{background:var(--sel)}
720
+ .wshead{display:flex;align-items:baseline;gap:var(--s2);min-width:0}
721
+ .wshead b{flex:none;font-size:12.5px}
722
+ .wspath{min-width:0;color:var(--dim);font:11px/1.4 ui-monospace,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
723
+ .wssnip{color:var(--dim);font-size:11.5px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
724
+ .wsnone{padding:var(--s1) var(--s3);color:var(--dim);font-size:12px}
725
+ /* the page open beside the graph */
726
+ .wprev{height:100%;display:flex;flex-direction:column;min-width:0;background:var(--panel);border:1px solid var(--line);border-radius:3px}
727
+ .wphead{flex:none;display:flex;align-items:center;gap:var(--s2);padding:var(--s2) var(--s3);border-bottom:1px solid var(--line);min-width:0}
728
+ .wptitle{flex:0 1 auto;min-width:0;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
729
+ .wphead .wspath{flex:1}
730
+ .wpplace{flex:none}
731
+ .wpplace button{padding:0 var(--s1)}
732
+ .wpplace .hico{width:14px;height:14px}
733
+ .wpbtn{flex:none;display:inline-flex;align-items:center;gap:var(--s1);height:22px;padding:0 var(--s2);border:1px solid var(--line);border-radius:999px;color:var(--dim);font-size:11.5px;text-decoration:none;background:transparent;cursor:pointer}
734
+ .wpbtn:hover{color:var(--ink);border-color:var(--accent)}
735
+ .wpbtn .hico{width:13px;height:13px}
736
+ .wpclose{flex:none;padding:0 var(--s2);font-size:14px;line-height:1.2}
737
+ /* the refs of a page: on demand (→, a link, a path) orange, loaded with it (@) blue and bold */
738
+ .wpbody a[data-ref=lazy]{color:var(--ref-lazy)}
739
+ .wpbody a[data-ref=lazy] code{color:var(--ref-lazy)}
740
+ .wpbody a[data-ref=eager]{color:var(--ref-eager);font-weight:700}
741
+ .wpbody{flex:1;min-height:0;overflow-y:auto;padding:var(--s2) var(--s4) var(--s4)}
742
+ .wpbody a[data-wiki]{text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px;cursor:pointer}
706
743
 
707
744
  /* ── comfy item view (comfy-ts's own panel fills the stage) ── */
708
745
  .comfyview{flex:1;min-height:0;display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3) var(--s4) var(--s4)}
@@ -44,6 +44,12 @@ export const ICON = {
44
44
  sun: 'M10 6.5a3.5 3.5 0 1 0 .01 0M10 2v2M10 16v2M2 10h2M16 10h2M4.3 4.3l1.4 1.4M14.3 14.3l1.4 1.4M4.3 15.7l1.4-1.4M14.3 5.7l1.4-1.4',
45
45
  moon: 'M15.5 12.5A6.5 6.5 0 0 1 7.5 4.5a6.5 6.5 0 1 0 8 8z',
46
46
  auto: 'M10 3a7 7 0 1 0 .01 0M10 3v14M10 5.5h4M10 8.5h6M10 11.5h6M10 14.5h4',
47
+ // where the wiki preview sits: a frame with its panel's edge
48
+ paneLeft: 'M3 4h14v12H3zM8 4v12M4.5 7h2M4.5 9.5h2',
49
+ paneRight: 'M3 4h14v12H3zM12 4v12M13.5 7h2M13.5 9.5h2',
50
+ paneBottom: 'M3 4h14v12H3zM3 11h14M6 13.5h8',
51
+ copy: 'M7 7h9v10H7zM4 13V3h9',
52
+ editor: 'M13 3l4 3v8l-4 3-7-6 7-5zM3 7.5l3 2.5-3 2.5',
47
53
  // the project list's fold: a panel with its rail marked
48
54
  rail: 'M3 4h14v12H3zM7.5 4v12',
49
55
  // a process's own switches on the overview
@@ -16,10 +16,10 @@ export const LIST: Bounds = { key: 'hub:list-width', min: 140, max: 360, def: 20
16
16
  export const SIDEBAR: Bounds = { key: 'hub:sidebar-width', min: 150, max: 640, def: 330 }
17
17
 
18
18
  /** the rail's icon sizes, in px: the foot's button cycles them */
19
- export const RAIL_ICONS = { s: 18, m: 24, l: 32 } as const
19
+ export const RAIL_ICONS = { s: 24, m: 32, l: 40 } as const
20
20
  export type RailIcon = keyof typeof RAIL_ICONS
21
21
  /** the rail is its icon and this much on each side */
22
- const RAIL_PAD = 13
22
+ const RAIL_PAD = 6
23
23
  /** the project list folded: its icons only, a row's name in its hover card */
24
24
  export const railPx = (size: RailIcon): number => RAIL_ICONS[size] + 2 * RAIL_PAD
25
25
  const RAIL_KEY = 'hub:list-rail'
@@ -70,3 +70,35 @@ export const renderReadme = (src: string, fileUrl: (rel: string) => string): str
70
70
  },
71
71
  exclusiveFilter: (frame) => frame.tag === 'img' && (frame.attribs.src ?? '') === '',
72
72
  })
73
+
74
+ /** a wiki page in the wikis page's preview: the card allowlist, and every ref
75
+ * the server resolved (`links`, token → node, graph-pages.ts) as an anchor
76
+ * that opens that page in place instead of a new tab, marked by its kind so
77
+ * the sheet tells them apart: `@` loads with the page (eager), `→`, a link or
78
+ * a path in a code span is read on demand (lazy, written `→ path` here
79
+ * whatever the page wrote) */
80
+ export const renderWikiPage = (src: string, links: Record<string, string>): string => {
81
+ const to = (t: string, eager = false): string | null => (links[t] == null ? null : `#wiki${eager ? '-eager' : ''}:${encodeURIComponent(links[t]!)}`)
82
+ // fences stay as written: a ref in a code block is an example, not a link
83
+ const linked = src.split(/(^```[\s\S]*?^```\s*$)/m).map((part, i) => i % 2 === 1 ? part : part
84
+ .replace(/\]\(([^)\s:]+\.md)\)/g, (m, t: string) => (to(t) == null ? m : `](${to(t)})`))
85
+ .replace(/→(\s*)([\w][\w./-]*\.md)\b/g, (m, sp: string, t: string) => (to(t) == null ? m : `→${sp}[${t}](${to(t)})`))
86
+ .replace(/(^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm, (m, pre: string, t: string) => (to(t, true) == null ? m : `${pre}[@${t}](${to(t, true)})`))
87
+ // a code span that is exactly a page's path: a lazy ref, its arrow added unless the page wrote one
88
+ .replace(/(→\s*)?(?<!\[)`([\w.][\w./-]*\.md)`/g, (m, arrow: string | undefined, t: string) => (to(t) == null ? m : `${arrow ?? '→ '}[\`${t}\`](${to(t)})`)),
89
+ ).join('')
90
+ return sanitizeHtml(marked.parse(linked) as string, {
91
+ allowedTags: [...CARD_TAGS, 'h5', 'h6', 'del'],
92
+ allowedAttributes: { a: ['href', 'target', 'rel', 'data-wiki', 'data-ref'] },
93
+ allowedSchemes: ['http', 'https'],
94
+ allowProtocolRelative: false,
95
+ transformTags: {
96
+ a: (tagName, attribs): sanitizeHtml.Tag => {
97
+ const href = attribs.href ?? ''
98
+ const ref = /^#wiki(-eager)?:(.*)$/.exec(href)
99
+ if (ref != null) return { tagName, attribs: { href, 'data-wiki': decodeURIComponent(ref[2]!), 'data-ref': ref[1] != null ? 'eager' : 'lazy' } }
100
+ return { tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }
101
+ },
102
+ },
103
+ })
104
+ }
@@ -69,8 +69,8 @@
69
69
  .pgi.h5{--c:var(--k5)}
70
70
  .pgi.h6{--c:var(--k6)}
71
71
  /* the row says three things and no more (projects-svc faceOf) */
72
- /* closed in VS Code: the icon and the name dim, the badge does not */
73
- .pjrow.dim img,.pjrow.dim .pgi,.pjrow.dim .pjname{opacity:.5}
72
+ /* closed in VS Code: the name dims, never the icon (the icon is how a repo is found, and the rail has no name) */
73
+ .pjrow.dim .pjname{opacity:.5}
74
74
  /* ⌘1 to ⌘9 select the first nine rows (keys.ts): the hint sits at the row's end */
75
75
  .pjkey{flex:none;font:10px/1 ui-monospace,Menlo,monospace;color:var(--dim);opacity:.6;background:none;border:0;padding:0}
76
76
  .pjrow:hover .pjkey,.pjrow.cur .pjkey,.pjpl:hover .pjkey,.pjpl.cur .pjkey{opacity:1}
@@ -128,12 +128,14 @@
128
128
  .pjlist.rail .pjname,.pjlist.rail .pjkey,.pjlist.rail .pjstep,.pjlist.rail .pgsortl,.pjlist.rail .pjarow span,.pjlist.rail .pgerr{display:none}
129
129
  .pjlist.rail .pjrow,.pjlist.rail .pjpl,.pjlist.rail .pjarow{justify-content:center;padding:0}
130
130
  /* the rail's icon size (layout-st RAIL_ICONS, the foot's S M L button): the repo icons and the row under them grow */
131
- .pjlist.rail .pjrow{height:calc(var(--ri,18px) + 10px)}
132
- .pjlist.rail .pjrow img,.pjlist.rail .pjrow .pgi{width:var(--ri,18px);height:var(--ri,18px)}
133
- .pjlist.rail .pjrow .pgi{font-size:calc(var(--ri,18px) * .55)}
131
+ .pjlist.rail .pjrow{height:calc(var(--ri,24px) + 6px)}
132
+ .pjlist.rail .pjrow img,.pjlist.rail .pjrow .pgi{width:var(--ri,24px);height:var(--ri,24px);border-radius:6px}
133
+ .pjlist.rail .pjrow .pgi{font-size:calc(var(--ri,24px) * .5)}
134
+ /* the app icon is a rounded plate already (core/app-icon.ts) */
135
+ .pjlist.rail .pjrow img.pjapp{border-radius:0}
134
136
  .pjsize{font:600 10px/1 ui-monospace,Menlo,monospace}
135
- .pjlist.rail .pga{position:absolute;top:var(--s0);left:50%;margin-left:var(--s1);min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;font-size:9px;line-height:14px}
136
- .pjlist.rail .pgd,.pjlist.rail .pgw{position:absolute;bottom:var(--s1);left:50%;margin-left:var(--s1)}
137
+ .pjlist.rail .pga{position:absolute;top:0;right:calc(50% - var(--ri,24px) / 2 - 4px);min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;font-size:9px;line-height:14px}
138
+ .pjlist.rail .pgd,.pjlist.rail .pgw{position:absolute;bottom:var(--s0);right:calc(50% - var(--ri,24px) / 2 - 2px)}
137
139
  .pjlist.rail .pjfoot{flex-direction:column;height:auto;padding:var(--s1) 0}
138
140
  .pjlist.rail .pjfoot button{justify-content:center;width:28px;padding:0}
139
141
  .pjlist.rail .pjarbtn span{display:none}