@drawnagency/primitives 0.1.110 → 0.1.112

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 (188) hide show
  1. package/dist/{chunk-3GAG6ZXB.js → chunk-3SZNUCXI.js} +8 -4
  2. package/dist/chunk-4I5OO5UB.js +785 -0
  3. package/dist/{chunk-ALMEC3XN.js → chunk-5KNXTMUC.js} +22 -18
  4. package/dist/{chunk-OUYB2GCW.js → chunk-BR4MIPTM.js} +1 -57
  5. package/dist/chunk-CXZ5YJG4.js +85 -0
  6. package/dist/{chunk-RA5IOFPY.js → chunk-DNY72JZN.js} +16 -38
  7. package/dist/{chunk-7MI3HCOF.js → chunk-EPWBPIV6.js} +10 -6
  8. package/dist/chunk-FV7JQRCN.js +94 -0
  9. package/dist/{chunk-UVIYI4J6.js → chunk-HSXMOS5Q.js} +15 -2
  10. package/dist/chunk-IIDKW7AV.js +41 -0
  11. package/dist/chunk-IINIZBQB.js +204 -0
  12. package/dist/chunk-MM7KZS5C.js +30 -0
  13. package/dist/{chunk-VCCNKTFD.js → chunk-OGGZ5AXT.js} +3 -31
  14. package/dist/{chunk-XQMDWXD6.js → chunk-P3ZNCHW7.js} +1 -1
  15. package/dist/chunk-QQ3S3P5P.js +38 -0
  16. package/dist/{chunk-ELQ4C677.js → chunk-TALRVOGO.js} +9 -44
  17. package/dist/chunk-VAYZJKVR.js +209 -0
  18. package/dist/{chunk-OTDA6KWL.js → chunk-VLPELTPI.js} +1 -1
  19. package/dist/{chunk-7WQJ57XG.js → chunk-VT3PYGVW.js} +68 -260
  20. package/dist/chunk-WUJ3XNFI.js +62 -0
  21. package/dist/{chunk-NXGIHSL5.js → chunk-Z2LO4PSQ.js} +11 -91
  22. package/dist/collab/rebase.js +1 -1
  23. package/dist/components/chat/ChatComposer.d.ts +25 -0
  24. package/dist/components/chat/ChatComposer.d.ts.map +1 -0
  25. package/dist/components/chat/ChatPanel.d.ts +5 -4
  26. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  27. package/dist/components/chat/markdown.d.ts +14 -0
  28. package/dist/components/chat/markdown.d.ts.map +1 -0
  29. package/dist/components/editor/index.js +16 -11
  30. package/dist/components/primitives/EditablePlainText.d.ts.map +1 -1
  31. package/dist/components/primitives/index.js +15 -10
  32. package/dist/components/sections/brand-guide.js +11 -9
  33. package/dist/components/sections/register-schemas.js +19 -14
  34. package/dist/components/sections/register.js +19 -14
  35. package/dist/components/shell/AudienceChips.d.ts +10 -0
  36. package/dist/components/shell/AudienceChips.d.ts.map +1 -0
  37. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  38. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  39. package/dist/components/shell/SiteSettingsDecks.d.ts +13 -0
  40. package/dist/components/shell/SiteSettingsDecks.d.ts.map +1 -0
  41. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  42. package/dist/deck/DeckComponentView.d.ts +13 -0
  43. package/dist/deck/DeckComponentView.d.ts.map +1 -0
  44. package/dist/deck/DeckPlayer.d.ts +26 -0
  45. package/dist/deck/DeckPlayer.d.ts.map +1 -0
  46. package/dist/deck/DeckSlideView.d.ts +17 -0
  47. package/dist/deck/DeckSlideView.d.ts.map +1 -0
  48. package/dist/deck/DeckStage.d.ts +16 -0
  49. package/dist/deck/DeckStage.d.ts.map +1 -0
  50. package/dist/deck/components/DeckImage.d.ts +14 -0
  51. package/dist/deck/components/DeckImage.d.ts.map +1 -0
  52. package/dist/deck/components/DeckImageGrid.d.ts +27 -0
  53. package/dist/deck/components/DeckImageGrid.d.ts.map +1 -0
  54. package/dist/deck/components/DeckRichText.d.ts +22 -0
  55. package/dist/deck/components/DeckRichText.d.ts.map +1 -0
  56. package/dist/deck/components/DeckVideo.d.ts +15 -0
  57. package/dist/deck/components/DeckVideo.d.ts.map +1 -0
  58. package/dist/deck/editing.d.ts +50 -0
  59. package/dist/deck/editing.d.ts.map +1 -0
  60. package/dist/deck/flip.d.ts +11 -0
  61. package/dist/deck/flip.d.ts.map +1 -0
  62. package/dist/deck/index.d.ts +24 -0
  63. package/dist/deck/index.d.ts.map +1 -0
  64. package/dist/deck/index.js +53 -0
  65. package/dist/deck/layouts/CenteredTextLayout.d.ts +12 -0
  66. package/dist/deck/layouts/CenteredTextLayout.d.ts.map +1 -0
  67. package/dist/deck/layouts/ColumnSplitLayout.d.ts +14 -0
  68. package/dist/deck/layouts/ColumnSplitLayout.d.ts.map +1 -0
  69. package/dist/deck/layouts/FullBleedImageLayout.d.ts +14 -0
  70. package/dist/deck/layouts/FullBleedImageLayout.d.ts.map +1 -0
  71. package/dist/deck/media.d.ts +33 -0
  72. package/dist/deck/media.d.ts.map +1 -0
  73. package/dist/deck/tokens.d.ts +22 -0
  74. package/dist/deck/tokens.d.ts.map +1 -0
  75. package/dist/deck/useStageScale.d.ts +10 -0
  76. package/dist/deck/useStageScale.d.ts.map +1 -0
  77. package/dist/deck-builder/DeckBuilderApp.d.ts +30 -0
  78. package/dist/deck-builder/DeckBuilderApp.d.ts.map +1 -0
  79. package/dist/deck-builder/DeckBuilderTopBar.d.ts +24 -0
  80. package/dist/deck-builder/DeckBuilderTopBar.d.ts.map +1 -0
  81. package/dist/deck-builder/DeckChatPanel.d.ts +45 -0
  82. package/dist/deck-builder/DeckChatPanel.d.ts.map +1 -0
  83. package/dist/deck-builder/DeckEditor.d.ts +37 -0
  84. package/dist/deck-builder/DeckEditor.d.ts.map +1 -0
  85. package/dist/deck-builder/DeckLibrary.d.ts +22 -0
  86. package/dist/deck-builder/DeckLibrary.d.ts.map +1 -0
  87. package/dist/deck-builder/DeckSlideSurface.d.ts +31 -0
  88. package/dist/deck-builder/DeckSlideSurface.d.ts.map +1 -0
  89. package/dist/deck-builder/deck-api.d.ts +76 -0
  90. package/dist/deck-builder/deck-api.d.ts.map +1 -0
  91. package/dist/deck-builder/deck-chat-client.d.ts +44 -0
  92. package/dist/deck-builder/deck-chat-client.d.ts.map +1 -0
  93. package/dist/deck-builder/deck-chat-history.d.ts +10 -0
  94. package/dist/deck-builder/deck-chat-history.d.ts.map +1 -0
  95. package/dist/deck-builder/index.d.ts +13 -0
  96. package/dist/deck-builder/index.d.ts.map +1 -0
  97. package/dist/deck-builder/index.js +1505 -0
  98. package/dist/deck-builder/starter-deck.d.ts +23 -0
  99. package/dist/deck-builder/starter-deck.d.ts.map +1 -0
  100. package/dist/deck-builder/types.d.ts +29 -0
  101. package/dist/deck-builder/types.d.ts.map +1 -0
  102. package/dist/deck-builder/useSortableSlide.d.ts +29 -0
  103. package/dist/deck-builder/useSortableSlide.d.ts.map +1 -0
  104. package/dist/index.js +70 -63
  105. package/dist/lib/deck-access.d.ts +86 -0
  106. package/dist/lib/deck-access.d.ts.map +1 -0
  107. package/dist/lib/deck-access.js +58 -0
  108. package/dist/lib/deck-dexie.d.ts +88 -0
  109. package/dist/lib/deck-dexie.d.ts.map +1 -0
  110. package/dist/lib/deck-dexie.js +22 -0
  111. package/dist/lib/deck-edit.d.ts +67 -0
  112. package/dist/lib/deck-edit.d.ts.map +1 -0
  113. package/dist/lib/deck-media-assets.d.ts +27 -0
  114. package/dist/lib/deck-media-assets.d.ts.map +1 -0
  115. package/dist/lib/deck-media-assets.js +25 -0
  116. package/dist/lib/deck-media-refs.d.ts +35 -0
  117. package/dist/lib/deck-media-refs.d.ts.map +1 -0
  118. package/dist/lib/deck-media-refs.js +36 -0
  119. package/dist/lib/deck-tool.d.ts +25 -0
  120. package/dist/lib/deck-tool.d.ts.map +1 -0
  121. package/dist/lib/deck-tool.js +58 -0
  122. package/dist/lib/index-ops.js +2 -2
  123. package/dist/lib/index.js +17 -12
  124. package/dist/lib/nav-progress.d.ts +14 -0
  125. package/dist/lib/nav-progress.d.ts.map +1 -0
  126. package/dist/lib/nav.d.ts.map +1 -1
  127. package/dist/media/index.js +3 -3
  128. package/dist/schemas/deck-meta.d.ts +85 -0
  129. package/dist/schemas/deck-meta.d.ts.map +1 -0
  130. package/dist/schemas/deck-meta.js +64 -0
  131. package/dist/schemas/deck.d.ts +954 -0
  132. package/dist/schemas/deck.d.ts.map +1 -0
  133. package/dist/schemas/deck.js +65 -0
  134. package/dist/schemas/index.js +39 -37
  135. package/dist/schemas/site-config.d.ts +13 -1
  136. package/dist/schemas/site-config.d.ts.map +1 -1
  137. package/package.json +37 -1
  138. package/src/components/chat/ChatComposer.tsx +100 -0
  139. package/src/components/chat/ChatPanel.tsx +20 -69
  140. package/src/components/chat/markdown.ts +32 -0
  141. package/src/components/primitives/EditablePlainText.tsx +11 -1
  142. package/src/components/shell/AudienceChips.tsx +30 -0
  143. package/src/components/shell/EditorShell.tsx +12 -0
  144. package/src/components/shell/SiteSettingsChatbot.tsx +1 -18
  145. package/src/components/shell/SiteSettingsDecks.tsx +41 -0
  146. package/src/components/shell/SiteSettingsModal.tsx +11 -0
  147. package/src/deck/DeckComponentView.tsx +28 -0
  148. package/src/deck/DeckPlayer.tsx +166 -0
  149. package/src/deck/DeckSlideView.tsx +38 -0
  150. package/src/deck/DeckStage.tsx +39 -0
  151. package/src/deck/components/DeckImage.tsx +38 -0
  152. package/src/deck/components/DeckImageGrid.tsx +254 -0
  153. package/src/deck/components/DeckRichText.tsx +111 -0
  154. package/src/deck/components/DeckVideo.tsx +81 -0
  155. package/src/deck/editing.tsx +201 -0
  156. package/src/deck/flip.ts +70 -0
  157. package/src/deck/index.ts +30 -0
  158. package/src/deck/layouts/CenteredTextLayout.tsx +29 -0
  159. package/src/deck/layouts/ColumnSplitLayout.tsx +69 -0
  160. package/src/deck/layouts/FullBleedImageLayout.tsx +58 -0
  161. package/src/deck/media.tsx +46 -0
  162. package/src/deck/tokens.ts +57 -0
  163. package/src/deck/useStageScale.ts +44 -0
  164. package/src/deck-builder/DeckBuilderApp.tsx +572 -0
  165. package/src/deck-builder/DeckBuilderTopBar.tsx +62 -0
  166. package/src/deck-builder/DeckChatPanel.tsx +334 -0
  167. package/src/deck-builder/DeckEditor.tsx +258 -0
  168. package/src/deck-builder/DeckLibrary.tsx +195 -0
  169. package/src/deck-builder/DeckSlideSurface.tsx +55 -0
  170. package/src/deck-builder/deck-api.ts +177 -0
  171. package/src/deck-builder/deck-chat-client.ts +129 -0
  172. package/src/deck-builder/deck-chat-history.ts +62 -0
  173. package/src/deck-builder/index.ts +12 -0
  174. package/src/deck-builder/starter-deck.ts +86 -0
  175. package/src/deck-builder/types.ts +24 -0
  176. package/src/deck-builder/useSortableSlide.ts +102 -0
  177. package/src/lib/deck-access.ts +132 -0
  178. package/src/lib/deck-dexie.ts +190 -0
  179. package/src/lib/deck-edit.ts +196 -0
  180. package/src/lib/deck-media-assets.ts +57 -0
  181. package/src/lib/deck-media-refs.ts +67 -0
  182. package/src/lib/deck-tool.ts +95 -0
  183. package/src/lib/nav-progress.ts +108 -0
  184. package/src/lib/nav.ts +21 -1
  185. package/src/schemas/deck-meta.ts +88 -0
  186. package/src/schemas/deck.ts +478 -0
  187. package/src/schemas/index.ts +4 -0
  188. package/src/schemas/site-config.ts +24 -1
@@ -0,0 +1,111 @@
1
+ import { Fragment, type ReactNode } from "react";
2
+ import type { DeckTextPath, DeckTextSlot } from "../../lib/deck-edit";
3
+ import type { DeckInlineText, DeckRichText as DeckRichTextContent } from "../../schemas/deck";
4
+ import { EditableText } from "../editing";
5
+
6
+ interface Props {
7
+ content: DeckRichTextContent;
8
+ /**
9
+ * Which slot of the enclosing Layout this component fills. Supplied only so a
10
+ * text leaf can address itself for inline editing (#39); absent means "no
11
+ * edit seam here", which is the read-only default every viewer surface gets.
12
+ */
13
+ slot?: DeckTextSlot;
14
+ }
15
+
16
+ /**
17
+ * A body line. A plain string renders as itself; a span run renders its marks
18
+ * as elements. Nothing here reads a string as markup — `bold` and `italic` are
19
+ * booleans on the span, which is the whole point of the representation.
20
+ *
21
+ * The editable unit is the LEAF, never the line: a marked run offers one field
22
+ * per span, so editing the words around a bold phrase cannot flatten it. That
23
+ * is what lets inline editing be plain-text-only without a formatting UI and
24
+ * without silently dropping marks (#39).
25
+ */
26
+ function InlineText({ value, path }: { value: DeckInlineText; path?: DeckTextPath }) {
27
+ if (typeof value === "string") return <EditableText path={path}>{value}</EditableText>;
28
+ return (
29
+ <>
30
+ {value.map((span, i) => {
31
+ let node: ReactNode = (
32
+ <EditableText path={path && { ...path, span: i }}>{span.text}</EditableText>
33
+ );
34
+ if (span.italic) node = <em className="italic">{node}</em>;
35
+ // Bold pulls the phrase back to full ink: body copy renders muted, so
36
+ // weight alone barely registers at presentation size.
37
+ if (span.bold) node = <strong className="font-semibold text-[var(--deck-ink)]">{node}</strong>;
38
+ return <Fragment key={i}>{node}</Fragment>;
39
+ })}
40
+ </>
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Deck Component — rich text.
46
+ *
47
+ * Every size is px against the 1600x900 stage; every color, family and radius
48
+ * is a token (`--deck-*` are set by the enclosing Layout, see ./tokens.ts).
49
+ * Fields are typographic roles, not markup: the schema carries plain strings
50
+ * and marked spans, so nothing here interpolates HTML.
51
+ */
52
+ export function DeckRichText({ content, slot }: Props) {
53
+ const { kicker, heading, subheading, paragraphs, bullets } = content;
54
+ const at = (field: DeckTextPath["field"], line?: number): DeckTextPath | undefined =>
55
+ slot === undefined ? undefined : { slot, field, line };
56
+
57
+ return (
58
+ <div data-deck-component="richText" className="flex flex-col gap-[28px]">
59
+ {kicker !== undefined && (
60
+ <div className="flex flex-col gap-[18px]">
61
+ <p className="text-[22px] font-semibold tracking-[0.2em] text-[var(--deck-accent)] [text-transform:var(--nav-text-transform)]">
62
+ <EditableText path={at("kicker")}>{kicker}</EditableText>
63
+ </p>
64
+ <span
65
+ aria-hidden="true"
66
+ // Inline, not `self-[var(…)]`: Tailwind never generated CSS for that
67
+ // arbitrary self- value, so the divider silently ignored --deck-align
68
+ // and hugged the left edge on centered layouts.
69
+ style={{ alignSelf: "var(--deck-align, start)" }}
70
+ className="h-[4px] w-[84px] rounded-[var(--radius-inner)] bg-[var(--deck-accent)]"
71
+ />
72
+ </div>
73
+ )}
74
+
75
+ {heading !== undefined && (
76
+ <h1 className="font-heading text-[88px] leading-[1.06] font-medium tracking-[-0.015em] text-[var(--deck-ink)] [text-transform:var(--heading-text-transform)]">
77
+ <EditableText path={at("heading")}>{heading}</EditableText>
78
+ </h1>
79
+ )}
80
+
81
+ {subheading !== undefined && (
82
+ <h2 className="font-heading text-[38px] leading-[1.25] font-normal text-[var(--deck-ink-muted)] [text-transform:var(--subheading-text-transform)]">
83
+ <EditableText path={at("subheading")}>{subheading}</EditableText>
84
+ </h2>
85
+ )}
86
+
87
+ {paragraphs !== undefined && paragraphs.length > 0 && (
88
+ <div className="flex max-w-[1000px] flex-col gap-[18px] self-[var(--deck-align,start)]">
89
+ {paragraphs.map((paragraph, i) => (
90
+ <p key={i} className="text-[26px] leading-[1.6] text-[var(--deck-ink-muted)]">
91
+ <InlineText value={paragraph} path={at("paragraphs", i)} />
92
+ </p>
93
+ ))}
94
+ </div>
95
+ )}
96
+
97
+ {bullets !== undefined && bullets.length > 0 && (
98
+ <ul className="flex flex-col gap-[16px] self-[var(--deck-align,start)] text-left">
99
+ {bullets.map((bullet, i) => (
100
+ <li key={i} className="flex items-start gap-[18px] text-[26px] leading-[1.45] text-[var(--deck-ink)]">
101
+ <span aria-hidden="true" className="mt-[14px] size-[10px] shrink-0 rounded-full bg-[var(--deck-accent)]" />
102
+ <span>
103
+ <InlineText value={bullet} path={at("bullets", i)} />
104
+ </span>
105
+ </li>
106
+ ))}
107
+ </ul>
108
+ )}
109
+ </div>
110
+ );
111
+ }
@@ -0,0 +1,81 @@
1
+ import { Play } from "lucide-react";
2
+ import { useRef, useState } from "react";
3
+ import type { DeckVideo as DeckVideoContent } from "../../schemas/deck";
4
+ import { DeckMissingMedia, useDeckMedia } from "../media";
5
+ import { DECK_ELEVATION, surfaceInk } from "../tokens";
6
+
7
+ interface Props {
8
+ content: DeckVideoContent;
9
+ }
10
+
11
+ /**
12
+ * Deck Component — video.
13
+ *
14
+ * Two states: a poster with a play affordance, and playback with the browser's
15
+ * own controls. Nothing custom drives the media element once it is playing —
16
+ * a presenter needs scrubbing and volume, and native controls are the ones
17
+ * their keyboard already knows.
18
+ */
19
+ export function DeckVideo({ content }: Props) {
20
+ const asset = useDeckMedia(content.media);
21
+ const videoRef = useRef<HTMLVideoElement>(null);
22
+ const [playing, setPlaying] = useState(false);
23
+
24
+ const start = () => {
25
+ setPlaying(true);
26
+ // Rejects when the source is unplayable (wrong codec, a media id whose
27
+ // bytes are gone) — the poster is already dismissed, so just don't leave
28
+ // an unhandled rejection behind.
29
+ videoRef.current?.play()?.catch(() => undefined);
30
+ };
31
+
32
+ return (
33
+ <div
34
+ data-deck-component="video"
35
+ data-deck-video-state={playing ? "playing" : "idle"}
36
+ style={DECK_ELEVATION}
37
+ className="relative size-full overflow-hidden bg-base-200"
38
+ >
39
+ {asset === null ? (
40
+ <DeckMissingMedia />
41
+ ) : (
42
+ <video
43
+ ref={videoRef}
44
+ src={asset.src}
45
+ poster={asset.poster}
46
+ // Nothing is fetched until the poster is dismissed: a deck is mostly
47
+ // slides you never reach, and the poster is an explicit URL anyway.
48
+ preload="none"
49
+ playsInline
50
+ controls={playing}
51
+ onEnded={() => setPlaying(false)}
52
+ className="size-full object-cover"
53
+ />
54
+ )}
55
+
56
+ {!playing && asset !== null && (
57
+ <button
58
+ type="button"
59
+ data-deck-video-play=""
60
+ onClick={start}
61
+ aria-label={content.title === undefined ? "Play video" : `Play ${content.title}`}
62
+ className="group absolute inset-0 grid cursor-pointer place-items-center"
63
+ >
64
+ <span className="grid size-[108px] place-items-center rounded-full bg-primary text-[var(--color-on-primary)] shadow-[var(--deck-lift)] transition-transform duration-200 group-hover:scale-105">
65
+ <Play aria-hidden="true" className="ml-[6px] size-[44px] fill-current" />
66
+ </span>
67
+ </button>
68
+ )}
69
+
70
+ {!playing && content.title !== undefined && (
71
+ <span
72
+ data-deck-surface="brand"
73
+ style={surfaceInk("brand")}
74
+ className="pointer-events-none absolute right-0 bottom-0 left-0 bg-primary px-[28px] py-[18px] text-[18px] tracking-[0.06em] text-[var(--deck-ink)] [text-transform:var(--nav-text-transform)]"
75
+ >
76
+ {content.title}
77
+ </span>
78
+ )}
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,201 @@
1
+ import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
2
+ import type { DeckTextPath } from "../lib/deck-edit";
3
+
4
+ /**
5
+ * The runtime's inline-editing seam (#39).
6
+ *
7
+ * Editing happens INSIDE the real renderer rather than in an overlay that
8
+ * imitates it, which is the only way "renders identically on blur" is true
9
+ * rather than approximately true — the element the author types into is the
10
+ * element the deck ships with.
11
+ *
12
+ * The seam is a context that defaults to null, so every read-only surface — a
13
+ * filmstrip thumbnail, an in-portal deck view, the Public Link route — renders
14
+ * exactly the markup it rendered before this file existed, with no affordance
15
+ * and no listeners. Providing the context is an explicit act, performed in one
16
+ * place (`DeckSlideSurface`).
17
+ */
18
+
19
+ export interface DeckTextEditor {
20
+ /** Commit a leaf edit. The provider owns validation and staging. */
21
+ onEditText: (path: DeckTextPath, text: string) => void;
22
+ /**
23
+ * Locked while a chat turn is in flight: a turn rewrites the WHOLE deck
24
+ * (ADR 0001), so an edit committed mid-stream would be written over by the
25
+ * turn's own `putDeckDraft` with nothing anywhere saying so.
26
+ */
27
+ disabled?: boolean;
28
+ }
29
+
30
+ const DeckTextEditContext = createContext<DeckTextEditor | null>(null);
31
+
32
+ export function DeckTextEditProvider({ editor, children }: { editor: DeckTextEditor | null; children: ReactNode }) {
33
+ return <DeckTextEditContext.Provider value={editor}>{children}</DeckTextEditContext.Provider>;
34
+ }
35
+
36
+ export function useDeckTextEditor(): DeckTextEditor | null {
37
+ return useContext(DeckTextEditContext);
38
+ }
39
+
40
+ interface EditableTextProps {
41
+ /** Absent when the enclosing component was rendered without a slot — i.e. by
42
+ * a surface that never carries an edit seam. Renders inert. */
43
+ path?: DeckTextPath;
44
+ /** The leaf's current text. Always a plain string; that is the schema's shape. */
45
+ children: string;
46
+ }
47
+
48
+ /**
49
+ * One editable text leaf, or plain text when there is no editor.
50
+ *
51
+ * The two states are DIFFERENT ELEMENTS on purpose. React does not manage the
52
+ * text of a `contentEditable` node it has no children for, so the field can be
53
+ * written to freely while it is open, and swapping back to a React-rendered
54
+ * span on commit is what makes the committed value the one that renders — no
55
+ * reconciliation against a DOM the author mutated behind React's back.
56
+ */
57
+ export function EditableText({ path, children }: EditableTextProps) {
58
+ const editor = useDeckTextEditor();
59
+ const [editing, setEditing] = useState(false);
60
+
61
+ const enabled = editor !== null && path !== undefined && editor.disabled !== true;
62
+
63
+ // Losing the seam mid-edit (a chat turn starting, the surface going
64
+ // read-only) closes the field rather than leaving an orphaned editable node.
65
+ useEffect(() => {
66
+ if (!enabled && editing) setEditing(false);
67
+ }, [enabled, editing]);
68
+
69
+ if (editor === null || path === undefined) return <>{children}</>;
70
+
71
+ if (editing && enabled) {
72
+ return (
73
+ <EditableField
74
+ initial={children}
75
+ onCommit={(text) => {
76
+ setEditing(false);
77
+ // An emptied field is a cancel, not an edit: every one of these leaves
78
+ // is `min(1)` in the schema, so committing "" could only ever be
79
+ // refused — and refusing it with an error message would read as a
80
+ // failure rather than as "nothing happened".
81
+ const trimmed = text.trim();
82
+ if (trimmed !== "" && trimmed !== children) editor.onEditText(path, trimmed);
83
+ }}
84
+ onCancel={() => setEditing(false)}
85
+ />
86
+ );
87
+ }
88
+
89
+ return (
90
+ <span
91
+ role="button"
92
+ tabIndex={enabled ? 0 : -1}
93
+ aria-disabled={enabled ? undefined : true}
94
+ data-deck-editable=""
95
+ onClick={enabled ? () => setEditing(true) : undefined}
96
+ onKeyDown={
97
+ enabled
98
+ ? (event) => {
99
+ if (event.key !== "Enter" && event.key !== " ") return;
100
+ event.preventDefault();
101
+ setEditing(true);
102
+ }
103
+ : undefined
104
+ }
105
+ className={
106
+ enabled
107
+ ? "cursor-text rounded-[var(--radius-inner)] decoration-dotted underline-offset-[6px] outline-none hover:underline focus-visible:underline"
108
+ : "opacity-70"
109
+ }
110
+ >
111
+ {children}
112
+ </span>
113
+ );
114
+ }
115
+
116
+ /**
117
+ * The open field. Rendered with no React children so React never fights the
118
+ * author for the node's text; the initial value is written once on mount.
119
+ */
120
+ function EditableField({
121
+ initial,
122
+ onCommit,
123
+ onCancel,
124
+ }: {
125
+ initial: string;
126
+ onCommit: (text: string) => void;
127
+ onCancel: () => void;
128
+ }) {
129
+ const ref = useRef<HTMLSpanElement>(null);
130
+ // Escape has to beat the blur that follows it, and blur reads this ref rather
131
+ // than state because the two happen in the same tick.
132
+ const cancelled = useRef(false);
133
+
134
+ useEffect(() => {
135
+ const element = ref.current;
136
+ if (!element) return;
137
+ element.textContent = initial;
138
+ element.focus();
139
+ const selection = window.getSelection?.();
140
+ if (selection) {
141
+ const range = document.createRange();
142
+ range.selectNodeContents(element);
143
+ selection.removeAllRanges();
144
+ selection.addRange(range);
145
+ }
146
+ }, [initial]);
147
+
148
+ return (
149
+ <span
150
+ ref={ref}
151
+ contentEditable
152
+ suppressContentEditableWarning
153
+ role="textbox"
154
+ aria-label="Edit text"
155
+ spellCheck
156
+ data-deck-editing=""
157
+ onKeyDown={(event) => {
158
+ // Mid-composition, Enter and Escape belong to the IME: Enter accepts a
159
+ // conversion candidate and Escape abandons it. Committing or cancelling
160
+ // the field there cuts a CJK author off mid-word.
161
+ if (event.nativeEvent.isComposing) return;
162
+ if (event.key === "Enter") {
163
+ // A deck text leaf is one line in the schema, so Enter means "done",
164
+ // not "newline" — the layouts own line breaking.
165
+ event.preventDefault();
166
+ event.currentTarget.blur();
167
+ return;
168
+ }
169
+ if (event.key === "Escape") {
170
+ event.preventDefault();
171
+ cancelled.current = true;
172
+ event.currentTarget.blur();
173
+ }
174
+ }}
175
+ onPaste={(event) => {
176
+ // A deck text leaf is a plain string, and the field yields `textContent`
177
+ // — so pasted markup would show live formatting that vanishes on blur,
178
+ // and pasted block elements would join with no separator at all
179
+ // (`<div>a</div><div>b</div>` reads back as "ab"). Take the plain text
180
+ // and flatten its line breaks to spaces, which is what a one-line field
181
+ // can honestly hold.
182
+ event.preventDefault();
183
+ const text = event.clipboardData.getData("text/plain").replace(/\s+/g, " ").trim();
184
+ const doc = event.currentTarget.ownerDocument;
185
+ // Guarded because jsdom does not implement it; `insertText` is used
186
+ // rather than a Selection-API splice because it is what keeps the
187
+ // field's native undo stack intact.
188
+ if (text !== "" && typeof doc.execCommand === "function") doc.execCommand("insertText", false, text);
189
+ }}
190
+ onBlur={(event) => {
191
+ if (cancelled.current) {
192
+ cancelled.current = false;
193
+ onCancel();
194
+ return;
195
+ }
196
+ onCommit(event.currentTarget.textContent ?? "");
197
+ }}
198
+ className="rounded-[var(--radius-inner)] outline-2 outline-offset-2 outline-[var(--deck-accent)]"
199
+ />
200
+ );
201
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * FLIP for the image grid's expand/collapse.
3
+ *
4
+ * The grid changes layout via React state, so the "First" rects have to be
5
+ * taken in the event handler (before `setState`) and the invert applied in a
6
+ * layout effect (after the DOM has moved, before paint).
7
+ */
8
+
9
+ const TILE_SELECTOR = "[data-deck-tile]";
10
+
11
+ export type TileRects = Map<Element, DOMRect>;
12
+
13
+ export function captureTileRects(grid: HTMLElement): TileRects {
14
+ const rects: TileRects = new Map();
15
+ for (const tile of grid.querySelectorAll(TILE_SELECTOR)) rects.set(tile, tile.getBoundingClientRect());
16
+ return rects;
17
+ }
18
+
19
+ /**
20
+ * Rects are screen-space but the transform applies inside the stage, which is
21
+ * itself scaled — so translations divide by that scale or the tile overshoots
22
+ * by exactly the stage factor. `DeckStage` publishes it as `data-deck-scale`.
23
+ */
24
+ function stageScale(el: Element): number {
25
+ const raw = el.closest("[data-deck-stage]")?.getAttribute("data-deck-scale");
26
+ const value = raw === null || raw === undefined ? Number.NaN : Number(raw);
27
+ return Number.isFinite(value) && value > 0 ? value : 1;
28
+ }
29
+
30
+ export function playTileFlip(grid: HTMLElement, before: TileRects): void {
31
+ const scale = stageScale(grid);
32
+ const moved: HTMLElement[] = [];
33
+
34
+ for (const tile of grid.querySelectorAll<HTMLElement>(TILE_SELECTOR)) {
35
+ const first = before.get(tile);
36
+ if (!first) continue;
37
+ const last = tile.getBoundingClientRect();
38
+ // Unmeasurable boxes (jsdom, a hidden ancestor) invert to NaN; skip them
39
+ // and the tile simply lands without an animation.
40
+ if (!first.width || !last.width) continue;
41
+
42
+ // Inverted about the element's CENTER, which is `transform-origin`'s
43
+ // default — so this never sets the property and never has to clean it up.
44
+ // A top-left origin would have to be cleared, and clearing it synchronously
45
+ // jumps an in-flight animation while leaving it behind makes the tile's
46
+ // next hover scale from its corner instead of its middle.
47
+ const dx = (first.left + first.width / 2 - (last.left + last.width / 2)) / scale;
48
+ const dy = (first.top + first.height / 2 - (last.top + last.height / 2)) / scale;
49
+ const sx = first.width / last.width;
50
+ const sy = first.height / last.height;
51
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5 && Math.abs(sx - 1) < 0.002) continue;
52
+
53
+ tile.style.transition = "none";
54
+ tile.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
55
+ moved.push(tile);
56
+ }
57
+
58
+ if (moved.length === 0) return;
59
+
60
+ // Commit the inverted start state with a synchronous reflow, NOT
61
+ // requestAnimationFrame: rAF does not run in a background tab, and a deck
62
+ // left on an inactive tab would come back with every tile stranded under its
63
+ // inverse transform.
64
+ void grid.offsetWidth;
65
+
66
+ for (const tile of moved) {
67
+ tile.style.transition = "";
68
+ tile.style.transform = "";
69
+ }
70
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The deck runtime — a sealed subtree, like ./document.
3
+ *
4
+ * NOT re-exported from the primitives root barrel, and deliberately not a
5
+ * section type: nothing on an ordinary viewer page may pull this graph in.
6
+ * Only deck surfaces (the Deck Builder, in-portal deck views, the Public Link
7
+ * route) import from here.
8
+ */
9
+ export { DeckPlayer } from "./DeckPlayer";
10
+ export { DeckStage } from "./DeckStage";
11
+ export { DeckSlideView } from "./DeckSlideView";
12
+ export { DeckComponentView } from "./DeckComponentView";
13
+ export { CenteredTextLayout } from "./layouts/CenteredTextLayout";
14
+ export { ColumnSplitLayout } from "./layouts/ColumnSplitLayout";
15
+ export { FullBleedImageLayout } from "./layouts/FullBleedImageLayout";
16
+ export { DeckRichText } from "./components/DeckRichText";
17
+ export { DeckImage } from "./components/DeckImage";
18
+ export { DeckImageGrid } from "./components/DeckImageGrid";
19
+ export { DeckVideo } from "./components/DeckVideo";
20
+ export {
21
+ DeckMediaProvider,
22
+ DeckMissingMedia,
23
+ useDeckMedia,
24
+ useDeckMediaResolver,
25
+ type DeckMediaAsset,
26
+ type DeckMediaResolver,
27
+ } from "./media";
28
+ export { DeckTextEditProvider, EditableText, useDeckTextEditor, type DeckTextEditor } from "./editing";
29
+ export { useStageScale } from "./useStageScale";
30
+ export { deckAlign, surfaceInk, DECK_ELEVATION, SURFACE_BACKGROUND, type DeckAlign, type DeckSurface } from "./tokens";
@@ -0,0 +1,29 @@
1
+ import { cn } from "../../lib/cn";
2
+ import type { CenteredTextLayout as CenteredTextLayoutNode } from "../../schemas/deck";
3
+ import { DeckComponentView } from "../DeckComponentView";
4
+ import { deckAlign, SURFACE_BACKGROUND, surfaceInk } from "../tokens";
5
+
6
+ interface Props {
7
+ layout: CenteredTextLayoutNode;
8
+ }
9
+
10
+ /**
11
+ * Layout — centered text. A full-slide title card holding one Deck Component,
12
+ * centered on both axes. The Layout owns the surface (background + ink tokens)
13
+ * and the stage-relative padding; the component owns nothing but its own type.
14
+ */
15
+ export function CenteredTextLayout({ layout }: Props) {
16
+ return (
17
+ <div
18
+ data-deck-layout="centeredText"
19
+ data-deck-surface={layout.background}
20
+ style={{ ...surfaceInk(layout.background), ...deckAlign("center") }}
21
+ className={cn(
22
+ "flex size-full flex-col items-center justify-center px-[180px] py-[110px] text-center",
23
+ SURFACE_BACKGROUND[layout.background],
24
+ )}
25
+ >
26
+ <DeckComponentView component={layout.content} slot="content" />
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,69 @@
1
+ import { cn } from "../../lib/cn";
2
+ import type { ColumnSplitLayout as ColumnSplitLayoutNode, DeckComponent, DeckSplitRatio } from "../../schemas/deck";
3
+ import { DeckComponentView } from "../DeckComponentView";
4
+ import { deckAlign, SURFACE_BACKGROUND, surfaceInk } from "../tokens";
5
+
6
+ interface Props {
7
+ layout: ColumnSplitLayoutNode;
8
+ }
9
+
10
+ const RATIO: Record<DeckSplitRatio, string> = {
11
+ "1-2": "grid-cols-[1fr_2fr]",
12
+ "2-1": "grid-cols-[2fr_1fr]",
13
+ };
14
+
15
+ /**
16
+ * How much air a column gives its component. Text needs generous margins; an
17
+ * image is meant to run to the stage edge; a grid and a video need enough
18
+ * breathing room for a tile to lift without clipping.
19
+ */
20
+ const COLUMN_PADDING: Record<DeckComponent["type"], string> = {
21
+ richText: "flex flex-col justify-center px-[64px] py-[96px]",
22
+ image: "",
23
+ imageGrid: "p-[44px]",
24
+ video: "p-[44px]",
25
+ };
26
+
27
+ /**
28
+ * Layout — left/right column split.
29
+ *
30
+ * The hairline runs down the text column's inner edge, which is the editorial
31
+ * treatment's only separator: the columns are set apart by the type and the
32
+ * crop, not by a card or a shadow.
33
+ */
34
+ export function ColumnSplitLayout({ layout }: Props) {
35
+ // The rule belongs to the seam, so exactly one column ever draws it. Asking
36
+ // each text column for its own inner border drew a 2px rule whenever both
37
+ // columns held text — the one case where the hairline is doing the most work.
38
+ const seam = layout.left.type === "richText" ? "left" : layout.right.type === "richText" ? "right" : null;
39
+
40
+ return (
41
+ <div
42
+ data-deck-layout="columnSplit"
43
+ data-deck-ratio={layout.ratio}
44
+ data-deck-surface="base"
45
+ style={{ ...surfaceInk("base"), ...deckAlign("start") }}
46
+ className={cn("grid size-full text-left", RATIO[layout.ratio], SURFACE_BACKGROUND.base)}
47
+ >
48
+ <Column component={layout.left} side="left" rule={seam === "left"} />
49
+ <Column component={layout.right} side="right" rule={seam === "right"} />
50
+ </div>
51
+ );
52
+ }
53
+
54
+ // `side` doubles as the edit-seam slot name (see DeckTextPath) — the two are
55
+ // the same coordinate, so keeping one avoids a mapping that could drift.
56
+ function Column({ component, side, rule }: { component: DeckComponent; side: "left" | "right"; rule: boolean }) {
57
+ return (
58
+ <div
59
+ data-deck-column={side}
60
+ className={cn(
61
+ "relative min-w-0 overflow-hidden",
62
+ COLUMN_PADDING[component.type],
63
+ rule && (side === "left" ? "border-r border-base-200" : "border-l border-base-200"),
64
+ )}
65
+ >
66
+ <DeckComponentView component={component} slot={side} />
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,58 @@
1
+ import { cn } from "../../lib/cn";
2
+ import type { DeckOverlayPlacement, FullBleedImageLayout as FullBleedImageLayoutNode } from "../../schemas/deck";
3
+ import { DeckRichText } from "../components/DeckRichText";
4
+ import { DeckMissingMedia, useDeckMedia } from "../media";
5
+ import { deckAlign, SURFACE_BACKGROUND, surfaceInk } from "../tokens";
6
+
7
+ interface Props {
8
+ layout: FullBleedImageLayoutNode;
9
+ }
10
+
11
+ /**
12
+ * Panel geometry, stage-relative. Hard edges and full bleed to the stage sides
13
+ * — the panel is a block of brand color laid over the picture, not a card
14
+ * floating on it.
15
+ */
16
+ const PLACEMENT: Record<DeckOverlayPlacement, string> = {
17
+ sideLeft: "top-0 bottom-0 left-0 w-[40%] justify-center px-[72px] py-[90px]",
18
+ sideRight: "top-0 bottom-0 right-0 w-[40%] justify-center px-[72px] py-[90px]",
19
+ bottomBand: "right-0 bottom-0 left-0 justify-end px-[96px] py-[56px]",
20
+ };
21
+
22
+ /**
23
+ * Layout — full-bleed image with an optional text overlay panel.
24
+ *
25
+ * The Layout owns both surfaces: the slide is base (the image covers it), the
26
+ * panel declares brand, so the rich text inside reads on-primary ink without
27
+ * knowing it sits over a photograph.
28
+ */
29
+ export function FullBleedImageLayout({ layout }: Props) {
30
+ const asset = useDeckMedia(layout.media);
31
+ const { overlay } = layout;
32
+
33
+ return (
34
+ <div
35
+ data-deck-layout="fullBleedImage"
36
+ data-deck-surface="base"
37
+ style={{ ...surfaceInk("base"), ...deckAlign("start") }}
38
+ className={cn("relative size-full overflow-hidden", SURFACE_BACKGROUND.base)}
39
+ >
40
+ {asset === null ? (
41
+ <DeckMissingMedia className="absolute inset-0" />
42
+ ) : (
43
+ <img src={asset.src} alt={asset.alt} className="absolute inset-0 size-full object-cover" />
44
+ )}
45
+
46
+ {overlay !== undefined && (
47
+ <div
48
+ data-deck-overlay={overlay.placement}
49
+ data-deck-surface="brand"
50
+ style={surfaceInk("brand")}
51
+ className={cn("absolute z-[2] flex flex-col text-left", SURFACE_BACKGROUND.brand, PLACEMENT[overlay.placement])}
52
+ >
53
+ <DeckRichText content={overlay.content} slot="overlay" />
54
+ </div>
55
+ )}
56
+ </div>
57
+ );
58
+ }