Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet | 1 | /** |
| 2 | * Slides (`kind: "slides"`): a deck's model and the changes agents make to | |
| 3 | * it. Artifacts mode, docs/ARTIFACTS_MODE.md section 3.2. Wire shapes are | |
| 4 | * snake_case. | |
| 5 | * | |
| 6 | * In the Yjs document: `Y.Map("deck")` holds `SlidesDeck`; `Y.Array("slides")` | |
| 7 | * holds one `Y.Map` per slide (`SlideMeta`); each slide region is a root | |
| 8 | * `XmlFragment` named by `slideFragment(id, region)`, a BlockNote editor of | |
| 9 | * its own. | |
| 10 | * | |
| 11 | * Agents, templates, export and the text rendition use the Markdown form: | |
| 12 | * slides separated by `---` lines, each starting `<!-- slide: <layout> -->`, | |
| 13 | * `::: left` / `::: right` for columns, and `Note:` for speaker notes. | |
| 14 | */ | |
| 15 | ||
| 16 | export type SlideLayout = "title" | "title-body" | "two-column" | "section" | "image" | "quote" | "big-number" | "blank"; | |
| 17 | export const SLIDE_LAYOUTS: readonly SlideLayout[] = ["title", "title-body", "two-column", "section", "image", "quote", "big-number", "blank"]; | |
| 18 | ||
| 19 | export const SLIDE_LAYOUT_LABELS: Record<SlideLayout, string> = { | |
| 20 | title: "Title", | |
| 21 | "title-body": "Title and body", | |
| 22 | "two-column": "Two columns", | |
| 23 | section: "Section", | |
| 24 | image: "Image", | |
| 25 | quote: "Quote", | |
| 26 | "big-number": "Big number", | |
| 27 | blank: "Blank", | |
| 28 | }; | |
| 29 | ||
| 30 | export type SlideRegion = "title" | "body" | "left" | "right" | "notes"; | |
| 31 | ||
| 32 | /** The regions each layout has; every slide also has `notes`. */ | |
| 33 | export const SLIDE_LAYOUT_REGIONS: Record<SlideLayout, readonly SlideRegion[]> = { | |
| 34 | title: ["title", "body", "notes"], | |
| 35 | "title-body": ["title", "body", "notes"], | |
| 36 | "two-column": ["title", "left", "right", "notes"], | |
| 37 | section: ["title", "notes"], | |
| 38 | image: ["title", "body", "notes"], | |
| 39 | quote: ["body", "notes"], | |
| 40 | "big-number": ["title", "body", "notes"], | |
| 41 | blank: ["body", "notes"], | |
| 42 | }; | |
| 43 | ||
| 44 | export type SlidesAspect = "16:9" | "4:3"; | |
| 45 | export const SLIDES_ASPECTS: readonly SlidesAspect[] = ["16:9", "4:3"]; | |
| 46 | ||
| 47 | /** The deck's settings: `Y.Map("deck")`. `theme` names one of the editor's themes. */ | |
| 48 | export type SlidesDeck = { theme: string; aspect: SlidesAspect; accent: string | null }; | |
| 49 | ||
| 50 | /** One slide's own fields: an entry of `Y.Array("slides")`. */ | |
| 51 | export type SlideMeta = { id: string; layout: SlideLayout; background: string | null; hidden: boolean; transition: "none" }; | |
| 52 | ||
| 53 | /** The Yjs names the deck uses. */ | |
| 54 | export const SLIDES_DECK_MAP = "deck"; | |
| 55 | export const SLIDES_ARRAY = "slides"; | |
| 56 | ||
| 57 | /** The root fragment that holds one region of one slide. */ | |
| 58 | export function slideFragment(slideId: string, region: SlideRegion): string { | |
| 59 | return `slide:${slideId}:${region}`; | |
| 60 | } | |
| 61 | ||
| 62 | /** The most slides in a deck. */ | |
| 63 | export const SLIDES_MAX = 300; | |
| 64 | ||
| 65 | /** What a card shows of a deck: its first slide, never more. */ | |
| 66 | export type SlidesPreview = { kind: "slides"; aspect: SlidesAspect; count: number; layout: SlideLayout; title: string }; | |
| 67 | ||
| 68 | /** A change an agent makes to a deck. Markdown is in the deck's Markdown form. */ | |
| 69 | export type SlidesOp = | |
| 70 | /** Replace every slide. */ | |
| 71 | | { op: "replace_deck"; markdown: string } | |
| 72 | /** Add slides after one (null: at the start). */ | |
| 73 | | { op: "insert_slides"; after_slide_id: string | null; markdown: string } | |
| 74 | /** Replace one slide with what the Markdown holds (one slide or more). */ | |
| 75 | | { op: "replace_slide"; slide_id: string; markdown: string } | |
| 76 | | { op: "delete_slides"; slide_ids: string[] } | |
| 77 | /** Move a slide after another (null: to the start). */ | |
| 78 | | { op: "move_slide"; slide_id: string; after_slide_id: string | null } | |
| 79 | | { op: "set_notes"; slide_id: string; markdown: string } | |
| 80 | | { op: "set_theme"; theme: string; accent: string | null }; | |
| 81 | ||
| 82 | export const SLIDES_OPS: readonly SlidesOp["op"][] = ["replace_deck", "insert_slides", "replace_slide", "delete_slides", "move_slide", "set_notes", "set_theme"]; | |
| 83 | ||
| 84 | /** The largest Markdown one op carries, in characters. */ | |
| 85 | export const SLIDES_MAX_MARKDOWN = 200_000; | |
| 86 | ||
| 87 | const THEME = /^[a-z0-9-]{1,40}$/; | |
| 88 | const COLOR = /^#[0-9a-f]{6}$/i; | |
| 89 | ||
| 90 | /** What is wrong with a slides op, or null. Checks its shape; whether its ids exist is the room's to say. */ | |
| 91 | export function slidesOpError(op: unknown): string | null { | |
| 92 | if (!isObject(op) || typeof op.op !== "string") return "A slides change has an op."; | |
| 93 | const markdown = () => { | |
| 94 | if (typeof op.markdown !== "string") return `${op.op} needs markdown.`; | |
| 95 | if (op.markdown.length > SLIDES_MAX_MARKDOWN) return `${op.op}'s markdown is too long.`; | |
| 96 | return null; | |
| 97 | }; | |
| 98 | const id = (key: string, nullable = false) => | |
| 99 | (nullable && op[key] === null) || (typeof op[key] === "string" && (op[key] as string).length > 0) ? null : `${op.op} needs ${key}.`; | |
| 100 | switch (op.op) { | |
| 101 | case "replace_deck": | |
| 102 | return markdown(); | |
| 103 | case "insert_slides": | |
| 104 | return id("after_slide_id", true) ?? markdown(); | |
| 105 | case "replace_slide": | |
| 106 | case "set_notes": | |
| 107 | return id("slide_id") ?? markdown(); | |
| 108 | case "delete_slides": | |
| 109 | return Array.isArray(op.slide_ids) && op.slide_ids.length > 0 && op.slide_ids.every((item) => typeof item === "string" && item.length > 0) | |
| 110 | ? null | |
| 111 | : "delete_slides needs slide_ids."; | |
| 112 | case "move_slide": | |
| 113 | return id("slide_id") ?? id("after_slide_id", true); | |
| 114 | case "set_theme": | |
| 115 | if (typeof op.theme !== "string" || !THEME.test(op.theme)) return "set_theme needs a theme name."; | |
| 116 | return op.accent === null || (typeof op.accent === "string" && COLOR.test(op.accent)) ? null : "accent is a colour like #8b7cf6, or null."; | |
| 117 | default: | |
| 118 | return `There is no slides op called ${op.op}.`; | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | function isObject(value: unknown): value is Record<string, unknown> { | |
| 123 | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 124 | } |