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.
- package/CHANGELOG.md +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/review/model.ts
CHANGED
|
@@ -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({
|
|
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
|
}
|
package/src/review/project.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/review/routes.ts
CHANGED
|
@@ -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'
|
package/src/review/store.ts
CHANGED
|
@@ -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=
|
|
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
|
}
|
package/src/review/unit.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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}:`)
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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'
|
package/src/serve/hub-app/md.ts
CHANGED
|
@@ -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
|
|
73
|
-
.pjrow.dim
|
|
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,
|
|
132
|
-
.pjlist.rail .pjrow img,.pjlist.rail .pjrow .pgi{width:var(--ri,
|
|
133
|
-
.pjlist.rail .pjrow .pgi{font-size:calc(var(--ri,
|
|
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:
|
|
136
|
-
.pjlist.rail .pgd,.pjlist.rail .pgw{position:absolute;bottom:var(--
|
|
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}
|