@agimon-ai/doompi-author 0.0.1-alpha.10

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 (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +1 -0
  4. package/dist/api-contracts.cjs +1 -0
  5. package/dist/api-contracts.d.cts +2 -0
  6. package/dist/api-contracts.d.mts +2 -0
  7. package/dist/api-contracts.mjs +1 -0
  8. package/dist/authorFacade.cjs +1 -0
  9. package/dist/authorFacade.d.cts +3 -0
  10. package/dist/authorFacade.d.mts +3 -0
  11. package/dist/authorFacade.mjs +1 -0
  12. package/dist/constants/author.cjs +4 -0
  13. package/dist/constants/author.cjs.map +1 -0
  14. package/dist/constants/author.mjs +4 -0
  15. package/dist/constants/author.mjs.map +1 -0
  16. package/dist/controllers/authorApi.cjs +2 -0
  17. package/dist/controllers/authorApi.cjs.map +1 -0
  18. package/dist/controllers/authorApi.mjs +2 -0
  19. package/dist/controllers/authorApi.mjs.map +1 -0
  20. package/dist/controllers/authorBridgeApi.cjs +2 -0
  21. package/dist/controllers/authorBridgeApi.cjs.map +1 -0
  22. package/dist/controllers/authorBridgeApi.mjs +2 -0
  23. package/dist/controllers/authorBridgeApi.mjs.map +1 -0
  24. package/dist/controllers/authorBridgeMethod.cjs +2 -0
  25. package/dist/controllers/authorBridgeMethod.cjs.map +1 -0
  26. package/dist/controllers/authorBridgeMethod.mjs +2 -0
  27. package/dist/controllers/authorBridgeMethod.mjs.map +1 -0
  28. package/dist/controllers/authorDocumentApi.cjs +2 -0
  29. package/dist/controllers/authorDocumentApi.cjs.map +1 -0
  30. package/dist/controllers/authorDocumentApi.mjs +2 -0
  31. package/dist/controllers/authorDocumentApi.mjs.map +1 -0
  32. package/dist/controllers/authorPiMode.cjs +2 -0
  33. package/dist/controllers/authorPiMode.cjs.map +1 -0
  34. package/dist/controllers/authorPiMode.mjs +2 -0
  35. package/dist/controllers/authorPiMode.mjs.map +1 -0
  36. package/dist/controllers/doomAuthorCommand.cjs +2 -0
  37. package/dist/controllers/doomAuthorCommand.cjs.map +1 -0
  38. package/dist/controllers/doomAuthorCommand.d.cts +5 -0
  39. package/dist/controllers/doomAuthorCommand.d.cts.map +1 -0
  40. package/dist/controllers/doomAuthorCommand.d.mts +5 -0
  41. package/dist/controllers/doomAuthorCommand.d.mts.map +1 -0
  42. package/dist/controllers/doomAuthorCommand.mjs +2 -0
  43. package/dist/controllers/doomAuthorCommand.mjs.map +1 -0
  44. package/dist/controllers/webAuthorChannel.cjs +2 -0
  45. package/dist/controllers/webAuthorChannel.cjs.map +1 -0
  46. package/dist/controllers/webAuthorChannel.mjs +2 -0
  47. package/dist/controllers/webAuthorChannel.mjs.map +1 -0
  48. package/dist/extensions/pi.cjs +2 -0
  49. package/dist/extensions/pi.cjs.map +1 -0
  50. package/dist/extensions/pi.d.cts +7 -0
  51. package/dist/extensions/pi.d.cts.map +1 -0
  52. package/dist/extensions/pi.d.mts +7 -0
  53. package/dist/extensions/pi.d.mts.map +1 -0
  54. package/dist/extensions/pi.mjs +2 -0
  55. package/dist/extensions/pi.mjs.map +1 -0
  56. package/dist/extensions/server.cjs +2 -0
  57. package/dist/extensions/server.cjs.map +1 -0
  58. package/dist/extensions/server.d.cts +5 -0
  59. package/dist/extensions/server.d.cts.map +1 -0
  60. package/dist/extensions/server.d.mts +5 -0
  61. package/dist/extensions/server.d.mts.map +1 -0
  62. package/dist/extensions/server.mjs +2 -0
  63. package/dist/extensions/server.mjs.map +1 -0
  64. package/dist/index.cjs +1 -0
  65. package/dist/index.d.cts +20 -0
  66. package/dist/index.d.mts +20 -0
  67. package/dist/index.mjs +1 -0
  68. package/dist/models/authorBridgeState.cjs +2 -0
  69. package/dist/models/authorBridgeState.cjs.map +1 -0
  70. package/dist/models/authorBridgeState.d.cts +31 -0
  71. package/dist/models/authorBridgeState.d.cts.map +1 -0
  72. package/dist/models/authorBridgeState.d.mts +31 -0
  73. package/dist/models/authorBridgeState.d.mts.map +1 -0
  74. package/dist/models/authorBridgeState.mjs +2 -0
  75. package/dist/models/authorBridgeState.mjs.map +1 -0
  76. package/dist/models/authorMode.cjs +2 -0
  77. package/dist/models/authorMode.cjs.map +1 -0
  78. package/dist/models/authorMode.d.cts +9 -0
  79. package/dist/models/authorMode.d.cts.map +1 -0
  80. package/dist/models/authorMode.d.mts +9 -0
  81. package/dist/models/authorMode.d.mts.map +1 -0
  82. package/dist/models/authorMode.mjs +2 -0
  83. package/dist/models/authorMode.mjs.map +1 -0
  84. package/dist/schemas/apiContracts.cjs +4 -0
  85. package/dist/schemas/apiContracts.cjs.map +1 -0
  86. package/dist/schemas/apiContracts.d.cts +53 -0
  87. package/dist/schemas/apiContracts.d.cts.map +1 -0
  88. package/dist/schemas/apiContracts.d.mts +53 -0
  89. package/dist/schemas/apiContracts.d.mts.map +1 -0
  90. package/dist/schemas/apiContracts.mjs +4 -0
  91. package/dist/schemas/apiContracts.mjs.map +1 -0
  92. package/dist/schemas/authorFacade.cjs +2 -0
  93. package/dist/schemas/authorFacade.cjs.map +1 -0
  94. package/dist/schemas/authorFacade.d.cts +112 -0
  95. package/dist/schemas/authorFacade.d.cts.map +1 -0
  96. package/dist/schemas/authorFacade.d.mts +112 -0
  97. package/dist/schemas/authorFacade.d.mts.map +1 -0
  98. package/dist/schemas/authorFacade.mjs +2 -0
  99. package/dist/schemas/authorFacade.mjs.map +1 -0
  100. package/dist/schemas/authorTools.cjs +2 -0
  101. package/dist/schemas/authorTools.cjs.map +1 -0
  102. package/dist/schemas/authorTools.d.cts +12 -0
  103. package/dist/schemas/authorTools.d.cts.map +1 -0
  104. package/dist/schemas/authorTools.d.mts +12 -0
  105. package/dist/schemas/authorTools.d.mts.map +1 -0
  106. package/dist/schemas/authorTools.mjs +2 -0
  107. package/dist/schemas/authorTools.mjs.map +1 -0
  108. package/dist/services/authorCatalog/index.cjs +2 -0
  109. package/dist/services/authorCatalog/index.cjs.map +1 -0
  110. package/dist/services/authorCatalog/index.d.cts +6 -0
  111. package/dist/services/authorCatalog/index.d.cts.map +1 -0
  112. package/dist/services/authorCatalog/index.d.mts +6 -0
  113. package/dist/services/authorCatalog/index.d.mts.map +1 -0
  114. package/dist/services/authorCatalog/index.mjs +2 -0
  115. package/dist/services/authorCatalog/index.mjs.map +1 -0
  116. package/dist/services/authorCatalog/monitor.cjs +2 -0
  117. package/dist/services/authorCatalog/monitor.cjs.map +1 -0
  118. package/dist/services/authorCatalog/monitor.d.cts +20 -0
  119. package/dist/services/authorCatalog/monitor.d.cts.map +1 -0
  120. package/dist/services/authorCatalog/monitor.d.mts +20 -0
  121. package/dist/services/authorCatalog/monitor.d.mts.map +1 -0
  122. package/dist/services/authorCatalog/monitor.mjs +2 -0
  123. package/dist/services/authorCatalog/monitor.mjs.map +1 -0
  124. package/dist/services/authorCatalog/type.d.cts +10 -0
  125. package/dist/services/authorCatalog/type.d.cts.map +1 -0
  126. package/dist/services/authorCatalog/type.d.mts +10 -0
  127. package/dist/services/authorCatalog/type.d.mts.map +1 -0
  128. package/dist/services/authorPrompt/index.cjs +2 -0
  129. package/dist/services/authorPrompt/index.cjs.map +1 -0
  130. package/dist/services/authorPrompt/index.mjs +2 -0
  131. package/dist/services/authorPrompt/index.mjs.map +1 -0
  132. package/dist/services/structuredDocuments/common.cjs +2 -0
  133. package/dist/services/structuredDocuments/common.cjs.map +1 -0
  134. package/dist/services/structuredDocuments/common.mjs +2 -0
  135. package/dist/services/structuredDocuments/common.mjs.map +1 -0
  136. package/dist/services/structuredDocuments/csv.cjs +7 -0
  137. package/dist/services/structuredDocuments/csv.cjs.map +1 -0
  138. package/dist/services/structuredDocuments/csv.d.cts +7 -0
  139. package/dist/services/structuredDocuments/csv.d.cts.map +1 -0
  140. package/dist/services/structuredDocuments/csv.d.mts +7 -0
  141. package/dist/services/structuredDocuments/csv.d.mts.map +1 -0
  142. package/dist/services/structuredDocuments/csv.mjs +7 -0
  143. package/dist/services/structuredDocuments/csv.mjs.map +1 -0
  144. package/dist/services/structuredDocuments/document.cjs +2 -0
  145. package/dist/services/structuredDocuments/document.cjs.map +1 -0
  146. package/dist/services/structuredDocuments/document.mjs +2 -0
  147. package/dist/services/structuredDocuments/document.mjs.map +1 -0
  148. package/dist/services/structuredDocuments/index.cjs +2 -0
  149. package/dist/services/structuredDocuments/index.cjs.map +1 -0
  150. package/dist/services/structuredDocuments/index.d.cts +13 -0
  151. package/dist/services/structuredDocuments/index.d.cts.map +1 -0
  152. package/dist/services/structuredDocuments/index.d.mts +13 -0
  153. package/dist/services/structuredDocuments/index.d.mts.map +1 -0
  154. package/dist/services/structuredDocuments/index.mjs +2 -0
  155. package/dist/services/structuredDocuments/index.mjs.map +1 -0
  156. package/dist/services/structuredDocuments/markdownSlides.cjs +6 -0
  157. package/dist/services/structuredDocuments/markdownSlides.cjs.map +1 -0
  158. package/dist/services/structuredDocuments/markdownSlides.d.cts +7 -0
  159. package/dist/services/structuredDocuments/markdownSlides.d.cts.map +1 -0
  160. package/dist/services/structuredDocuments/markdownSlides.d.mts +7 -0
  161. package/dist/services/structuredDocuments/markdownSlides.d.mts.map +1 -0
  162. package/dist/services/structuredDocuments/markdownSlides.mjs +6 -0
  163. package/dist/services/structuredDocuments/markdownSlides.mjs.map +1 -0
  164. package/dist/services/structuredDocuments/ooxmlArchive.cjs +2 -0
  165. package/dist/services/structuredDocuments/ooxmlArchive.cjs.map +1 -0
  166. package/dist/services/structuredDocuments/ooxmlArchive.d.cts +13 -0
  167. package/dist/services/structuredDocuments/ooxmlArchive.d.cts.map +1 -0
  168. package/dist/services/structuredDocuments/ooxmlArchive.d.mts +13 -0
  169. package/dist/services/structuredDocuments/ooxmlArchive.d.mts.map +1 -0
  170. package/dist/services/structuredDocuments/ooxmlArchive.mjs +2 -0
  171. package/dist/services/structuredDocuments/ooxmlArchive.mjs.map +1 -0
  172. package/dist/services/structuredDocuments/pptx.cjs +2 -0
  173. package/dist/services/structuredDocuments/pptx.cjs.map +1 -0
  174. package/dist/services/structuredDocuments/pptx.d.cts +7 -0
  175. package/dist/services/structuredDocuments/pptx.d.cts.map +1 -0
  176. package/dist/services/structuredDocuments/pptx.d.mts +7 -0
  177. package/dist/services/structuredDocuments/pptx.d.mts.map +1 -0
  178. package/dist/services/structuredDocuments/pptx.mjs +2 -0
  179. package/dist/services/structuredDocuments/pptx.mjs.map +1 -0
  180. package/dist/services/structuredDocuments/xlsx.cjs +2 -0
  181. package/dist/services/structuredDocuments/xlsx.cjs.map +1 -0
  182. package/dist/services/structuredDocuments/xlsx.d.cts +7 -0
  183. package/dist/services/structuredDocuments/xlsx.d.cts.map +1 -0
  184. package/dist/services/structuredDocuments/xlsx.d.mts +7 -0
  185. package/dist/services/structuredDocuments/xlsx.d.mts.map +1 -0
  186. package/dist/services/structuredDocuments/xlsx.mjs +2 -0
  187. package/dist/services/structuredDocuments/xlsx.mjs.map +1 -0
  188. package/dist/services/structuredDocuments/xmlText.cjs +2 -0
  189. package/dist/services/structuredDocuments/xmlText.cjs.map +1 -0
  190. package/dist/services/structuredDocuments/xmlText.mjs +2 -0
  191. package/dist/services/structuredDocuments/xmlText.mjs.map +1 -0
  192. package/dist/tools/authorTools.cjs +2 -0
  193. package/dist/tools/authorTools.cjs.map +1 -0
  194. package/dist/tools/authorTools.d.cts +6 -0
  195. package/dist/tools/authorTools.d.cts.map +1 -0
  196. package/dist/tools/authorTools.d.mts +6 -0
  197. package/dist/tools/authorTools.d.mts.map +1 -0
  198. package/dist/tools/authorTools.mjs +2 -0
  199. package/dist/tools/authorTools.mjs.map +1 -0
  200. package/dist/types/author.cjs +2 -0
  201. package/dist/types/author.cjs.map +1 -0
  202. package/dist/types/author.d.cts +39 -0
  203. package/dist/types/author.d.cts.map +1 -0
  204. package/dist/types/author.d.mts +39 -0
  205. package/dist/types/author.d.mts.map +1 -0
  206. package/dist/types/author.mjs +2 -0
  207. package/dist/types/author.mjs.map +1 -0
  208. package/dist/types/authorApi.cjs +2 -0
  209. package/dist/types/authorApi.cjs.map +1 -0
  210. package/dist/types/authorApi.d.cts +23 -0
  211. package/dist/types/authorApi.d.cts.map +1 -0
  212. package/dist/types/authorApi.d.mts +23 -0
  213. package/dist/types/authorApi.d.mts.map +1 -0
  214. package/dist/types/authorApi.mjs +2 -0
  215. package/dist/types/authorApi.mjs.map +1 -0
  216. package/dist/types/extension.d.cts +16 -0
  217. package/dist/types/extension.d.cts.map +1 -0
  218. package/dist/types/extension.d.mts +16 -0
  219. package/dist/types/extension.d.mts.map +1 -0
  220. package/dist/types/structuredDocuments.d.cts +60 -0
  221. package/dist/types/structuredDocuments.d.cts.map +1 -0
  222. package/dist/types/structuredDocuments.d.mts +60 -0
  223. package/dist/types/structuredDocuments.d.mts.map +1 -0
  224. package/dist/types/webAuthor.cjs +2 -0
  225. package/dist/types/webAuthor.cjs.map +1 -0
  226. package/dist/types/webAuthor.d.cts +67 -0
  227. package/dist/types/webAuthor.d.cts.map +1 -0
  228. package/dist/types/webAuthor.d.mts +67 -0
  229. package/dist/types/webAuthor.d.mts.map +1 -0
  230. package/dist/types/webAuthor.mjs +2 -0
  231. package/dist/types/webAuthor.mjs.map +1 -0
  232. package/llms.txt +11 -0
  233. package/package.json +147 -0
  234. package/src/extensions/web.ts +71 -0
  235. package/src/prompts/doompi-use-author/SKILL.md +17 -0
  236. package/src/types/author.ts +45 -0
  237. package/src/types/structuredDocuments.ts +63 -0
  238. package/src/types/webAuthor.ts +85 -0
  239. package/src/web/api/authorBroker.ts +14 -0
  240. package/src/web/api/authorBrowserBridge.ts +229 -0
  241. package/src/web/api/authorFiles.ts +221 -0
  242. package/src/web/api/authorMedia.ts +19 -0
  243. package/src/web/api/authorRuntime.ts +165 -0
  244. package/src/web/components/AuthorDocumentPanel.tsx +233 -0
  245. package/src/web/components/AuthorGridOverlay.tsx +82 -0
  246. package/src/web/components/AuthorMediaPreview.tsx +42 -0
  247. package/src/web/components/AuthorMediaView.tsx +357 -0
  248. package/src/web/components/AuthorPanel.tsx +102 -0
  249. package/src/web/components/AuthorRegionDrafts.tsx +103 -0
  250. package/src/web/components/AuthorRequestLog.tsx +179 -0
  251. package/src/web/components/AuthorStructuredView.tsx +122 -0
  252. package/src/web/components/AuthorTextView.tsx +98 -0
  253. package/src/web/components/AuthorToolPalette.tsx +104 -0
  254. package/src/web/components/AuthorVideoControls.tsx +111 -0
  255. package/src/web/components/DescribeAuthorToolsToolCard.tsx +51 -0
  256. package/src/web/components/OpenAuthoringFileToolCard.tsx +67 -0
  257. package/src/web/components/UseAuthorToolsToolCard.tsx +51 -0
  258. package/src/web/lib/authorGrid.ts +125 -0
  259. package/src/web/lib/authorRegions.ts +30 -0
  260. package/src/web/lib/authorViewportTypes.ts +202 -0
  261. package/src/web/lib/describeAuthorToolsToolRender.ts +20 -0
  262. package/src/web/lib/useAuthorToolsToolRender.ts +20 -0
  263. package/src/web/stores/authorCapture.ts +323 -0
  264. package/src/web/stores/authorGridTools.ts +316 -0
  265. package/src/web/stores/authorProfiles.ts +130 -0
  266. package/src/web/stores/authorRequestLifecycle.ts +83 -0
  267. package/src/web/stores/authorStore.ts +61 -0
  268. package/src/web/stores/authorWorkspaceStore.ts +526 -0
  269. package/src/web/tsconfig.json +22 -0
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ import {
4
+ AUTHOR_GRID_COLUMNS,
5
+ AUTHOR_GRID_SIZE,
6
+ clearAuthorGridGeometry,
7
+ updateAuthorGridGeometry,
8
+ } from '../lib/authorGrid';
9
+ import type { AuthorWorkspaceDocument } from '../stores/authorWorkspaceStore';
10
+
11
+ export function autonomousVoiceGridVisible(statuses: Readonly<Record<string, string>>): boolean {
12
+ return statuses['doom-voice']?.trimStart().startsWith('voice auto:') === true;
13
+ }
14
+
15
+ export function AuthorGridOverlay({
16
+ sessionId,
17
+ document,
18
+ visible,
19
+ }: {
20
+ sessionId: string;
21
+ document: AuthorWorkspaceDocument;
22
+ visible: boolean;
23
+ }) {
24
+ const host = useRef<HTMLDivElement>(null);
25
+ useEffect(() => {
26
+ const element = host.current?.parentElement;
27
+ if (!visible || element === null || element === undefined) {
28
+ clearAuthorGridGeometry(sessionId);
29
+ return;
30
+ }
31
+ const publish = () => {
32
+ const bounds = element.getBoundingClientRect();
33
+ updateAuthorGridGeometry(sessionId, {
34
+ documentPath: document.path,
35
+ revision: document.version,
36
+ sourceSha256: document.sourceSha256,
37
+ viewport: {
38
+ width: bounds.width,
39
+ height: bounds.height,
40
+ originX: bounds.left,
41
+ originY: bounds.top,
42
+ scrollX: element.scrollLeft,
43
+ scrollY: element.scrollTop,
44
+ zoom: window.devicePixelRatio,
45
+ },
46
+ });
47
+ };
48
+ publish();
49
+ const observer = new ResizeObserver(publish);
50
+ observer.observe(element);
51
+ element.addEventListener('scroll', publish, { passive: true, capture: true });
52
+ window.addEventListener('focus', publish);
53
+ return () => {
54
+ observer.disconnect();
55
+ element.removeEventListener('scroll', publish, true);
56
+ window.removeEventListener('focus', publish);
57
+ clearAuthorGridGeometry(sessionId);
58
+ };
59
+ }, [document.path, document.sourceSha256, document.version, sessionId, visible]);
60
+ if (!visible) return null;
61
+ return (
62
+ <div
63
+ ref={host}
64
+ data-testid="author-grid"
65
+ aria-hidden="true"
66
+ className="pointer-events-none absolute inset-0 z-20 grid grid-cols-8 grid-rows-8"
67
+ >
68
+ {Array.from({ length: AUTHOR_GRID_SIZE * AUTHOR_GRID_SIZE }, (_, index) => {
69
+ const column = index % AUTHOR_GRID_SIZE;
70
+ const row = Math.floor(index / AUTHOR_GRID_SIZE);
71
+ return (
72
+ <div key={index} className="relative border border-doom-red/25">
73
+ <span className="absolute left-1 top-0.5 rounded bg-doom-deep/80 px-1 text-2xs font-bold text-doom-red">
74
+ {AUTHOR_GRID_COLUMNS[column]}
75
+ {row + 1}
76
+ </span>
77
+ </div>
78
+ );
79
+ })}
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,42 @@
1
+ import { MediaPreview } from '@agimon-ai/doompi-web-components';
2
+ import { useEffect, useState } from 'react';
3
+
4
+ import { authorSessionFileUrl } from '../api/authorFiles';
5
+ import { loadAuthorMedia } from '../api/authorMedia';
6
+
7
+ /** Viewing an attachment does not require enabling authoring or its editing tools. */
8
+ export function AuthorMediaPreview({ sessionId, path }: { sessionId: string; path: string }) {
9
+ const source = authorSessionFileUrl(sessionId, path);
10
+ const [loaded, setLoaded] = useState<{ source: string; url?: string; error?: string }>();
11
+ useEffect(() => {
12
+ const controller = new AbortController();
13
+ let dispose: (() => void) | undefined;
14
+ void loadAuthorMedia(source, controller.signal)
15
+ .then((asset) => {
16
+ if (controller.signal.aborted) {
17
+ asset.dispose();
18
+ return;
19
+ }
20
+ dispose = () => asset.dispose();
21
+ setLoaded({ source, url: asset.url });
22
+ })
23
+ .catch((error: unknown) => {
24
+ if (!controller.signal.aborted)
25
+ setLoaded({ source, error: error instanceof Error ? error.message : String(error) });
26
+ });
27
+ return () => {
28
+ controller.abort();
29
+ dispose?.();
30
+ };
31
+ }, [source]);
32
+ const current = loaded?.source === source ? loaded : undefined;
33
+ return (
34
+ <div className="min-h-0 flex-1 overflow-auto p-4">
35
+ {current?.url === undefined ? (
36
+ <p role={current?.error ? 'alert' : 'status'}>{current?.error ?? 'Loading media...'}</p>
37
+ ) : (
38
+ <MediaPreview src={current.url} path={path} data-testid="author-media-preview" />
39
+ )}
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,357 @@
1
+ import {
2
+ MediaPreview,
3
+ type MediaPlaybackState,
4
+ type MediaPreviewController,
5
+ type PdfPreviewController,
6
+ } from '@agimon-ai/doompi-web-components';
7
+ import { useEffect, useRef, useState, type PointerEvent } from 'react';
8
+
9
+ import { loadAuthorMedia } from '../api/authorMedia';
10
+ import { registerAuthorGridResolver } from '../lib/authorGrid';
11
+ import { normalizedAuthorRectangle } from '../lib/authorRegions';
12
+ import type { AuthorDisplayedRegion, AuthorNativeAnchor, AuthorToolMode } from '../lib/authorViewportTypes';
13
+ import {
14
+ authorSessionWorkspace,
15
+ setAuthorRegionCandidate,
16
+ setAuthorToolMode,
17
+ type AuthorSessionWorkspace,
18
+ type AuthorWorkspaceDocument,
19
+ } from '../stores/authorWorkspaceStore';
20
+ import { AuthorVideoControls } from './AuthorVideoControls';
21
+
22
+ /** Marks source media. Native playback and page controls remain usable in select mode. */
23
+ export function AuthorMediaView({
24
+ sessionId,
25
+ document: source,
26
+ activeTool,
27
+ displayedRegions,
28
+ pendingCandidate = false,
29
+ seekRequest,
30
+ }: {
31
+ sessionId: string;
32
+ document: AuthorWorkspaceDocument;
33
+ activeTool: AuthorToolMode;
34
+ displayedRegions: readonly AuthorDisplayedRegion[];
35
+ pendingCandidate?: boolean;
36
+ seekRequest?: AuthorSessionWorkspace['videoSeekRequest'];
37
+ }) {
38
+ const host = useRef<HTMLDivElement>(null);
39
+ const image = useRef<HTMLImageElement>(null);
40
+ const video = useRef<MediaPreviewController>(null);
41
+ const pdf = useRef<PdfPreviewController>(null);
42
+ const start = useRef<
43
+ { x: number; y: number; element: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement } | undefined
44
+ >(undefined);
45
+ const [playback, setPlayback] = useState<MediaPlaybackState>({ playing: false, currentTime: 0, duration: 0 });
46
+ const [frameReady, setFrameReady] = useState(false);
47
+ const [selection, setSelection] = useState<{
48
+ path: string;
49
+ rect: NonNullable<ReturnType<typeof normalizedAuthorRectangle>>;
50
+ }>();
51
+ const [error, setError] = useState<string>();
52
+ const [media, setMedia] = useState<{ source: string; url: string }>();
53
+ const mediaUrl = media?.source === source.mediaUrl ? media?.url : undefined;
54
+ useEffect(() => {
55
+ if (source.mediaUrl === undefined) return;
56
+ const input = source.mediaUrl;
57
+ const controller = new AbortController();
58
+ let dispose: (() => void) | undefined;
59
+ setError(undefined);
60
+ void loadAuthorMedia(input, controller.signal)
61
+ .then((asset) => {
62
+ if (controller.signal.aborted) {
63
+ asset.dispose();
64
+ return;
65
+ }
66
+ dispose = () => asset.dispose();
67
+ setMedia({ source: input, url: asset.url });
68
+ })
69
+ .catch((cause: unknown) => {
70
+ if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause));
71
+ });
72
+ return () => {
73
+ controller.abort();
74
+ dispose?.();
75
+ };
76
+ }, [source.mediaUrl]);
77
+ const seek = (seconds: number) => {
78
+ if (authorSessionWorkspace(sessionId).candidate || !Number.isFinite(seconds)) return;
79
+ const player = video.current;
80
+ if (!player) return;
81
+ player.pause();
82
+ setAuthorToolMode(sessionId, 'select');
83
+ setSelection(undefined);
84
+ if (player.getState().currentTime === seconds) return;
85
+ setFrameReady(false);
86
+ player.seek(seconds);
87
+ };
88
+ useEffect(() => {
89
+ if (
90
+ seekRequest?.path === source.path &&
91
+ seekRequest.generation === authorSessionWorkspace(sessionId).focusedDocument?.generation
92
+ ) {
93
+ seek(seekRequest.timeSeconds);
94
+ }
95
+ }, [seekRequest]);
96
+ useEffect(() => {
97
+ if (!pendingCandidate && start.current === undefined) setSelection(undefined);
98
+ }, [pendingCandidate]);
99
+ useEffect(
100
+ () =>
101
+ registerAuthorGridResolver(sessionId, (cell, geometry) => {
102
+ const target = image.current;
103
+ if (source.kind !== 'image' || target === null || target.naturalWidth === 0 || target.naturalHeight === 0)
104
+ return undefined;
105
+ const { originX = 0, originY = 0, width, height } = geometry.viewport;
106
+ const cellBounds = {
107
+ left: originX + cell.rect.x * width,
108
+ top: originY + cell.rect.y * height,
109
+ right: originX + (cell.rect.x + cell.rect.width) * width,
110
+ bottom: originY + (cell.rect.y + cell.rect.height) * height,
111
+ };
112
+ const bounds = target.getBoundingClientRect();
113
+ const left = Math.max(bounds.left, cellBounds.left);
114
+ const top = Math.max(bounds.top, cellBounds.top);
115
+ const right = Math.min(bounds.right, cellBounds.right);
116
+ const bottom = Math.min(bounds.bottom, cellBounds.bottom);
117
+ if (right <= left || bottom <= top || bounds.width <= 0 || bounds.height <= 0) return undefined;
118
+ return {
119
+ anchor: {
120
+ kind: 'image-rect',
121
+ rect: {
122
+ x: (left - bounds.left) / bounds.width,
123
+ y: (top - bounds.top) / bounds.height,
124
+ width: (right - left) / bounds.width,
125
+ height: (bottom - top) / bounds.height,
126
+ },
127
+ naturalWidth: target.naturalWidth,
128
+ naturalHeight: target.naturalHeight,
129
+ },
130
+ };
131
+ }),
132
+ [sessionId, source.kind, source.path, source.version],
133
+ );
134
+ const mark = async (event: PointerEvent<HTMLDivElement>) => {
135
+ const drag = start.current;
136
+ start.current = undefined;
137
+ if (!drag) return;
138
+ const bounds = drag.element.getBoundingClientRect();
139
+ const rect = normalizedAuthorRectangle(bounds, {
140
+ left: drag.x,
141
+ top: drag.y,
142
+ right: event.clientX,
143
+ bottom: event.clientY,
144
+ });
145
+ if (!rect) return;
146
+ setSelection({ path: source.path, rect });
147
+ const focused = authorSessionWorkspace(sessionId).focusedDocument;
148
+ if (!focused || focused.path !== source.path) return;
149
+ let anchor: AuthorNativeAnchor;
150
+ let blob: Blob | null = null;
151
+ let thumbnailUrl: string | undefined;
152
+ try {
153
+ if (drag.element instanceof HTMLImageElement) {
154
+ const image = drag.element;
155
+ if (!image.naturalWidth || !image.naturalHeight) throw new Error('Image is not ready.');
156
+ anchor = { kind: 'image-rect', rect, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight };
157
+ const canvas = document.createElement('canvas');
158
+ const scale = Math.min(1, 1600 / Math.max(image.naturalWidth, image.naturalHeight));
159
+ canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
160
+ canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
161
+ const context = canvas.getContext('2d');
162
+ if (!context) throw new Error('Canvas is unavailable.');
163
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
164
+ blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
165
+ } else if (source.kind === 'pdf') {
166
+ const page = pdf.current?.getState();
167
+ if (!page?.sourceWidth) throw new Error('PDF page is not ready.');
168
+ anchor = { kind: 'pdf-page-rect', page: page.page, rect };
169
+ blob = await pdf.current!.capturePage();
170
+ } else {
171
+ const frame = await video.current?.captureFrame();
172
+ if (!frame) throw new Error('Video frame is not ready.');
173
+ anchor = {
174
+ kind: 'video-time-rect',
175
+ rect,
176
+ timeSeconds: frame.timeSeconds,
177
+ intrinsicWidth: frame.width,
178
+ intrinsicHeight: frame.height,
179
+ };
180
+ blob = frame.blob;
181
+ }
182
+ if (!blob) throw new Error('Unable to capture selected media.');
183
+ if (authorSessionWorkspace(sessionId).focusedDocument?.generation !== focused.generation) return;
184
+ thumbnailUrl = URL.createObjectURL(blob);
185
+ setAuthorRegionCandidate(sessionId, {
186
+ documentPath: source.path,
187
+ revision: source.version,
188
+ sourceSha256: source.sourceSha256,
189
+ anchor,
190
+ viewport: { width: bounds.width, height: bounds.height },
191
+ thumbnailUrl,
192
+ createdAt: Date.now(),
193
+ });
194
+ setError(undefined);
195
+ } catch (reason) {
196
+ if (thumbnailUrl) URL.revokeObjectURL(thumbnailUrl);
197
+ setError(reason instanceof Error ? reason.message : String(reason));
198
+ }
199
+ };
200
+ const selected = selection?.path === source.path ? selection.rect : undefined;
201
+ const selectionOverlay =
202
+ selected === undefined ? null : (
203
+ <div
204
+ data-testid="author-media-selection"
205
+ className="pointer-events-none absolute border-2 border-doom-red bg-doom-red/10"
206
+ style={{
207
+ left: `${selected.x * 100}%`,
208
+ top: `${selected.y * 100}%`,
209
+ width: `${selected.width * 100}%`,
210
+ height: `${selected.height * 100}%`,
211
+ }}
212
+ />
213
+ );
214
+ return (
215
+ <div
216
+ ref={host}
217
+ className="min-h-0 flex-1 overflow-auto p-4"
218
+ style={{
219
+ touchAction: activeTool === 'select' ? 'auto' : 'none',
220
+ cursor: activeTool === 'select' ? undefined : 'crosshair',
221
+ }}
222
+ onPointerMove={(event) => {
223
+ const drag = start.current;
224
+ if (drag === undefined) return;
225
+ const rect = normalizedAuthorRectangle(drag.element.getBoundingClientRect(), {
226
+ left: drag.x,
227
+ top: drag.y,
228
+ right: event.clientX,
229
+ bottom: event.clientY,
230
+ });
231
+ setSelection(rect === null ? undefined : { path: source.path, rect });
232
+ }}
233
+ onPointerDownCapture={(event) => {
234
+ if (activeTool === 'select' || event.button !== 0 || pendingCandidate) return;
235
+ const element = event.target;
236
+ if (
237
+ !(
238
+ element instanceof HTMLImageElement ||
239
+ element instanceof HTMLVideoElement ||
240
+ element instanceof HTMLCanvasElement
241
+ )
242
+ )
243
+ return;
244
+ event.preventDefault();
245
+ setSelection(undefined);
246
+ if (element instanceof HTMLVideoElement) {
247
+ if (video.current?.isFrameReady?.() === false) {
248
+ setError('Wait for the selected frame to finish loading.');
249
+ return;
250
+ }
251
+ video.current?.pause();
252
+ }
253
+ start.current = { x: event.clientX, y: event.clientY, element };
254
+ event.currentTarget.setPointerCapture(event.pointerId);
255
+ }}
256
+ onPointerUp={(event) => void mark(event)}
257
+ onPointerCancel={() => {
258
+ start.current = undefined;
259
+ setSelection(undefined);
260
+ }}
261
+ >
262
+ {mediaUrl === undefined ? (
263
+ <p className="text-doom-dim">{error === undefined ? 'Loading media...' : 'Media unavailable.'}</p>
264
+ ) : source.kind === 'image' ? (
265
+ <div className="relative inline-block max-w-full">
266
+ <img ref={image} src={mediaUrl} alt={source.path} draggable={false} className="block max-w-full" />
267
+ {selectionOverlay}
268
+ {displayedRegions.flatMap(({ ordinal, region }) => {
269
+ if (region.anchor.kind !== 'image-rect') return [];
270
+ const { rect } = region.anchor;
271
+ return [
272
+ <div
273
+ key={region.id}
274
+ data-author-region={ordinal}
275
+ className="pointer-events-none absolute border border-doom-yellow bg-doom-yellow/10"
276
+ style={{
277
+ left: `${String(rect.x * 100)}%`,
278
+ top: `${String(rect.y * 100)}%`,
279
+ width: `${String(rect.width * 100)}%`,
280
+ height: `${String(rect.height * 100)}%`,
281
+ }}
282
+ >
283
+ <span className="absolute -left-2 -top-2 flex h-4 min-w-4 items-center justify-center rounded-full bg-doom-yellow px-1 text-2xs font-bold text-doom-deep">
284
+ {ordinal}
285
+ </span>
286
+ </div>,
287
+ ];
288
+ })}
289
+ </div>
290
+ ) : (
291
+ <div className="relative w-fit max-w-full">
292
+ <MediaPreview
293
+ src={mediaUrl}
294
+ path={source.path}
295
+ kind={source.kind === 'pdf' ? 'pdf' : source.kind === 'video' ? 'video' : 'download'}
296
+ controllerRef={video}
297
+ pdfControllerRef={pdf}
298
+ data-testid="author-media"
299
+ videoControls={false}
300
+ onPlaybackStateChange={(state) => {
301
+ setPlayback(state);
302
+ setFrameReady(video.current?.isFrameReady() ?? false);
303
+ if (state.playing) setSelection(undefined);
304
+ }}
305
+ />
306
+ {source.kind === 'video' ? selectionOverlay : null}
307
+ {source.kind === 'video'
308
+ ? displayedRegions.map(({ ordinal, region }) => {
309
+ if (
310
+ region.anchor.kind !== 'video-time-rect' ||
311
+ playback.playing ||
312
+ Math.abs(region.anchor.timeSeconds - playback.currentTime) > 0.1
313
+ )
314
+ return null;
315
+ const { rect } = region.anchor;
316
+ return (
317
+ <div
318
+ key={region.id}
319
+ data-author-region={ordinal}
320
+ className="pointer-events-none absolute border border-doom-yellow bg-doom-yellow/10"
321
+ style={{
322
+ left: `${rect.x * 100}%`,
323
+ top: `${rect.y * 100}%`,
324
+ width: `${rect.width * 100}%`,
325
+ height: `${rect.height * 100}%`,
326
+ }}
327
+ >
328
+ <span className="bg-doom-yellow text-doom-deep">{ordinal}</span>
329
+ </div>
330
+ );
331
+ })
332
+ : null}
333
+ </div>
334
+ )}
335
+ {source.kind === 'video' ? (
336
+ <AuthorVideoControls
337
+ playback={playback}
338
+ ready={frameReady}
339
+ locked={pendingCandidate}
340
+ marking={activeTool === 'mark'}
341
+ onSeek={seek}
342
+ onToggle={() => {
343
+ setAuthorToolMode(sessionId, 'select');
344
+ setSelection(undefined);
345
+ if (playback.playing) video.current?.pause();
346
+ else void video.current?.play().catch((reason: unknown) => setError(String(reason)));
347
+ }}
348
+ onAnnotate={() => {
349
+ video.current?.pause();
350
+ setAuthorToolMode(sessionId, 'mark');
351
+ }}
352
+ />
353
+ ) : null}
354
+ {error ? <output className="text-base text-doom-red">{error}</output> : null}
355
+ </div>
356
+ );
357
+ }
@@ -0,0 +1,102 @@
1
+ import type { WebPluginSlotProps } from '@agimon-ai/doompi-core/web';
2
+ import { Button } from '@agimon-ai/doompi-web-components';
3
+ import { useStore } from '@tanstack/react-store';
4
+ import { useState } from 'react';
5
+
6
+ import { authorGrid } from '../lib/authorGrid';
7
+ import { authorCaptureContext, createAuthorCapturePacket, multiRegionCaptureProvider } from '../stores/authorCapture';
8
+ import { authorWorkspace } from '../stores/authorWorkspaceStore';
9
+ import { autonomousVoiceGridVisible } from './AuthorGridOverlay';
10
+ import { AuthorRegionDrafts } from './AuthorRegionDrafts';
11
+ import { AuthorRequestLog } from './AuthorRequestLog';
12
+ import { AuthorToolPalette } from './AuthorToolPalette';
13
+
14
+ export function AuthorPanel({ sessionId, activeMinorModes, submitCapture, statuses }: WebPluginSlotProps) {
15
+ const [captureStatus, setCaptureStatus] = useState<string>();
16
+ const [capturing, setCapturing] = useState(false);
17
+ const documents = useStore(authorWorkspace.store, (state) => {
18
+ if (sessionId === null) return [];
19
+ const prefix = `${sessionId}\n`;
20
+ return Object.entries(state.documents)
21
+ .filter(([key]) => key.startsWith(prefix))
22
+ .map(([, document]) => document);
23
+ });
24
+ const workspace = useStore(authorWorkspace.store, (state) =>
25
+ sessionId === null ? undefined : state.sessions[sessionId],
26
+ );
27
+ const grid = useStore(authorGrid.store, (state) => (sessionId === null ? undefined : state.sessions[sessionId]));
28
+ const focused = documents.find((document) => document.path === workspace?.focusedDocument?.path);
29
+ if (!activeMinorModes?.includes('author') || focused === undefined) return null;
30
+ return (
31
+ <section data-testid="author-panel" className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3">
32
+ {sessionId !== null && workspace !== undefined && focused !== undefined ? (
33
+ <>
34
+ <h2 className="text-base font-semibold text-doom-text">Annotations</h2>
35
+ <p className="text-base leading-normal text-doom-dim sm:text-sm">
36
+ Select a region, add a comment, then submit here. Requests queue while the agent is working.
37
+ </p>
38
+ {focused.kind === 'video' ? (
39
+ <p className="text-base text-doom-dim sm:text-sm">
40
+ Annotations reference video frames. They do not save changes to the source video.
41
+ </p>
42
+ ) : null}
43
+ <AuthorToolPalette sessionId={sessionId} kind={focused.kind} activeTool={workspace.activeTool} />
44
+ {autonomousVoiceGridVisible(statuses) && grid !== undefined ? (
45
+ <div
46
+ data-testid="author-grid-snapshot"
47
+ className="rounded border border-doom-red/40 bg-doom-red/5 p-2 text-base text-doom-dim"
48
+ >
49
+ <strong className="text-doom-red">VOICE GRID A1–H8</strong>
50
+ <p className="mt-1 truncate">token {grid.geometryToken}</p>
51
+ </div>
52
+ ) : null}
53
+ <AuthorRegionDrafts key={focused.path} sessionId={sessionId} workspace={workspace} />
54
+ {workspace.regions.length > 0 ? (
55
+ <div className="space-y-1.5 border-b border-doom-border-soft pb-3">
56
+ <Button
57
+ className="min-h-11 min-w-11 w-full text-base [@media(pointer:fine)]:min-h-8 sm:text-sm"
58
+ variant="outline"
59
+ data-testid="author-attach-capture"
60
+ disabled={capturing || workspace.candidate !== undefined || submitCapture === undefined}
61
+ onClick={async () => {
62
+ if (capturing || workspace.candidate || submitCapture === undefined) return;
63
+ setCapturing(true);
64
+ setCaptureStatus('Submitting annotations…');
65
+ try {
66
+ const packet = createAuthorCapturePacket(
67
+ crypto.randomUUID(),
68
+ Date.now(),
69
+ focused,
70
+ workspace.regions,
71
+ );
72
+ const image = await multiRegionCaptureProvider(workspace.regions).capture();
73
+ await submitCapture({ ...image, context: authorCaptureContext(packet) });
74
+ setCaptureStatus('Request submitted. You can keep annotating here.');
75
+ } catch (reason) {
76
+ setCaptureStatus(reason instanceof Error ? reason.message : String(reason));
77
+ } finally {
78
+ setCapturing(false);
79
+ }
80
+ }}
81
+ >
82
+ {capturing
83
+ ? 'Submitting…'
84
+ : `Submit ${workspace.regions.length} annotation${workspace.regions.length === 1 ? '' : 's'}`}
85
+ </Button>
86
+ {workspace.candidate ? (
87
+ <p className="text-base text-doom-dim sm:text-sm">
88
+ Add or discard the current selection before submitting.
89
+ </p>
90
+ ) : null}
91
+ </div>
92
+ ) : null}
93
+ {submitCapture === undefined ? (
94
+ <output className="text-sm text-doom-red">Submission unavailable. Reload to update the app.</output>
95
+ ) : null}
96
+ {captureStatus ? <output className="block text-sm text-doom-dim">{captureStatus}</output> : null}
97
+ </>
98
+ ) : null}
99
+ <AuthorRequestLog requests={workspace?.requests ?? []} />
100
+ </section>
101
+ );
102
+ }
@@ -0,0 +1,103 @@
1
+ import { Button } from '@agimon-ai/doompi-web-components';
2
+ import { useState } from 'react';
3
+
4
+ import type { AuthorSessionWorkspace } from '../stores/authorWorkspaceStore';
5
+ import {
6
+ commitAuthorRegion,
7
+ removeAuthorRegion,
8
+ seekAuthorVideo,
9
+ setAuthorRegionCandidate,
10
+ } from '../stores/authorWorkspaceStore';
11
+
12
+ export function AuthorRegionDrafts({ sessionId, workspace }: { sessionId: string; workspace: AuthorSessionWorkspace }) {
13
+ const [comment, setComment] = useState('');
14
+ const [error, setError] = useState<string>();
15
+ const add = () => {
16
+ try {
17
+ commitAuthorRegion(sessionId, comment);
18
+ setComment('');
19
+ setError(undefined);
20
+ } catch (reason) {
21
+ setError(reason instanceof Error ? reason.message : String(reason));
22
+ }
23
+ };
24
+ if (!workspace.candidate && workspace.regions.length === 0) return null;
25
+ return (
26
+ <section className="space-y-2" data-testid="author-region-drafts">
27
+ {workspace.candidate ? (
28
+ <>
29
+ <h3 className="text-base font-semibold text-doom-text">Current selection</h3>
30
+ <p className="text-base text-doom-dim sm:text-sm">Add a comment to keep this selection as an unsent draft.</p>
31
+ {workspace.candidate.anchor.kind === 'video-time-rect' ? (
32
+ <p className="text-base text-doom-dim sm:text-sm">
33
+ Frame at {workspace.candidate.anchor.timeSeconds.toFixed(3)}s
34
+ </p>
35
+ ) : null}
36
+ <textarea
37
+ aria-label="Region comment"
38
+ value={comment}
39
+ onChange={(event) => setComment(event.target.value)}
40
+ placeholder="What should change here?"
41
+ rows={3}
42
+ className="min-h-11 w-full resize-y rounded border border-doom-border bg-doom-deep p-2 text-lg leading-normal text-doom-text focus:border-doom-red focus:outline-none sm:text-sm"
43
+ />
44
+ <Button
45
+ className="min-h-11 min-w-11 text-base [@media(pointer:fine)]:min-h-8 sm:text-sm"
46
+ variant="outline"
47
+ onClick={add}
48
+ disabled={!workspace.candidate || !comment.trim() || workspace.regions.length >= 16}
49
+ >
50
+ add region
51
+ </Button>
52
+ <Button
53
+ variant="ghost"
54
+ className="min-h-11 min-w-11 text-base [@media(pointer:fine)]:min-h-8 sm:text-sm"
55
+ onClick={() => {
56
+ setAuthorRegionCandidate(sessionId, undefined);
57
+ setComment('');
58
+ setError(undefined);
59
+ }}
60
+ >
61
+ Discard selection
62
+ </Button>
63
+ </>
64
+ ) : null}
65
+ {error ? <output className="block text-base text-doom-red">{error}</output> : null}
66
+ {workspace.regions.length > 0 ? (
67
+ <h3 className="text-base font-semibold text-doom-text">Unsent drafts ({workspace.regions.length})</h3>
68
+ ) : null}
69
+ <ol className="space-y-2">
70
+ {workspace.regions.map((region, index) => (
71
+ <li
72
+ key={region.id}
73
+ className="rounded border border-doom-border bg-doom-panel p-2 text-base leading-normal text-doom-text sm:text-sm"
74
+ >
75
+ <span>
76
+ ({index + 1}) {region.comment}
77
+ </span>
78
+ {region.anchor.kind === 'video-time-rect' ? (
79
+ <Button
80
+ variant="outline"
81
+ className="min-h-11 min-w-11 text-base [@media(pointer:fine)]:min-h-8 sm:text-sm"
82
+ disabled={workspace.candidate !== undefined}
83
+ onClick={() => {
84
+ if (region.anchor.kind === 'video-time-rect') seekAuthorVideo(sessionId, region.anchor.timeSeconds);
85
+ }}
86
+ >
87
+ Go to {region.anchor.timeSeconds.toFixed(3)}s
88
+ </Button>
89
+ ) : null}
90
+ <Button
91
+ className="min-h-11 min-w-11 text-base [@media(pointer:fine)]:min-h-8 sm:text-sm"
92
+ variant="ghost"
93
+ aria-label={`Remove region ${index + 1}`}
94
+ onClick={() => removeAuthorRegion(sessionId, region.id)}
95
+ >
96
+ remove
97
+ </Button>
98
+ </li>
99
+ ))}
100
+ </ol>
101
+ </section>
102
+ );
103
+ }