@hajimism/dek 0.2.1 → 0.2.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 (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.ja.md +3 -1
  3. package/README.md +3 -1
  4. package/package.json +3 -1
  5. package/skills/dek/SKILL.md +44 -0
  6. package/src/cli/annotations.ts +26 -0
  7. package/src/cli/commands.ts +66 -0
  8. package/src/cli/contract.ts +75 -4
  9. package/src/cli/files.ts +3 -3
  10. package/src/cli/flags.ts +1 -1
  11. package/src/cli/format.ts +3 -0
  12. package/src/cli/pptx.ts +20 -0
  13. package/src/cli/show.ts +17 -2
  14. package/src/cli/text.ts +43 -0
  15. package/src/cli/theme.ts +7 -1
  16. package/src/core/annotation-rows.ts +176 -0
  17. package/src/core/annotations.ts +362 -0
  18. package/src/core/diagnostic.ts +1 -0
  19. package/src/core/html-scan.ts +54 -0
  20. package/src/core/html.ts +53 -8
  21. package/src/core/lint/slide-html.ts +92 -2
  22. package/src/core/lint.ts +8 -1
  23. package/src/core/live-protocol.ts +4 -1
  24. package/src/core/page.ts +6 -0
  25. package/src/core/playwright-module.d.ts +4 -1
  26. package/src/core/playwright-visual.ts +74 -1
  27. package/src/core/playwright.ts +51 -1
  28. package/src/core/pptx-package.ts +312 -0
  29. package/src/core/pptx-text.ts +363 -0
  30. package/src/core/pptx.ts +75 -0
  31. package/src/core/slide-measure.ts +1 -0
  32. package/src/core/sync.ts +13 -5
  33. package/src/core/theme-excerpt.ts +61 -9
  34. package/src/core/theme-facts.ts +12 -2
  35. package/src/core/zip.ts +77 -0
  36. package/src/runtime/annotate-pick.ts +2 -6
  37. package/src/runtime/annotate-view.ts +115 -101
  38. package/src/runtime/annotate.ts +5 -1
  39. package/src/runtime/browser.ts +5 -1
  40. package/src/runtime/live.ts +1 -0
  41. package/src/runtime/routes.ts +3 -0
  42. package/src/server/auth.ts +3 -1
  43. package/src/server/routes.ts +68 -1
  44. package/src/runtime/annotate-notes.ts +0 -207
package/src/core/html.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { cutText } from "./annotation-rows.ts";
1
2
  import { assetInliner } from "./assets.ts";
2
3
  import { deckPaths } from "./deck-paths.ts";
3
4
  import { escapeAttr, escapeHtml } from "./escape.ts";
@@ -233,8 +234,11 @@ export function stampSlide(html: string, stamp: SlideStamp): string {
233
234
  });
234
235
  }
235
236
 
236
- /** Where a start tag is written, and its classes as written; none when it has no `class`. */
237
- type TagSpot = LineColumn & { classes?: string };
237
+ /**
238
+ * Where a start tag is written, its tag, its classes as written (none when it has no `class`),
239
+ * and the text inside it as written, whitespace collapsed.
240
+ */
241
+ type TagSpot = LineColumn & { tag: string; classes?: string; text: string };
238
242
 
239
243
  /**
240
244
  * Where each start tag of `html` is written in the file it was taken from, in document order,
@@ -243,12 +247,30 @@ type TagSpot = LineColumn & { classes?: string };
243
247
  */
244
248
  function startTagSpots(html: string, source: SlideSource): TagSpot[] {
245
249
  const mark = uniqueMark(html);
246
- const classes: Array<string | null> = [];
250
+ const tags: Array<{ tag: string; classes: string | null; text: string }> = [];
251
+ const open: number[] = [];
247
252
  const marked = rewriteHtml(html, (rewriter) => {
248
253
  rewriter.on("*", {
249
254
  element(el) {
250
255
  el.before(mark, { html: true });
251
- classes.push(el.getAttribute("class"));
256
+ const index = tags.length;
257
+ tags.push({ tag: el.tagName.toLowerCase(), classes: el.getAttribute("class"), text: "" });
258
+ if (el.canHaveContent) {
259
+ open.push(index);
260
+ el.onEndTag(() => {
261
+ open.splice(open.lastIndexOf(index), 1);
262
+ });
263
+ }
264
+ },
265
+ });
266
+ rewriter.onDocument({
267
+ text(chunk) {
268
+ for (const index of open) {
269
+ const tag = tags[index];
270
+ if (tag) {
271
+ tag.text += chunk.text;
272
+ }
273
+ }
252
274
  },
253
275
  });
254
276
  });
@@ -259,13 +281,36 @@ function startTagSpots(html: string, source: SlideSource): TagSpot[] {
259
281
  .slice(0, -1)
260
282
  .map((before, index) => {
261
283
  offset += before.length;
262
- const written = classes[index];
263
- return written === null || written === undefined
264
- ? spot(offset)
265
- : { ...spot(offset), classes: written.split(/\s+/).filter(Boolean).join(" ") };
284
+ const { tag = "", classes = null, text = "" } = tags[index] ?? {};
285
+ const found = { ...spot(offset), tag, text: text.replace(/\s+/g, " ").trim() };
286
+ return classes === null
287
+ ? found
288
+ : { ...found, classes: classes.split(/\s+/).filter(Boolean).join(" ") };
266
289
  });
267
290
  }
268
291
 
292
+ /**
293
+ * An element of a written slide as annotate mode names it, whatever a script adds to it: where
294
+ * its start tag is written, `<line>:<column>`, its tag and classes as written, as `div.chevron`,
295
+ * and its text as written, cut short. The slide itself has no text: all of the slide names
296
+ * nothing.
297
+ */
298
+ export type WrittenElement = { source: string; name: string; text: string };
299
+
300
+ /** Every element of the slide `slides/<slug>.html` writes, in document order; none for a skeleton. */
301
+ export function writtenElements(slides: DeckSlides, slug: string): WrittenElement[] | undefined {
302
+ const html = slides.written(slug);
303
+ const source = slides.source(slug);
304
+ if (html === undefined || source === undefined) {
305
+ return undefined;
306
+ }
307
+ return startTagSpots(html, source).map((spot, index) => ({
308
+ source: `${spot.line}:${spot.column}`,
309
+ name: [spot.tag, ...(spot.classes ?? "").split(" ").filter(Boolean)].join("."),
310
+ text: index === 0 ? "" : cutText(spot.text),
311
+ }));
312
+ }
313
+
269
314
  /**
270
315
  * Every slide of the deck in script order, as the player pages hold them. `sources` says where
271
316
  * each tag is written, for the dev server's pages.
@@ -1,8 +1,9 @@
1
1
  import { type Diagnostic, diag } from "../diagnostic.ts";
2
- import type { HtmlAttribute, HtmlScan, SourceSpot } from "../html-scan.ts";
2
+ import type { HtmlAttribute, HtmlScan, Picture, SourceSpot } from "../html-scan.ts";
3
3
  import type { Section } from "../schema.ts";
4
4
  import { beatAt, formatStepChoices, resolveStep, stepChoices } from "../step.ts";
5
5
  import { suggest } from "../suggest.ts";
6
+ import { RUNTIME_CLASSES } from "../theme-facts.ts";
6
7
  import { isUrlAttribute } from "../url-attributes.ts";
7
8
  import { assetRefDiagnostics } from "./asset-refs.ts";
8
9
  import { RESERVED_MORPHS } from "./morph.ts";
@@ -52,6 +53,7 @@ export function lintSlideHtml(
52
53
  ...presentationDiagnostics(slide),
53
54
  ...emptyHeadingDiagnostics(slide),
54
55
  ...hiddenTextDiagnostics(slide),
56
+ ...pictureDiagnostics(slide),
55
57
  ...(options.classes
56
58
  ? unknownClassDiagnostics(slide, options.classes, options.hasScript === true)
57
59
  : []),
@@ -442,7 +444,85 @@ function hiddenTextDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[]
442
444
  });
443
445
  }
444
446
 
445
- /** DEK010: a class neither theme.css nor the slide's own stylesheet defines. */
447
+ /**
448
+ * DEK034: a picture with nothing for a screen reader to say: an `<img>` without `alt`, an `<svg>`
449
+ * with no text and no name, or an element whose `role="img"` has no name. Each is either content,
450
+ * which wants words, or decoration, which says so: `alt=""` or `aria-hidden="true"`. An SVG with
451
+ * text of its own is read as it is, so the hint never sends a labeled figure to `aria-hidden`,
452
+ * where DEK029 would warn of it.
453
+ */
454
+ function pictureDiagnostics({ section, path, scan }: SlideHtml): Diagnostic[] {
455
+ return scan.pictures.flatMap((picture) => {
456
+ const found = unnamedPicture(picture);
457
+ return found
458
+ ? [
459
+ diag("DEK034", {
460
+ message: found.message,
461
+ path,
462
+ ...spotOf(picture.element),
463
+ slug: section.slug,
464
+ hint: found.hint,
465
+ data: found.data,
466
+ }),
467
+ ]
468
+ : [];
469
+ });
470
+ }
471
+
472
+ function unnamedPicture({
473
+ element,
474
+ text,
475
+ titled,
476
+ }: Picture): { message: string; hint: string; data: Record<string, string> } | undefined {
477
+ const value = (name: string): string | undefined =>
478
+ element.attributes.find((attribute) => attribute.name === name)?.value;
479
+ const named = [value("aria-label"), value("aria-labelledby"), value("title")].some(
480
+ (name) => name !== undefined && name.trim() !== "",
481
+ );
482
+ const { tag } = element;
483
+ if (named || (tag === "img" && value("alt") !== undefined)) {
484
+ return undefined;
485
+ }
486
+ if ((value("role") ?? "").trim().toLowerCase().split(/\s+/)[0] === "img") {
487
+ if (titled) {
488
+ return undefined;
489
+ }
490
+ const data = { tag, role: "img" };
491
+ return text
492
+ ? {
493
+ message: `<${tag} role="img"> has no name, and role="img" keeps a screen reader from reading its text`,
494
+ hint: 'give it aria-label="…" saying what it shows, or remove role="img" so its text is read',
495
+ data,
496
+ }
497
+ : {
498
+ message: `<${tag} role="img"> has no name, so a screen reader cannot say what it shows`,
499
+ hint: 'give it aria-label="…" saying what it shows, or replace role="img" with aria-hidden="true" if it is decoration',
500
+ data,
501
+ };
502
+ }
503
+ if (tag === "img") {
504
+ const src = value("src");
505
+ return {
506
+ message: `<img${src === undefined ? "" : ` src="${src}"`}> has no alt, so a screen reader cannot say what it shows`,
507
+ hint: 'add alt="…" saying what the picture shows, or alt="" if it is decoration',
508
+ data: src === undefined ? { tag } : { tag, src },
509
+ };
510
+ }
511
+ if (text) {
512
+ return undefined;
513
+ }
514
+ return {
515
+ message: `<${tag}> has nothing to read and no name, so a screen reader skips what it shows`,
516
+ hint: 'give it role="img" and aria-label="…" saying what it shows, or aria-hidden="true" if it is decoration',
517
+ data: { tag },
518
+ };
519
+ }
520
+
521
+ /**
522
+ * DEK010: a class neither theme.css nor the slide's own stylesheet defines, or a state class the
523
+ * player sets, which markup that writes it would claim before the player does. `classes` holds
524
+ * neither.
525
+ */
446
526
  function unknownClassDiagnostics(
447
527
  { section, path, scan }: SlideHtml,
448
528
  classes: Set<string>,
@@ -468,6 +548,16 @@ function unknownClassDiagnostics(
468
548
  }
469
549
  }
470
550
  return [...unknown].map(([name, attribute]) => {
551
+ if (RUNTIME_CLASSES.has(name)) {
552
+ return diag("DEK010", {
553
+ message: `class "${name}" is a state the player sets at runtime, not a class for markup`,
554
+ path,
555
+ ...spotOf(attribute),
556
+ slug: section.slug,
557
+ hint: `remove "${name}" from the class attribute: the player puts is-current on the slide on screen and is-shown on each data-step element once its beat plays. To style that state, select it in theme.css or slides/${section.slug}.css, as .slide.is-current [data-step].is-shown`,
558
+ data: { class: name, state: true },
559
+ });
560
+ }
471
561
  const suggestion = suggest(name, known);
472
562
  return diag("DEK010", {
473
563
  message: `class "${name}" is not defined in theme.css`,
package/src/core/lint.ts CHANGED
@@ -19,6 +19,7 @@ import { voiceDiagnostics } from "./lint/voice.ts";
19
19
  import { asResolvedDeck, type Project, type ProjectDeck } from "./resolve.ts";
20
20
  import type { Section } from "./schema.ts";
21
21
  import { evaluateSlideScripts, slideScriptsProblems } from "./slide-script-eval.ts";
22
+ import { RUNTIME_CLASSES } from "./theme-facts.ts";
22
23
 
23
24
  export { unreadableScriptDiagnostics } from "./lint/script.ts";
24
25
  export { silentCueDiagnostics } from "./lint/voice.ts";
@@ -122,7 +123,13 @@ function slideDiagnostics(ctx: LintContext, section: Section): Diagnostic[] {
122
123
  deckDir: ctx.deck.dir,
123
124
  skeleton: source.skeleton,
124
125
  hasScript: script !== undefined,
125
- classes: ctx.theme && new Set([...ctx.theme.classes, ...(sheet ? cssClassNames(sheet) : [])]),
126
+ classes:
127
+ ctx.theme &&
128
+ new Set(
129
+ [...ctx.theme.classes, ...(sheet ? cssClassNames(sheet) : [])].filter(
130
+ (name) => !RUNTIME_CLASSES.has(name),
131
+ ),
132
+ ),
126
133
  // A theme with no layouts at all has nothing to check a data-layout against.
127
134
  ...(ctx.theme && ctx.theme.layouts.length > 0
128
135
  ? {
@@ -12,7 +12,9 @@ export type LiveEvent =
12
12
  | { type: "reload-theme" }
13
13
  | { type: "reload-script"; slugs: string[] }
14
14
  | { type: "diagnostics"; diagnostics: Diagnostic[] }
15
- | { type: "timeline" };
15
+ | { type: "timeline" }
16
+ /** The deck's annotations changed; a page that shows them asks for them again. */
17
+ | { type: "annotations" };
16
18
 
17
19
  export type LiveEventType = LiveEvent["type"];
18
20
 
@@ -32,6 +34,7 @@ const FIELDS_OK: Record<LiveEventType, (event: Record<string, unknown>) => boole
32
34
  "reload-script": (event) => isStrings(event.slugs),
33
35
  diagnostics: (event) => Array.isArray(event.diagnostics),
34
36
  timeline: () => true,
37
+ annotations: () => true,
35
38
  };
36
39
 
37
40
  /** Whether a message read off the stream is one this build of the page understands. */
package/src/core/page.ts CHANGED
@@ -33,6 +33,12 @@ export const PAGE_ID = {
33
33
  announce: "dek-announce",
34
34
  } as const;
35
35
 
36
+ /**
37
+ * The event the player fires on `document` when the dev server says the deck's annotations
38
+ * changed, for annotate mode, which is compiled apart from the player, to ask for them again.
39
+ */
40
+ export const ANNOTATIONS_CHANGED = "dek-annotations";
41
+
36
42
  export type PageMode = "player" | "presenter" | "video";
37
43
 
38
44
  /** The page's settings, written on <body> as `data-*` and read back once by the runtime. */
@@ -34,7 +34,10 @@ declare module "playwright" {
34
34
  };
35
35
  export type Browser = {
36
36
  newPage(options?: { viewport?: Viewport }): Promise<Page>;
37
- newContext(options?: { viewport?: Viewport }): Promise<BrowserContext>;
37
+ newContext(options?: {
38
+ viewport?: Viewport;
39
+ deviceScaleFactor?: number;
40
+ }): Promise<BrowserContext>;
38
41
  close(): Promise<void>;
39
42
  };
40
43
  export const chromium: {
@@ -21,12 +21,15 @@ import type {
21
21
  PagesRequest,
22
22
  PagesResponse,
23
23
  PdfRequest,
24
+ PptxRequest,
25
+ PptxResponse,
24
26
  ResponseTo,
25
27
  VisualPage,
26
28
  VisualRequest,
27
29
  } from "./playwright.ts";
28
30
  import { captureMotion } from "./playwright-motion.ts";
29
31
  import { renderSheets } from "./playwright-sheet.ts";
32
+ import { measurePptxTextsInPage, PPTX_OWNER_ATTR } from "./pptx-text.ts";
30
33
  import { pseudoTextBoxes } from "./pseudo-text.ts";
31
34
  import { motionSheets } from "./sheet.ts";
32
35
  import {
@@ -47,7 +50,11 @@ export async function runVisualRequest<R extends VisualRequest>(
47
50
  browser: Browser,
48
51
  request: R,
49
52
  ): Promise<ResponseTo<R>> {
50
- const context = await browser.newContext({ viewport: request.viewport });
53
+ // A PPTX's pictures are shown full screen, so they are drawn at twice the slide's size.
54
+ const context = await browser.newContext({
55
+ viewport: request.viewport,
56
+ ...(request.kind === "pptx" ? { deviceScaleFactor: 2 } : {}),
57
+ });
51
58
  try {
52
59
  return (await answer(context, request)) as ResponseTo<R>;
53
60
  } finally {
@@ -65,6 +72,8 @@ function answer(context: BrowserContext, request: VisualRequest) {
65
72
  return shootMorph(context, request);
66
73
  case "motion":
67
74
  return shootMotion(context, request);
75
+ case "pptx":
76
+ return shootPptx(context, request);
68
77
  default: {
69
78
  const unknown: never = request;
70
79
  throw new Error(`unknown visual request ${JSON.stringify(unknown)}`);
@@ -86,6 +95,70 @@ async function printPdf(context: BrowserContext, request: PdfRequest): Promise<D
86
95
  return {};
87
96
  }
88
97
 
98
+ /** Each slide's text measured and taken out, and the slide shot without it, page by page. */
99
+ async function shootPptx(context: BrowserContext, request: PptxRequest): Promise<PptxResponse> {
100
+ const slides = await visitInPages(
101
+ request.pages,
102
+ () => context.newPage(),
103
+ async (page, pageReq) => {
104
+ await page.setContent(pageReq.html, { waitUntil: "load" });
105
+ await page.evaluate(() => document.fonts.ready.then(() => undefined));
106
+ const measured = await page.evaluate(measurePptxTextsInPage);
107
+ const fonts = await platformFonts(page);
108
+ await page.screenshot({ path: pageReq.screenshotPath, fullPage: false });
109
+ return {
110
+ slug: pageReq.slug,
111
+ description: measured.description,
112
+ boxes: measured.boxes.map((box) => ({
113
+ ...box,
114
+ runs: box.runs.map(({ owner, ...run }) => {
115
+ const font = fonts.get(owner);
116
+ return font === undefined ? run : { ...run, font };
117
+ }),
118
+ })),
119
+ };
120
+ },
121
+ );
122
+ return { slides };
123
+ }
124
+
125
+ /**
126
+ * The font Chromium drew each marked element's text with, by the element's number: the one that
127
+ * drew most of its glyphs, as the DevTools protocol reports it. A theme names a stack, or only
128
+ * `sans-serif`; this is the face that was found.
129
+ */
130
+ async function platformFonts(page: Page): Promise<Map<number, string>> {
131
+ const fonts = new Map<number, string>();
132
+ const cdp = await page.context().newCDPSession(page);
133
+ try {
134
+ await cdp.send("DOM.enable");
135
+ await cdp.send("CSS.enable");
136
+ const { root } = (await cdp.send("DOM.getDocument", { depth: -1 })) as {
137
+ root: { nodeId: number };
138
+ };
139
+ const { nodeIds } = (await cdp.send("DOM.querySelectorAll", {
140
+ nodeId: root.nodeId,
141
+ selector: `[${PPTX_OWNER_ATTR}]`,
142
+ })) as { nodeIds: number[] };
143
+ for (const nodeId of nodeIds) {
144
+ const { attributes } = (await cdp.send("DOM.getAttributes", { nodeId })) as {
145
+ attributes: string[];
146
+ };
147
+ const owner = Number(attributes[attributes.indexOf(PPTX_OWNER_ATTR) + 1]);
148
+ const { fonts: used } = (await cdp.send("CSS.getPlatformFontsForNode", { nodeId })) as {
149
+ fonts: Array<{ familyName: string; glyphCount: number }>;
150
+ };
151
+ const main = [...used].sort((a, b) => b.glyphCount - a.glyphCount)[0];
152
+ if (main && Number.isInteger(owner)) {
153
+ fonts.set(owner, main.familyName);
154
+ }
155
+ }
156
+ } finally {
157
+ await cdp.detach();
158
+ }
159
+ return fonts;
160
+ }
161
+
89
162
  /** The move between two slides, frozen at a moment and shot. */
90
163
  async function shootMorph(context: BrowserContext, request: MorphRequest): Promise<DoneResponse> {
91
164
  const page = await context.newPage();
@@ -6,6 +6,7 @@ import { DekError } from "./error.ts";
6
6
  import type { Fill } from "./fill.ts";
7
7
  import { type Collision, EDGES, type Overflow } from "./overflow.ts";
8
8
  import { moduleFilePath } from "./path.ts";
9
+ import type { PptxTextBox } from "./pptx-package.ts";
9
10
  import type { MotionBeat, SheetSpec } from "./sheet.ts";
10
11
  import { exitWorker, readWorkerRequest, runJsonWorker, workerCommand } from "./spawn.ts";
11
12
  import type { Position } from "./step.ts";
@@ -79,8 +80,23 @@ export type MotionRequest = {
79
80
  motion: MotionSpec;
80
81
  };
81
82
 
83
+ /**
84
+ * Each slide's still page drawn for a PPTX, at twice its size: its text measured line by line,
85
+ * then the page shot without it, to `screenshotPath`.
86
+ */
87
+ export type PptxRequest = {
88
+ kind: "pptx";
89
+ viewport: Viewport;
90
+ pages: Array<{ html: string; slug: string; screenshotPath: string }>;
91
+ };
92
+
93
+ /** Each page's text, in the order of the request's pages, with the font each run was drawn in. */
94
+ export type PptxResponse = {
95
+ slides: Array<{ slug: string; description: string; boxes: PptxTextBox[] }>;
96
+ };
97
+
82
98
  /** What the Playwright worker is asked; the kind says which payload comes with it. */
83
- export type VisualRequest = PagesRequest | PdfRequest | MorphRequest | MotionRequest;
99
+ export type VisualRequest = PagesRequest | PdfRequest | MorphRequest | MotionRequest | PptxRequest;
84
100
 
85
101
  type OverflowFinding = Overflow & { slug: string; step: string };
86
102
 
@@ -151,6 +167,7 @@ type VisualResponses = {
151
167
  pdf: DoneResponse;
152
168
  morph: DoneResponse;
153
169
  motion: MotionResponse;
170
+ pptx: PptxResponse;
154
171
  };
155
172
 
156
173
  /** The answer to a request of `R`'s kind. */
@@ -297,6 +314,7 @@ const RESPONSE_PARSERS: {
297
314
  morph: () => ({}),
298
315
  motion: ({ motion, sheets }) =>
299
316
  isArrayOf(motion, isMotionBeat) && isArrayOf(sheets, isString) ? { motion, sheets } : null,
317
+ pptx: ({ slides }) => (isArrayOf(slides, isPptxSlide) ? { slides } : null),
300
318
  };
301
319
 
302
320
  /** The worker's JSON as the answer to a `kind` request, or null when it is not one. */
@@ -415,6 +433,38 @@ function isContrastFinding(value: unknown): value is ContrastFinding {
415
433
  );
416
434
  }
417
435
 
436
+ function isPptxSlide(value: unknown): value is PptxResponse["slides"][number] {
437
+ if (!value || typeof value !== "object") {
438
+ return false;
439
+ }
440
+ const { slug, description, boxes } = value as Record<string, unknown>;
441
+ return isString(slug) && isString(description) && isArrayOf(boxes, isPptxBox);
442
+ }
443
+
444
+ function isPptxBox(value: unknown): value is PptxTextBox {
445
+ if (!value || typeof value !== "object") {
446
+ return false;
447
+ }
448
+ const box = value as Record<string, unknown>;
449
+ return (
450
+ ["x", "y", "width", "height", "lineHeight"].every((key) => isNumber(box[key])) &&
451
+ isArrayOf(box.runs, (run: unknown): run is PptxTextBox["runs"][number] => {
452
+ if (!run || typeof run !== "object") {
453
+ return false;
454
+ }
455
+ const { text, size, alpha, letterSpacing, color, font } = run as Record<string, unknown>;
456
+ return (
457
+ isString(text) &&
458
+ isNumber(size) &&
459
+ isNumber(alpha) &&
460
+ isNumber(letterSpacing) &&
461
+ isArrayOf(color, isNumber) &&
462
+ (font === undefined || isString(font))
463
+ );
464
+ })
465
+ );
466
+ }
467
+
418
468
  function isMotionBeat(value: unknown): value is MotionBeat {
419
469
  if (!value || typeof value !== "object") {
420
470
  return false;