@hajimism/dek 0.1.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 (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -0,0 +1,431 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { Browser } from "playwright";
5
+ import { DekError } from "./error.ts";
6
+ import type { Fill } from "./fill.ts";
7
+ import { type Collision, EDGES, type Overflow } from "./overflow.ts";
8
+ import { moduleFilePath } from "./path.ts";
9
+ import type { MotionBeat, SheetSpec } from "./sheet.ts";
10
+ import { exitWorker, readWorkerRequest, runJsonWorker, workerCommand } from "./spawn.ts";
11
+ import type { Position } from "./step.ts";
12
+
13
+ /** One slide page to measure; a page that names a `screenshotPath` is shot there too. */
14
+ export type VisualPage = {
15
+ html: string;
16
+ slug: string;
17
+ step: string;
18
+ screenshotPath?: string;
19
+ };
20
+
21
+ /** Freeze the view transition from `from` to `to` at `at` (0..1) and screenshot it. */
22
+ export type MorphSpec = {
23
+ from: Position;
24
+ to: Position;
25
+ at: number;
26
+ };
27
+
28
+ /**
29
+ * Capture a slide's beats as they move: for each, the go that begins it, frozen at `fractions`
30
+ * of how long that go runs, then its settled end. The page is the video document.
31
+ */
32
+ export type MotionSpec = {
33
+ /** Where the talk stands before the first beat; none when the deck starts there. */
34
+ from?: Position;
35
+ beats: Array<{ position: Position; label: string }>;
36
+ fractions: number[];
37
+ /** Where the frames and the sheets go. */
38
+ dir: string;
39
+ title: string;
40
+ };
41
+
42
+ /** What to measure on each page; a screenshot is asked for by the page's `screenshotPath`. */
43
+ export type PageAction = "overflow" | "contrast" | "fill";
44
+
45
+ type Viewport = { width: number; height: number };
46
+
47
+ /** Each page measured as `actions` asks and shot where it says, then `sheets` drawn from disk. */
48
+ export type PagesRequest = {
49
+ kind: "pages";
50
+ viewport: Viewport;
51
+ actions: PageAction[];
52
+ pages: VisualPage[];
53
+ /** Contact sheets to draw once the pages are done, from images on disk. */
54
+ sheets?: SheetSpec[];
55
+ };
56
+
57
+ /** One page printed to `pdfPath`, a PDF page per slide. */
58
+ export type PdfRequest = {
59
+ kind: "pdf";
60
+ viewport: Viewport;
61
+ html: string;
62
+ pdfPath: string;
63
+ };
64
+
65
+ /** One frame of a move between slides, in the video document `html`. */
66
+ export type MorphRequest = {
67
+ kind: "morph";
68
+ viewport: Viewport;
69
+ html: string;
70
+ screenshotPath: string;
71
+ morph: MorphSpec;
72
+ };
73
+
74
+ /** A slide's beats in motion, in the video document `html`. */
75
+ export type MotionRequest = {
76
+ kind: "motion";
77
+ viewport: Viewport;
78
+ html: string;
79
+ motion: MotionSpec;
80
+ };
81
+
82
+ /** What the Playwright worker is asked; the kind says which payload comes with it. */
83
+ export type VisualRequest = PagesRequest | PdfRequest | MorphRequest | MotionRequest;
84
+
85
+ type OverflowFinding = Overflow & { slug: string; step: string };
86
+
87
+ export type ContrastFinding = {
88
+ slug: string;
89
+ step: string;
90
+ ratio: number;
91
+ box: string;
92
+ text?: string;
93
+ /** Computed colors as CSS rgb(). */
94
+ fg: string;
95
+ bg: string;
96
+ /** Computed font-size in px. */
97
+ fontSize: number;
98
+ /** Computed font-weight as a number. */
99
+ fontWeight: number;
100
+ /**
101
+ * For text below its threshold, which stylesheet draws it so: "theme" when it stays below
102
+ * with the slide's own CSS taken away, "slide" when that CSS brings it there. Absent when the
103
+ * text passes, or shows no glyph without the slide's CSS.
104
+ */
105
+ origin?: ContrastOrigin;
106
+ };
107
+
108
+ export type ContrastOrigin = "theme" | "slide" | "script";
109
+
110
+ /**
111
+ * A slide's draw that misbehaved as a still page drew it at the end of a beat: it threw, changed
112
+ * the page outside its slide, or drew the end differently after drawing the start.
113
+ */
114
+ type DrawErrorFinding = {
115
+ slug: string;
116
+ step: string;
117
+ t: number;
118
+ kind: "throw" | "reach" | "seek";
119
+ message: string;
120
+ };
121
+
122
+ /** Two texts drawn over each other on one page. */
123
+ type CollisionFinding = Collision & { slug: string; step: string };
124
+
125
+ /** How much of the frame one page fills, and where. */
126
+ export type FillFinding = Fill & { slug: string; step: string };
127
+
128
+ export type PagesResponse = {
129
+ overflows: OverflowFinding[];
130
+ contrasts: ContrastFinding[];
131
+ /** Absent from a runner that has none to report. */
132
+ drawErrors?: DrawErrorFinding[];
133
+ /** Absent from a runner that has none to report. */
134
+ collisions?: CollisionFinding[];
135
+ /** One for each page, when `fill` was asked for; absent from a runner that has none. */
136
+ fills?: FillFinding[];
137
+ };
138
+
139
+ export type MotionResponse = {
140
+ /** The frames of each beat the request asked for, in its order. */
141
+ motion: MotionBeat[];
142
+ /** The contact sheets that tile them, in order. */
143
+ sheets: string[];
144
+ };
145
+
146
+ /** A morph frame or a PDF is on disk where the request named it; the answer only says it is done. */
147
+ export type DoneResponse = Record<string, never>;
148
+
149
+ type VisualResponses = {
150
+ pages: PagesResponse;
151
+ pdf: DoneResponse;
152
+ morph: DoneResponse;
153
+ motion: MotionResponse;
154
+ };
155
+
156
+ /** The answer to a request of `R`'s kind. */
157
+ export type ResponseTo<R extends VisualRequest> = VisualResponses[R["kind"]];
158
+
159
+ export type VisualResponse = VisualResponses[VisualRequest["kind"]];
160
+
161
+ /** Answers a request, or null when Playwright is not installed. */
162
+ export type PlaywrightRunner = (request: VisualRequest) => Promise<VisualResponse | null>;
163
+
164
+ type SpawnTimeoutOptions = {
165
+ /** Covers the whole worker run; see `runJsonWorker`. */
166
+ timeoutMs?: number;
167
+ };
168
+
169
+ const requireFromDek = createRequire(import.meta.url);
170
+
171
+ export function resolvePlaywrightModule(): string | undefined {
172
+ // From dek's own install only: a repository someone else wrote can commit a node_modules.
173
+ try {
174
+ return requireFromDek.resolve("playwright");
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ export function playwrightResolved(): boolean {
181
+ const bin = process.env.DEK_PLAYWRIGHT;
182
+ if (bin) {
183
+ return bin.endsWith(".ts") || existsSync(bin);
184
+ }
185
+ return resolvePlaywrightModule() !== undefined;
186
+ }
187
+
188
+ /** Whether `runner` can answer: a runner given is taken at its word, the default needs Playwright. */
189
+ export function playwrightReady(runner?: PlaywrightRunner): boolean {
190
+ return runner !== undefined || playwrightResolved();
191
+ }
192
+
193
+ /** The module comes first; `playwright install` alone only fetches browsers. */
194
+ export const PLAYWRIGHT_INSTALL = "bun add -d playwright && bunx playwright install chromium";
195
+
196
+ export function playwrightMissingError(): DekError {
197
+ return new DekError("Playwright is not installed", { hint: PLAYWRIGHT_INSTALL });
198
+ }
199
+
200
+ /**
201
+ * `runner`'s answer to `request`, typed by the request's kind; null when Playwright is not
202
+ * installed. The runner answers the kind it was asked, so the cast only names what that is.
203
+ */
204
+ export function askPlaywright<R extends VisualRequest>(
205
+ request: R,
206
+ runner: PlaywrightRunner = defaultPlaywrightRunner,
207
+ ): Promise<ResponseTo<R> | null> {
208
+ return runner(request) as Promise<ResponseTo<R> | null>;
209
+ }
210
+
211
+ /** As `askPlaywright`, where no Playwright is an error that says how to install it. */
212
+ export async function requirePlaywright<R extends VisualRequest>(
213
+ request: R,
214
+ runner?: PlaywrightRunner,
215
+ ): Promise<ResponseTo<R>> {
216
+ const response = await askPlaywright(request, runner);
217
+ if (response === null) {
218
+ throw playwrightMissingError();
219
+ }
220
+ return response;
221
+ }
222
+
223
+ export async function importPlaywright(): Promise<typeof import("playwright")> {
224
+ const spec = resolvePlaywrightModule();
225
+ if (!spec) {
226
+ throw new Error("playwright not found");
227
+ }
228
+ return import(pathToFileURL(spec).href) as Promise<typeof import("playwright")>;
229
+ }
230
+
231
+ /**
232
+ * A Playwright worker's whole life: load Playwright, read the request, launch Chromium, answer
233
+ * with what `handle` returns, and close the browser. Any failure exits with its reason on stderr.
234
+ */
235
+ export async function runPlaywrightWorker<Request, Response>(
236
+ handle: (browser: Browser, request: Request) => Promise<Response>,
237
+ ): Promise<void> {
238
+ let playwright: Awaited<ReturnType<typeof importPlaywright>>;
239
+ try {
240
+ playwright = await importPlaywright();
241
+ } catch {
242
+ exitWorker(`playwright not found; ${PLAYWRIGHT_INSTALL}`);
243
+ }
244
+ const request = await readWorkerRequest<Request>();
245
+ try {
246
+ const browser = await playwright.chromium.launch({ headless: true });
247
+ try {
248
+ const response = await handle(browser, request);
249
+ process.stdout.write(`${JSON.stringify(response)}\n`);
250
+ } finally {
251
+ await browser.close();
252
+ }
253
+ } catch (error) {
254
+ exitWorker(error);
255
+ }
256
+ }
257
+
258
+ export async function defaultPlaywrightRunner(
259
+ request: VisualRequest,
260
+ options: SpawnTimeoutOptions = {},
261
+ ): Promise<VisualResponse | null> {
262
+ if (!playwrightResolved()) {
263
+ return null;
264
+ }
265
+ return spawnRunner(workerCommand(process.env.DEK_PLAYWRIGHT || workerPath()), request, options);
266
+ }
267
+
268
+ function workerPath(): string {
269
+ return moduleFilePath(new URL("./playwright-worker.ts", import.meta.url));
270
+ }
271
+
272
+ function spawnRunner(
273
+ cmd: string[],
274
+ request: VisualRequest,
275
+ options: SpawnTimeoutOptions,
276
+ ): Promise<VisualResponse> {
277
+ return runJsonWorker(cmd, request, (text) => parseVisualResponse(text, request.kind), {
278
+ label: "Playwright worker failed",
279
+ hint: `${PLAYWRIGHT_INSTALL} or check DEK_PLAYWRIGHT`,
280
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
281
+ });
282
+ }
283
+
284
+ /** One parser per kind; each keeps what its kind answers with, or rejects the whole answer. */
285
+ const RESPONSE_PARSERS: {
286
+ [K in keyof VisualResponses]: (fields: Record<string, unknown>) => VisualResponses[K] | null;
287
+ } = {
288
+ pages: ({ overflows, contrasts, drawErrors = [], collisions = [], fills = [] }) =>
289
+ isArrayOf(overflows, isOverflowFinding) &&
290
+ isArrayOf(contrasts, isContrastFinding) &&
291
+ isArrayOf(drawErrors, isDrawErrorFinding) &&
292
+ isArrayOf(collisions, isCollisionFinding) &&
293
+ isArrayOf(fills, isFillFinding)
294
+ ? { overflows, contrasts, drawErrors, collisions, fills }
295
+ : null,
296
+ pdf: () => ({}),
297
+ morph: () => ({}),
298
+ motion: ({ motion, sheets }) =>
299
+ isArrayOf(motion, isMotionBeat) && isArrayOf(sheets, isString) ? { motion, sheets } : null,
300
+ };
301
+
302
+ /** The worker's JSON as the answer to a `kind` request, or null when it is not one. */
303
+ export function parseVisualResponse<K extends VisualRequest["kind"]>(
304
+ text: string,
305
+ kind: K,
306
+ ): VisualResponses[K] | null {
307
+ let parsed: unknown;
308
+ try {
309
+ parsed = JSON.parse(text);
310
+ } catch {
311
+ return null;
312
+ }
313
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
314
+ return null;
315
+ }
316
+ return RESPONSE_PARSERS[kind](parsed as Record<string, unknown>);
317
+ }
318
+
319
+ function isArrayOf<T>(value: unknown, isItem: (item: unknown) => item is T): value is T[] {
320
+ return Array.isArray(value) && value.every(isItem);
321
+ }
322
+
323
+ function isString(value: unknown): value is string {
324
+ return typeof value === "string";
325
+ }
326
+
327
+ function isNumber(value: unknown): value is number {
328
+ return typeof value === "number" && Number.isFinite(value);
329
+ }
330
+
331
+ function isFinding(value: unknown): value is Record<string, unknown> {
332
+ if (!value || typeof value !== "object") {
333
+ return false;
334
+ }
335
+ const { slug, step, box, text } = value as Record<string, unknown>;
336
+ return (
337
+ isString(slug) && isString(step) && isString(box) && (text === undefined || isString(text))
338
+ );
339
+ }
340
+
341
+ function isCollisionFinding(value: unknown): value is CollisionFinding {
342
+ if (!isFinding(value)) {
343
+ return false;
344
+ }
345
+ const { other, otherText } = value;
346
+ return isString(other) && (otherText === undefined || isString(otherText));
347
+ }
348
+
349
+ function isFillFinding(value: unknown): value is FillFinding {
350
+ if (!value || typeof value !== "object") {
351
+ return false;
352
+ }
353
+ const { slug, step, coverage, box, rows, columns } = value as Record<string, unknown>;
354
+ const isBox = (found: unknown): boolean =>
355
+ found !== null &&
356
+ typeof found === "object" &&
357
+ ["left", "top", "right", "bottom"].every((side) =>
358
+ isNumber((found as Record<string, unknown>)[side]),
359
+ );
360
+ return (
361
+ isString(slug) &&
362
+ isString(step) &&
363
+ isNumber(coverage) &&
364
+ (box === undefined || isBox(box)) &&
365
+ isArrayOf(rows, isNumber) &&
366
+ isArrayOf(columns, isNumber)
367
+ );
368
+ }
369
+
370
+ function isDrawErrorFinding(value: unknown): value is DrawErrorFinding {
371
+ if (!value || typeof value !== "object") {
372
+ return false;
373
+ }
374
+ const { slug, step, t, kind, message } = value as Record<string, unknown>;
375
+ return (
376
+ isString(slug) &&
377
+ isString(step) &&
378
+ isNumber(t) &&
379
+ (kind === "throw" || kind === "reach" || kind === "seek") &&
380
+ isString(message)
381
+ );
382
+ }
383
+
384
+ function isOverflowFinding(value: unknown): value is OverflowFinding {
385
+ if (!isFinding(value)) {
386
+ return false;
387
+ }
388
+ const { by } = value;
389
+ return (
390
+ Boolean(by) &&
391
+ typeof by === "object" &&
392
+ Object.entries(by as object).every(
393
+ ([edge, px]) => (EDGES as string[]).includes(edge) && isNumber(px),
394
+ ) &&
395
+ (value.origin === undefined ||
396
+ value.origin === "script" ||
397
+ value.origin === "slide" ||
398
+ value.origin === "content") &&
399
+ (value.clip === undefined || isString(value.clip))
400
+ );
401
+ }
402
+
403
+ function isContrastFinding(value: unknown): value is ContrastFinding {
404
+ if (!isFinding(value)) {
405
+ return false;
406
+ }
407
+ const { ratio, fg, bg, fontSize, fontWeight, origin } = value;
408
+ return (
409
+ isNumber(ratio) &&
410
+ isString(fg) &&
411
+ isString(bg) &&
412
+ isNumber(fontSize) &&
413
+ isNumber(fontWeight) &&
414
+ (origin === undefined || origin === "theme" || origin === "slide" || origin === "script")
415
+ );
416
+ }
417
+
418
+ function isMotionBeat(value: unknown): value is MotionBeat {
419
+ if (!value || typeof value !== "object") {
420
+ return false;
421
+ }
422
+ const { label, frames } = value as { label?: unknown; frames?: unknown };
423
+ return (
424
+ isString(label) &&
425
+ Array.isArray(frames) &&
426
+ frames.every((frame: unknown) => {
427
+ const { ms, path, end } = (frame ?? {}) as { ms?: unknown; path?: unknown; end?: unknown };
428
+ return isNumber(ms) && isString(path) && (end === undefined || end === true);
429
+ })
430
+ );
431
+ }
@@ -0,0 +1,146 @@
1
+ import { type BeatRef, type Position, stopCount } from "./step.ts";
2
+
3
+ /** A slide as navigation sees it: its slug, and how many stops it has. */
4
+ type SlideStops = { slug: string; stops: number };
5
+
6
+ /** The deck as navigation sees it, in slide order. */
7
+ export type DeckStops = readonly SlideStops[];
8
+
9
+ /** Only the stop counts matter to stepping; the hash also needs each slug. */
10
+ type Stops = ReadonlyArray<{ stops: number }>;
11
+
12
+ export function deckStops(slides: ReadonlyArray<{ slug: string; beats: BeatRef[] }>): DeckStops {
13
+ return slides.map((slide) => ({ slug: slide.slug, stops: stopCount(slide.beats) }));
14
+ }
15
+
16
+ export function formatHash(pos: Position, slugs: string[]): string {
17
+ const slug = slugs[pos.slideIndex];
18
+ if (!slug) {
19
+ return "";
20
+ }
21
+ if (pos.beatIndex <= 0) {
22
+ return `#${slug}`;
23
+ }
24
+ return `#${slug}/${pos.beatIndex}`;
25
+ }
26
+
27
+ function parseHash(hash: string, slugs: string[]): Position {
28
+ const origin = { slideIndex: 0, beatIndex: 0 };
29
+ const raw = hash.startsWith("#") ? hash.slice(1) : hash;
30
+ if (!raw) {
31
+ return origin;
32
+ }
33
+ const [slug, beatRaw] = raw.split("/");
34
+ const slideIndex = slugs.indexOf(slug ?? "");
35
+ if (slideIndex < 0) {
36
+ return origin;
37
+ }
38
+ const beat = beatRaw ? Number(beatRaw) : 0;
39
+ if (!Number.isInteger(beat) || beat < 0) {
40
+ return { slideIndex, beatIndex: 0 };
41
+ }
42
+ return { slideIndex, beatIndex: beat };
43
+ }
44
+
45
+ /**
46
+ * `pos` inside the deck: a beat past its slide's last is that last beat, and a slide past the
47
+ * deck's last is the last slide's last beat. None when the deck has no slides.
48
+ */
49
+ export function clampPosition(pos: Position, slides: Stops): Position | undefined {
50
+ const last = slides.length - 1;
51
+ if (last < 0) {
52
+ return undefined;
53
+ }
54
+ const slideIndex = Math.min(pos.slideIndex, last);
55
+ const lastBeat = Math.max(0, (slides[slideIndex]?.stops ?? 1) - 1);
56
+ return {
57
+ slideIndex,
58
+ beatIndex: slideIndex < pos.slideIndex ? lastBeat : Math.min(pos.beatIndex, lastBeat),
59
+ };
60
+ }
61
+
62
+ export function positionsEqual(a: Position, b: Position): boolean {
63
+ return a.slideIndex === b.slideIndex && a.beatIndex === b.beatIndex;
64
+ }
65
+
66
+ /** Talk order: negative when `a` comes before `b`. */
67
+ export function comparePositions(a: Position, b: Position): number {
68
+ return a.slideIndex - b.slideIndex || a.beatIndex - b.beatIndex;
69
+ }
70
+
71
+ /** The position a hash names, with its beat clamped to the slide's last. */
72
+ export function positionFromHash(hash: string, slides: DeckStops): Position {
73
+ const parsed = parseHash(
74
+ hash,
75
+ slides.map((slide) => slide.slug),
76
+ );
77
+ return clampPosition(parsed, slides) ?? { slideIndex: 0, beatIndex: 0 };
78
+ }
79
+
80
+ export function hashChangeTarget(
81
+ current: Position,
82
+ hash: string,
83
+ slides: DeckStops,
84
+ ): Position | undefined {
85
+ const next = positionFromHash(hash, slides);
86
+ if (positionsEqual(current, next)) {
87
+ return undefined;
88
+ }
89
+ return next;
90
+ }
91
+
92
+ /**
93
+ * How a move is written to the URL. Each slide gets one history entry and its beats replace it,
94
+ * so Back leaves the slide rather than stepping back through every beat.
95
+ */
96
+ export function historyMode(from: Position, to: Position): "push" | "replace" {
97
+ return from.slideIndex === to.slideIndex ? "replace" : "push";
98
+ }
99
+
100
+ export type Move = "advance" | "retreat" | "first" | "last";
101
+
102
+ export function advance(pos: Position, slides: Stops): Position | null {
103
+ const count = slides[pos.slideIndex]?.stops;
104
+ if (count === undefined) {
105
+ return null;
106
+ }
107
+ if (pos.beatIndex + 1 < count) {
108
+ return { slideIndex: pos.slideIndex, beatIndex: pos.beatIndex + 1 };
109
+ }
110
+ const nextSlide = pos.slideIndex + 1;
111
+ if (nextSlide >= slides.length) {
112
+ return null;
113
+ }
114
+ return { slideIndex: nextSlide, beatIndex: 0 };
115
+ }
116
+
117
+ export function retreat(pos: Position, slides: Stops): Position | null {
118
+ if (pos.beatIndex > 0) {
119
+ return { slideIndex: pos.slideIndex, beatIndex: pos.beatIndex - 1 };
120
+ }
121
+ const prevSlide = pos.slideIndex - 1;
122
+ if (prevSlide < 0) {
123
+ return null;
124
+ }
125
+ const prevCount = slides[prevSlide]?.stops ?? 0;
126
+ return { slideIndex: prevSlide, beatIndex: Math.max(prevCount - 1, 0) };
127
+ }
128
+
129
+ /** Where `move` leads from `pos`, or null at either end of the talk. */
130
+ export function moveTarget(move: Move, pos: Position, slides: Stops): Position | null {
131
+ switch (move) {
132
+ case "advance":
133
+ return advance(pos, slides);
134
+ case "retreat":
135
+ return retreat(pos, slides);
136
+ case "first":
137
+ return slides.length > 0 ? { slideIndex: 0, beatIndex: 0 } : null;
138
+ case "last": {
139
+ const slideIndex = slides.length - 1;
140
+ if (slideIndex < 0) {
141
+ return null;
142
+ }
143
+ return { slideIndex, beatIndex: Math.max((slides[slideIndex]?.stops ?? 0) - 1, 0) };
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,43 @@
1
+ import { beatAt, lastStop, type Position } from "./step.ts";
2
+
3
+ export type PresenterBeat = {
4
+ id?: string;
5
+ title: string;
6
+ };
7
+
8
+ export type PresenterSlide = {
9
+ slug: string;
10
+ title: string;
11
+ script: string;
12
+ beats: PresenterBeat[];
13
+ budgetSeconds?: number;
14
+ };
15
+
16
+ export type PresenterState = {
17
+ current: PresenterSlide;
18
+ next: PresenterSlide | null;
19
+ script: string;
20
+ currentBeatIndex: number;
21
+ currentBeat: PresenterBeat | null;
22
+ };
23
+
24
+ export function presenterState(slides: PresenterSlide[], pos: Position): PresenterState {
25
+ const current = slides[pos.slideIndex];
26
+ if (!current) {
27
+ throw new Error(`slide index ${pos.slideIndex} out of range`);
28
+ }
29
+ return {
30
+ current,
31
+ next: slides[pos.slideIndex + 1] ?? null,
32
+ script: current.script,
33
+ currentBeatIndex: pos.beatIndex,
34
+ currentBeat: beatAt(current.beats, pos.beatIndex) ?? null,
35
+ };
36
+ }
37
+
38
+ export function nextPresenterTitle(state: PresenterState): string {
39
+ if (state.currentBeatIndex < lastStop(state.current.beats)) {
40
+ return state.current.title;
41
+ }
42
+ return state.next?.title ?? "";
43
+ }