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