@bendyline/squisq 1.5.2 → 2.0.1

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 (268) hide show
  1. package/README.md +63 -10
  2. package/dist/{Doc-BBVGq1_9.d.ts → Doc-ylqWKDc_.d.ts} +248 -37
  3. package/dist/{ImageEditDoc-FuyGtt6o.d.ts → ImageEditDoc-D828IXJ1.d.ts} +1 -1
  4. package/dist/annotationCoercion-Dr_DY9Hx.d.ts +86 -0
  5. package/dist/{chunk-T5UK6YOB.js → chunk-2PWOO4ZE.js} +60 -17
  6. package/dist/chunk-2PWOO4ZE.js.map +1 -0
  7. package/dist/{chunk-VWUFZ6ZG.js → chunk-3E5F2XMR.js} +2 -29
  8. package/dist/chunk-3E5F2XMR.js.map +1 -0
  9. package/dist/chunk-4X3JQXNM.js +35 -0
  10. package/dist/chunk-4X3JQXNM.js.map +1 -0
  11. package/dist/{chunk-P3K7NLHF.js → chunk-5DKBFXPY.js} +31 -121
  12. package/dist/chunk-5DKBFXPY.js.map +1 -0
  13. package/dist/{chunk-DLXZMS5K.js → chunk-5J4HKRJF.js} +51 -3
  14. package/dist/chunk-5J4HKRJF.js.map +1 -0
  15. package/dist/chunk-5UYI3RJB.js +2503 -0
  16. package/dist/chunk-5UYI3RJB.js.map +1 -0
  17. package/dist/{chunk-BEQODJWV.js → chunk-66QO7XFB.js} +11 -291
  18. package/dist/chunk-66QO7XFB.js.map +1 -0
  19. package/dist/{chunk-HWVFJAAH.js → chunk-AMIR72JP.js} +28 -10
  20. package/dist/chunk-AMIR72JP.js.map +1 -0
  21. package/dist/chunk-CY2R2VPR.js +884 -0
  22. package/dist/chunk-CY2R2VPR.js.map +1 -0
  23. package/dist/chunk-FVJUUTEM.js +74 -0
  24. package/dist/chunk-FVJUUTEM.js.map +1 -0
  25. package/dist/{chunk-Q3ROPT5H.js → chunk-GZY66SDN.js} +1 -1
  26. package/dist/{chunk-Q3ROPT5H.js.map → chunk-GZY66SDN.js.map} +1 -1
  27. package/dist/chunk-JOQYPRDZ.js +127 -0
  28. package/dist/chunk-JOQYPRDZ.js.map +1 -0
  29. package/dist/{chunk-A4O7GIWE.js → chunk-MMPBOUK7.js} +10 -73
  30. package/dist/chunk-MMPBOUK7.js.map +1 -0
  31. package/dist/{chunk-LDQ2HJIX.js → chunk-PXLVXTLW.js} +252 -54
  32. package/dist/chunk-PXLVXTLW.js.map +1 -0
  33. package/dist/{chunk-TQWLI6S2.js → chunk-Q7LROV2H.js} +4 -2
  34. package/dist/{chunk-TQWLI6S2.js.map → chunk-Q7LROV2H.js.map} +1 -1
  35. package/dist/{chunk-GPLTCSXO.js → chunk-R2GC6RLD.js} +7 -7
  36. package/dist/chunk-R2GC6RLD.js.map +1 -0
  37. package/dist/chunk-R2YZTCLO.js +292 -0
  38. package/dist/chunk-R2YZTCLO.js.map +1 -0
  39. package/dist/chunk-S7A5D7ZJ.js +174 -0
  40. package/dist/chunk-S7A5D7ZJ.js.map +1 -0
  41. package/dist/chunk-TFJVXUHH.js +2358 -0
  42. package/dist/chunk-TFJVXUHH.js.map +1 -0
  43. package/dist/{chunk-LTN6I7QW.js → chunk-TUWMK62B.js} +55 -4
  44. package/dist/chunk-TUWMK62B.js.map +1 -0
  45. package/dist/{chunk-IMSCRLLV.js → chunk-VR7UPJ7C.js} +35 -25
  46. package/dist/chunk-VR7UPJ7C.js.map +1 -0
  47. package/dist/{chunk-KKNUBQ6Y.js → chunk-W4O2TX5I.js} +157 -92
  48. package/dist/chunk-W4O2TX5I.js.map +1 -0
  49. package/dist/{chunk-5TYS5V3G.js → chunk-X6DUONGT.js} +4204 -3560
  50. package/dist/chunk-X6DUONGT.js.map +1 -0
  51. package/dist/doc/index.d.ts +2334 -5
  52. package/dist/doc/index.js +127 -33
  53. package/dist/generate/index.d.ts +7 -2
  54. package/dist/generate/index.js +1 -1
  55. package/dist/imageEdit/index.d.ts +9 -6
  56. package/dist/imageEdit/index.js +3 -3
  57. package/dist/index.d.ts +12 -10
  58. package/dist/index.js +246 -80
  59. package/dist/jsonForm/index.d.ts +7 -3
  60. package/dist/jsonForm/index.js +9 -4
  61. package/dist/markdown/index.d.ts +6 -70
  62. package/dist/markdown/index.js +13 -9
  63. package/dist/narration/index.d.ts +618 -0
  64. package/dist/narration/index.js +82 -0
  65. package/dist/recommend/index.d.ts +15 -1
  66. package/dist/recommend/index.js +2 -2
  67. package/dist/schemas/index.d.ts +28 -6
  68. package/dist/schemas/index.js +15 -13
  69. package/dist/spatial/index.d.ts +0 -1
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.js +1 -1
  72. package/dist/{themeLibrary-CehcJhzz.d.ts → themeLibrary-RjVgLpUi.d.ts} +10 -11
  73. package/dist/timing/index.js +5 -3
  74. package/dist/transform/index.d.ts +35 -24
  75. package/dist/transform/index.js +9 -10
  76. package/dist/{types-DlAZ7MW4.d.ts → types-BwMPl2CB.d.ts} +1 -1
  77. package/dist/{types-D98li1gV.d.ts → types-C4kiRn_k.d.ts} +3 -1
  78. package/dist/versions/index.d.ts +2 -1
  79. package/dist/versions/index.js +2 -2
  80. package/package.json +6 -6
  81. package/src/__tests__/applyNarrationTiming.test.ts +190 -0
  82. package/src/__tests__/applyRenderStyle.test.ts +3 -3
  83. package/src/__tests__/asciiDiagramBattle.test.ts +246 -0
  84. package/src/__tests__/asciiDiagramDetect.test.ts +162 -0
  85. package/src/__tests__/asciiDiagramMapping.test.ts +38 -0
  86. package/src/__tests__/asciiDiagramParse.test.ts +233 -0
  87. package/src/__tests__/asciiDiagramPipeline.test.ts +273 -0
  88. package/src/__tests__/asciiDiagramRender.test.ts +162 -0
  89. package/src/__tests__/asciiDiagramRepair.test.ts +84 -0
  90. package/src/__tests__/asciiTimelineDetect.test.ts +157 -0
  91. package/src/__tests__/asciiTimelineParse.test.ts +205 -0
  92. package/src/__tests__/asciiTimelinePipeline.test.ts +349 -0
  93. package/src/__tests__/asciiTimelineRender.test.ts +376 -0
  94. package/src/__tests__/attrTokens.test.ts +5 -0
  95. package/src/__tests__/contentContainer.test.ts +43 -0
  96. package/src/__tests__/customTemplatesFrontmatter.test.ts +0 -15
  97. package/src/__tests__/customThemesFrontmatter.test.ts +1 -0
  98. package/src/__tests__/dataTable.test.ts +25 -15
  99. package/src/__tests__/diagramBlock.test.ts +3 -3
  100. package/src/__tests__/diagramText.test.ts +26 -0
  101. package/src/__tests__/docToMarkdownMedia.test.ts +83 -0
  102. package/src/__tests__/drawingBlock.test.ts +3 -3
  103. package/src/__tests__/fixtures/asciiDiagramBattle.ts +512 -0
  104. package/src/__tests__/fixtures/asciiDiagrams.ts +287 -0
  105. package/src/__tests__/fixtures/asciiTimelines.ts +35 -0
  106. package/src/__tests__/fixtures/treeviewFixtures.ts +243 -0
  107. package/src/__tests__/getLayers.test.ts +32 -18
  108. package/src/__tests__/getLayersFallback.test.ts +8 -8
  109. package/src/__tests__/imageEditVersions.test.ts +2 -0
  110. package/src/__tests__/jsonForm.pointer.test.ts +47 -1
  111. package/src/__tests__/layoutBlock.test.ts +3 -3
  112. package/src/__tests__/majorApiTypes.test.ts +18 -0
  113. package/src/__tests__/markdown.test.ts +54 -4
  114. package/src/__tests__/markdownSanitize.test.ts +7 -5
  115. package/src/__tests__/matchFontFamily.test.ts +114 -0
  116. package/src/__tests__/materializeBlockLayers.test.ts +135 -0
  117. package/src/__tests__/materializeTestUtils.ts +17 -0
  118. package/src/__tests__/narrationAlign.test.ts +150 -0
  119. package/src/__tests__/narrationFeatures.test.ts +71 -0
  120. package/src/__tests__/narrationNuclei.test.ts +45 -0
  121. package/src/__tests__/narrationPacing.test.ts +189 -0
  122. package/src/__tests__/narrationScript.test.ts +149 -0
  123. package/src/__tests__/narrationSessionRepro.test.ts +62 -0
  124. package/src/__tests__/narrationSidecar.test.ts +88 -0
  125. package/src/__tests__/narrationSyllables.test.ts +65 -0
  126. package/src/__tests__/narrationTestSignals.ts +167 -0
  127. package/src/__tests__/narrationVad.test.ts +66 -0
  128. package/src/__tests__/resolveDocTheme.test.ts +16 -2
  129. package/src/__tests__/rootExports.test.ts +18 -0
  130. package/src/__tests__/scopedContentContainer.test.ts +20 -0
  131. package/src/__tests__/shapeGeometry.test.ts +12 -0
  132. package/src/__tests__/templateAnnotationParse.test.ts +10 -0
  133. package/src/__tests__/templateInputs.test.ts +69 -0
  134. package/src/__tests__/templateMetadata.test.ts +2 -1
  135. package/src/__tests__/templates.test.ts +143 -17
  136. package/src/__tests__/themeCompile.test.ts +49 -22
  137. package/src/__tests__/transformProvenance.test.ts +160 -0
  138. package/src/__tests__/transformV2.test.ts +40 -14
  139. package/src/__tests__/treeviewBattle.test.ts +76 -0
  140. package/src/__tests__/treeviewDetect.test.ts +168 -0
  141. package/src/__tests__/treeviewParse.test.ts +118 -0
  142. package/src/__tests__/treeviewPipeline.test.ts +183 -0
  143. package/src/__tests__/treeviewRender.test.ts +91 -0
  144. package/src/__tests__/versions.test.ts +44 -0
  145. package/src/doc/applyNarrationTiming.ts +244 -0
  146. package/src/doc/asciiDiagram/chars.ts +210 -0
  147. package/src/doc/asciiDiagram/detect.ts +175 -0
  148. package/src/doc/asciiDiagram/index.ts +38 -0
  149. package/src/doc/asciiDiagram/mapping.ts +225 -0
  150. package/src/doc/asciiDiagram/parse.ts +1095 -0
  151. package/src/doc/asciiDiagram/render.ts +963 -0
  152. package/src/doc/asciiDiagram/repair.ts +400 -0
  153. package/src/doc/asciiDiagram/types.ts +65 -0
  154. package/src/doc/asciiTimeline/detect.ts +138 -0
  155. package/src/doc/asciiTimeline/index.ts +26 -0
  156. package/src/doc/asciiTimeline/mapping.ts +43 -0
  157. package/src/doc/asciiTimeline/parse.ts +870 -0
  158. package/src/doc/asciiTimeline/render.ts +427 -0
  159. package/src/doc/asciiTimeline/types.ts +73 -0
  160. package/src/doc/asciiTimeline/wrappedFlow.ts +427 -0
  161. package/src/doc/audioMapping.ts +60 -46
  162. package/src/doc/customTemplatesFrontmatter.ts +0 -41
  163. package/src/doc/docToMarkdown.ts +70 -2
  164. package/src/doc/index.ts +76 -4
  165. package/src/doc/markdownToDoc.ts +138 -2
  166. package/src/doc/materializeBlockLayers.ts +304 -0
  167. package/src/doc/mediaAnnotations.ts +20 -0
  168. package/src/doc/resolveDocTheme.ts +8 -7
  169. package/src/doc/templateInputs.ts +185 -2
  170. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +22 -8
  171. package/src/doc/templates/__tests__/listBlock.test.ts +64 -0
  172. package/src/doc/templates/diagramBlock.ts +237 -38
  173. package/src/doc/templates/fallbackBlock.ts +1 -1
  174. package/src/doc/templates/index.ts +134 -279
  175. package/src/doc/templates/inputDescriptors.ts +20 -1
  176. package/src/doc/templates/listBlock.ts +88 -30
  177. package/src/doc/templates/metadata.ts +10 -0
  178. package/src/doc/templates/persistentLayers.ts +12 -126
  179. package/src/doc/templates/registry.ts +88 -0
  180. package/src/doc/templates/templateNames.ts +22 -0
  181. package/src/doc/templates/timelineBlock.ts +613 -0
  182. package/src/doc/templates/treeBlock.ts +131 -0
  183. package/src/doc/treeview/chars.ts +72 -0
  184. package/src/doc/treeview/detect.ts +145 -0
  185. package/src/doc/treeview/index.ts +30 -0
  186. package/src/doc/treeview/mapping.ts +95 -0
  187. package/src/doc/treeview/parse.ts +201 -0
  188. package/src/doc/treeview/render.ts +41 -0
  189. package/src/doc/treeview/types.ts +43 -0
  190. package/src/doc/utils/applyRenderStyle.ts +2 -9
  191. package/src/doc/utils/diagramText.ts +78 -0
  192. package/src/doc/utils/shapeGeometry.ts +67 -5
  193. package/src/doc/validate.ts +1 -1
  194. package/src/generate/templateMapper.ts +7 -0
  195. package/src/imageEdit/export.ts +6 -5
  196. package/src/imageEdit/versionPaths.ts +13 -26
  197. package/src/imageEdit/versions.ts +21 -100
  198. package/src/index.ts +1 -0
  199. package/src/internal/immutable.ts +58 -0
  200. package/src/jsonForm/index.ts +9 -1
  201. package/src/jsonForm/pointer.ts +68 -23
  202. package/src/markdown/attrTokens.ts +7 -5
  203. package/src/markdown/index.ts +1 -0
  204. package/src/markdown/stringify.ts +4 -9
  205. package/src/markdown/utils.ts +36 -14
  206. package/src/narration/align.ts +514 -0
  207. package/src/narration/features.ts +231 -0
  208. package/src/narration/index.ts +86 -0
  209. package/src/narration/nuclei.ts +118 -0
  210. package/src/narration/pacing.ts +211 -0
  211. package/src/narration/script.ts +239 -0
  212. package/src/narration/session.ts +100 -0
  213. package/src/narration/sidecar.ts +187 -0
  214. package/src/narration/syllables.ts +85 -0
  215. package/src/narration/trace.ts +50 -0
  216. package/src/narration/types.ts +299 -0
  217. package/src/narration/vad.ts +131 -0
  218. package/src/recommend/numberHighlight.ts +25 -0
  219. package/src/recommend/templates.ts +108 -12
  220. package/src/schemas/BlockTemplates.ts +156 -7
  221. package/src/schemas/Doc.ts +72 -9
  222. package/src/schemas/Media.ts +12 -2
  223. package/src/schemas/Theme.ts +45 -25
  224. package/src/schemas/Viewport.ts +2 -2
  225. package/src/schemas/fontStacks.ts +79 -0
  226. package/src/schemas/themeCompile.ts +15 -0
  227. package/src/schemas/themeConstants.ts +2 -0
  228. package/src/schemas/themeLibrary.ts +25 -19
  229. package/src/schemas/themeValidator.ts +1 -1
  230. package/src/schemas/themes/gezellig.json +1 -1
  231. package/src/spatial/Geohash.ts +0 -3
  232. package/src/storage/ContentContainer.ts +17 -9
  233. package/src/storage/ScopedContentContainer.ts +45 -15
  234. package/src/transform/applyTransform.ts +5 -5
  235. package/src/transform/index.ts +3 -1
  236. package/src/transform/registry.ts +169 -49
  237. package/src/transform/templateSelector.ts +21 -2
  238. package/src/transform/timingAllocator.ts +152 -21
  239. package/src/transform/types.ts +14 -1
  240. package/src/versions/gitignore.ts +34 -0
  241. package/src/versions/index.ts +2 -0
  242. package/src/versions/operations.ts +18 -104
  243. package/src/versions/paths.ts +13 -28
  244. package/src/versions/snapshotStore.ts +202 -0
  245. package/src/versions/types.ts +3 -1
  246. package/dist/chunk-5TYS5V3G.js.map +0 -1
  247. package/dist/chunk-A4O7GIWE.js.map +0 -1
  248. package/dist/chunk-BEQODJWV.js.map +0 -1
  249. package/dist/chunk-DLXZMS5K.js.map +0 -1
  250. package/dist/chunk-GPLTCSXO.js.map +0 -1
  251. package/dist/chunk-HWVFJAAH.js.map +0 -1
  252. package/dist/chunk-IMSCRLLV.js.map +0 -1
  253. package/dist/chunk-JELIRVDP.js +0 -24
  254. package/dist/chunk-JELIRVDP.js.map +0 -1
  255. package/dist/chunk-KKNUBQ6Y.js.map +0 -1
  256. package/dist/chunk-LDQ2HJIX.js.map +0 -1
  257. package/dist/chunk-LTN6I7QW.js.map +0 -1
  258. package/dist/chunk-P3K7NLHF.js.map +0 -1
  259. package/dist/chunk-T5UK6YOB.js.map +0 -1
  260. package/dist/chunk-UY7KGQ5R.js +0 -192
  261. package/dist/chunk-UY7KGQ5R.js.map +0 -1
  262. package/dist/chunk-VLJJHDAC.js +0 -218
  263. package/dist/chunk-VLJJHDAC.js.map +0 -1
  264. package/dist/chunk-VWUFZ6ZG.js.map +0 -1
  265. package/dist/story/index.d.ts +0 -1801
  266. package/dist/story/index.js +0 -279
  267. package/src/doc/getLayers.ts +0 -195
  268. /package/dist/{story → narration}/index.js.map +0 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Conservative parser for two-or-more-lane flow art whose last edge wraps
3
+ * around the right margin. This dialect is common in AI-authored architecture
4
+ * notes and sequence sketches:
5
+ *
6
+ * CLIENT KERNEL
7
+ * ────── ──────
8
+ * input ──► command ────► validate
9
+ * ├─ invalid → rejected ─┐
10
+ * surfaced ◄─────────────────────────────────────┘
11
+ *
12
+ * It is intentionally strict. Every nonblank row must be a heading,
13
+ * underline, connector-only row, parenthetical continuation, or prose joined
14
+ * by an explicit arrow. Accepted art is converted to the ordinary timeline
15
+ * model, after which the canonical timeline renderer owns the authored form.
16
+ */
17
+
18
+ import type {
19
+ AsciiTimeline,
20
+ AsciiTimelineEvent,
21
+ AsciiTimelineLink,
22
+ AsciiTimelineTrack,
23
+ } from './types.js';
24
+
25
+ interface TextSegment {
26
+ text: string;
27
+ start: number;
28
+ end: number;
29
+ trackIndex: number;
30
+ }
31
+
32
+ interface TrackBand {
33
+ id: string;
34
+ label: string;
35
+ row: number;
36
+ start: number;
37
+ end: number;
38
+ }
39
+
40
+ interface EventDraft {
41
+ id: string;
42
+ label: string;
43
+ description?: string;
44
+ trackIndex: number;
45
+ order: number;
46
+ }
47
+
48
+ interface LinkDraft {
49
+ source: string;
50
+ target: string;
51
+ label?: string;
52
+ }
53
+
54
+ interface PendingDescription {
55
+ eventId: string;
56
+ text: string;
57
+ }
58
+
59
+ const UNDERLINE_RE = /[─━═-]{6,}/gu;
60
+ const FLOW_CHAR_RE = /[─━═│|►◄→←┌┐└┘├┤]/u;
61
+ const ARROW_RE = /[►◄→←]/u;
62
+ const LEFT_ARROW_RE = /[◄←]/u;
63
+ const RIGHT_ARROW_RE = /[►→]/u;
64
+ const CONNECTOR_ONLY_RE = /^[\s─━═│|►◄→←┌┐└┘├┤]+$/u;
65
+ const HEADER_RE = /^[A-Z][A-Z0-9 _/-]*(?:\s+\([^)]*\))?$/u;
66
+
67
+ /** Parse the wrapped-flow dialect, or return null without claiming the art. */
68
+ export function parseWrappedFlowTimeline(text: string): AsciiTimeline | null {
69
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
70
+ const header = findHeader(lines);
71
+ if (!header || header.tracks.length < 2) return null;
72
+
73
+ const drafts: EventDraft[] = [];
74
+ const links: LinkDraft[] = [];
75
+ const usedIds = new Set<string>();
76
+ const lastByTrack = new Map<number, string>();
77
+ let pendingDescription: PendingDescription | null = null;
78
+ let activeBranchRoot: string | null = null;
79
+ let wrapSource: string | null = null;
80
+ let arrowCount = 0;
81
+ let hasLeftArrow = false;
82
+ let hasRightArrow = false;
83
+ let hasWrapTop = false;
84
+ let hasWrapBottom = false;
85
+
86
+ const addEvent = (segment: TextSegment): EventDraft => {
87
+ const label = normalizeText(segment.text);
88
+ const id = uniqueId(label, usedIds);
89
+ const draft: EventDraft = {
90
+ id,
91
+ label,
92
+ trackIndex: segment.trackIndex,
93
+ order: drafts.length,
94
+ };
95
+ drafts.push(draft);
96
+ return draft;
97
+ };
98
+ const addLink = (source: string | undefined, target: string | undefined, label?: string) => {
99
+ if (!source || !target || source === target) return;
100
+ if (
101
+ links.some((link) => link.source === source && link.target === target && link.label === label)
102
+ ) {
103
+ return;
104
+ }
105
+ links.push({ source, target, ...(label ? { label } : {}) });
106
+ };
107
+
108
+ for (let row = header.underlineRow + 1; row < lines.length; row++) {
109
+ const original = lines[row];
110
+ if (!original.trim()) continue;
111
+ if (CONNECTOR_ONLY_RE.test(original)) {
112
+ hasWrapTop ||= original.includes('┐');
113
+ hasWrapBottom ||= original.includes('┘');
114
+ continue;
115
+ }
116
+
117
+ const trimmed = original.trim();
118
+ if (trimmed.startsWith('(') && !ARROW_RE.test(original)) {
119
+ const trackIndex = trackIndexAt(header.tracks, firstContentColumn(original));
120
+ const eventId = lastByTrack.get(trackIndex);
121
+ if (!eventId) return null;
122
+ pendingDescription = {
123
+ eventId,
124
+ text: normalizeText(original.replace(/[│|]+\s*$/u, '')),
125
+ };
126
+ continue;
127
+ }
128
+
129
+ let line = original;
130
+ let continuationTarget: string | null = null;
131
+ if (pendingDescription) {
132
+ const firstArrow = firstArrowColumn(line);
133
+ if (firstArrow < 0) return null;
134
+ const continuation = normalizeText(Array.from(line).slice(0, firstArrow).join(''));
135
+ const event = drafts.find((candidate) => candidate.id === pendingDescription?.eventId);
136
+ if (!event) return null;
137
+ event.description = normalizeText(`${pendingDescription.text} ${continuation}`);
138
+ continuationTarget = event.id;
139
+ line = `${' '.repeat(firstArrow)}${Array.from(line).slice(firstArrow).join('')}`;
140
+ pendingDescription = null;
141
+ }
142
+
143
+ const segments = extractSegments(line, header.tracks);
144
+ if (segments.length === 0) return null;
145
+ if (segments.some((segment) => !/[\p{L}\p{N}]/u.test(segment.text))) return null;
146
+
147
+ const previousByTrack = new Map(lastByTrack);
148
+ const events = segments.map(addEvent);
149
+ const directions: Array<'left' | 'right' | null> = [];
150
+ for (let index = 0; index < segments.length - 1; index++) {
151
+ const between = Array.from(line)
152
+ .slice(segments[index].end + 1, segments[index + 1].start)
153
+ .join('');
154
+ const direction = LEFT_ARROW_RE.test(between)
155
+ ? 'left'
156
+ : RIGHT_ARROW_RE.test(between)
157
+ ? 'right'
158
+ : null;
159
+ directions.push(direction);
160
+ if (direction) arrowCount++;
161
+ hasLeftArrow ||= direction === 'left';
162
+ hasRightArrow ||= direction === 'right';
163
+ if (direction === 'left') addLink(events[index + 1].id, events[index].id);
164
+ if (direction === 'right') addLink(events[index].id, events[index + 1].id);
165
+ }
166
+
167
+ const leadingBranch = /^[\s│|]*[├└]/u.exec(original)?.[0] ?? '';
168
+ if (leadingBranch) {
169
+ if (!activeBranchRoot) {
170
+ activeBranchRoot = previousByTrack.get(events[0].trackIndex) ?? null;
171
+ }
172
+ addLink(activeBranchRoot ?? undefined, events[0].id);
173
+ if (leadingBranch.includes('└')) activeBranchRoot = null;
174
+ } else {
175
+ activeBranchRoot = null;
176
+ }
177
+
178
+ const allLeft = directions.length > 0 && directions.every((direction) => direction === 'left');
179
+ const ordered = allLeft ? [...events].reverse() : events;
180
+ const first = ordered[0];
181
+ if (!leadingBranch && !original.includes('┘')) {
182
+ addLink(previousByTrack.get(first.trackIndex), first.id);
183
+ }
184
+ if (continuationTarget) addLink(ordered[ordered.length - 1]?.id, continuationTarget);
185
+
186
+ for (const event of ordered) lastByTrack.set(event.trackIndex, event.id);
187
+
188
+ if (original.includes('┐')) {
189
+ hasWrapTop = true;
190
+ wrapSource = events[events.length - 1]?.id ?? null;
191
+ }
192
+ if (original.includes('┘')) {
193
+ hasWrapBottom = true;
194
+ const target = allLeft ? events[0]?.id : events[events.length - 1]?.id;
195
+ addLink(wrapSource ?? undefined, target);
196
+ }
197
+ }
198
+
199
+ if (pendingDescription || drafts.length < 6) return null;
200
+ if (arrowCount < 4 || !hasLeftArrow || !hasRightArrow || !hasWrapTop || !hasWrapBottom) {
201
+ return null;
202
+ }
203
+ if (!hasCrossTrackLink(drafts, links)) return null;
204
+
205
+ const columns = assignColumns(drafts, links);
206
+ const endColumn = Math.max(10, ...columns.values());
207
+ const tracks: AsciiTimelineTrack[] = header.tracks.map((band, trackIndex) => {
208
+ const trackEvents = drafts
209
+ .filter((event) => event.trackIndex === trackIndex)
210
+ .sort(
211
+ (left, right) =>
212
+ (columns.get(left.id) ?? 0) - (columns.get(right.id) ?? 0) || left.order - right.order,
213
+ );
214
+ let previousColumn = -1;
215
+ const events: AsciiTimelineEvent[] = trackEvents.map((event, index) => {
216
+ const ideal = columns.get(event.id) ?? index * 10;
217
+ const column = Math.max(previousColumn + 1, ideal);
218
+ previousColumn = column;
219
+ return {
220
+ id: event.id,
221
+ label: event.label,
222
+ ...(event.description ? { description: event.description } : {}),
223
+ column,
224
+ side: index % 2 === 0 ? 'above' : 'below',
225
+ marker: 'filled',
226
+ };
227
+ });
228
+ return {
229
+ id: band.id,
230
+ label: band.label,
231
+ row: band.row,
232
+ startColumn: 0,
233
+ endColumn,
234
+ events,
235
+ };
236
+ });
237
+ const width = Math.max(0, ...lines.map((line) => Array.from(line).length));
238
+ links.sort(
239
+ (left, right) =>
240
+ left.source.localeCompare(right.source) ||
241
+ left.target.localeCompare(right.target) ||
242
+ (left.label ?? '').localeCompare(right.label ?? ''),
243
+ );
244
+ return {
245
+ tracks,
246
+ links: links satisfies AsciiTimelineLink[],
247
+ width,
248
+ height: lines.length,
249
+ style: 'unicode',
250
+ warnings: [],
251
+ };
252
+ }
253
+
254
+ /** High-confidence predicate used by the editor's lossless rewrite gate. */
255
+ export function isWrappedFlowTimelineSource(text: string): boolean {
256
+ return parseWrappedFlowTimeline(text) !== null;
257
+ }
258
+
259
+ function findHeader(
260
+ lines: readonly string[],
261
+ ): { tracks: TrackBand[]; underlineRow: number } | null {
262
+ for (let row = 1; row < Math.min(lines.length, 5); row++) {
263
+ const runs = Array.from(lines[row].matchAll(UNDERLINE_RE)).map((match) => ({
264
+ start: match.index,
265
+ end: (match.index ?? 0) + Array.from(match[0]).length - 1,
266
+ }));
267
+ if (runs.length < 2) continue;
268
+ const headings = splitHeader(lines[row - 1]);
269
+ if (
270
+ headings.length !== runs.length ||
271
+ headings.some((heading) => !HEADER_RE.test(heading.text))
272
+ ) {
273
+ continue;
274
+ }
275
+ const tracks = runs.map((run, index): TrackBand => {
276
+ const heading = headings[index];
277
+ const base = heading.text.replace(/\s+\([^)]*\)\s*$/u, '').trim();
278
+ return {
279
+ id: safeId(base),
280
+ label: heading.text,
281
+ row: row - 1,
282
+ start: run.start,
283
+ end: run.end,
284
+ };
285
+ });
286
+ return { tracks, underlineRow: row };
287
+ }
288
+ return null;
289
+ }
290
+
291
+ function splitHeader(line: string): Array<{ text: string; start: number; end: number }> {
292
+ const chars = Array.from(line);
293
+ const segments: Array<{ text: string; start: number; end: number }> = [];
294
+ let start = -1;
295
+ let spaces = 0;
296
+ for (let index = 0; index <= chars.length; index++) {
297
+ const char = chars[index];
298
+ if (char === ' ') {
299
+ spaces++;
300
+ if (start >= 0 && spaces >= 3) {
301
+ const end = index - spaces;
302
+ const text = chars
303
+ .slice(start, end + 1)
304
+ .join('')
305
+ .trim();
306
+ if (text) segments.push({ text, start, end });
307
+ start = -1;
308
+ }
309
+ continue;
310
+ }
311
+ spaces = 0;
312
+ if (char !== undefined && start < 0) start = index;
313
+ if (char === undefined && start >= 0) {
314
+ const text = chars.slice(start).join('').trim();
315
+ if (text) segments.push({ text, start, end: chars.length - 1 });
316
+ }
317
+ }
318
+ return segments;
319
+ }
320
+
321
+ function extractSegments(line: string, tracks: readonly TrackBand[]): TextSegment[] {
322
+ const chars = Array.from(line);
323
+ const segments: TextSegment[] = [];
324
+ let start = -1;
325
+ for (let index = 0; index <= chars.length; index++) {
326
+ const structural = index === chars.length || FLOW_CHAR_RE.test(chars[index] ?? '');
327
+ if (!structural && start < 0) start = index;
328
+ if (!structural || start < 0) continue;
329
+ const raw = chars.slice(start, index).join('');
330
+ const leading = raw.length - raw.trimStart().length;
331
+ const text = normalizeText(raw);
332
+ if (text) {
333
+ const segmentStart = start + leading;
334
+ const segmentEnd = segmentStart + Array.from(text).length - 1;
335
+ segments.push({
336
+ text,
337
+ start: segmentStart,
338
+ end: segmentEnd,
339
+ trackIndex: trackIndexAt(tracks, segmentStart),
340
+ });
341
+ }
342
+ start = -1;
343
+ }
344
+ return segments;
345
+ }
346
+
347
+ function trackIndexAt(tracks: readonly TrackBand[], column: number): number {
348
+ for (let index = 1; index < tracks.length; index++) {
349
+ if (column < tracks[index].start) return index - 1;
350
+ }
351
+ return tracks.length - 1;
352
+ }
353
+
354
+ function firstContentColumn(line: string): number {
355
+ const chars = Array.from(line);
356
+ const index = chars.findIndex((char) => /[\p{L}\p{N}(]/u.test(char));
357
+ return Math.max(0, index);
358
+ }
359
+
360
+ function firstArrowColumn(line: string): number {
361
+ return Array.from(line).findIndex((char) => ARROW_RE.test(char));
362
+ }
363
+
364
+ function normalizeText(value: string): string {
365
+ return value
366
+ .normalize('NFC')
367
+ .replace(/^[\s:]+|[\s:]+$/gu, '')
368
+ .replace(/\s+/gu, ' ')
369
+ .trim();
370
+ }
371
+
372
+ function safeId(value: string): string {
373
+ return (
374
+ value
375
+ .normalize('NFC')
376
+ .toLowerCase()
377
+ .replace(/[^a-z0-9_.~-]+/gu, '-')
378
+ .replace(/^-+|-+$/gu, '') || 'event'
379
+ );
380
+ }
381
+
382
+ function uniqueId(label: string, used: Set<string>): string {
383
+ const base = safeId(label);
384
+ if (!used.has(base)) {
385
+ used.add(base);
386
+ return base;
387
+ }
388
+ let suffix = 2;
389
+ while (used.has(`${base}-${suffix}`)) suffix++;
390
+ const id = `${base}-${suffix}`;
391
+ used.add(id);
392
+ return id;
393
+ }
394
+
395
+ function hasCrossTrackLink(events: readonly EventDraft[], links: readonly LinkDraft[]): boolean {
396
+ const trackById = new Map(events.map((event) => [event.id, event.trackIndex]));
397
+ return links.some((link) => trackById.get(link.source) !== trackById.get(link.target));
398
+ }
399
+
400
+ function assignColumns(
401
+ events: readonly EventDraft[],
402
+ links: readonly LinkDraft[],
403
+ ): Map<string, number> {
404
+ const outgoing = new Map<string, string[]>();
405
+ const indegree = new Map(events.map((event) => [event.id, 0]));
406
+ for (const link of links) {
407
+ outgoing.set(link.source, [...(outgoing.get(link.source) ?? []), link.target]);
408
+ indegree.set(link.target, (indegree.get(link.target) ?? 0) + 1);
409
+ }
410
+ const ranks = new Map(events.map((event) => [event.id, 0]));
411
+ const queue = events.filter((event) => indegree.get(event.id) === 0).map((event) => event.id);
412
+ let visited = 0;
413
+ while (queue.length > 0) {
414
+ const source = queue.shift() as string;
415
+ visited++;
416
+ for (const target of outgoing.get(source) ?? []) {
417
+ ranks.set(target, Math.max(ranks.get(target) ?? 0, (ranks.get(source) ?? 0) + 1));
418
+ const next = (indegree.get(target) ?? 1) - 1;
419
+ indegree.set(target, next);
420
+ if (next === 0) queue.push(target);
421
+ }
422
+ }
423
+ if (visited < events.length) {
424
+ for (const event of events) ranks.set(event.id, event.order);
425
+ }
426
+ return new Map(Array.from(ranks, ([id, rank]) => [id, rank * 10]));
427
+ }
@@ -14,8 +14,10 @@
14
14
  */
15
15
 
16
16
  import type { Doc, Block, AudioSegment, AudioTimingData } from '../schemas/Doc.js';
17
+ import { resolveMediaSchedule } from '../schemas/Media.js';
17
18
  import type { ContentContainer } from '../storage/ContentContainer.js';
18
19
  import { extractPlainText } from '../markdown/utils.js';
20
+ import { applyNarrationTiming } from './applyNarrationTiming.js';
19
21
 
20
22
  // ── Helpers ──────────────────────────────────────────────────────────
21
23
 
@@ -115,12 +117,9 @@ function flattenForAudio(blocks: Block[]): Block[] {
115
117
  /**
116
118
  * Info about a narration audio file found in the container.
117
119
  *
118
- * Originally limited to MP3, this now also covers WebM/MP4/Ogg/M4A
119
- * audio files that the browser recorder writes into `audio/`. The type
120
- * name is retained for backwards compatibility with the surrounding
121
- * matching logic; the data shape is identical regardless of codec.
120
+ * Covers MP3/WebM/MP4/Ogg/M4A audio files, including browser-recorder output.
122
121
  */
123
- interface Mp3Info {
122
+ interface NarrationAudioInfo {
124
123
  /** Path in the container (e.g., "audio/narration-2026.webm"). */
125
124
  path: string;
126
125
  /** Filename without path (e.g., "narration-2026.webm"). */
@@ -189,10 +188,10 @@ function parseConsolidatedTiming(data: ArrayBuffer): Record<string, AudioTimingD
189
188
  * Checks for a consolidated timing.json first, then falls back to
190
189
  * per-file `.timing.json` sidecars.
191
190
  */
192
- async function discoverMp3s(container: ContentContainer): Promise<Mp3Info[]> {
191
+ async function discoverNarrationAudio(container: ContentContainer): Promise<NarrationAudioInfo[]> {
193
192
  const files = await container.listFiles();
194
193
  const audioFiles = files.filter((f) => isNarrationAudioPath(f.path));
195
- const results: Mp3Info[] = [];
194
+ const results: NarrationAudioInfo[] = [];
196
195
 
197
196
  // Try to load consolidated timing.json
198
197
  let consolidatedSections: Record<string, AudioTimingData> | null = null;
@@ -237,22 +236,22 @@ async function discoverMp3s(container: ContentContainer): Promise<Mp3Info[]> {
237
236
  */
238
237
  function resolveFromAnnotations(
239
238
  blocks: Block[],
240
- mp3s: Mp3Info[],
241
- ): Array<{ block: Block; mp3: Mp3Info }> {
242
- const matches: Array<{ block: Block; mp3: Mp3Info }> = [];
239
+ audioFiles: NarrationAudioInfo[],
240
+ ): Array<{ block: Block; audio: NarrationAudioInfo }> {
241
+ const matches: Array<{ block: Block; audio: NarrationAudioInfo }> = [];
243
242
  const flat = flattenForAudio(blocks);
244
243
 
245
244
  for (const block of flat) {
246
245
  const audioRef = block.templateOverrides?.audio;
247
246
  if (!audioRef) continue;
248
247
 
249
- // Find the MP3 by filename (exact or suffix match)
250
- const mp3 = mp3s.find(
248
+ // Find the narration file by filename (exact or suffix match).
249
+ const audio = audioFiles.find(
251
250
  (m) => m.filename === audioRef || m.path === audioRef || m.path.endsWith(`/${audioRef}`),
252
251
  );
253
252
 
254
- if (mp3) {
255
- matches.push({ block, mp3 });
253
+ if (audio) {
254
+ matches.push({ block, audio });
256
255
  }
257
256
  }
258
257
 
@@ -266,27 +265,27 @@ const MIN_CONTENT_SIMILARITY = 0.35;
266
265
  const MIN_FILENAME_SIMILARITY = 0.5;
267
266
 
268
267
  /**
269
- * Auto-match MP3 files to blocks by content similarity.
268
+ * Auto-match narration files to blocks by content similarity.
270
269
  * Uses timing.json sourceText when available, falls back to filename matching.
271
270
  */
272
271
  function autoMatchBlocks(
273
272
  blocks: Block[],
274
- mp3s: Mp3Info[],
275
- ): Array<{ block: Block; mp3: Mp3Info; score: number }> {
273
+ audioFiles: NarrationAudioInfo[],
274
+ ): Array<{ block: Block; audio: NarrationAudioInfo; score: number }> {
276
275
  const flat = flattenForAudio(blocks);
277
- if (flat.length === 0 || mp3s.length === 0) return [];
276
+ if (flat.length === 0 || audioFiles.length === 0) return [];
278
277
 
279
- // Score all (mp3, block) pairs
278
+ // Score all (audio, block) pairs.
280
279
  const scoredPairs: Array<{
281
280
  block: Block;
282
281
  blockIndex: number;
283
- mp3: Mp3Info;
284
- mp3Index: number;
282
+ audio: NarrationAudioInfo;
283
+ audioIndex: number;
285
284
  score: number;
286
285
  }> = [];
287
286
 
288
- for (let mi = 0; mi < mp3s.length; mi++) {
289
- const mp3 = mp3s[mi];
287
+ for (let audioIndex = 0; audioIndex < audioFiles.length; audioIndex++) {
288
+ const audio = audioFiles[audioIndex];
290
289
 
291
290
  for (let bi = 0; bi < flat.length; bi++) {
292
291
  const block = flat[bi];
@@ -296,14 +295,14 @@ function autoMatchBlocks(
296
295
  let score = 0;
297
296
 
298
297
  // Primary: content similarity via timing.json sourceText
299
- if (mp3.timing?.sourceText) {
300
- score = scoreTextSimilarity(mp3.timing.sourceText, blockText);
298
+ if (audio.timing?.sourceText) {
299
+ score = scoreTextSimilarity(audio.timing.sourceText, blockText);
301
300
  }
302
301
 
303
302
  // Fallback: filename-based matching
304
303
  if (score < MIN_CONTENT_SIMILARITY && block.title) {
305
304
  const titleSlug = slugify(block.title);
306
- const fileSlug = slugify(stripAudioExtension(mp3.filename));
305
+ const fileSlug = slugify(stripAudioExtension(audio.filename));
307
306
  // Check if the title slug appears in the filename slug
308
307
  if (fileSlug.includes(titleSlug) && titleSlug.length >= 3) {
309
308
  score = Math.max(score, MIN_FILENAME_SIMILARITY + 0.1);
@@ -311,22 +310,22 @@ function autoMatchBlocks(
311
310
  }
312
311
 
313
312
  if (score >= MIN_CONTENT_SIMILARITY) {
314
- scoredPairs.push({ block, blockIndex: bi, mp3, mp3Index: mi, score });
313
+ scoredPairs.push({ block, blockIndex: bi, audio, audioIndex, score });
315
314
  }
316
315
  }
317
316
  }
318
317
 
319
- // Greedy assignment: highest score first, each mp3 and block used at most once
318
+ // Greedy assignment: highest score first, each audio file and block at most once.
320
319
  scoredPairs.sort((a, b) => b.score - a.score);
321
320
  const usedBlocks = new Set<number>();
322
- const usedMp3s = new Set<number>();
323
- const matches: Array<{ block: Block; mp3: Mp3Info; score: number }> = [];
321
+ const usedAudio = new Set<number>();
322
+ const matches: Array<{ block: Block; audio: NarrationAudioInfo; score: number }> = [];
324
323
 
325
324
  for (const pair of scoredPairs) {
326
- if (usedBlocks.has(pair.blockIndex) || usedMp3s.has(pair.mp3Index)) continue;
325
+ if (usedBlocks.has(pair.blockIndex) || usedAudio.has(pair.audioIndex)) continue;
327
326
  usedBlocks.add(pair.blockIndex);
328
- usedMp3s.add(pair.mp3Index);
329
- matches.push({ block: pair.block, mp3: pair.mp3, score: pair.score });
327
+ usedAudio.add(pair.audioIndex);
328
+ matches.push({ block: pair.block, audio: pair.audio, score: pair.score });
330
329
  }
331
330
 
332
331
  return matches;
@@ -344,22 +343,35 @@ function autoMatchBlocks(
344
343
  * assigned to the correct `audioSegment` indices.
345
344
  *
346
345
  * @param doc - The source Doc (not mutated).
347
- * @param container - ContentContainer with MP3 and timing files.
346
+ * @param container - ContentContainer with narration and timing files.
348
347
  * @returns New Doc with audio segments resolved, or original doc if no matches.
349
348
  */
350
349
  export async function resolveAudioMapping(doc: Doc, container: ContentContainer): Promise<Doc> {
351
- // Discover MP3 files and their timing data
352
- const mp3s = await discoverMp3s(container);
353
- if (mp3s.length === 0) return doc;
350
+ // Step 0: a document-anchored narration take (preamble `{[audio src=…
351
+ // anchor=document]}` + v3 timing sidecar) re-times the block timeline
352
+ // to the recorded voice. See applyNarrationTiming for precedence.
353
+ const narration = await applyNarrationTiming(doc, container);
354
+ doc = narration.doc;
355
+
356
+ const audioFiles = await discoverNarrationAudio(container);
357
+
358
+ // Files already playing through the media schedule (documentMedia /
359
+ // block clips) must not ALSO be auto-mapped into audio.segments —
360
+ // that would play the narration twice.
361
+ const scheduledSrcs = new Set(resolveMediaSchedule(doc).map((clip) => clip.src));
362
+ const available = audioFiles.filter((f) => !scheduledSrcs.has(f.path));
363
+ if (available.length === 0) return doc;
354
364
 
355
365
  // Try annotations first
356
- let matches = resolveFromAnnotations(doc.blocks, mp3s);
366
+ let matches = resolveFromAnnotations(doc.blocks, available);
357
367
 
358
- // Fall back to auto-matching if no annotations found
359
- if (matches.length === 0) {
360
- matches = autoMatchBlocks(doc.blocks, mp3s).map((m) => ({
368
+ // Fall back to auto-matching if no annotations found. When narration
369
+ // timing was applied, content auto-matching is skipped — the take owns
370
+ // the timeline, and fuzzy matches against other files would fight it.
371
+ if (matches.length === 0 && !narration.applied) {
372
+ matches = autoMatchBlocks(doc.blocks, available).map((m) => ({
361
373
  block: m.block,
362
- mp3: m.mp3,
374
+ audio: m.audio,
363
375
  }));
364
376
  }
365
377
 
@@ -377,11 +389,11 @@ export async function resolveAudioMapping(doc: Doc, container: ContentContainer)
377
389
  let startTime = 0;
378
390
 
379
391
  for (let i = 0; i < matches.length; i++) {
380
- const { block, mp3 } = matches[i];
381
- const duration = mp3.timing?.duration ?? 30; // Fallback duration if no timing
392
+ const { block, audio } = matches[i];
393
+ const duration = audio.timing?.duration ?? 30; // Fallback duration if no timing
382
394
 
383
395
  segments.push({
384
- src: mp3.path,
396
+ src: audio.path,
385
397
  name: block.title ?? block.id,
386
398
  duration,
387
399
  startTime,
@@ -397,7 +409,9 @@ export async function resolveAudioMapping(doc: Doc, container: ContentContainer)
397
409
  return {
398
410
  ...doc,
399
411
  blocks: newBlocks,
400
- duration: startTime,
412
+ // A narration take owns the overall duration; explicit per-block
413
+ // segments only ever extend it.
414
+ duration: narration.applied ? Math.max(doc.duration, startTime) : startTime,
401
415
  audio: { segments },
402
416
  };
403
417
  }