@heyramzi/review 0.0.0-stage → 1.0.2

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/dist/node/client/http.d.ts +18 -0
  3. package/dist/node/client/http.js +79 -0
  4. package/dist/node/core/draft.d.ts +36 -0
  5. package/dist/node/core/draft.js +67 -0
  6. package/dist/node/core/enums.d.ts +13 -0
  7. package/dist/node/core/enums.js +28 -0
  8. package/dist/node/core/notes.d.ts +34 -0
  9. package/dist/node/core/notes.js +57 -0
  10. package/dist/node/core/openscreen.d.ts +63 -0
  11. package/dist/node/core/openscreen.js +97 -0
  12. package/dist/node/core/render-words.d.ts +8 -0
  13. package/dist/node/core/render-words.js +12 -0
  14. package/dist/node/core/types.d.ts +435 -0
  15. package/dist/node/core/types.js +2 -0
  16. package/dist/node/diagrams/figure.d.ts +22 -0
  17. package/dist/node/diagrams/figure.js +29 -0
  18. package/dist/node/diagrams/placeholder.d.ts +21 -0
  19. package/dist/node/diagrams/placeholder.js +42 -0
  20. package/dist/node/index.d.ts +4 -0
  21. package/dist/node/index.js +3 -0
  22. package/dist/node/mount.d.ts +49 -0
  23. package/dist/node/mount.js +63 -0
  24. package/dist/node/server/brand.d.ts +29 -0
  25. package/dist/node/server/brand.js +144 -0
  26. package/dist/node/server/errors.d.ts +5 -0
  27. package/dist/node/server/errors.js +9 -0
  28. package/dist/node/server/guards.d.ts +6 -0
  29. package/dist/node/server/guards.js +69 -0
  30. package/dist/node/server/handler.d.ts +14 -0
  31. package/dist/node/server/handler.js +143 -0
  32. package/dist/node/server/http.d.ts +33 -0
  33. package/dist/node/server/http.js +167 -0
  34. package/dist/node/server/index.d.ts +7 -0
  35. package/dist/node/server/index.js +6 -0
  36. package/dist/node/server/notes.d.ts +31 -0
  37. package/dist/node/server/notes.js +73 -0
  38. package/dist/node/ui/sprite.d.ts +11 -0
  39. package/dist/node/ui/sprite.js +56 -0
  40. package/dist/review.css +2 -0
  41. package/dist/review.js +18369 -0
  42. package/package.json +67 -3
  43. package/src/theme/base.css +346 -0
  44. package/src/theme/tokens.css +196 -0
  45. package/README.md +0 -3
@@ -0,0 +1,435 @@
1
+ /** The review contract: one model the page draws, one action union it sends, one adapter a tool implements. */
2
+ /** Where a pass is. `review` means the machine finished and beats are waiting on a person. */
3
+ export type PassState = "todo" | "started" | "review" | "done" | "blocked";
4
+ /**
5
+ * What clicking a pass opens in the main pane. `none` makes the row a status line, not a button.
6
+ */
7
+ export type PassView = "transcript" | "beats" | "music" | "handoff" | "none";
8
+ /** How a transcript view marks the words a pass touched. */
9
+ export type TranscriptLens = "read" | "cut" | "moved" | "pace";
10
+ export type Pass = {
11
+ id: string;
12
+ label: string;
13
+ /** A group id from `ReviewModel.groups`. The rail draws one small heading per group. */
14
+ group: string;
15
+ state: PassState;
16
+ view: PassView;
17
+ /** For `view: "transcript"`: which marks to light up. */
18
+ lens?: TranscriptLens;
19
+ /** For `view: "beats"`: which beat kinds this pass owns. Also filters the feedback panel. */
20
+ kinds?: string[];
21
+ /** Beats still undecided. Shown as "3 left". */
22
+ open?: number;
23
+ };
24
+ export type PassGroup = {
25
+ id: string;
26
+ label: string;
27
+ };
28
+ /** What a pass did to a stretch of words, drawn by the matching lens. */
29
+ export type LineMark = "cut" | "moved" | "pause";
30
+ export type TranscriptLine = {
31
+ /** Clock label, "0:18". */
32
+ at: string;
33
+ seconds: number;
34
+ text: string;
35
+ /** When each word of `text` starts, so a beat pin sits at its own word instead of after the line. */
36
+ wordSeconds?: number[];
37
+ marks?: LineMark[];
38
+ };
39
+ export type Paragraph = {
40
+ at: string;
41
+ chapter?: string;
42
+ lines: TranscriptLine[];
43
+ };
44
+ export type BeatState = "proposed" | "changed" | "approved" | "skipped";
45
+ /**
46
+ * A kind of beat and how it looks. The adapter names them; `tone` picks a theme colour token.
47
+ */
48
+ export type BeatKind = {
49
+ id: string;
50
+ label: string;
51
+ icon: IconName;
52
+ tone: Tone;
53
+ };
54
+ /** `radius` is the layer's own corner radius, in the same units as the box. */
55
+ export type LayoutBox = {
56
+ x: number;
57
+ y: number;
58
+ w: number;
59
+ h: number;
60
+ role: string;
61
+ radius?: number;
62
+ /** A text layer's name, such as "Caption" or "Text 1". */
63
+ text?: string;
64
+ /** How far a picture layer zooms into its media, 1 being none. */
65
+ zoom?: number;
66
+ };
67
+ /** The boxes a layout diagram is drawn from, in the card's own frame. */
68
+ export type LayoutPreview = {
69
+ w: number;
70
+ h: number;
71
+ boxes: LayoutBox[];
72
+ };
73
+ /** One card of a layout pack, as the library draws it. */
74
+ export type LibraryCard = {
75
+ name: string;
76
+ group?: string;
77
+ job?: string;
78
+ preview: LayoutPreview;
79
+ /** The plain text the card's spark button copies for an agent. No button without it. */
80
+ agent?: string;
81
+ /** A clip (webm with alpha) that fills the card's Media slot, shown as its thumbnail instead of the empty frame. */
82
+ clip?: string;
83
+ };
84
+ /** One sound of a roster, as the sound library draws it. */
85
+ export type SoundCard = {
86
+ /** The key everything else uses. The page never shows it. */
87
+ id: string;
88
+ /** The plain name the card shows. Equal to `id` when the roster named none, and the card then words the id itself. */
89
+ name: string;
90
+ group?: string;
91
+ /** What the sound is for, in one plain line with no ids or video codes. The roster's own words; none is invented. */
92
+ job?: string;
93
+ /** The audio's URL. Absent when no file of the sound exists on this machine. */
94
+ src?: string;
95
+ seconds: number;
96
+ /** The pin's gain times the clip's own level, as the renderer plays it. Null while the level is unmeasured. */
97
+ gain: number | null;
98
+ /** The plain text the card's spark button copies for an agent. No button without it. */
99
+ agent?: string;
100
+ };
101
+ export type BeatOption = {
102
+ id: string;
103
+ name: string;
104
+ kind: "video" | "audio" | "layout" | "image";
105
+ /** A URL the page can load as is. Adapters resolve paths to URLs, never the page. */
106
+ src?: string;
107
+ /** For `kind: "layout"`: the boxes a layout diagram is drawn from. */
108
+ preview?: LayoutPreview;
109
+ meta?: string;
110
+ /** A transparent-background render. The card wears an Alpha tag. */
111
+ alpha?: boolean;
112
+ };
113
+ export type Beat = {
114
+ id: string;
115
+ kind: string;
116
+ seconds: number;
117
+ at: string;
118
+ /** The spoken line the beat sits on. */
119
+ line: string;
120
+ options: BeatOption[];
121
+ state: BeatState;
122
+ pick?: string;
123
+ /** Its line is no longer in the cut. Reported, never dropped. */
124
+ orphaned?: boolean;
125
+ chat?: boolean;
126
+ /** Why it has nothing to show yet, e.g. "Rendering". */
127
+ pending?: string;
128
+ };
129
+ export type Music = {
130
+ decision: "undecided" | "kept" | "skipped";
131
+ keep?: string;
132
+ /** More tracks were asked for and haven't arrived. */
133
+ more: boolean;
134
+ options: {
135
+ id: string;
136
+ title: string;
137
+ mood: string;
138
+ seconds: number;
139
+ src?: string;
140
+ }[];
141
+ };
142
+ export type Lane = {
143
+ id: string;
144
+ label: string;
145
+ spans: {
146
+ from: number;
147
+ to: number;
148
+ label?: string;
149
+ tone?: Tone;
150
+ }[];
151
+ };
152
+ /** One piece of a take as edit.json holds it, in source seconds. The cut plays the kept ones in this order. */
153
+ export type LiveRange = {
154
+ source: string;
155
+ start: number;
156
+ end: number;
157
+ kept: boolean;
158
+ };
159
+ /** What the cut pass plays on its own: the edit's ranges and each take's proxy, so a note moves the cut with no render. */
160
+ export type LivePlan = {
161
+ fps: number;
162
+ ranges: LiveRange[];
163
+ sources: Record<string, string>;
164
+ };
165
+ export type Player = {
166
+ /** The rendered preview. Absent until the first one is made; the cut pass doesn't need it. */
167
+ src?: string;
168
+ duration: number;
169
+ poster?: string;
170
+ lanes?: Lane[];
171
+ /** Present for a local edit. The cut pass plays this; every other pass plays `src`. */
172
+ live?: LivePlan;
173
+ };
174
+ /** Where a point or an area sits on the frame, 0 to 1 from the top left. */
175
+ export type Frame = {
176
+ x: number;
177
+ y: number;
178
+ w?: number;
179
+ h?: number;
180
+ };
181
+ /**
182
+ * What a note is about: a beat, a transcript range, a moment of the cut, or the whole video.
183
+ */
184
+ export type NoteTarget = {
185
+ kind: "video";
186
+ } | {
187
+ kind: "beat";
188
+ beat: string;
189
+ } | {
190
+ kind: "text";
191
+ from: string;
192
+ to: string;
193
+ quote: string;
194
+ } | {
195
+ kind: "time";
196
+ at: number;
197
+ span?: number;
198
+ frame?: Frame;
199
+ anchor?: {
200
+ source: string;
201
+ at: number;
202
+ };
203
+ };
204
+ /**
205
+ * open → queued → working → answered, then the person ticks it `done`.
206
+ */
207
+ export type NoteStatus = "open" | "queued" | "working" | "answered" | "done" | "proposal" | "dismissed";
208
+ export type Note = {
209
+ id: string;
210
+ target: NoteTarget;
211
+ text: string;
212
+ status: NoteStatus;
213
+ by: "person" | "agent";
214
+ reply?: string;
215
+ thread?: {
216
+ by: "person" | "agent";
217
+ text: string;
218
+ }[];
219
+ /** What the agent that took it says it's doing now. A reply clears it. */
220
+ working?: {
221
+ text: string;
222
+ at: string;
223
+ agent?: string;
224
+ };
225
+ /** The pass this note belongs to, so selecting a pass narrows the panel. Adapters set it. */
226
+ pass?: string;
227
+ /** Its moment was cut out of the edit. */
228
+ orphaned?: boolean;
229
+ /** Marked true if the note was added from the CLI (chat). */
230
+ chat?: boolean;
231
+ /** A failing cut check the studio shows as a note. Only the check clears it, so the page offers no verbs on it. `png` is the filmstrip of its cut, when the check made one. */
232
+ check?: {
233
+ png: string | null;
234
+ };
235
+ createdAt: string;
236
+ };
237
+ export type Tone = "neutral" | "accent" | "ok" | "warn" | "bad";
238
+ /** Whether an agent is on the other end. Drives the agent dock. */
239
+ export type Session = "offline" | "idle" | "listening";
240
+ /**
241
+ * Which tool owns the edit. `openscreen` is the experimental backend, and it turns on the
242
+ * project page's Preview, Sync and Export actions.
243
+ */
244
+ export type ReviewBackend = "descript" | "local" | "openscreen";
245
+ /** What the renderer says it's doing while it draws a preview or a final. */
246
+ export type RenderProgress = {
247
+ stage: "preparing";
248
+ } | {
249
+ stage: "matting";
250
+ done: number;
251
+ total: number;
252
+ } | {
253
+ stage: "rendering";
254
+ done: number;
255
+ total: number;
256
+ } | {
257
+ stage: "encoding";
258
+ done: number;
259
+ total: number;
260
+ } | {
261
+ stage: "finishing";
262
+ };
263
+ /**
264
+ * Where one render of a video stands. `current` matches the video's inputs, `none` was never made.
265
+ * `outdated` is a final whose inputs moved since it was made. A preview never sits outdated: it joins the line.
266
+ * `queued` and `rendering` carry `outdated` when a file already on disk is the one they replace.
267
+ */
268
+ export type JobStatus = {
269
+ state: "current";
270
+ } | {
271
+ state: "none";
272
+ } | {
273
+ state: "outdated";
274
+ } | {
275
+ state: "queued";
276
+ ahead: number;
277
+ outdated: boolean;
278
+ } | {
279
+ state: "rendering";
280
+ elapsedMs: number;
281
+ expectedMs?: number;
282
+ progress?: RenderProgress;
283
+ outdated: boolean;
284
+ } | {
285
+ state: "failed";
286
+ stage: "probe" | "render";
287
+ error: string;
288
+ };
289
+ /** The cut check that follows a preview. Its failing cuts arrive as notes, so the page keeps only the count. */
290
+ export type CheckStatus = {
291
+ state: "none";
292
+ } | {
293
+ state: "waiting";
294
+ } | {
295
+ state: "checking";
296
+ } | {
297
+ state: "done";
298
+ checkedAt: string;
299
+ cuts: number;
300
+ failing: number;
301
+ } | {
302
+ state: "error";
303
+ error: string;
304
+ };
305
+ /** Everything a video is rendering: its preview, its final, the check on the preview and the clips of its beats. */
306
+ export type RenderStatus = {
307
+ preview: JobStatus;
308
+ final: JobStatus;
309
+ /** The take proxies the cut pass plays. Never `current` until every take has one. */
310
+ live: JobStatus;
311
+ check: CheckStatus;
312
+ /** Beat clips not on disk yet, by beat id. */
313
+ clips: Record<string, "queued" | "rendering" | "failed">;
314
+ };
315
+ /**
316
+ * Speech the transcript check heard and no model could put words to. The page shows it where it
317
+ * plays and takes the line a person types; `seconds` is on the cut's clock, `start` and `end` in the take.
318
+ */
319
+ export type SpeechGap = {
320
+ source: string;
321
+ start: number;
322
+ end: number;
323
+ /** Where it plays in the cut. A stretch the cut removed sits at the next kept line. */
324
+ seconds: number;
325
+ cut?: boolean;
326
+ };
327
+ export type ReviewModel = {
328
+ id: string;
329
+ title: string;
330
+ /** "In Descript", "On this computer". */
331
+ route: string;
332
+ /** The tool behind the edit, carried from the edit by the shared model. Absent means the page shows nothing extra. */
333
+ backend?: ReviewBackend;
334
+ /** Where "Open in Descript" goes: the project on the web, and the same project through the desktop app's link. Absent for a project that isn't a Descript one. */
335
+ descript?: {
336
+ web: string;
337
+ app: string;
338
+ };
339
+ /** The one line the header says about where the video is, e.g. "Reviewing inserts". */
340
+ status: {
341
+ tone: Tone;
342
+ text: string;
343
+ };
344
+ groups: PassGroup[];
345
+ passes: Pass[];
346
+ kinds: BeatKind[];
347
+ paragraphs: Paragraph[];
348
+ beats: Beat[];
349
+ music?: Music;
350
+ player?: Player;
351
+ /** Where the video's preview, final, cut check and clips stand. Absent for tools that don't render. The checks that failed arrive as notes with `check` set. */
352
+ render?: RenderStatus;
353
+ /** Speech with no words yet, from the last transcript check. Absent when there is none. */
354
+ speechGaps?: SpeechGap[];
355
+ notes: Note[];
356
+ session: Session;
357
+ /** The prompt the agent dock copies: pasted into your agent, it plugs that session into this page. */
358
+ connect?: string;
359
+ /** The file the model was read from failed its schema. The page shows it with a fix action. */
360
+ error?: string;
361
+ };
362
+ export type NoteAct = "tick" | "reopen" | "accept" | "dismiss" | "remove";
363
+ export type ReviewAction = {
364
+ type: "decide";
365
+ beat: string;
366
+ state: BeatState;
367
+ pick?: string;
368
+ } | {
369
+ type: "music";
370
+ keep: string;
371
+ } | {
372
+ type: "music";
373
+ skip: true;
374
+ } | {
375
+ type: "music";
376
+ more: true;
377
+ } | {
378
+ type: "note";
379
+ target: NoteTarget;
380
+ text: string;
381
+ chat?: boolean;
382
+ reply?: string;
383
+ } | {
384
+ type: "note.act";
385
+ note: string;
386
+ act: NoteAct;
387
+ } | {
388
+ type: "note.reply";
389
+ note: string;
390
+ text: string;
391
+ } | {
392
+ type: "speech";
393
+ source: string;
394
+ start: number;
395
+ end: number;
396
+ text: string;
397
+ } | {
398
+ type: "refresh";
399
+ };
400
+ export type ActionResult = {
401
+ ok: true;
402
+ id?: string;
403
+ } | {
404
+ ok: false;
405
+ error: string;
406
+ };
407
+ /**
408
+ * A tool plugs into the review by implementing this on the server, so it never touches a route.
409
+ */
410
+ export interface ReviewAdapter {
411
+ /** Build the model for one video. Throw a `ReviewError` for a missing video. */
412
+ load(id: string): Promise<ReviewModel>;
413
+ /** Apply one action. Validation of the action's shape has already happened. */
414
+ act(id: string, action: ReviewAction): Promise<ActionResult>;
415
+ /** The file on disk behind a media URL the model handed out, or null to refuse it. */
416
+ media(id: string, file: string): string | null;
417
+ /** Call `changed` whenever the video's files change. Return the unsubscribe. */
418
+ watch(id: string, changed: () => void): () => void;
419
+ /** The shelves the note box's @ picker offers. A pick is written into the note as `[shelf: item id]`. */
420
+ library?(id: string): Promise<Shelf[]>;
421
+ }
422
+ /** One shelf of reusable media: motion templates, layouts, b-roll, sounds. `src` is a preview URL. */
423
+ export type Shelf = {
424
+ id: string;
425
+ label: string;
426
+ items: {
427
+ id: string;
428
+ name: string;
429
+ src?: string;
430
+ meta?: string;
431
+ preview?: LayoutPreview;
432
+ }[];
433
+ };
434
+ /** Every icon the package draws. One sprite, one stroke weight. */
435
+ export type IconName = "layout" | "motion" | "broll" | "sound" | "music" | "caption" | "zoom" | "note" | "check" | "refresh" | "keys" | "plug" | "panel-left" | "panel-right" | "back" | "x" | "play" | "pause" | "clock" | "span" | "frame" | "quote" | "video" | "trash" | "undo" | "send" | "chevron" | "plus" | "expand" | "minus" | "alert" | "bot" | "sun" | "moon" | "spark" | "info" | "skip-back" | "skip-fwd" | "external";
@@ -0,0 +1,2 @@
1
+ /** The review contract: one model the page draws, one action union it sends, one adapter a tool implements. */
2
+ export {};
@@ -0,0 +1,22 @@
1
+ import type { LayoutBox } from "../core/types.js";
2
+ /**
3
+ * A bust in head radii: a round head on a short neck, broad rounded shoulders and a torso that
4
+ * widens to the bottom edge. Head centre at 42% height, grown by the zoom.
5
+ * Every layer, a cut-out included, draws its figure from its own rect: the keyed person in the render
6
+ * stands in the cut-out layer's box, so a taller cut-out puts the head above a shorter camera panel.
7
+ */
8
+ export declare function person(box: LayoutBox): {
9
+ r: number;
10
+ cx: number;
11
+ cy: number;
12
+ top: number;
13
+ head: string;
14
+ body: string;
15
+ neck: string;
16
+ chin: {
17
+ cx: number;
18
+ cy: number;
19
+ rx: number;
20
+ ry: number;
21
+ };
22
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A bust in head radii: a round head on a short neck, broad rounded shoulders and a torso that
3
+ * widens to the bottom edge. Head centre at 42% height, grown by the zoom.
4
+ * Every layer, a cut-out included, draws its figure from its own rect: the keyed person in the render
5
+ * stands in the cut-out layer's box, so a taller cut-out puts the head above a shorter camera panel.
6
+ */
7
+ export function person(box) {
8
+ const zoom = box.zoom ?? 1;
9
+ const r = Math.min(box.w * 0.18, box.h * 0.15) * zoom;
10
+ const cx = box.x + box.w / 2;
11
+ const cy = box.y + box.h * (0.42 + (zoom - 1) * 0.2);
12
+ const bottom = box.y + box.h + r;
13
+ // One point, dx and dy in head radii from the head centre, k = -1 for the left side.
14
+ const at = (k, dx, dy) => `${cx + k * dx * r} ${cy + dy * r}`;
15
+ const head = `M${at(1, 0, -1.1)}` +
16
+ `C${at(1, 0.64, -1.1)} ${at(1, 1.12, -0.62)} ${at(1, 1.12, -0.05)}` +
17
+ `C${at(1, 1.12, 0.52)} ${at(1, 0.72, 1.12)} ${at(1, 0, 1.12)}` +
18
+ `C${at(-1, 0.72, 1.12)} ${at(-1, 1.12, 0.52)} ${at(-1, 1.12, -0.05)}` +
19
+ `C${at(-1, 1.12, -0.62)} ${at(-1, 0.64, -1.1)} ${at(1, 0, -1.1)}Z`;
20
+ const neck = `M${at(-1, 0.5, 0.6)}L${at(-1, 0.56, 1.6)}H${cx + 0.56 * r}L${at(1, 0.5, 0.6)}Z`;
21
+ // Neck base, a gentle slope out to a rounded shoulder, then down and out to the bottom edge.
22
+ const side = (k) => `C${at(k, 1.1, 1.5)} ${at(k, 1.8, 1.6)} ${at(k, 2.2, 1.95)}` +
23
+ `C${at(k, 2.5, 2.2)} ${at(k, 2.6, 2.6)} ${at(k, 2.65, 3.2)}`;
24
+ const body = `M${at(-1, 0.56, 1.45)}${side(-1)}L${cx - 2.85 * r} ${bottom}H${cx + 2.85 * r}` +
25
+ `L${at(1, 2.65, 3.2)}C${at(1, 2.6, 2.6)} ${at(1, 2.5, 2.2)} ${at(1, 2.2, 1.95)}` +
26
+ `C${at(1, 1.8, 1.6)} ${at(1, 1.1, 1.5)} ${at(1, 0.56, 1.45)}Q${at(1, 0, 1.75)} ${at(-1, 0.56, 1.45)}Z`;
27
+ const chin = { cx, cy: cy + r * 1.0, rx: r * 0.5, ry: r * 0.12 };
28
+ return { r, cx, cy, top: cy - 1.1 * r, head, body, neck, chin };
29
+ }
@@ -0,0 +1,21 @@
1
+ /** Descript's empty picture box (fill #0b0b0b, ink #818181, icon 36px, label 23px on a 703px card), sized as fractions of FRAME width. No imports, so Node can load it. */
2
+ export declare const PLACEHOLDER: {
3
+ readonly fill: "#0b0b0b";
4
+ readonly ink: "#818181";
5
+ };
6
+ type Placeholder = {
7
+ /** Where the box sits when it is nested in a bigger SVG. Omit both for a standalone SVG. */
8
+ x?: number;
9
+ y?: number;
10
+ w: number;
11
+ h: number;
12
+ /** The box's own corner radius, in the same units as w and h. */
13
+ radius?: number;
14
+ /** The card's picker name. Left off, only the icon draws. */
15
+ label?: string;
16
+ /** The frame's width in the same units, which everything scales from. */
17
+ frameW: number;
18
+ };
19
+ /** The SVG markup for one placeholder box. */
20
+ export declare function placeholderSvg({ x, y, w, h, radius, label, frameW }: Placeholder): string;
21
+ export {};
@@ -0,0 +1,42 @@
1
+ /** Descript's empty picture box (fill #0b0b0b, ink #818181, icon 36px, label 23px on a 703px card), sized as fractions of FRAME width. No imports, so Node can load it. */
2
+ export const PLACEHOLDER = { fill: "#0b0b0b", ink: "#818181" };
3
+ /** Descript's 703px card, measured: icon 36px, label 23px, stroke 2.5px, 32px between them. */
4
+ const ICON = 36 / 703;
5
+ const FONT = 23 / 703;
6
+ const STROKE = 2.5 / 703;
7
+ const GAP = 14 / 703;
8
+ const esc = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
9
+ const num = (n) => Math.round(n * 100) / 100;
10
+ /** The SVG markup for one placeholder box. */
11
+ export function placeholderSvg({ x, y, w, h, radius = 0, label, frameW }) {
12
+ // A small box (the bubble) shrinks the icon to fit; a big one keeps Descript's proportion.
13
+ const fit = Math.min(1, (Math.min(w, h) * 0.42) / (ICON * frameW));
14
+ const icon = ICON * frameW * fit;
15
+ const font = FONT * frameW * fit;
16
+ const gap = GAP * frameW * fit;
17
+ const words = label?.trim() ?? "";
18
+ const textW = words.length * font * 0.56;
19
+ const showText = words !== "" && textW < w * 0.9 && icon + gap + font * 1.2 < h * 0.8;
20
+ const showIcon = icon * 1.5 < Math.min(w, h);
21
+ const rx = Math.min(radius, w / 2, h / 2);
22
+ const total = showText ? icon + gap + font : icon;
23
+ const top = (h - total) / 2;
24
+ const stroke = Math.max(STROKE * frameW * fit, 0.5);
25
+ // A 24-unit layout icon: a rounded square, a tall left pane, two stacked right panes.
26
+ const k = icon / 24;
27
+ const at = `translate(${num((w - icon) / 2)} ${num(top)}) scale(${k.toFixed(5)})`;
28
+ const line = `fill="none" stroke="${PLACEHOLDER.ink}" stroke-width="${num(stroke / k)}" stroke-linecap="round" stroke-linejoin="round"`;
29
+ const pos = x === undefined || y === undefined ? "" : ` x="${num(x)}" y="${num(y)}"`;
30
+ const size = x === undefined
31
+ ? ` width="100%" height="100%" preserveAspectRatio="none"`
32
+ : ` width="${num(w)}" height="${num(h)}"`;
33
+ return (`<svg class="ph"${pos}${size} viewBox="0 0 ${num(w)} ${num(h)}" overflow="hidden">` +
34
+ `<rect width="${num(w)}" height="${num(h)}" rx="${num(rx)}" fill="${PLACEHOLDER.fill}"/>` +
35
+ (showIcon
36
+ ? `<g transform="${at}" ${line}><rect x="2" y="3" width="20" height="18" rx="3.5"/><path d="M10.5 3v18M10.5 12H22"/></g>`
37
+ : "") +
38
+ (showIcon && showText
39
+ ? `<text x="${num(w / 2)}" y="${num(top + icon + gap + font * 0.78)}" text-anchor="middle" font-size="${num(font)}" font-weight="300" font-style="normal" font-family="'DM Sans','Inter',ui-sans-serif,system-ui,sans-serif" fill="${PLACEHOLDER.ink}">${esc(words)}</text>`
40
+ : "") +
41
+ `</svg>`);
42
+ }
@@ -0,0 +1,4 @@
1
+ export { mountLabs, mountLayoutLibrary, mountReview, mountSoundLibrary, mountStartChoice, type MountOptions, type MountedHost, type MountedReview, type StartChoiceOptions, } from "./mount.js";
2
+ export type * from "./core/types.js";
3
+ export { FEATURES_OFF, hostPost, OPENSCREEN_RELEASES, OPENSCREEN_ROUTES, OPENSCREEN_TESTED_VERSION, gateOpen, isOpenScreen, readFeatures, secondsToMs, type Features, type HostResult, } from "./core/openscreen.js";
4
+ export { progressOf, renderEstimate, type RenderEstimate } from "./core/draft.js";
@@ -0,0 +1,3 @@
1
+ export { mountLabs, mountLayoutLibrary, mountReview, mountSoundLibrary, mountStartChoice, } from "./mount.js";
2
+ export { FEATURES_OFF, hostPost, OPENSCREEN_RELEASES, OPENSCREEN_ROUTES, OPENSCREEN_TESTED_VERSION, gateOpen, isOpenScreen, readFeatures, secondsToMs, } from "./core/openscreen.js";
3
+ export { progressOf, renderEstimate } from "./core/draft.js";
@@ -0,0 +1,49 @@
1
+ import { type ReviewClient } from "./client/http.js";
2
+ import type { Features } from "./core/openscreen.js";
3
+ import type { LibraryCard, SoundCard } from "./core/types.js";
4
+ import "../../src/theme/tokens.css";
5
+ import "../../src/theme/base.css";
6
+ export type MountOptions = {
7
+ /** URL prefix the server handler was created with, e.g. "/review". */
8
+ base: string;
9
+ /** The video id. */
10
+ id: string;
11
+ /** Where the Back button goes. */
12
+ backHref?: string;
13
+ /** The video's width over its height, when the host knows it before anything loads. The skeleton and the players start in that shape, not 16:9. */
14
+ ratio?: number;
15
+ /** Swap the HTTP client, for tests. */
16
+ client?: ReviewClient;
17
+ };
18
+ export type MountedReview = {
19
+ destroy(): void;
20
+ };
21
+ /** A component a host page mounts on its own, out of the review page's way. */
22
+ export type MountedHost = {
23
+ destroy(): void;
24
+ };
25
+ /** Draw the review page into `target`. The target should have a height, the page fills it. */
26
+ export declare function mountReview(target: HTMLElement, opts: MountOptions): MountedReview;
27
+ export type StartChoiceOptions = {
28
+ /** The Descript prompt the card offers, the same line the shelf already says to the agent. */
29
+ say?: string;
30
+ /** Read the gate before mounting, so a test can stand in for `/features`. */
31
+ features?: Features;
32
+ /** Told the gate, so the host can hide a duplicate prompt when it is on. */
33
+ onFeatures?: (features: Features) => void;
34
+ };
35
+ /** Draw the "Start with" choice into `target`. The gate off draws nothing at all. */
36
+ export declare function mountStartChoice(target: HTMLElement, opts?: StartChoiceOptions): MountedHost;
37
+ /** Draw every card of a layout pack into `target`, grouped and searchable. */
38
+ export declare function mountLayoutLibrary(target: HTMLElement, opts: {
39
+ layouts: LibraryCard[];
40
+ }): MountedHost;
41
+ /** Draw every sound of a roster into `target`: playable, grouped and searchable. */
42
+ export declare function mountSoundLibrary(target: HTMLElement, opts: {
43
+ sounds: SoundCard[];
44
+ }): MountedHost;
45
+ /** Draw the setup page's Labs toggle into `target`. */
46
+ export declare function mountLabs(target: HTMLElement, opts?: {
47
+ features?: Features;
48
+ onFeatures?: (features: Features) => void;
49
+ }): MountedHost;
@@ -0,0 +1,63 @@
1
+ import { mount, unmount } from "svelte";
2
+ import { createHttpClient } from "./client/http.js";
3
+ import App from "./ui/App.svelte";
4
+ import Labs from "./ui/Labs.svelte";
5
+ import LayoutLibrary from "./ui/LayoutLibrary.svelte";
6
+ import SoundLibrary from "./ui/SoundLibrary.svelte";
7
+ import StartChoice from "./ui/StartChoice.svelte";
8
+ import { ensureSprite } from "./ui/sprite.js";
9
+ import "../../src/theme/tokens.css";
10
+ import "../../src/theme/base.css";
11
+ /** Draw the review page into `target`. The target should have a height, the page fills it. */
12
+ export function mountReview(target, opts) {
13
+ ensureSprite();
14
+ target.replaceChildren();
15
+ const app = mount(App, {
16
+ target,
17
+ props: {
18
+ client: opts.client ?? createHttpClient({ base: opts.base, id: opts.id }),
19
+ backHref: opts.backHref ?? "/",
20
+ ratio: opts.ratio,
21
+ },
22
+ });
23
+ return { destroy: () => void unmount(app) };
24
+ }
25
+ /**
26
+ * A wrapper carrying `.rv`, because Svelte's mount writes the component's class onto the target, which can't be a host node with its own classes.
27
+ */
28
+ function rvHost(target) {
29
+ const host = document.createElement("div");
30
+ host.className = "rv";
31
+ target.replaceChildren(host);
32
+ return host;
33
+ }
34
+ /** Draw the "Start with" choice into `target`. The gate off draws nothing at all. */
35
+ export function mountStartChoice(target, opts = {}) {
36
+ ensureSprite();
37
+ const app = mount(StartChoice, {
38
+ target: rvHost(target),
39
+ props: { say: opts.say, features: opts.features, onFeatures: opts.onFeatures },
40
+ });
41
+ return { destroy: () => void unmount(app) };
42
+ }
43
+ /** Draw every card of a layout pack into `target`, grouped and searchable. */
44
+ export function mountLayoutLibrary(target, opts) {
45
+ ensureSprite();
46
+ const app = mount(LayoutLibrary, { target: rvHost(target), props: { layouts: opts.layouts } });
47
+ return { destroy: () => void unmount(app) };
48
+ }
49
+ /** Draw every sound of a roster into `target`: playable, grouped and searchable. */
50
+ export function mountSoundLibrary(target, opts) {
51
+ ensureSprite();
52
+ const app = mount(SoundLibrary, { target: rvHost(target), props: { sounds: opts.sounds } });
53
+ return { destroy: () => void unmount(app) };
54
+ }
55
+ /** Draw the setup page's Labs toggle into `target`. */
56
+ export function mountLabs(target, opts = {}) {
57
+ ensureSprite();
58
+ const app = mount(Labs, {
59
+ target: rvHost(target),
60
+ props: { features: opts.features, onFeatures: opts.onFeatures },
61
+ });
62
+ return { destroy: () => void unmount(app) };
63
+ }