@quillmark/svelte 0.0.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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +58 -0
  3. package/README.md +241 -0
  4. package/THEMING.md +153 -0
  5. package/dist/core/address.d.ts +56 -0
  6. package/dist/core/address.js +95 -0
  7. package/dist/core/bloom.d.ts +37 -0
  8. package/dist/core/bloom.js +116 -0
  9. package/dist/core/codec/anchor.d.ts +19 -0
  10. package/dist/core/codec/anchor.js +36 -0
  11. package/dist/core/codec/code.d.ts +12 -0
  12. package/dist/core/codec/code.js +118 -0
  13. package/dist/core/codec/decode.d.ts +24 -0
  14. package/dist/core/codec/decode.js +270 -0
  15. package/dist/core/codec/encode.d.ts +71 -0
  16. package/dist/core/codec/encode.js +480 -0
  17. package/dist/core/codec/field.d.ts +171 -0
  18. package/dist/core/codec/field.js +493 -0
  19. package/dist/core/codec/index.d.ts +22 -0
  20. package/dist/core/codec/index.js +43 -0
  21. package/dist/core/codec/inputrules.d.ts +7 -0
  22. package/dist/core/codec/inputrules.js +168 -0
  23. package/dist/core/codec/islands.d.ts +36 -0
  24. package/dist/core/codec/islands.js +82 -0
  25. package/dist/core/codec/keymap.d.ts +13 -0
  26. package/dist/core/codec/keymap.js +29 -0
  27. package/dist/core/codec/lists.d.ts +22 -0
  28. package/dist/core/codec/lists.js +104 -0
  29. package/dist/core/codec/marks.d.ts +37 -0
  30. package/dist/core/codec/marks.js +74 -0
  31. package/dist/core/codec/positions.d.ts +17 -0
  32. package/dist/core/codec/positions.js +108 -0
  33. package/dist/core/codec/prose.css +458 -0
  34. package/dist/core/codec/reconcile.d.ts +26 -0
  35. package/dist/core/codec/reconcile.js +64 -0
  36. package/dist/core/codec/schema.d.ts +13 -0
  37. package/dist/core/codec/schema.js +155 -0
  38. package/dist/core/codec/slash.d.ts +79 -0
  39. package/dist/core/codec/slash.js +0 -0
  40. package/dist/core/codec/table-view.d.ts +50 -0
  41. package/dist/core/codec/table-view.js +928 -0
  42. package/dist/core/codec/table.d.ts +85 -0
  43. package/dist/core/codec/table.js +224 -0
  44. package/dist/core/errors.d.ts +73 -0
  45. package/dist/core/errors.js +24 -0
  46. package/dist/core/index.d.ts +5 -0
  47. package/dist/core/index.js +19 -0
  48. package/dist/core/lifecycle.d.ts +7 -0
  49. package/dist/core/lifecycle.js +21 -0
  50. package/dist/core/rebind.svelte.d.ts +18 -0
  51. package/dist/core/rebind.svelte.js +44 -0
  52. package/dist/core/teardown.d.ts +20 -0
  53. package/dist/core/teardown.js +37 -0
  54. package/dist/core/theme.css +276 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +9 -0
  57. package/dist/preset/index.css +10 -0
  58. package/dist/preset/recipes.css +188 -0
  59. package/dist/preset/scale.css +97 -0
  60. package/dist/preview/Preview.svelte +118 -0
  61. package/dist/preview/Preview.svelte.d.ts +39 -0
  62. package/dist/preview/bridge.d.ts +13 -0
  63. package/dist/preview/bridge.js +88 -0
  64. package/dist/preview/controller.d.ts +40 -0
  65. package/dist/preview/controller.js +141 -0
  66. package/dist/preview/geometry.d.ts +37 -0
  67. package/dist/preview/geometry.js +45 -0
  68. package/dist/preview/index.d.ts +5 -0
  69. package/dist/preview/index.js +14 -0
  70. package/dist/preview/overlay.d.ts +10 -0
  71. package/dist/preview/overlay.js +82 -0
  72. package/dist/preview/paint.d.ts +17 -0
  73. package/dist/preview/paint.js +262 -0
  74. package/dist/preview/strings.d.ts +14 -0
  75. package/dist/preview/strings.js +22 -0
  76. package/dist/visual/ArrayField.svelte +362 -0
  77. package/dist/visual/ArrayField.svelte.d.ts +24 -0
  78. package/dist/visual/BooleanField.svelte +88 -0
  79. package/dist/visual/BooleanField.svelte.d.ts +20 -0
  80. package/dist/visual/Card.svelte +849 -0
  81. package/dist/visual/Card.svelte.d.ts +43 -0
  82. package/dist/visual/CardControls.svelte +92 -0
  83. package/dist/visual/CardControls.svelte.d.ts +11 -0
  84. package/dist/visual/DateField.svelte +203 -0
  85. package/dist/visual/DateField.svelte.d.ts +28 -0
  86. package/dist/visual/DiagnosticList.svelte +50 -0
  87. package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
  88. package/dist/visual/EnumField.svelte +169 -0
  89. package/dist/visual/EnumField.svelte.d.ts +25 -0
  90. package/dist/visual/Field.svelte +270 -0
  91. package/dist/visual/Field.svelte.d.ts +37 -0
  92. package/dist/visual/FieldHint.svelte +185 -0
  93. package/dist/visual/FieldHint.svelte.d.ts +13 -0
  94. package/dist/visual/FieldLabel.svelte +141 -0
  95. package/dist/visual/FieldLabel.svelte.d.ts +19 -0
  96. package/dist/visual/FormatPopover.svelte +396 -0
  97. package/dist/visual/FormatPopover.svelte.d.ts +9 -0
  98. package/dist/visual/NumberField.svelte +72 -0
  99. package/dist/visual/NumberField.svelte.d.ts +19 -0
  100. package/dist/visual/ObjectField.svelte +131 -0
  101. package/dist/visual/ObjectField.svelte.d.ts +17 -0
  102. package/dist/visual/ProseArrayElement.svelte +98 -0
  103. package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
  104. package/dist/visual/ProseField.svelte +213 -0
  105. package/dist/visual/ProseField.svelte.d.ts +50 -0
  106. package/dist/visual/SlashMenu.svelte +126 -0
  107. package/dist/visual/SlashMenu.svelte.d.ts +14 -0
  108. package/dist/visual/TextField.svelte +63 -0
  109. package/dist/visual/TextField.svelte.d.ts +23 -0
  110. package/dist/visual/TipsCard.svelte +191 -0
  111. package/dist/visual/TipsCard.svelte.d.ts +13 -0
  112. package/dist/visual/VisualEditor.svelte +96 -0
  113. package/dist/visual/VisualEditor.svelte.d.ts +14 -0
  114. package/dist/visual/VisualEditorInner.svelte +1032 -0
  115. package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
  116. package/dist/visual/controls.css +272 -0
  117. package/dist/visual/diagnostics.d.ts +52 -0
  118. package/dist/visual/diagnostics.js +83 -0
  119. package/dist/visual/domid.d.ts +21 -0
  120. package/dist/visual/domid.js +36 -0
  121. package/dist/visual/ext.d.ts +19 -0
  122. package/dist/visual/ext.js +29 -0
  123. package/dist/visual/icons/Icon.svelte +39 -0
  124. package/dist/visual/icons/Icon.svelte.d.ts +12 -0
  125. package/dist/visual/icons/nodes.d.ts +103 -0
  126. package/dist/visual/icons/nodes.js +55 -0
  127. package/dist/visual/index.d.ts +9 -0
  128. package/dist/visual/index.js +24 -0
  129. package/dist/visual/motion.d.ts +18 -0
  130. package/dist/visual/motion.js +53 -0
  131. package/dist/visual/props.d.ts +77 -0
  132. package/dist/visual/props.js +1 -0
  133. package/dist/visual/signals.d.ts +59 -0
  134. package/dist/visual/signals.js +1 -0
  135. package/dist/visual/strings.d.ts +123 -0
  136. package/dist/visual/strings.js +80 -0
  137. package/dist/visual/structure.d.ts +224 -0
  138. package/dist/visual/structure.js +270 -0
  139. package/dist/visual/synced.svelte.d.ts +15 -0
  140. package/dist/visual/synced.svelte.js +34 -0
  141. package/dist/visual/tips.d.ts +24 -0
  142. package/dist/visual/tips.js +50 -0
  143. package/package.json +102 -0
@@ -0,0 +1,1032 @@
1
+ <!--
2
+ The federated WYSIWYG surface (VISUAL_EDITOR). A THIN composition over many
3
+ small editors; NOT one PM document spanning the page.
4
+
5
+ MOUNTED ONCE PER DOCUMENT. `VisualEditor` keys this component on `doc`, so
6
+ everything below seeds from a handle that cannot change under it: `cardIds`, the
7
+ id `seq`, the leaf registry, the commit-error map, the active address and the
8
+ card refs are all mount-scoped, and the prose leaves close over the `doc` they
9
+ mounted against. That is what makes them plain state rather than something
10
+ threaded through a generation token.
11
+
12
+ It owns:
13
+ • structure: the schema × payload join, re-derived from the live `Document`;
14
+ • stable card identity: a session-id array reordered in lockstep with the
15
+ content, resolved to an index ONLY at the mutation boundary;
16
+ • commit routing: prose leaves lower to `applyChange` (in the codec); scalars/
17
+ arrays/objects go through the typed `writer`; structure through the mutators;
18
+ • focus + the bridge outputs (`onActiveLeafChange`, `onCaretMove`) and the
19
+ `setCaret(hit)` entry wired to the preview;
20
+ • the ONE formatting popover (`FormatPopover`, mounted once, observing the
21
+ active leaf via `getActiveLeaf`) and diagnostics routing (`diagnostics.ts`:
22
+ quill.validate + local commit errors + the external `diagnostics` prop,
23
+ merged into `diagByKey` and threaded to each `<Field>`/card body).
24
+
25
+ REACTIVITY ACROSS THE WASM HANDLE. The `Document` is opaque to Svelte, so a
26
+ `revision` counter is bumped after every scalar/structure mutation and the card
27
+ tree is `$derived` by RE-READING `doc.main`/`doc.cards`/`quill.schema`. Prose
28
+ leaves are mounted ONCE per stable leaf key (keyed `<Card>`/`<ProseField>`);
29
+ they commit to the doc directly and do NOT bump `revision`, so a re-derive never
30
+ remounts them or drops a caret. `doc.main`/`doc.cards` allocate per read: read
31
+ once per derive.
32
+ -->
33
+ <script lang="ts">
34
+ import { onDestroy, tick } from 'svelte';
35
+ import { DropdownMenu } from 'bits-ui';
36
+ import { isQuillmarkError, MAIN_CARD_ADDR } from '@quillmark/wasm';
37
+ import { cardPath, fieldPathForAddr, type DocPath } from '../core/address.js';
38
+ import { errorMessage, reportError } from '../core/errors.js';
39
+ import { bloomInside } from '../core/bloom.js';
40
+ import { createLifespan } from '../core/teardown.js';
41
+ import type {
42
+ Addr,
43
+ CardAddr,
44
+ ContentHit,
45
+ Diagnostic,
46
+ Resolved,
47
+ ResolvedField
48
+ } from '@quillmark/wasm';
49
+ import type { VisualEditorProps } from './props.js';
50
+ import { mergeStrings, setWording } from './strings.js';
51
+ import type { CardId, ChangeSource } from './signals.js';
52
+ import type { FieldController } from '../core/codec/index.js';
53
+ import {
54
+ IdSeq,
55
+ fieldModels,
56
+ groupOrder,
57
+ groupSections,
58
+ groupLabel,
59
+ cardTitle,
60
+ bodyEnabled,
61
+ humanize,
62
+ provenanceMap,
63
+ resolvedByCardIndex,
64
+ ghostDefault,
65
+ stringifyGhost,
66
+ resolveBodyGhost,
67
+ NO_RESOLVED_ROWS,
68
+ type BodyPlaceholder,
69
+ type CardModel,
70
+ type ResolvedCardRows
71
+ } from './structure.js';
72
+ import {
73
+ fieldKeyToString,
74
+ parsePath,
75
+ resolveCardKey,
76
+ routeAndResolve,
77
+ mergeDiagnostics,
78
+ type FieldKey,
79
+ type RoutedDiagnostic
80
+ } from './diagnostics.js';
81
+ import { fieldDomIds, groupPanelId } from './domid.js';
82
+ import { reorder } from './motion.js';
83
+ import { tipsChannel } from './tips.js';
84
+ import { patchEditorExt } from './ext.js';
85
+ import Card from './Card.svelte';
86
+ import TipsCard from './TipsCard.svelte';
87
+ import FormatPopover from './FormatPopover.svelte';
88
+ // The add trigger and the kind menu draw shared recipes; a component carrying a
89
+ // shared class without also pulling its rule ships unstyled (controls.css).
90
+ import './controls.css';
91
+
92
+ let {
93
+ doc,
94
+ quill,
95
+ onActiveLeafChange,
96
+ onCaretMove,
97
+ onChange,
98
+ onError,
99
+ diagnostics,
100
+ enumOptionAllowed,
101
+ strings,
102
+ formatDiagnostic,
103
+ class: className,
104
+ style
105
+ }: VisualEditorProps = $props();
106
+
107
+ // The wording, merged once and published to the tree. A getter pair rather than a
108
+ // snapshot: a consumer swapping locale mid-session re-renders every label,
109
+ // including the ones eight components down that never see a prop.
110
+ const merged = $derived(mergeStrings(strings));
111
+ setWording({
112
+ get strings() {
113
+ return merged;
114
+ },
115
+ get formatDiagnostic() {
116
+ return formatDiagnostic;
117
+ }
118
+ });
119
+
120
+ // ── Reactivity + session identity ───────────────────────────────────────────
121
+ let revision = $state(0);
122
+ // This editor's own id, prefixed onto every field's DOM ids (`domid.ts`). The
123
+ // leaf-key space is unique per EDITOR, not per page: two editors mounted
124
+ // side-by-side both hold `main:subject`, and a duplicate `id` makes `for` resolve
125
+ // to whichever mounted first. `$props.id()` is stable across SSR and hydration,
126
+ // which a module counter is not.
127
+ const uid = $props.id();
128
+ const seq = new IdSeq();
129
+ // Session ids, one per composable card, reordered in lockstep with structure ops.
130
+ // svelte-ignore state_referenced_locally
131
+ let cardIds = $state<string[]>(seq.take(doc.cardCount));
132
+
133
+ // Local commit-error diagnostics (VISUAL_EDITOR §Diagnostics, producer #2):
134
+ // one slot per field key, replaced on each failed `commitScalar`, cleared on
135
+ // the next successful one for that field. Id-keyed (not positional) so an
136
+ // error stays pinned to its field across a card reorder.
137
+ let commitErrors = $state(new Map<string, RoutedDiagnostic>());
138
+ /** Edit the commit-error map copy-on-write: a `$state` Map is not deeply
139
+ * reactive, so a mutation in place re-derives nothing. */
140
+ function editCommitErrors(edit: (m: Map<string, RoutedDiagnostic>) => void): void {
141
+ const next = new Map(commitErrors);
142
+ edit(next);
143
+ commitErrors = next;
144
+ }
145
+
146
+ const kinds = $derived(Object.keys(quill.schema.card_kinds ?? {}));
147
+
148
+ /**
149
+ * Re-derive the card tree and report the edit. Called for the two lanes that go
150
+ * through this component; a prose leaf commits itself and reports through
151
+ * `proseChanged`, which does NOT bump the revision (bumping it would re-derive
152
+ * and remount every leaf, costing the caret on every keystroke).
153
+ *
154
+ * `cardId` is passed rather than derived from `at`: the sites below hold the id
155
+ * already, and the removal has no address to derive one from. `at` is an `Addr` for
156
+ * a leaf and a CARD INDEX for a structure op, whose subject is the card rather than
157
+ * anything inside it; either way the path is minted HERE, after the bump, because
158
+ * an op's own index is only addressable against the tree the bump re-derived (an
159
+ * insert's card does not exist in the previous one, and a retype's kind is the
160
+ * previous kind).
161
+ */
162
+ function bump(source: ChangeSource, cardId?: CardId, at?: Addr | number): void {
163
+ revision++;
164
+ const path =
165
+ at == null ? undefined : typeof at === 'number' ? cardPath(at, liveKinds()) : pathFor(at);
166
+ onChange?.({ source, cardId, path });
167
+ }
168
+ /** Resolve a stable card id to its current content index, or -1 if gone: read
169
+ * at the mutation boundary, never cached (VISUAL_EDITOR §"The address is the spine"). */
170
+ function cardIndexOf(id: string): number {
171
+ return cardIds.indexOf(id);
172
+ }
173
+ /** The inverse, for the two signals that arrive as an address and nothing else
174
+ * (a focus, a prose commit): `'main'` for a main address, else the live id at
175
+ * that index. Resolved at the emit site off the same array the mutation boundary
176
+ * reads, never cached. */
177
+ function cardIdOf(addr: Addr): CardId {
178
+ return addr.card == null ? 'main' : cardIds[addr.card];
179
+ }
180
+
181
+ // ── Teardown (core/teardown.ts: unregister, cancel, then free) ──────────────
182
+ // A document swap is a REMOUNT (see the remount contract above), so a destroy is
183
+ // the only way this surface ends and one span covers both. Cancellers register
184
+ // below in the order they run: the registry that resolves new work first, then
185
+ // the work already scheduled. The document handle is the consumer's to free, so
186
+ // the span holds nothing to release.
187
+ const span = createLifespan();
188
+ onDestroy(() => span.end());
189
+
190
+ // ── Leaf registry (setCaret target lookup + the active-leaf seam) ───────────
191
+ const leaves = new Map<string, FieldController>();
192
+ // Each leaf unregisters itself on its own teardown; this covers the surface
193
+ // going away as a whole, where a leaf's cleanup order relative to the parent's
194
+ // is Svelte's business and not something to depend on.
195
+ span.onEnd(() => leaves.clear());
196
+ // Card handles, for `setCaret`'s reveal hop: the one thing a leaf's own controller
197
+ // cannot do, since which group is open is the card's state (Card §revealLeaf).
198
+ type CardHandle = {
199
+ revealLeaf(key: string): void;
200
+ scrollIntoViewCard(block: ScrollLogicalPosition): void;
201
+ };
202
+ let mainCard = $state<CardHandle | undefined>(undefined);
203
+ let cardRefs = $state<(CardHandle | undefined)[]>([]);
204
+ /** The stack's own element: it carries `data-qm-root`, so it is what the kind
205
+ * menu portals INTO, the way each leaf's surface resolves its nearest root. */
206
+ let rootEl = $state<HTMLElement | undefined>(undefined);
207
+ function register(key: string, controller: FieldController): void {
208
+ leaves.set(key, controller);
209
+ }
210
+ function unregister(key: string): void {
211
+ leaves.delete(key);
212
+ }
213
+
214
+ // ── Focus + bridge outputs ──────────────────────────────────────────────────
215
+ // `activeCardId` is the id-keyed half of `activeAddr` (whose `card` is positional),
216
+ // and it feeds lookups only: the `activeController` seam below, and the clear on
217
+ // delete. Nothing draws it: a card's active treatment is its controls' reveal,
218
+ // which the card reads off `:focus-within`.
219
+ let activeAddr = $state<Addr | undefined>(undefined);
220
+ let activeCardId = $state<CardId | undefined>(undefined);
221
+
222
+ /** Snapshot a (possibly getter-backed) addr to a plain, index-resolved value. */
223
+ function normalize(addr: Addr): Addr {
224
+ const card = addr.card;
225
+ return card != null ? { card, field: addr.field } : { field: addr.field };
226
+ }
227
+ function handleFocus(addr: Addr): void {
228
+ const plain = normalize(addr);
229
+ activeAddr = plain;
230
+ activeCardId = cardIdOf(plain);
231
+ const field = pathFor(plain);
232
+ if (field != null) onActiveLeafChange?.({ field, cardId: activeCardId });
233
+ }
234
+ /** No card kinds are read for a main address; the shared empty stands in so the
235
+ * common case allocates nothing. */
236
+ const NO_KINDS: readonly string[] = [];
237
+ /**
238
+ * The live kinds by content index, off the DERIVED card tree rather than
239
+ * `doc.cards`: they are already in hand, and `doc.cards` serializes every card on
240
+ * each read, which is not a thing to do per keystroke.
241
+ */
242
+ function liveKinds(): string[] {
243
+ return model.cards.map((c) => c.kind);
244
+ }
245
+ /**
246
+ * A leaf's canonical `DocPath`. `undefined` for an address outside the live card
247
+ * array (a stale addr: drop rather than mis-target).
248
+ */
249
+ function pathFor(addr: Addr): DocPath | undefined {
250
+ return fieldPathForAddr(addr, addr.card == null ? NO_KINDS : liveKinds());
251
+ }
252
+ // The last PLACE reported, so a caret that did not move is not news. A leaf fires
253
+ // one caret signal per transaction, and a transaction need not have moved the
254
+ // caret to exist: a mark toggle over the same range, an anchor op, a plugin's own
255
+ // bookkeeping. The memo is the editor's rather than each leaf's, because a place
256
+ // spans leaves: a caret that leaves `main.body` at 5 for a card leaf and comes
257
+ // back to 5 made two moves, and a per-leaf memo would swallow the second. An edit
258
+ // that reflows the page UNDER a caret holding its offset is the case this drops,
259
+ // and a preview does not need the signal to answer it: the place is unchanged, so
260
+ // the one it already followed is the one its recompile re-locates.
261
+ let lastCaretField: DocPath | undefined;
262
+ let lastCaretPos: number | undefined;
263
+ function handleCaret(addr: Addr, pos: number): void {
264
+ const field = pathFor(normalize(addr));
265
+ if (field == null) return;
266
+ if (field === lastCaretField && pos === lastCaretPos) return;
267
+ lastCaretField = field;
268
+ lastCaretPos = pos;
269
+ onCaretMove?.({ field, pos });
270
+ }
271
+ /** A prose leaf's own commit: the third change lane, and the one that must not
272
+ * bump `revision` (see {@link bump}). */
273
+ function proseChanged(addr: Addr): void {
274
+ const plain = normalize(addr);
275
+ onChange?.({ source: 'prose', cardId: cardIdOf(plain), path: pathFor(plain) });
276
+ }
277
+
278
+ // ── Commit routing ──────────────────────────────────────────────────────────
279
+ // Scalars / arrays / objects → the typed writer (schema-checked). Prose leaves
280
+ // commit themselves via the codec (applyChange) and do NOT pass through here.
281
+ // A scalar control commits `undefined` for a cleared entry: the unset lane
282
+ // below (`doc.removeField`), not a write.
283
+ //
284
+ // A bad value makes `writer.set` THROW a `QuillmarkError`, whose
285
+ // `diagnostics[0]` carries a `code` and a canonical `path` (e.g.
286
+ // `edit::field_coercion_failed` at `main.font_size`, or `edit::unknown_field`). The
287
+ // editor already KNOWS the field/card being committed, so it KEYS the entry
288
+ // from THAT address: id-keyed so it survives a later card reorder, never
289
+ // parsed from the positional path; while surfacing the thrown diagnostic
290
+ // verbatim (its `code`/`message`) as the payload (VISUAL_EDITOR §Diagnostics,
291
+ // producer #2). It is stashed in `commitErrors`; a subsequent SUCCESSFUL commit
292
+ // for the same field clears it. Nothing here gates: the value is not written
293
+ // (the document is unchanged on throw, per the boundary's own transactional
294
+ // contract) and editing continues.
295
+ function commitScalar(id: string, isMain: boolean, name: string, value: unknown): void {
296
+ const key: FieldKey = { card: isMain ? undefined : id, field: name };
297
+ const keyStr = fieldKeyToString(key);
298
+ try {
299
+ if (value === undefined) {
300
+ // The UNSET rung of the commitment ladder (a cleared scalar control,
301
+ // VISUAL_EDITOR §"Structure mirrors the schema"): REMOVE the field so the
302
+ // engine's authored › `default:` › zero-fill resolve renders the
303
+ // default, rather than baking a snapshot the schema can't track
304
+ // (canon SCHEMAS.md: the engine never persists a default; nor do we).
305
+ // Removal writes no value, so there is nothing for a schema to conform:
306
+ // it goes through the quill-free `doc.removeField` (bare string = main
307
+ // `{ field }`; `{ card, field }` for a card), NOT the typed writer.
308
+ if (isMain) {
309
+ doc.removeField(name);
310
+ } else {
311
+ const i = cardIndexOf(id);
312
+ if (i < 0) return;
313
+ doc.removeField({ card: i, field: name });
314
+ }
315
+ } else {
316
+ const w = quill.writer(doc);
317
+ if (isMain) {
318
+ w.set(name, value);
319
+ } else {
320
+ const i = cardIndexOf(id);
321
+ if (i < 0) return;
322
+ w.card(i).set(name, value);
323
+ }
324
+ }
325
+ if (commitErrors.has(keyStr)) editCommitErrors((m) => m.delete(keyStr));
326
+ bump('field', id, makeAddr(id, isMain, name));
327
+ } catch (e) {
328
+ const diagnostic: Diagnostic = (isQuillmarkError(e) ? e.diagnostics[0] : undefined) ?? {
329
+ severity: 'error',
330
+ message: errorMessage(e)
331
+ };
332
+ editCommitErrors((m) => m.set(keyStr, { key, diagnostic }));
333
+ // Both channels, deliberately: the diagnostic pins to the field on
334
+ // screen, the error reaches the app's sink (core/errors.ts).
335
+ reportError(onError, {
336
+ code: 'commit-refused',
337
+ severity: 'error',
338
+ message: `write to ${name} refused: ${errorMessage(e)}`,
339
+ cause: e,
340
+ path: pathFor(makeAddr(id, isMain, name))
341
+ });
342
+ }
343
+ }
344
+
345
+ // ── Structure mutators (resolve id→index here, then reorder ids in lockstep) ──
346
+ /**
347
+ * Scroll the card `id` into view once the mutation that placed it has rendered:
348
+ * on a document of any length an insert lands off-screen, and a
349
+ * viewport that does not follow reads as nothing happening.
350
+ *
351
+ * Coalesced on a single pending id: two quick adds resolve their `tick()` in
352
+ * order, the earlier one sees a newer pending id and drops out, so one smooth
353
+ * scroll runs to the last card rather than two fighting over the same scroller.
354
+ * The two guards below answer different questions: the span, whether this surface
355
+ * is still there; the pending id, whether this continuation is still the current one.
356
+ */
357
+ let pendingScrollId: string | null = null;
358
+ async function scrollCardIntoView(id: string, block: ScrollLogicalPosition): Promise<void> {
359
+ pendingScrollId = id;
360
+ if (!(await span.resumes(tick()))) return;
361
+ if (pendingScrollId !== id) return;
362
+ pendingScrollId = null;
363
+ const i = cardIndexOf(id);
364
+ if (i >= 0) cardRefs[i]?.scrollIntoViewCard(block);
365
+ }
366
+ /** Returns the new card's session key, or `undefined` when the kind seeds nothing
367
+ * or the insert threw: what `insertCard` hands a host that wants to track it. */
368
+ function addCard(atIndex: number, kind: string): CardId | undefined {
369
+ try {
370
+ const overlay = doc.seedOverlay(kind);
371
+ const card = quill.seedCard(kind, overlay);
372
+ if (!card) return undefined;
373
+ doc.insertCard(card, atIndex);
374
+ const id = seq.next();
375
+ cardIds = [...cardIds.slice(0, atIndex), id, ...cardIds.slice(atIndex)];
376
+ bump('structure', id, atIndex);
377
+ // `center` for an insert: the new card is the subject, and centring it shows
378
+ // the neighbours it landed between.
379
+ void scrollCardIntoView(id, 'center');
380
+ return id;
381
+ } catch (e) {
382
+ reportError(onError, {
383
+ code: 'card-op-failed',
384
+ severity: 'error',
385
+ message: `adding a ${kind} card failed: ${errorMessage(e)}`,
386
+ cause: e
387
+ });
388
+ return undefined;
389
+ }
390
+ }
391
+ // The reorder gesture's arming window: the reconcile that moves a
392
+ // slot is the trip, and every other reconcile that happens to move one is not. Read
393
+ // through a getter rather than passed as a value, so the flag stays out of the
394
+ // template and needs no reactivity for it: `animate:` asks at apply time, which is a
395
+ // microtask after the mutation and well inside the frame that disarms it.
396
+ let reordering = false;
397
+ let reorderFrame = 0;
398
+ function armReorder(): void {
399
+ reordering = true;
400
+ reorderFrame = requestAnimationFrame(() => (reordering = false));
401
+ }
402
+ span.onEnd(() => cancelAnimationFrame(reorderFrame));
403
+ const isReordering = (): boolean => reordering;
404
+
405
+ function moveCardById(id: string, dir: -1 | 1): void {
406
+ const from = cardIndexOf(id);
407
+ if (from < 0) return;
408
+ const to = from + dir;
409
+ if (to < 0 || to >= doc.cardCount) return;
410
+ armReorder();
411
+ doc.moveCard(from, to);
412
+ const w = cardIds.slice();
413
+ const [x] = w.splice(from, 1);
414
+ w.splice(to, 0, x);
415
+ cardIds = w;
416
+ bump('structure', id, to);
417
+ // `nearest` for a reorder: the card was already in view and only needs to stay
418
+ // there, so a card that never left the viewport does not move it at all.
419
+ void scrollCardIntoView(id, 'nearest');
420
+ }
421
+ function removeCardById(id: string): void {
422
+ const i = cardIndexOf(id);
423
+ if (i < 0) return;
424
+ doc.removeCard(i);
425
+ cardIds = cardIds.filter((_, k) => k !== i);
426
+ if (activeCardId === id) {
427
+ activeCardId = undefined;
428
+ activeAddr = undefined;
429
+ }
430
+ // Drop any commit-error diagnostics id-keyed to the now-gone card: id-keying
431
+ // (VISUAL_EDITOR §"The address is the spine") avoids mis-attributing them to
432
+ // whichever card next takes this position, but an orphaned entry would
433
+ // otherwise sit in the map forever (ids are never reused).
434
+ if ([...commitErrors.keys()].some((k) => k.startsWith(`${id}:`)))
435
+ editCommitErrors((m) => {
436
+ for (const k of [...m.keys()]) if (k.startsWith(`${id}:`)) m.delete(k);
437
+ });
438
+ // No addr: the removed card has no address left, and the surviving cards'
439
+ // addresses all shifted. The stack changed, not a leaf. The id still names
440
+ // WHICH card went, which is the one thing a host keying on it needs, and the
441
+ // only handle the removal leaves it.
442
+ bump('structure', id);
443
+ }
444
+ function retypeCardById(id: string, kind: string): void {
445
+ const i = cardIndexOf(id);
446
+ if (i < 0) return;
447
+ try {
448
+ doc.setCardKind(i, kind);
449
+ bump('structure', id, i);
450
+ } catch (e) {
451
+ reportError(onError, {
452
+ code: 'card-op-failed',
453
+ severity: 'error',
454
+ message: `retyping the card to ${kind} failed: ${errorMessage(e)}`,
455
+ cause: e
456
+ });
457
+ }
458
+ }
459
+ function renameCardById(id: string, title: string): void {
460
+ const i = cardIndexOf(id);
461
+ if (i < 0) return;
462
+ patchEditorExt(doc, { card: i }, { title });
463
+ bump('structure', id, i);
464
+ }
465
+ /**
466
+ * Clear the tips channel: the dismissal write, and the ONLY write
467
+ * tips make. `undefined` drops the key while `title` and any later sibling ride
468
+ * through; `ext.ts` holds why that matters.
469
+ */
470
+ function dismissTips(): void {
471
+ patchEditorExt(doc, MAIN_CARD_ADDR, { tips: undefined });
472
+ // Document-level chrome, not a leaf's: no addr, as for a card removal, and no
473
+ // card either. Tips ride `main`'s `$ext` because that is where a document-scoped
474
+ // `$ext` lives, which is not a claim that the main card changed.
475
+ bump('structure');
476
+ }
477
+
478
+ // ── Addressing + per-card op bundle ─────────────────────────────────────────
479
+ /** A LIVE card address: `card` is a getter, so a reorder re-targets in place. */
480
+ function cardAddr(id: string, field?: string): Addr {
481
+ return field != null
482
+ ? ({
483
+ get card() {
484
+ return cardIndexOf(id);
485
+ },
486
+ field
487
+ } as Addr)
488
+ : ({
489
+ get card() {
490
+ return cardIndexOf(id);
491
+ }
492
+ } as Addr);
493
+ }
494
+ function makeAddr(id: string, isMain: boolean, field?: string): Addr {
495
+ if (isMain) return field != null ? { field } : {};
496
+ return cardAddr(id, field);
497
+ }
498
+
499
+ // ── Diagnostics routing (VISUAL_EDITOR §Diagnostics) ────────────────────────
500
+ // Producer #1: quill.validate(doc), re-run every revision (empirically always
501
+ // `[]` for usaf_memo (no field in the fixture carries a `!must_fill` marker)
502
+ // but the routing exists for the general contract, not just this fixture).
503
+ const validation = $derived.by(() => {
504
+ revision; // re-run on every mutation, per VISUAL_EDITOR §Diagnostics
505
+ try {
506
+ return quill.validate(doc);
507
+ } catch (e) {
508
+ reportError(onError, {
509
+ code: 'validate-failed',
510
+ severity: 'error',
511
+ message: `quill.validate threw; this derive contributes no validation diagnostics: ${errorMessage(e)}`,
512
+ cause: e
513
+ });
514
+ return [] as Diagnostic[];
515
+ }
516
+ });
517
+
518
+ // Merge all three producers: validate() + external (both positional `.path`,
519
+ // resolved to the live stable-id keying) + local commit errors (already
520
+ // id-keyed). Precedence is errors-before-warnings within a field's list
521
+ // (mergeDiagnostics sorts; nothing is dropped: diagnostics never gate, so
522
+ // nothing here hides one either).
523
+ const diagByKey = $derived.by(() => {
524
+ const fromValidate = routeAndResolve(validation, cardIds);
525
+ const fromExternal = routeAndResolve(diagnostics, cardIds);
526
+ return mergeDiagnostics(fromValidate, fromExternal, [...commitErrors.values()]);
527
+ });
528
+ function opsFor(id: string, isMain: boolean) {
529
+ // ONE identity per field: the leaf registry, the DOM's three names, and the
530
+ // diagnostics map all key off this string, so a leaf, its label and its
531
+ // diagnostics can only ever resolve together.
532
+ const leafKey = (field?: string) => fieldKeyToString({ card: isMain ? undefined : id, field });
533
+ return {
534
+ makeAddr: (field?: string) => makeAddr(id, isMain, field),
535
+ leafKey,
536
+ domIds: (field?: string) => fieldDomIds(uid, leafKey(field)),
537
+ panelId: (group: string) => groupPanelId(uid, isMain ? undefined : id, group),
538
+ commit: (name: string, value: unknown) => commitScalar(id, isMain, name, value),
539
+ move: (dir: -1 | 1) => moveCardById(id, dir),
540
+ remove: () => removeCardById(id),
541
+ retype: (kind: string) => retypeCardById(id, kind),
542
+ rename: (title: string) => renameCardById(id, title),
543
+ diagFor: (field?: string) => diagByKey.get(leafKey(field)),
544
+ // Bind the consumer policy hook to this field's resolved addr;
545
+ // no hook → every option allowed.
546
+ enumAllowed: (field: string, value: string) =>
547
+ enumOptionAllowed?.(makeAddr(id, isMain, field), value) ?? true
548
+ };
549
+ }
550
+
551
+ // ── The empty-body ghost's consumer wording ─────────────────────────────────
552
+ // The hook is consulted once per KIND and its answer kept for the session, which
553
+ // is the whole determinism guarantee: a hook that samples a witty set at random
554
+ // is impure by design, and this cache is what makes its answer look chosen:
555
+ // same string for every card of a kind, and the same one after a remount or any
556
+ // re-derive. Keyed by kind rather than by card id deliberately: two empty cards
557
+ // that are the same kind ARE the same invitation, and disagreeing ghosts read as
558
+ // a glitch. Retyping a card crosses to another key and re-asks, which is right:
559
+ // it is a different card now. Plain (non-`$state`) on purpose: memoization, so
560
+ // filling it during a derive must not feed back into one.
561
+ let ghostHook: BodyPlaceholder | undefined;
562
+ const ghostByKind = new Map<string, string | undefined>();
563
+ function customBodyGhost(kind: string, isMain: boolean): string | undefined {
564
+ const bodyPlaceholder = merged.bodyPlaceholder;
565
+ // A swapped hook invalidates every answer the old one gave.
566
+ if (bodyPlaceholder !== ghostHook) {
567
+ ghostByKind.clear();
568
+ ghostHook = bodyPlaceholder;
569
+ }
570
+ if (!bodyPlaceholder) return undefined;
571
+ // `main` is not a `card_kinds` key, so a kind that spells it collides without
572
+ // the flag in the key.
573
+ const key = `${isMain ? '1' : '0'}\0${kind}`;
574
+ if (!ghostByKind.has(key)) ghostByKind.set(key, bodyPlaceholder({ kind, isMain }));
575
+ return ghostByKind.get(key);
576
+ }
577
+
578
+ // ── The derived card tree (schema × payload join) ───────────────────────────
579
+ function buildCard(
580
+ id: string,
581
+ isMain: boolean,
582
+ kind: string,
583
+ card: {
584
+ payloadItems: { type: string; key?: string; value?: unknown }[];
585
+ ext?: Record<string, unknown>;
586
+ },
587
+ cardSchema: Parameters<typeof fieldModels>[0] | undefined,
588
+ rows: ResolvedCardRows
589
+ ): CardModel {
590
+ const values: Record<string, unknown> = {};
591
+ for (const p of card.payloadItems)
592
+ if (p.type === 'field' && p.key != null) values[p.key] = p.value;
593
+ const fields = cardSchema ? fieldModels(cardSchema) : [];
594
+ const sections = cardSchema
595
+ ? groupSections(fields, groupOrder(cardSchema), (g) => groupLabel(cardSchema, g))
596
+ : [];
597
+ const extEditor = card.ext?.editor as { title?: string } | undefined;
598
+ const hasBody = bodyEnabled(cardSchema);
599
+ return {
600
+ id,
601
+ isMain,
602
+ kind,
603
+ // No schema for this kind → a recovery shell, not a field list.
604
+ // `main` always resolves `schema.main`, so it is never unschemable.
605
+ unschemable: !isMain && !cardSchema,
606
+ titleOverride: extEditor?.title ?? '',
607
+ titlePlaceholder: cardTitle(cardSchema, kind, values, undefined),
608
+ values,
609
+ provenance: provenanceMap(rows.fields),
610
+ sections,
611
+ hasBody,
612
+ // The body ghosts its resolved `default:` exactly as a scalar does: the
613
+ // same text-ghost projection `<Field>` applies to a field's row; and falls
614
+ // back to an invitation where a scalar shows nothing, because an empty body
615
+ // is a surface to write on and an empty control is a value not yet given.
616
+ // Asked only for a card that HAS a body, so the hook is never consulted
617
+ // about one that renders none.
618
+ bodyGhost: hasBody
619
+ ? resolveBodyGhost(
620
+ stringifyGhost(ghostDefault(rows.body ?? undefined)),
621
+ customBodyGhost(kind, isMain),
622
+ merged.bodyGhost
623
+ )
624
+ : undefined
625
+ };
626
+ }
627
+
628
+ const model = $derived.by(() => {
629
+ revision; // re-derive on every mutation
630
+ const schema = quill.schema;
631
+ const main = doc.main; // allocate once
632
+ const cards = doc.cards; // allocate once
633
+ // The provenance channel (FIELD_PROVENANCE): one whole-doc resolve per
634
+ // derive, feeding the ghosted `default:` only. Guarded: provenance is
635
+ // chrome, so a resolve failure degrades to no ghosts, never a blank form.
636
+ let resolved: Resolved | undefined;
637
+ try {
638
+ resolved = quill.resolve(doc);
639
+ } catch (e) {
640
+ reportError(onError, {
641
+ code: 'resolve-failed',
642
+ severity: 'error',
643
+ message: `quill.resolve threw; ghosted defaults fall back to none: ${errorMessage(e)}`,
644
+ cause: e
645
+ });
646
+ }
647
+ const byCard = resolvedByCardIndex(resolved);
648
+ return {
649
+ // Tips are DOCUMENT-level, not a property of the main card: they
650
+ // hang off `main`'s `$ext` because that is where a document-scoped `$ext`
651
+ // lives, and the model says so at the root rather than making every card
652
+ // carry a field one card renders.
653
+ tips: tipsChannel((main.ext?.editor as { tips?: unknown } | undefined)?.tips),
654
+ main: buildCard('main', true, 'main', main, schema.main, {
655
+ fields: resolved?.main.fields ?? [],
656
+ body: resolved?.main.body ?? null
657
+ }),
658
+ cards: cards.map((c, i) =>
659
+ buildCard(
660
+ cardIds[i] ?? `orphan${i}`,
661
+ false,
662
+ c.kind,
663
+ c,
664
+ schema.card_kinds?.[c.kind],
665
+ byCard.get(i) ?? NO_RESOLVED_ROWS
666
+ )
667
+ )
668
+ };
669
+ });
670
+
671
+ // ── Public entry points ─────────────────────────────────────────────────────
672
+ /**
673
+ * Resolve a preview `ContentHit` to a mounted leaf and place its caret.
674
+ *
675
+ * Async because the reveal has to RENDER before the landing: a collapsed group
676
+ * is `inert`, which swallows a focus silently, so a caret placed in the same
677
+ * tick as the reveal would go nowhere and report nothing. The consumer's
678
+ * `onCaretPick` ignores the promise: awaiting it is for a caller that wants to
679
+ * observe where the caret went.
680
+ *
681
+ * A destroy lands INSIDE this one: `leaf` is looked up before the flush and
682
+ * dispatched into after it, and a PM view destroyed in that window throws on the
683
+ * dispatch. A consumer's `onCaretPick` outlives the surface it points at too, so
684
+ * the span is asked on the way in as well as after the await.
685
+ */
686
+ export async function setCaret(hit: ContentHit): Promise<void> {
687
+ const leaf = await revealLeaf(hit.field);
688
+ if (!leaf) return;
689
+ // A `'segment'` hit landed on origin-less ink (list markers, a code fence's
690
+ // interior): `pos` is the segment START, not a cluster-exact caret
691
+ // (HitGranularity), so just focus the leaf rather than snap the caret to a
692
+ // spot the click did not resolve. `'cluster'` (and an absent granularity:
693
+ // the backend did not report it, treat as exact) places the caret.
694
+ if (hit.granularity === 'segment') leaf.focus();
695
+ else leaf.setCaret(hit.pos);
696
+ // The arrival cue. Unconditional, unlike the preview side's change-guarded
697
+ // bloom: a preview click is one discrete act, and its commonest target is the
698
+ // leaf ALREADY focused (where landing a caret changes nothing on screen) or
699
+ // one off-screen, where the browser's focus-scroll moves the page and leaves
700
+ // the caret to be hunted for in a long form.
701
+ bloomInside(leaf.el);
702
+ }
703
+ /** The active leaf's controller: the formatting popover's observation seam. */
704
+ export function getActiveLeaf(): FieldController | undefined {
705
+ if (!activeAddr) return undefined;
706
+ const card = activeAddr.card != null ? activeCardId : undefined;
707
+ return leaves.get(fieldKeyToString({ card, field: activeAddr.field }));
708
+ }
709
+
710
+ /**
711
+ * Reveal the leaf at `field` and hand back its controller, once the reveal has
712
+ * RENDERED. The shared half of the two landing verbs: a collapsed group is clipped
713
+ * to zero height and sits inside an `inert` panel, which swallows a focus silently,
714
+ * so a caret placed in the same tick as the reveal goes nowhere and reports
715
+ * nothing. Exactly one card holds the key; the rest do nothing.
716
+ *
717
+ * A destroy lands INSIDE this: the leaf is looked up before the flush and
718
+ * dispatched into after it, and a PM view destroyed in that window throws on the
719
+ * dispatch. A consumer's call outlives the surface it points at too, so the span is
720
+ * asked on the way in as well as after the await.
721
+ */
722
+ async function revealLeaf(field: DocPath): Promise<FieldController | undefined> {
723
+ if (!span.alive) return undefined;
724
+ const key = leafKeyForHit(field);
725
+ const leaf = key == null ? undefined : leaves.get(key);
726
+ if (!leaf) return undefined;
727
+ mainCard?.revealLeaf(key!);
728
+ for (const card of cardRefs) card?.revealLeaf(key!);
729
+ if (!(await span.resumes(tick()))) return undefined;
730
+ return leaf;
731
+ }
732
+
733
+ // ── The verbs, as instance exports ──────────────────────────────────────────
734
+ // The same functions the card's own chrome calls, reached through `bind:this`: a
735
+ // host toolbar, command palette or shortcut wants the door the card header gets,
736
+ // and every one of them reports through `onChange` exactly as the click does.
737
+ // They speak the public vocabulary — a `DocPath` for a place, a `CardId` for a
738
+ // card — so a host drives them with what the hooks handed it.
739
+ //
740
+ // A target the surface does not hold is a NO-OP that reports `target-unknown` at
741
+ // `dev`: the chrome cannot mint a bad one, so it only ever fires on a host holding
742
+ // a key from a previous session or a card already removed.
743
+
744
+ /** Reveal and focus the leaf at `field`, without placing a caret inside it. */
745
+ export async function focusField(field: DocPath): Promise<void> {
746
+ const leaf = await revealLeaf(field);
747
+ if (!leaf) return missed(`no mounted leaf at ${field}`, field);
748
+ leaf.focus();
749
+ bloomInside(leaf.el);
750
+ }
751
+ /** Seed a card of `kind` and insert it at `at` (default: the end). Returns the new
752
+ * card's session key, or `undefined` when the quill seeds no card of that kind. */
753
+ export function insertCard(kind: string, at: number = cardIds.length): CardId | undefined {
754
+ return addCard(Math.min(Math.max(at, 0), cardIds.length), kind);
755
+ }
756
+ export function removeCard(cardId: CardId): void {
757
+ if (!holds(cardId)) return;
758
+ removeCardById(cardId);
759
+ }
760
+ /** Move a card one slot. The step the reorder control takes, and the one the
761
+ * surface animates; at either edge it is a no-op. */
762
+ export function moveCard(cardId: CardId, dir: -1 | 1): void {
763
+ if (!holds(cardId)) return;
764
+ moveCardById(cardId, dir);
765
+ }
766
+ export function setKind(cardId: CardId, kind: string): void {
767
+ if (!holds(cardId)) return;
768
+ retypeCardById(cardId, kind);
769
+ }
770
+
771
+ function holds(cardId: CardId): boolean {
772
+ if (cardIndexOf(cardId) >= 0) return true;
773
+ missed(`no card ${cardId} in this session`);
774
+ return false;
775
+ }
776
+ function missed(message: string, path?: DocPath): void {
777
+ reportError(onError, { code: 'target-unknown', severity: 'dev', message, path });
778
+ }
779
+
780
+ /** Map a `ContentHit.field` (a canonical `DocPath`) to a mounted leaf key: the
781
+ * same `addrForFieldPath` route the diagnostics take, the absolute card index
782
+ * resolved to its live stable id, then the shared `fieldKeyToString` form. */
783
+ function leafKeyForHit(field: string): string | undefined {
784
+ const key = parsePath(field);
785
+ if (!key) return undefined;
786
+ const resolved = resolveCardKey(key, cardIds);
787
+ return resolved ? fieldKeyToString(resolved) : undefined;
788
+ }
789
+ </script>
790
+
791
+ <div class="qm-editor {className ?? ''}" {style} data-qm-root bind:this={rootEl}>
792
+ <!-- `main` and the tips card are ONE block in the stack: the tips card tucks under
793
+ `main`'s bottom corners, so the two share a seam rather than a gutter and the
794
+ wrapper is what holds them to it. -->
795
+ <div class="qm-primary">
796
+ <Card
797
+ bind:this={mainCard}
798
+ card={model.main}
799
+ {doc}
800
+ {quill}
801
+ index={-1}
802
+ isFirst={true}
803
+ isLast={true}
804
+ {kinds}
805
+ ops={opsFor('main', true)}
806
+ onFocus={handleFocus}
807
+ onCaretMove={handleCaret}
808
+ onChange={proseChanged}
809
+ {onError}
810
+ {register}
811
+ {unregister}
812
+ />
813
+
814
+ <!-- The tips card: a fixed slot after `main`, ahead of the cards, so
815
+ document-level guidance reads as document-level and never displaces a field.
816
+ Absent when the channel is empty; which is what dismissal makes it, so the
817
+ card leaves for good (VISUAL_EDITOR §"Card operations"). -->
818
+ {#if model.tips.length}
819
+ <TipsCard tips={model.tips} onDismiss={dismissTips} {onError} />
820
+ {/if}
821
+ </div>
822
+
823
+ {@render addAffordance(0)}
824
+ <!-- A card and the gap under it are one SLOT, which is what a reorder moves: the
825
+ strip below a card is the same strip wherever the card lands, so it rides along
826
+ rather than being slid across. It is also the shape `animate:` asks for, being the
827
+ keyed block's only child. -->
828
+ {#each model.cards as c, i (c.id)}
829
+ <div class="qm-card-slot" animate:reorder={isReordering}>
830
+ <Card
831
+ bind:this={cardRefs[i]}
832
+ card={c}
833
+ {doc}
834
+ {quill}
835
+ index={i}
836
+ isFirst={i === 0}
837
+ isLast={i === model.cards.length - 1}
838
+ {kinds}
839
+ ops={opsFor(c.id, false)}
840
+ onFocus={handleFocus}
841
+ onCaretMove={handleCaret}
842
+ onChange={proseChanged}
843
+ {onError}
844
+ {register}
845
+ {unregister}
846
+ />
847
+ {@render addAffordance(i + 1)}
848
+ </div>
849
+ {/each}
850
+ </div>
851
+
852
+ <FormatPopover {getActiveLeaf} />
853
+
854
+ <!-- Cards always render; the ADD affordance is gated on the schema declaring
855
+ `card_kinds`, since there is nothing to seed otherwise. A card already in the
856
+ document shows regardless of its kind: a kind with no schema (foreign, or a
857
+ schema with no `card_kinds` at all) degrades to a recovery shell inside <Card>
858
+ (retype + delete), never gated away, so its content is neither dropped nor
859
+ trapped. The gate lives HERE rather than at each call site: the strip is one
860
+ decision, and two copies of it drift into a stack with a gap at one end. -->
861
+ {#snippet addAffordance(atIndex: number)}
862
+ {#if kinds.length}
863
+ <!-- The strip past the last card is the APPEND point, the one a reader looks for
864
+ rather than finds by position, so it alone states itself in words at rest. The
865
+ gaps between cards stay bare: the strip IS the gap, and the pill each fills on
866
+ hover draws what a label would state, the space the new card takes. -->
867
+ {@const marked = atIndex === model.cards.length}
868
+ <div class="qm-add-card" class:qm-add-card-marked={marked}>
869
+ <!-- Marked, the words ARE the accessible name and no `aria-label` doubles
870
+ them; bare, the kind is the whole reading, the one with no geometry to
871
+ carry it. -->
872
+ {#if kinds.length === 1}
873
+ <button
874
+ type="button"
875
+ class="qm-add-btn qm-add-affordance"
876
+ aria-label={marked ? undefined : merged.addCardOfKind(humanize(kinds[0]))}
877
+ onclick={() => addCard(atIndex, kinds[0])}
878
+ >{marked ? merged.addCardMark(humanize(kinds[0])) : ''}</button
879
+ >
880
+ {:else}
881
+ <!-- Multi-kind add: pick the kind, then seed + insert. A MENU rather than a
882
+ disclosure: it floats out of the stack, so raising it moves no card, and
883
+ it dismisses on pick, on Escape and on a click outside, none of which a
884
+ `<details>` does. The trigger is bits-ui's `<button>`, which is why the
885
+ rules below reach it through `:global`. -->
886
+ <DropdownMenu.Root>
887
+ <DropdownMenu.Trigger
888
+ class="qm-add-btn qm-add-affordance"
889
+ aria-label={marked ? undefined : merged.addCard}
890
+ >{marked ? merged.addCardMarkAny : ''}</DropdownMenu.Trigger
891
+ >
892
+ <DropdownMenu.Portal to={rootEl}>
893
+ <DropdownMenu.Content sideOffset={4}>
894
+ <!-- Portalled out of the row but INTO the stack's root, and carrying the
895
+ marker itself: floating is still a detached subtree to the
896
+ derivation, like FormatPopover and the enum listbox. -->
897
+ <div class="qm-menu-surface" data-qm-root>
898
+ {#each kinds as k (k)}
899
+ <DropdownMenu.Item class="qm-menu-item" onSelect={() => addCard(atIndex, k)}
900
+ >{humanize(k)}</DropdownMenu.Item
901
+ >
902
+ {/each}
903
+ </div>
904
+ </DropdownMenu.Content>
905
+ </DropdownMenu.Portal>
906
+ </DropdownMenu.Root>
907
+ {/if}
908
+ </div>
909
+ {/if}
910
+ {/snippet}
911
+
912
+ <style>
913
+ /* The private scale lands via `data-qm-root` on the root element above: this is
914
+ a DETACHED root, one of those core/theme.css applies the derivation to. The
915
+ root rule also carries the baseline font and colour, so nothing here restates
916
+ them. Nothing here mints; `check:style` enforces that. */
917
+ /* The COLUMN, not only the cards in it: the gutter the stack sits in and the tone
918
+ behind it are the surface's own, so a bare `<div>` is a mounting site and
919
+ nothing is owed before the editor looks right. Consumer CSS is unlayered and
920
+ beats all of it, which is how a host that wants the column back takes it.
921
+ `border-box`, so a height from the caller is the height this draws. */
922
+ .qm-editor {
923
+ box-sizing: border-box;
924
+ display: flex;
925
+ flex-direction: column;
926
+ gap: var(--_qm-space-2);
927
+ padding: var(--_qm-space-2);
928
+ background: var(--_qm-surface);
929
+ color: var(--_qm-ink);
930
+ }
931
+ /* A fixed-height pane, which the stack is NOT by default, and the four popovers
932
+ are the reason: each resolves its portal target with
933
+ `closest('[data-qm-root]')`, which is this element, so an `overflow` here
934
+ clips the menu a leaf raised. Mounted in a page that scrolls, nothing clips;
935
+ mounted in a pane that does, the host says so and takes the clipping it would
936
+ have had from its own scrolling frame either way. The TAIL rides with the
937
+ scroller because it is only meaningful under one: dead space below the last
938
+ card, so it can be read mid-pane rather than against the bottom edge.
939
+
940
+ The HEIGHT is what makes the rest of the rule mean anything, and it is the
941
+ pane's rather than a number of ours: `overflow` on a box that grows to its
942
+ content never has anything to scroll, so the stack would run past the pane
943
+ and be clipped by whatever the host frames it with. Taking the pane's height
944
+ is the whole of what `.qm-pane` claims (THEMING §"Drop it in": the surface
945
+ becomes its own scroll container), and it resolves to `auto` — today's page
946
+ behaviour, so a class on a mounting site with no height of its own changes
947
+ nothing. The cards below do not shrink to fit it: a flex item's automatic
948
+ minimum floors each at its own height, so the stack overflows and scrolls
949
+ rather than squashing. `.qm-preview` states the same thing for the same
950
+ reason, unconditionally, having no page form to keep. */
951
+ .qm-editor.qm-pane {
952
+ height: 100%;
953
+ overflow: auto;
954
+ overscroll-behavior: contain;
955
+ padding-block-end: var(--_qm-tail);
956
+ }
957
+ /* The stack's one gapless seam, which `TipsCard` draws and this holds the two
958
+ blocks to. `main` has to paint OVER the tip: a later sibling paints over an earlier
959
+ one otherwise, and the tip's background would cover the corners it is there to
960
+ fill. `isolation` keeps that z-index local, so one wrapper owns the ordering and no
961
+ card below inherits an argument about it. The wrapper is a single flex item, so the
962
+ pair takes the editor's gap once, together, exactly as one card does. */
963
+ .qm-primary {
964
+ isolation: isolate;
965
+ }
966
+ .qm-primary > :global(.qm-card) {
967
+ position: relative;
968
+ z-index: 1;
969
+ }
970
+ /* A slot restates the stack's own column and rhythm, so grouping a card with the
971
+ strip under it changes no geometry: the strip takes the same gap back on both
972
+ sides whether the gap above it is the slot's or the stack's, and a slot's own
973
+ height is short by exactly the gap the strip absorbed at its foot. */
974
+ .qm-card-slot {
975
+ display: flex;
976
+ flex-direction: column;
977
+ gap: var(--_qm-space-2);
978
+ }
979
+ /* The strip between two blocks, and the whole of it is the target: a gap is found
980
+ by POSITION, so reveal and hit region are the full-bleed row rather than a word
981
+ to aim at in the middle of it. It takes the editor's gap back on BOTH sides, so
982
+ it is not a control sitting in the gutter; it IS the gutter: what separates two
983
+ cards is the trigger's own height at the tap floor and nothing else, and the
984
+ pill it fills on hover is edge to edge the space the new card opens into. No
985
+ band is held back as miss-tolerance: gutter that reads as the trigger and
986
+ inserts nothing unsays what the fill claims, so a press anywhere between two
987
+ cards inserts. Absorbed rather than removed, because `gap` is also what separates
988
+ the one seam no strip sits in: every card from the next under a quill declaring no
989
+ kinds, where the affordance does not render at all. */
990
+ .qm-add-card {
991
+ display: flex;
992
+ margin-top: calc(var(--_qm-space-2) * -1);
993
+ margin-bottom: calc(var(--_qm-space-2) * -1);
994
+ }
995
+ /* Unboxed, like every button, and not dashed: a
996
+ dashed edge is the PLACEHOLDER idiom ("nothing is here yet") which on a button
997
+ reads as disabled or as a drop target. It stays honest in one place, the
998
+ un-schemable card (`Card.svelte`), which is a state rather than a control. A bare
999
+ trigger rests invisible, so the FILL is the whole of what it shows: the pill fills
1000
+ the strip because the strip is what was pressed, and the strip is the gap, so the
1001
+ fill is the card-to-be drawn where it will land. Every gap is an equal entry point,
1002
+ reached by the name rather than by a mark: a card goes anywhere in the stack, not
1003
+ just after the last one.
1004
+
1005
+ `:global`, because the multi-kind trigger is bits-ui's own element and a `class`
1006
+ passed to a primitive is a plain string that never picks up the scoping hash:
1007
+ the same seam the enum trigger is styled through. */
1008
+ .qm-add-card :global(.qm-add-btn) {
1009
+ /* No inset of its own: the pill and the gap are the same rectangle, and the tap
1010
+ floor is the whole of the height, which is what the marked strip's words
1011
+ centre in. */
1012
+ display: flex;
1013
+ align-items: center;
1014
+ justify-content: center;
1015
+ width: 100%;
1016
+ }
1017
+ /* Words at rest, so the marked strip restates the rung they take: the LABEL rung,
1018
+ chrome's size, off the body size the family's type rule hands every button
1019
+ (`controls.css`), which is the size the fields above are READ at. */
1020
+ .qm-add-card-marked :global(.qm-add-btn) {
1021
+ font-size: var(--_qm-text-label);
1022
+ }
1023
+ /* The two engaged states the shared `:hover` rung does not cover, both filling the
1024
+ same pill it does. Keyboard focus, because the fill is the only thing that reads
1025
+ as the trigger on a bare strip; and an open menu, because it portals out of the
1026
+ strip, so a pointer moving onto an item has left the row whose hover was drawing
1027
+ the pill the menu hangs from. */
1028
+ .qm-add-card :global(.qm-add-btn:focus-visible),
1029
+ .qm-add-card :global(.qm-add-btn[data-state='open']) {
1030
+ background: var(--_qm-surface-hover);
1031
+ }
1032
+ </style>