caracms 0.0.0-stage → 0.2.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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,823 @@
1
+ /**
2
+ * The media field (M5b Phase 3; Decisions 3, 6, 7, 8 and 12; the mockup's #media-empty,
3
+ * #media-drop, #media-upload, #media-image, #media-decorative, #focal, #focal-unset,
4
+ * #media-clip, #media-processing, #media-failed, #media-only, #media-unset). One slot holds
5
+ * an image or a clip, one kind at a time:
6
+ *
7
+ * - **Empty**: a drop target, and Choose… (the library as a picker: pane/levels/MediaLevel.tsx).
8
+ * - **An image**: its card (the thumbnail with its focal point, the file, its size, "Focal
9
+ * point set"), Replace…, Focal point, Remove; then its alt text with **Decorative** on the
10
+ * label's row (ticked, the alt input empties and turns off, and nothing warns).
11
+ * - **The focal point** (Decision 8) on the image's own preview: a click or a drag puts it,
12
+ * the arrow keys move it 5% a press, Reset to centre clears it; stored rounded to two
13
+ * places. The ring moves at once; the value (and so the stage's next render, with new
14
+ * signed URLs and cold imgproxy renders) follows on a short debounce, and at once when
15
+ * the pointer lifts or focus leaves.
16
+ * - **A clip**: its poster, title and length, Replace…, Poster (the clip's own preview,
17
+ * scrubbed, and "Use this frame"), Remove; Plays as Loop / Player (none in a loop-only
18
+ * slot, which says screen readers skip it); the name screen readers hear only for a player.
19
+ * - **Uploading, processing, failed** (the store's upload for this slot), a refusal in red.
20
+ * - **Media not set up**: the 503's sentence in place of the controls (Decision 5).
21
+ *
22
+ * Remove only clears the slot (with Undo); nothing leaves the library. In a list (the
23
+ * gallery) the slot is a row's body (ListField.tsx), and Remove removes the row.
24
+ * Thumbnails always come from the server's signed imgproxy URL (never the original:
25
+ * Chrome can't show a HEIC), through `thumbnailSrc`.
26
+ *
27
+ * - **Alt text asked for after placing** (M5b Phase 4, Decision 9; #alt-ask): an image
28
+ * placed (picked or uploaded) with no alt text focuses the slot's alt input with the
29
+ * ask, in the tool's blue. Typing answers it; leaving it empty (a blur, Skip for now)
30
+ * turns it into the amber warning (#alt-missing), which stays while `alt` is empty.
31
+ * Several picked at once into a gallery are asked in turn (`altAskFor`). Not for a
32
+ * decorative image, a clip, or a brand slot.
33
+ * - **A brand slot** (`brand`: Site Settings' logo and tab icon, Decision 11;
34
+ * #settings-brand): image-only, compact, the whole picture, with no alt input and no
35
+ * focal point.
36
+ *
37
+ * `[data-alt="<path>"]` is the slot's alt input and `[data-path="<path>"]` its wrapper
38
+ * (Publish's Add alt text focuses them: live/publish.tsx).
39
+ */
40
+ import { useEffect, useRef, useState, type DragEvent, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
41
+ import type { FieldWarning, MediaField as MediaDescriptor } from '../../../fields.ts';
42
+ import { muxMp4, parseMedia, type ClipValue, type ImageValue, type MediaValue } from '../../../media-value.ts';
43
+ import { useEditor, useStore } from '../context.tsx';
44
+ import type { Editor } from '../editor.ts';
45
+ import { clipStill, mediaFor, slotKey, thumbnailSrc, type MediaUpload, type SlotTarget } from '../media/store.ts';
46
+ import { createStore, type Store } from '../state/store.ts';
47
+ import { Icon } from '../shell/Icons.tsx';
48
+ import { blockLabel, PAGE_SCOPE } from '../state/document-store.ts';
49
+ import { pageFieldsLabel } from '../documents/actions.ts';
50
+ import { FieldFrame } from './FieldFrame.tsx';
51
+ import { useFieldWarnings, useHost } from './host.tsx';
52
+ import { Warnings } from './parts.tsx';
53
+
54
+ const TAKES = 'JPEG, PNG, WebP, AVIF, HEIC, GIF or SVG';
55
+
56
+ /** The field's meta: what the slot takes. */
57
+ export const slotKinds = (field: Pick<MediaDescriptor, 'only' | 'play'>) =>
58
+ field.only === 'image' ? 'Images only' : field.only === 'video' ? 'Clips only' : field.play === 'loop' ? 'Image or loop' : 'Image or clip';
59
+
60
+ const pad2 = (n: number) => String(n).padStart(2, '0');
61
+ export const fmtDur = (s: number | null | undefined) => `${Math.floor((s || 0) / 60)}:${pad2(Math.round((s || 0) % 60))}`;
62
+ export const fmtT = (s: number | null | undefined) => `${Math.floor((s || 0) / 60)}:${pad2(Math.floor((s || 0) % 60))}.${Math.round(((s || 0) % 1) * 10) % 10}`;
63
+ export const extOf = (name: string) => ((/\.(\w+)$/.exec(name) ?? [, ''])[1] ?? '').toUpperCase().replace('JPEG', 'JPG');
64
+ export const fileOfKey = (key: string) => key.split('/').pop() ?? key;
65
+ const pct = (v: number) => Math.round(v * 100);
66
+ const round2 = (n: number) => Math.round(n * 100) / 100;
67
+ const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
68
+
69
+ /**
70
+ * The slot whose alt text is being asked for (Decision 9: after an image with no alt text
71
+ * is placed), by slotKey, with its path and the placement's number so each placement
72
+ * focuses once. Null once answered, skipped or left.
73
+ *
74
+ * Several images picked in a row from a list's Add image (the gallery) are asked in turn,
75
+ * in the order picked: Done opens the first one's row, and Enter or Tab in its alt input,
76
+ * Skip for now or Decorative opens the next. Leaving for anything else ends the run, and
77
+ * the rest show the amber warning, as one image left empty does.
78
+ */
79
+ type AltAsk = { slot: string; scope: string; path: string; n: number } | null;
80
+ const asks = new WeakMap<Editor, { store: Store<AltAsk>; queue: NonNullable<AltAsk>[] }>();
81
+ const listOf = (path: string) => path.slice(0, path.lastIndexOf('.'));
82
+ function asksFor(editor: Editor) {
83
+ let entry = asks.get(editor);
84
+ if (!entry) {
85
+ const made = { store: createStore<AltAsk>(null), queue: [] as NonNullable<AltAsk>[] };
86
+ entry = made;
87
+ asks.set(editor, made);
88
+ mediaFor(editor).placed.subscribe((p) => {
89
+ if (!p) return;
90
+ const slot = slotKey(p);
91
+ const rest = made.queue.filter((q) => q.slot !== slot);
92
+ const ask = p.kind === 'image' && p.alt === '' ? { slot, scope: p.scope, path: p.path, n: p.n } : null;
93
+ // One of a run of picks in the same list joins the queue; anything else starts afresh.
94
+ const head = rest[0];
95
+ const sameRun = p.multi && !!head && head.scope === p.scope && listOf(head.path) === listOf(p.path);
96
+ made.queue = sameRun ? (ask ? [...rest, ask] : rest) : ask ? [ask] : [];
97
+ made.store.set(made.queue[0] ?? null);
98
+ });
99
+ }
100
+ return entry;
101
+ }
102
+ export function altAskFor(editor: Editor): Store<AltAsk> {
103
+ return asksFor(editor).store;
104
+ }
105
+
106
+ /**
107
+ * The ask for `slot` ends: with `next`, the queue's next image is asked (returned, so the
108
+ * caller opens its row); without, the whole run ends. Nothing happens unless `slot` is the
109
+ * one being asked.
110
+ */
111
+ function endAltAsk(editor: Editor, slot: string, next: boolean): AltAsk {
112
+ const asks = asksFor(editor);
113
+ if (asks.store.get()?.slot !== slot) return null;
114
+ asks.queue = next ? asks.queue.slice(1) : [];
115
+ asks.store.set(asks.queue[0] ?? null);
116
+ return asks.store.get();
117
+ }
118
+
119
+ /** The block's (or form's) name, for the picker's trail and sub line: "Text and media", "Harbour Coffee". */
120
+ export function ownerOf(editor: Editor, scope: string): { owner: string; back: 'block' | 'document' } {
121
+ const session = editor.doc.get();
122
+ // M6: a page's own share image (SEO_SCOPE), at its document level.
123
+ if (scope === 'seo') return { owner: 'Search and sharing', back: 'document' };
124
+ // M6c (G2): a section builder's page fields, at its document level too.
125
+ if (scope === PAGE_SCOPE) {
126
+ const collection = editor.site.collections.find((c) => c.name === session.ref.collection);
127
+ return { owner: collection ? pageFieldsLabel(collection) : 'Page details', back: 'document' };
128
+ }
129
+ if (session.document.kind === 'sections') {
130
+ const block = editor.doc.block(scope);
131
+ return { owner: block ? blockLabel(editor.site, block) : 'Block', back: 'block' };
132
+ }
133
+ return { owner: session.document.title || session.entry.label, back: 'document' };
134
+ }
135
+
136
+ /** The media field at the top level of a block or a form: its frame and its slot. */
137
+ export function MediaField(props: { name: string; label: string; field: MediaDescriptor; value: unknown }) {
138
+ const host = useHost();
139
+ const warnings = useFieldWarnings(props.name, props.field, props.value);
140
+ return (
141
+ <FieldFrame blockKey={host.scope} name={props.name} label={props.label} help={props.field.help} className="media-field" meta={<span className="field-meta">{slotKinds(props.field)}</span>}>
142
+ <MediaSlot path={props.name} field={props.field} value={props.value} warnings={warnings} pickLabel={props.label} />
143
+ </FieldFrame>
144
+ );
145
+ }
146
+
147
+ /**
148
+ * One slot's body: in a field's frame, or a gallery row's (`row`: the list's path and the
149
+ * row's index, so Remove removes the row). `warnings` are relative to the slot (`''`, `'alt'`).
150
+ */
151
+ export function MediaSlot(props: {
152
+ path: string;
153
+ field: MediaDescriptor;
154
+ value: unknown;
155
+ warnings: readonly FieldWarning[];
156
+ /** The field's label for the picker's sub line ("Image or video", "Images, image 3"). */
157
+ pickLabel: string;
158
+ row?: { list: string; index: number; label: string };
159
+ /** Site Settings' logo or tab icon (Decision 11): image-only, compact, no alt input, no focal point. */
160
+ brand?: { label: string };
161
+ /** M6: a share image (Decision 13): an image with a focal point but no alt input (it's never on the page). */
162
+ noAlt?: boolean;
163
+ }) {
164
+ const editor = useEditor();
165
+ const host = useHost();
166
+ const media = mediaFor(editor);
167
+ // Made before any placement, so the first placement's ask is seen.
168
+ altAskFor(editor);
169
+ const { path, field } = props;
170
+ const setup = useStore(editor.mediaSetup);
171
+ const upload = useStore(media.uploads, (list) => list.find((u) => u.slot === slotKey({ scope: host.scope, path })) ?? null);
172
+ const note = useStore(media.slotNotes, (map) => map.get(slotKey({ scope: host.scope, path })) ?? null);
173
+ const value = parseMedia(props.value);
174
+ const target: SlotTarget = { scope: host.scope, path, only: field.only, play: field.play };
175
+ const ro = host.readOnly;
176
+ // The library answers where a key came from (its name) and a clip's title; read once, kept as last known.
177
+ useEffect(() => {
178
+ if (value && 'key' in value && media.images.get().list === null) void media.loadImages();
179
+ if (value && 'video' in value && media.clips.get().list === null) void media.loadClips();
180
+ }, [media, value && ('key' in value ? value.key : value.video)]);
181
+
182
+ const choose = () => {
183
+ if (ro) return;
184
+ const { owner, back } = ownerOf(editor, host.scope);
185
+ editor.openMedia({
186
+ pick: { scope: host.scope, path, only: field.only, play: field.play, label: `${owner} · ${props.pickLabel}`, owner, multi: false, back },
187
+ tab: value && 'video' in value ? 'clips' : 'images',
188
+ });
189
+ };
190
+ const remove = () => {
191
+ if (ro || !value) return;
192
+ const title = 'key' in value ? fileOfKey(value.key) : 'the clip';
193
+ if (props.row) host.removeItem(props.row.list, props.row.index, `Removed ${props.row.label}: ${title}`);
194
+ else {
195
+ host.set(path, null, { undoLabel: `Removed ${title}` });
196
+ editor.announce(`Removed ${title}.`);
197
+ }
198
+ };
199
+ const write = (next: MediaValue) => host.set(path, parseMedia(next));
200
+ const drop = useDrop((files) => !ro && media.upload(files, target));
201
+
202
+ // The image routes answer 503: the sentence in place of the controls (a clip-only slot needs Mux's).
203
+ const unset = field.only === 'video' ? setup.clips : value && 'video' in value ? setup.clips : setup.images;
204
+ if (unset) {
205
+ return (
206
+ <div className="mslot" data-path={path} tabIndex={-1}>
207
+ <div className="note tight" data-media-unset="">
208
+ <Icon name="media" />
209
+ <div>
210
+ <b>Media isn't set up on this server yet</b>
211
+ <p>{unset.error}</p>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ let body: ReactNode;
219
+ if (upload && upload.phase === 'failed') body = null;
220
+ else if (upload) body = <UploadCard upload={upload} onCancel={() => media.dismissUpload(upload.id)} />;
221
+ else if (props.brand) body = value && 'key' in value ? <BrandCard value={value} label={props.brand.label} readOnly={ro} onChoose={choose} onRemove={remove} drop={drop} /> : <BrandDrop readOnly={ro} onChoose={choose} drop={drop} />;
222
+ else if (value && 'key' in value) body = <ImageCard value={value} path={path} readOnly={ro} warnings={props.warnings} onChoose={choose} onRemove={remove} write={write} drop={drop} noAlt={props.noAlt} />;
223
+ else if (value && 'video' in value) body = <ClipCard value={value} path={path} loopOnly={field.play === 'loop'} readOnly={ro} onChoose={choose} onRemove={remove} write={write} drop={drop} />;
224
+ else body = <DropZone only={field.only} readOnly={ro} onChoose={choose} drop={drop} />;
225
+
226
+ return (
227
+ <div className="mslot" data-path={path} tabIndex={-1}>
228
+ {upload?.phase === 'failed' ? (
229
+ <div className="note danger tight" role="alert" style={{ marginBottom: 'var(--space-2)' }}>
230
+ <Icon name="warn" />
231
+ <div>
232
+ <b>{upload.name} couldn't be made ready to play</b>
233
+ <p>{upload.error}</p>
234
+ <div className="acts">
235
+ <button type="button" className="btn sm" disabled={ro} onClick={choose}>
236
+ Choose another…
237
+ </button>
238
+ <button type="button" className="btn sm ghost" onClick={() => media.dismissUpload(upload.id)}>
239
+ Dismiss
240
+ </button>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ ) : null}
245
+ {body}
246
+ {upload?.phase === 'failed' && !value ? <DropZone only={field.only} readOnly={ro} onChoose={choose} drop={drop} /> : null}
247
+ {note ? (
248
+ <p className="field-err" role="alert">
249
+ <Icon name="warn" size="sm" />
250
+ <span>{note}</span>
251
+ </p>
252
+ ) : null}
253
+ <Warnings warnings={props.warnings.filter((w) => w.path === '')} />
254
+ </div>
255
+ );
256
+ }
257
+
258
+ /** Drag-and-drop of files onto a slot (or the library): `over` while a file is held over it. */
259
+ export function useDrop(onFiles: (files: File[]) => void) {
260
+ const [over, setOver] = useState(false);
261
+ const depth = useRef(0);
262
+ const hasFiles = (event: DragEvent) => [...(event.dataTransfer?.types ?? [])].includes('Files');
263
+ return {
264
+ over,
265
+ props: {
266
+ onDragEnter(event: DragEvent) {
267
+ if (!hasFiles(event)) return;
268
+ event.preventDefault();
269
+ depth.current += 1;
270
+ setOver(true);
271
+ },
272
+ onDragOver(event: DragEvent) {
273
+ if (!hasFiles(event)) return;
274
+ event.preventDefault();
275
+ event.dataTransfer.dropEffect = 'copy';
276
+ },
277
+ onDragLeave(event: DragEvent) {
278
+ if (!hasFiles(event)) return;
279
+ depth.current = Math.max(0, depth.current - 1);
280
+ if (depth.current === 0) setOver(false);
281
+ },
282
+ onDrop(event: DragEvent) {
283
+ if (!hasFiles(event)) return;
284
+ event.preventDefault();
285
+ event.stopPropagation();
286
+ depth.current = 0;
287
+ setOver(false);
288
+ const files = [...(event.dataTransfer?.files ?? [])];
289
+ if (files.length) onFiles(files);
290
+ },
291
+ },
292
+ };
293
+ }
294
+
295
+ type Drop = ReturnType<typeof useDrop>;
296
+
297
+ function DropZone(props: { only?: 'image' | 'video'; readOnly: boolean; onChoose: () => void; drop: Drop }) {
298
+ const what = props.only === 'image' ? 'an image' : props.only === 'video' ? 'a clip' : 'an image or a clip';
299
+ const fine = props.only === 'image' ? `${TAKES}, up to 40 MB` : `Images: ${TAKES}, up to 40 MB.`;
300
+ return (
301
+ <div className={`mdrop${props.drop.over ? ' is-over' : ''}`} data-drop="" {...(props.readOnly ? {} : props.drop.props)}>
302
+ <Icon name="upload" />
303
+ <b>
304
+ <span className="when-idle">Drop {what} here</span>
305
+ <span className="when-over">Drop to upload</span>
306
+ </b>
307
+ <span className="or">
308
+ or{' '}
309
+ <button type="button" className="btn sm" disabled={props.readOnly} onClick={props.onChoose}>
310
+ <Icon name="media" size="sm" />
311
+ Choose…
312
+ </button>{' '}
313
+ from the library
314
+ </span>
315
+ <span className="fine">{fine}</span>
316
+ </div>
317
+ );
318
+ }
319
+
320
+ /** A brand slot, empty (#settings-brand): a small drop target and Choose…. */
321
+ function BrandDrop(props: { readOnly: boolean; onChoose: () => void; drop: Drop }) {
322
+ return (
323
+ <div className={`mdrop sm${props.drop.over ? ' is-over' : ''}`} data-drop="" {...(props.readOnly ? {} : props.drop.props)}>
324
+ <Icon name="upload" />
325
+ <span className="t">
326
+ <span className="when-idle">Drop an image here</span>
327
+ <span className="when-over">Drop to upload</span>
328
+ </span>
329
+ <button type="button" className="btn sm" disabled={props.readOnly} onClick={props.onChoose}>
330
+ Choose…
331
+ </button>
332
+ </div>
333
+ );
334
+ }
335
+
336
+ /** A brand slot with its image (#settings-brand): the whole picture (a logo isn't cropped), its file and size, Replace… and Remove. No alt, no focal point. */
337
+ function BrandCard(props: { value: ImageValue; label: string; readOnly: boolean; onChoose: () => void; onRemove: () => void; drop: Drop }) {
338
+ const editor = useEditor();
339
+ const media = mediaFor(editor);
340
+ const { value, readOnly: ro } = props;
341
+ const name = useStore(media.images, (l) => l.list?.find((x) => x.key === value.key)?.name ?? null) ?? fileOfKey(value.key);
342
+ const thumb = thumbnailSrc(value.key, 160);
343
+ return (
344
+ <div className="media compact" {...(ro ? {} : props.drop.props)}>
345
+ <div className="media-main">
346
+ <span className="thumb ph contain" style={value.w && value.h ? { ['--r' as string]: (value.w / value.h).toFixed(4) } : undefined}>
347
+ <img className="ph-img" src={thumb} srcSet={`${thumb} 1x, ${thumbnailSrc(value.key, 320)} 2x`} alt="" />
348
+ </span>
349
+ <div className="media-txt">
350
+ <b title={value.key}>{name}</b>
351
+ <small>
352
+ {value.w && value.h ? `${value.w} × ${value.h} · ` : ''}
353
+ {extOf(value.key)}
354
+ </small>
355
+ </div>
356
+ <button type="button" className="btn sm" disabled={ro} onClick={props.onChoose}>
357
+ Replace…
358
+ </button>
359
+ <button type="button" className="ib" disabled={ro} aria-label={`Remove the ${props.label.toLowerCase()}`} onClick={props.onRemove}>
360
+ <Icon name="x" size="sm" />
361
+ </button>
362
+ </div>
363
+ </div>
364
+ );
365
+ }
366
+
367
+ /** An upload in the slot: real progress, then Mux's processing for a clip. The file's own picture when the browser can show it. */
368
+ export function UploadCard({ upload, onCancel }: { upload: MediaUpload; onCancel: () => void }) {
369
+ const clip = upload.kind === 'clip';
370
+ const proc = upload.phase === 'proc';
371
+ const line = proc ? 'Processing: getting it ready to play' : `Uploading${clip ? ' to Mux' : ''} · ${upload.pct}%`;
372
+ const note = proc ? 'Usually under a minute.' : clip ? 'Keep this tab open until it’s done.' : null;
373
+ return (
374
+ <div className="media" role="status">
375
+ <div className="media-main">
376
+ {upload.preview ? (
377
+ <span className={`thumb${clip ? ' wide' : ''} ph`} style={{ opacity: 0.6 }}>
378
+ <img className="ph-img" src={upload.preview} alt="" />
379
+ </span>
380
+ ) : (
381
+ <span className={`thumb${clip ? ' wide' : ''} ph pending`}>{clip ? <Icon name="film" /> : extOf(upload.name)}</span>
382
+ )}
383
+ <div className="media-txt">
384
+ <b>{upload.name}</b>
385
+ <small>{line}</small>
386
+ </div>
387
+ {proc ? null : (
388
+ <button type="button" className="btn sm ghost" onClick={onCancel}>
389
+ Cancel
390
+ </button>
391
+ )}
392
+ </div>
393
+ <div className="media-prog">
394
+ <div className={`mbar${proc ? ' busy' : ''}`} style={{ ['--p' as string]: `${upload.pct}%` }}>
395
+ <i />
396
+ </div>
397
+ {note ? <p>{note}</p> : null}
398
+ </div>
399
+ </div>
400
+ );
401
+ }
402
+
403
+ function ImageCard(props: {
404
+ value: ImageValue;
405
+ path: string;
406
+ readOnly: boolean;
407
+ warnings: readonly FieldWarning[];
408
+ onChoose: () => void;
409
+ onRemove: () => void;
410
+ write: (next: MediaValue) => void;
411
+ drop: Drop;
412
+ noAlt?: boolean;
413
+ }) {
414
+ const editor = useEditor();
415
+ const media = mediaFor(editor);
416
+ const { value, path, readOnly: ro } = props;
417
+ const [focalOpen, setFocalOpen] = useState(false);
418
+ const name = useStore(media.images, (l) => l.list?.find((x) => x.key === value.key)?.name ?? null) ?? fileOfKey(value.key);
419
+ const focal = value.focal ?? null;
420
+ const thumb = thumbnailSrc(value.key, 320);
421
+ return (
422
+ <>
423
+ <div className="media" {...(ro ? {} : props.drop.props)}>
424
+ <div className="media-main">
425
+ <button type="button" className="thumb ph" aria-label="Focal point" aria-expanded={focalOpen} disabled={ro} onClick={() => setFocalOpen(!focalOpen)}>
426
+ <img className="ph-img" src={thumb} srcSet={`${thumb} 1x, ${thumbnailSrc(value.key, 640)} 2x`} alt="" style={focal ? { objectPosition: `${pct(focal[0])}% ${pct(focal[1])}%` } : undefined} />
427
+ {focal ? <span className="fp-dot" style={{ left: `${pct(focal[0])}%`, top: `${pct(focal[1])}%` }} /> : null}
428
+ </button>
429
+ <div className="media-txt">
430
+ <b title={value.key}>{name}</b>
431
+ <small>
432
+ {value.w && value.h ? `${value.w} × ${value.h} · ` : ''}
433
+ {extOf(value.key)}
434
+ </small>
435
+ <small className={focal ? 'fp-on' : ''}>
436
+ <Icon name="target" size="xs" />
437
+ {focal ? 'Focal point set' : 'No focal point'}
438
+ </small>
439
+ </div>
440
+ </div>
441
+ <div className="media-acts">
442
+ <button type="button" className="btn sm" disabled={ro} onClick={props.onChoose}>
443
+ Replace…
444
+ </button>
445
+ <button type="button" className="btn sm" aria-expanded={focalOpen} disabled={ro} onClick={() => setFocalOpen(!focalOpen)}>
446
+ <Icon name="target" size="sm" />
447
+ Focal point
448
+ </button>
449
+ <button type="button" className="btn sm ghost end" disabled={ro} onClick={props.onRemove}>
450
+ Remove
451
+ </button>
452
+ </div>
453
+ </div>
454
+ {focalOpen ? <FocalEditor value={value} readOnly={ro} write={props.write} /> : null}
455
+ {props.noAlt ? null : <AltText value={value} path={path} readOnly={ro} warnings={props.warnings} write={props.write} />}
456
+ </>
457
+ );
458
+ }
459
+
460
+ const fpVal = (f: readonly [number, number] | null) => (f ? `${pct(f[0])}% across · ${pct(f[1])}% down` : 'The centre (not set)');
461
+ const fpLabel = (f: readonly [number, number] | null) =>
462
+ `Focal point: ${f ? `${pct(f[0])}% from the left, ${pct(f[1])}% from the top` : 'not set, so the centre'}. Click or drag on the picture, or use the arrow keys.`;
463
+
464
+ /** How long the stage waits after the last move before it renders the new focal point (each render signs new URLs). */
465
+ export const FOCAL_DEBOUNCE_MS = 200;
466
+
467
+ /**
468
+ * The focal point on the image's own preview (Decision 8). The ring follows the pointer
469
+ * and the keys at once (local state); the value follows FOCAL_DEBOUNCE_MS after the last
470
+ * move, and at once when the pointer lifts or focus leaves.
471
+ */
472
+ function FocalEditor(props: { value: ImageValue; readOnly: boolean; write: (next: MediaValue) => void }) {
473
+ const { value, readOnly: ro } = props;
474
+ const [draft, setDraft] = useState<[number, number] | null | undefined>(undefined);
475
+ const [dragging, setDragging] = useState(false);
476
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
477
+ const pending = useRef<{ f: [number, number] | null } | null>(null);
478
+ const latest = useRef(props);
479
+ latest.current = props;
480
+ const shown = draft !== undefined ? draft : (value.focal ?? null);
481
+
482
+ const flush = () => {
483
+ if (timer.current) clearTimeout(timer.current);
484
+ timer.current = null;
485
+ const p = pending.current;
486
+ pending.current = null;
487
+ if (!p) return;
488
+ const { value: v, write } = latest.current;
489
+ const next: ImageValue = { ...v };
490
+ if (p.f) next.focal = p.f;
491
+ else delete next.focal;
492
+ write(next);
493
+ setDraft(undefined);
494
+ };
495
+ useEffect(() => () => flush(), []);
496
+
497
+ const put = (f: [number, number] | null, now = false) => {
498
+ if (ro) return;
499
+ const rounded: [number, number] | null = f ? [round2(clamp01(f[0])), round2(clamp01(f[1]))] : null;
500
+ setDraft(rounded);
501
+ pending.current = { f: rounded };
502
+ if (timer.current) clearTimeout(timer.current);
503
+ if (now) flush();
504
+ else timer.current = setTimeout(flush, FOCAL_DEBOUNCE_MS);
505
+ };
506
+ const at = (event: PointerEvent<HTMLDivElement>): [number, number] => {
507
+ const r = event.currentTarget.getBoundingClientRect();
508
+ return [(event.clientX - r.left) / r.width, (event.clientY - r.top) / r.height];
509
+ };
510
+ const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
511
+ const step: Record<string, [number, number]> = { ArrowLeft: [-0.05, 0], ArrowRight: [0.05, 0], ArrowUp: [0, -0.05], ArrowDown: [0, 0.05] };
512
+ const d = step[event.key];
513
+ if (!d || ro) return;
514
+ event.preventDefault();
515
+ const from = shown ?? [0.5, 0.5];
516
+ put([from[0] + d[0], from[1] + d[1]]);
517
+ };
518
+ const ratio = value.w && value.h ? value.w / value.h : 1.5;
519
+ const x = shown ? shown[0] : 0.5;
520
+ const y = shown ? shown[1] : 0.5;
521
+ return (
522
+ <div className="fp">
523
+ <div className="fp-stage">
524
+ <div
525
+ className={`fp-img${dragging ? ' is-dragging' : ''}`}
526
+ tabIndex={ro ? -1 : 0}
527
+ role="group"
528
+ aria-label={fpLabel(shown)}
529
+ aria-disabled={ro ? 'true' : undefined}
530
+ data-focal=""
531
+ style={{ ['--r' as string]: ratio.toFixed(4) }}
532
+ onPointerDown={(event) => {
533
+ if (ro || event.button !== 0) return;
534
+ event.currentTarget.setPointerCapture(event.pointerId);
535
+ event.currentTarget.focus({ preventScroll: true });
536
+ setDragging(true);
537
+ put(at(event));
538
+ }}
539
+ onPointerMove={(event) => {
540
+ if (dragging) put(at(event));
541
+ }}
542
+ onPointerUp={(event) => {
543
+ if (!dragging) return;
544
+ setDragging(false);
545
+ put(at(event), true);
546
+ }}
547
+ onPointerCancel={() => {
548
+ setDragging(false);
549
+ flush();
550
+ }}
551
+ onKeyDown={onKey}
552
+ onBlur={flush}
553
+ >
554
+ <img className="ph-img" src={thumbnailSrc(value.key, 640)} alt="" draggable={false} />
555
+ <span className={`fp-ring${shown ? '' : ' unset'}`} style={{ left: `${x * 100}%`, top: `${y * 100}%` }} />
556
+ </div>
557
+ </div>
558
+ <div className="fp-row">
559
+ <span className="fp-val">{fpVal(shown)}</span>
560
+ <button type="button" className="linkbtn subtle" disabled={!shown || ro} onClick={() => put(null, true)}>
561
+ Reset to centre
562
+ </button>
563
+ </div>
564
+ <p className="fp-help">Click what matters most; crops keep it in view.</p>
565
+ </div>
566
+ );
567
+ }
568
+
569
+ /**
570
+ * Alt text with Decorative on its label's row (Decision 3); the ask after placing, then the
571
+ * amber missing-alt warning under it (Decision 9).
572
+ */
573
+ function AltText(props: { value: ImageValue; path: string; readOnly: boolean; warnings: readonly FieldWarning[]; write: (next: MediaValue) => void }) {
574
+ const editor = useEditor();
575
+ const host = useHost();
576
+ const media = mediaFor(editor);
577
+ const { value, path, readOnly: ro } = props;
578
+ const id = `alt-${host.scope}-${path}`.replace(/[^\w-]/g, '-');
579
+ const deco = !!value.decorative;
580
+ const missing = props.warnings.filter((w) => w.code === 'missing-alt');
581
+ // Picked with an alt text already used for it: say where it came from while it's unchanged (#library-picked-alt).
582
+ const placed = useStore(media.placed);
583
+ const from = placed && placed.scope === host.scope && placed.path === path && placed.altFrom && placed.alt === value.alt && value.alt ? placed.altFrom : null;
584
+ // The ask after placing (#alt-ask): this slot's, while the alt is still empty and it isn't decorative.
585
+ const askStore = altAskFor(editor);
586
+ const ask = useStore(askStore);
587
+ const mine = slotKey({ scope: host.scope, path });
588
+ const asking = !!ask && ask.slot === mine && !deco && value.alt === '' && !ro;
589
+ const input = useRef<HTMLInputElement>(null);
590
+ const focusedFor = useRef<number | null>(null);
591
+ // Answered, skipped or decorative (`next`): a run's next image is asked, its row opened. Left: the run ends.
592
+ const endAsk = (next: boolean) => {
593
+ const head = endAltAsk(editor, mine, next);
594
+ if (head && head.scope === host.scope) host.pick(head.path);
595
+ return !!head;
596
+ };
597
+ useEffect(() => {
598
+ if (!asking || !ask || focusedFor.current === ask.n) return;
599
+ focusedFor.current = ask.n;
600
+ // The level the slot is in may still be coming on screen: try for a few frames.
601
+ let tries = 20;
602
+ let frame = 0;
603
+ const attempt = () => {
604
+ const el = input.current;
605
+ if (el && el.offsetParent !== null && !el.disabled) {
606
+ el.scrollIntoView({ block: 'nearest' });
607
+ el.focus({ preventScroll: true });
608
+ if (document.activeElement === el) return;
609
+ }
610
+ if (tries-- > 0) frame = requestAnimationFrame(attempt);
611
+ };
612
+ frame = requestAnimationFrame(attempt);
613
+ return () => cancelAnimationFrame(frame);
614
+ }, [asking, ask?.n]);
615
+ return (
616
+ <>
617
+ <div className="alt-head">
618
+ <label className="sub-label" htmlFor={id}>
619
+ Alt text
620
+ </label>
621
+ <label className="deco">
622
+ <input
623
+ type="checkbox"
624
+ checked={deco}
625
+ disabled={ro}
626
+ aria-describedby={`${id}-m`}
627
+ onChange={(e) => {
628
+ const next: ImageValue = { ...value };
629
+ if (e.target.checked) {
630
+ next.decorative = true;
631
+ next.alt = '';
632
+ endAsk(true);
633
+ } else delete next.decorative;
634
+ props.write(next);
635
+ }}
636
+ />
637
+ Decorative
638
+ </label>
639
+ </div>
640
+ <input
641
+ ref={input}
642
+ className="input alt"
643
+ id={id}
644
+ data-alt={path}
645
+ value={deco ? '' : value.alt}
646
+ placeholder={deco ? 'Decorative' : 'Describe the picture'}
647
+ autoComplete="off"
648
+ aria-describedby={`${id}-m`}
649
+ aria-invalid={missing.length > 0 && !deco && !asking ? 'true' : undefined}
650
+ disabled={ro || deco}
651
+ onChange={(e) => props.write({ ...value, alt: e.target.value.replace(/\n+/g, ' ') })}
652
+ onKeyDown={(e) => {
653
+ // Enter, or Tab, moves on to a run's next image (Tab goes on as usual when there's none).
654
+ if (e.key !== 'Enter' && (e.key !== 'Tab' || e.shiftKey)) return;
655
+ if (askStore.get()?.slot !== mine) return;
656
+ if (endAsk(true) || e.key === 'Enter') e.preventDefault();
657
+ }}
658
+ onBlur={(e) => {
659
+ // Left (within the slot is staying, as for Decorative): the ask ends. Left empty, it turns into the amber warning (#alt-missing).
660
+ if (e.relatedTarget instanceof Node && e.currentTarget.closest('.mslot')?.contains(e.relatedTarget)) return;
661
+ endAsk(false);
662
+ }}
663
+ />
664
+ <div id={`${id}-m`} data-altmsg="" data-ask={asking ? '' : undefined}>
665
+ {deco ? (
666
+ <p className="field-hint">
667
+ <Icon name="check" size="xs" />
668
+ <span>Screen readers skip it.</span>
669
+ </p>
670
+ ) : asking ? (
671
+ <p className="field-ask">
672
+ <Icon name="image" size="sm" />
673
+ <span>
674
+ Describe the picture in a sentence, or tick Decorative.{' '}
675
+ <button
676
+ type="button"
677
+ className="linkbtn subtle"
678
+ data-act="alt-skip"
679
+ onMouseDown={(e) => e.preventDefault()}
680
+ onClick={() => endAsk(true)}
681
+ >
682
+ Skip for now
683
+ </button>
684
+ </span>
685
+ </p>
686
+ ) : missing.length ? (
687
+ <Warnings warnings={missing} />
688
+ ) : from ? (
689
+ <p className="field-hint">
690
+ <Icon name="copy" size="xs" />
691
+ <span>Already used for this image on {from}. Check it fits here too.</span>
692
+ </p>
693
+ ) : null}
694
+ </div>
695
+ </>
696
+ );
697
+ }
698
+
699
+ function ClipCard(props: {
700
+ value: ClipValue;
701
+ path: string;
702
+ loopOnly: boolean;
703
+ readOnly: boolean;
704
+ onChoose: () => void;
705
+ onRemove: () => void;
706
+ write: (next: MediaValue) => void;
707
+ drop: Drop;
708
+ }) {
709
+ const editor = useEditor();
710
+ const host = useHost();
711
+ const media = mediaFor(editor);
712
+ const { value, path, readOnly: ro } = props;
713
+ const [frameOpen, setFrameOpen] = useState(false);
714
+ const title = useStore(media.clips, (l) => l.list?.find((c) => c.playbackId === value.video)?.title ?? null) ?? 'A clip';
715
+ const play = props.loopOnly ? 'loop' : value.play;
716
+ const id = `nm-${host.scope}-${path}`.replace(/[^\w-]/g, '-');
717
+ return (
718
+ <>
719
+ <div className="media" {...(ro ? {} : props.drop.props)}>
720
+ <div className="media-main">
721
+ <button type="button" className="thumb wide ph" aria-label="Poster frame" aria-expanded={frameOpen} disabled={ro} onClick={() => setFrameOpen(!frameOpen)}>
722
+ <img className="ph-img" src={clipStill(value.video, value.poster)} alt="" />
723
+ <span className="dur">{fmtDur(value.duration)}</span>
724
+ </button>
725
+ <div className="media-txt">
726
+ <b>{title}</b>
727
+ <small>
728
+ {fmtDur(value.duration)}
729
+ {value.w && value.h ? ` · ${value.w} × ${value.h}` : ''} · Clip
730
+ </small>
731
+ <small>
732
+ <Icon name="image" size="xs" />
733
+ Poster: {value.poster !== undefined ? `the frame at ${fmtT(value.poster)}` : 'the first frame'}
734
+ </small>
735
+ </div>
736
+ </div>
737
+ <div className="media-acts">
738
+ <button type="button" className="btn sm" disabled={ro} onClick={props.onChoose}>
739
+ Replace…
740
+ </button>
741
+ <button type="button" className="btn sm" aria-expanded={frameOpen} aria-label="Poster frame" disabled={ro} onClick={() => setFrameOpen(!frameOpen)}>
742
+ <Icon name="image" size="sm" />
743
+ Poster
744
+ </button>
745
+ <button type="button" className="btn sm ghost end" disabled={ro} onClick={props.onRemove}>
746
+ Remove
747
+ </button>
748
+ </div>
749
+ </div>
750
+ {frameOpen ? <FrameEditor value={value} readOnly={ro} write={props.write} /> : null}
751
+ {props.loopOnly ? (
752
+ // A loop-only slot (the hero) has no Plays as: every clip in it loops (Decision 3).
753
+ <p className="field-hint">
754
+ <Icon name="film" size="xs" />
755
+ Silent loop; reduced-motion users see the poster.
756
+ </p>
757
+ ) : (
758
+ <>
759
+ <span className="sub-label">Plays as</span>
760
+ <div className="mini-seg" role="radiogroup" aria-label="Plays as">
761
+ {(['loop', 'player'] as const).map((p) => (
762
+ <button key={p} type="button" role="radio" aria-checked={play === p} disabled={ro} onClick={() => play !== p && props.write({ ...value, play: p })}>
763
+ {p === 'loop' ? 'Loop' : 'Player'}
764
+ </button>
765
+ ))}
766
+ </div>
767
+ <p className="field-help" style={{ margin: 0 }}>
768
+ {play === 'player'
769
+ ? 'Poster with a play button.'
770
+ : 'Silent loop; reduced-motion users see the poster.'}
771
+ </p>
772
+ {play === 'player' ? (
773
+ <>
774
+ <label className="sub-label" htmlFor={id}>
775
+ Name for screen readers
776
+ </label>
777
+ <input
778
+ className="input"
779
+ id={id}
780
+ data-clip-name={path}
781
+ value={value.name ?? ''}
782
+ placeholder="What happens in the clip"
783
+ autoComplete="off"
784
+ disabled={ro}
785
+ onChange={(e) => props.write({ ...value, name: e.target.value.replace(/\n+/g, ' ') })}
786
+ />
787
+ <p className="field-hint">Read out as “Play video{value.name ? `: ${value.name}` : ''}”.</p>
788
+ </>
789
+ ) : null}
790
+ </>
791
+ )}
792
+ </>
793
+ );
794
+ }
795
+
796
+ /** "Use this frame" (Decision 12): the clip's own MP4, scrubbed to a frame, sets `poster` (tenths of a second). */
797
+ function FrameEditor(props: { value: ClipValue; readOnly: boolean; write: (next: MediaValue) => void }) {
798
+ const { value, readOnly: ro } = props;
799
+ const [t, setT] = useState(value.poster ?? 0);
800
+ const video = useRef<HTMLVideoElement>(null);
801
+ const duration = value.duration ?? 0;
802
+ useEffect(() => {
803
+ if (video.current && Number.isFinite(t)) video.current.currentTime = t;
804
+ }, [t]);
805
+ const chosen = Math.round(t * 10) / 10;
806
+ return (
807
+ <div className="fr">
808
+ <div className="fr-vid ph">
809
+ <video ref={video} src={muxMp4(value.video)} poster={clipStill(value.video, value.poster, 640)} muted playsInline preload="metadata" aria-label="The clip, at the frame chosen" />
810
+ <span className="fr-time">
811
+ {fmtT(t)} / {fmtDur(duration)}
812
+ </span>
813
+ </div>
814
+ <input type="range" className="fr-range" min={0} max={duration || 0} step={0.1} value={t} aria-label="Find a frame" disabled={ro} onChange={(e) => setT(Number(e.target.value))} />
815
+ <div className="fr-row">
816
+ <span>{value.poster !== undefined ? `Poster: the frame at ${fmtT(value.poster)}` : 'Poster: the first frame'}</span>
817
+ <button type="button" className="btn sm" disabled={ro || chosen === value.poster} onClick={() => props.write({ ...value, poster: chosen })}>
818
+ Use this frame
819
+ </button>
820
+ </div>
821
+ </div>
822
+ );
823
+ }