@quillmark/svelte 0.1.1 → 0.3.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 (124) hide show
  1. package/README.md +16 -12
  2. package/THEMING.md +48 -25
  3. package/dist/core/address.d.ts +42 -7
  4. package/dist/core/address.js +49 -21
  5. package/dist/core/bloom.d.ts +5 -5
  6. package/dist/core/bloom.js +8 -8
  7. package/dist/core/codec/anchor.d.ts +1 -1
  8. package/dist/core/codec/anchor.js +1 -1
  9. package/dist/core/codec/atoms.d.ts +10 -0
  10. package/dist/core/codec/atoms.js +78 -0
  11. package/dist/core/codec/clipboard.d.ts +59 -0
  12. package/dist/core/codec/clipboard.js +202 -0
  13. package/dist/core/codec/code.d.ts +1 -1
  14. package/dist/core/codec/code.js +4 -4
  15. package/dist/core/codec/decode.d.ts +1 -1
  16. package/dist/core/codec/decode.js +8 -8
  17. package/dist/core/codec/encode.d.ts +4 -4
  18. package/dist/core/codec/encode.js +14 -11
  19. package/dist/core/codec/field.d.ts +22 -27
  20. package/dist/core/codec/field.js +33 -32
  21. package/dist/core/codec/index.js +5 -8
  22. package/dist/core/codec/inputrules.js +56 -18
  23. package/dist/core/codec/islands.d.ts +11 -7
  24. package/dist/core/codec/islands.js +16 -10
  25. package/dist/core/codec/keymap.d.ts +5 -6
  26. package/dist/core/codec/keymap.js +8 -7
  27. package/dist/core/codec/lists.d.ts +3 -3
  28. package/dist/core/codec/lists.js +32 -13
  29. package/dist/core/codec/marks.d.ts +1 -1
  30. package/dist/core/codec/marks.js +1 -1
  31. package/dist/core/codec/positions.js +1 -1
  32. package/dist/core/codec/prose.css +266 -228
  33. package/dist/core/codec/schema.js +4 -4
  34. package/dist/core/codec/slash.d.ts +39 -41
  35. package/dist/core/codec/slash.js +0 -0
  36. package/dist/core/codec/table-view.d.ts +12 -16
  37. package/dist/core/codec/table-view.js +488 -292
  38. package/dist/core/codec/table.d.ts +42 -15
  39. package/dist/core/codec/table.js +73 -27
  40. package/dist/core/errors.d.ts +17 -7
  41. package/dist/core/index.d.ts +1 -1
  42. package/dist/core/index.js +6 -5
  43. package/dist/core/lifecycle.d.ts +21 -5
  44. package/dist/core/lifecycle.js +34 -8
  45. package/dist/core/rebind.svelte.d.ts +18 -4
  46. package/dist/core/rebind.svelte.js +30 -12
  47. package/dist/core/teardown.d.ts +1 -1
  48. package/dist/core/teardown.js +1 -1
  49. package/dist/core/theme.css +173 -58
  50. package/dist/preset/index.css +1 -1
  51. package/dist/preset/recipes.css +79 -30
  52. package/dist/preset/scale.css +40 -28
  53. package/dist/preview/Preview.svelte +22 -19
  54. package/dist/preview/Preview.svelte.d.ts +8 -8
  55. package/dist/preview/bridge.d.ts +6 -4
  56. package/dist/preview/bridge.js +19 -9
  57. package/dist/preview/controller.d.ts +13 -6
  58. package/dist/preview/controller.js +8 -5
  59. package/dist/preview/geometry.d.ts +17 -2
  60. package/dist/preview/geometry.js +21 -1
  61. package/dist/preview/index.d.ts +1 -0
  62. package/dist/preview/index.js +9 -2
  63. package/dist/preview/overlay.d.ts +2 -1
  64. package/dist/preview/overlay.js +26 -11
  65. package/dist/preview/paint.js +19 -17
  66. package/dist/visual/ArrayField.svelte +103 -83
  67. package/dist/visual/ArrayField.svelte.d.ts +5 -4
  68. package/dist/visual/BooleanField.svelte +8 -6
  69. package/dist/visual/BooleanField.svelte.d.ts +2 -2
  70. package/dist/visual/Card.svelte +161 -333
  71. package/dist/visual/Card.svelte.d.ts +5 -4
  72. package/dist/visual/CardControls.svelte +1 -1
  73. package/dist/visual/DateField.svelte +19 -19
  74. package/dist/visual/DateField.svelte.d.ts +3 -3
  75. package/dist/visual/DiagnosticList.svelte +2 -2
  76. package/dist/visual/EnumField.svelte +48 -28
  77. package/dist/visual/EnumField.svelte.d.ts +5 -4
  78. package/dist/visual/Field.svelte +207 -132
  79. package/dist/visual/Field.svelte.d.ts +11 -7
  80. package/dist/visual/FieldHint.svelte +79 -48
  81. package/dist/visual/FieldHint.svelte.d.ts +1 -1
  82. package/dist/visual/FieldLabel.svelte +8 -8
  83. package/dist/visual/FormatPopover.svelte +82 -35
  84. package/dist/visual/NumberField.svelte +5 -5
  85. package/dist/visual/NumberField.svelte.d.ts +1 -1
  86. package/dist/visual/ObjectField.svelte +28 -9
  87. package/dist/visual/ObjectField.svelte.d.ts +4 -2
  88. package/dist/visual/ProseArrayElement.svelte +12 -19
  89. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -2
  90. package/dist/visual/ProseField.svelte +62 -85
  91. package/dist/visual/ProseField.svelte.d.ts +14 -17
  92. package/dist/visual/SlashMenu.svelte +42 -16
  93. package/dist/visual/TextField.svelte +5 -5
  94. package/dist/visual/TextField.svelte.d.ts +1 -1
  95. package/dist/visual/TipsCard.svelte +43 -28
  96. package/dist/visual/VisualEditor.svelte +18 -19
  97. package/dist/visual/VisualEditor.svelte.d.ts +2 -3
  98. package/dist/visual/VisualEditorInner.svelte +251 -184
  99. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
  100. package/dist/visual/controls.css +85 -38
  101. package/dist/visual/diagnostics.d.ts +13 -21
  102. package/dist/visual/diagnostics.js +7 -19
  103. package/dist/visual/domid.js +3 -3
  104. package/dist/visual/ext.d.ts +1 -1
  105. package/dist/visual/ext.js +1 -1
  106. package/dist/visual/icons/nodes.d.ts +3 -0
  107. package/dist/visual/icons/nodes.js +2 -1
  108. package/dist/visual/index.d.ts +1 -0
  109. package/dist/visual/index.js +10 -3
  110. package/dist/visual/leaves.d.ts +47 -0
  111. package/dist/visual/leaves.js +31 -0
  112. package/dist/visual/links.d.ts +24 -0
  113. package/dist/visual/links.js +67 -0
  114. package/dist/visual/props.d.ts +33 -12
  115. package/dist/visual/signals.d.ts +6 -6
  116. package/dist/visual/strings.d.ts +14 -19
  117. package/dist/visual/strings.js +5 -7
  118. package/dist/visual/structure.d.ts +24 -17
  119. package/dist/visual/structure.js +8 -8
  120. package/dist/visual/synced.svelte.d.ts +1 -1
  121. package/dist/visual/synced.svelte.js +4 -4
  122. package/dist/visual/tips.d.ts +4 -2
  123. package/dist/visual/tips.js +7 -4
  124. package/package.json +2 -2
@@ -1,8 +1,8 @@
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
@@ -12,21 +12,21 @@
12
12
  It owns:
13
13
  • structure: the schema × payload join, re-derived from the live `Document`;
14
14
  • stable card identity: a session-id array reordered in lockstep with the
15
- content, resolved to an index ONLY at the mutation boundary;
15
+ content, resolved to an index only at the mutation boundary;
16
16
  • commit routing: prose leaves lower to `applyChange` (in the codec); scalars/
17
17
  arrays/objects go through the typed `writer`; structure through the mutators;
18
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
19
+ `setCaret(at)` entry wired to the preview;
20
+ • the one formatting popover (`FormatPopover`, mounted once, observing the
21
21
  active leaf via `getActiveLeaf`) and diagnostics routing (`diagnostics.ts`:
22
22
  quill.validate + local commit errors + the external `diagnostics` prop,
23
23
  merged into `diagByKey` and threaded to each `<Field>`/card body).
24
24
 
25
- REACTIVITY ACROSS THE WASM HANDLE. The `Document` is opaque to Svelte, so a
25
+ Reactivity across the WASM handle. The `Document` is opaque to Svelte, so a
26
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
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
30
  remounts them or drops a caret. `doc.main`/`doc.cards` allocate per read: read
31
31
  once per derive.
32
32
  -->
@@ -34,24 +34,25 @@
34
34
  import { onDestroy, tick } from 'svelte';
35
35
  import { DropdownMenu } from 'bits-ui';
36
36
  import { isQuillmarkError, MAIN_CARD_ADDR } from '@quillmark/wasm';
37
- import { cardPath, fieldPathForAddr, type DocPath } from '../core/address.js';
37
+ import {
38
+ addrForFieldPath,
39
+ cardPath,
40
+ elementAddrForFieldPath,
41
+ fieldPathForAddr,
42
+ type DocPath,
43
+ type Landing
44
+ } from '../core/address.js';
38
45
  import { errorMessage, reportError } from '../core/errors.js';
39
46
  import { bloomInside } from '../core/bloom.js';
40
47
  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';
48
+ import type { Addr, CardAddr, Diagnostic, Resolved, ResolvedField } from '@quillmark/wasm';
49
49
  import type { VisualEditorProps } from './props.js';
50
50
  import { mergeStrings, setWording } from './strings.js';
51
51
  import type { CardId, ChangeSource } from './signals.js';
52
52
  import type { FieldController } from '../core/codec/index.js';
53
53
  import {
54
54
  IdSeq,
55
+ controlKind,
55
56
  fieldModels,
56
57
  groupOrder,
57
58
  groupSections,
@@ -65,13 +66,11 @@
65
66
  stringifyGhost,
66
67
  resolveBodyGhost,
67
68
  NO_RESOLVED_ROWS,
68
- type BodyPlaceholder,
69
69
  type CardModel,
70
70
  type ResolvedCardRows
71
71
  } from './structure.js';
72
72
  import {
73
73
  fieldKeyToString,
74
- parsePath,
75
74
  resolveCardKey,
76
75
  routeAndResolve,
77
76
  mergeDiagnostics,
@@ -79,6 +78,7 @@
79
78
  type RoutedDiagnostic
80
79
  } from './diagnostics.js';
81
80
  import { fieldDomIds, groupPanelId } from './domid.js';
81
+ import { createLeafRegistry, type FieldControl } from './leaves.js';
82
82
  import { reorder } from './motion.js';
83
83
  import { tipsChannel } from './tips.js';
84
84
  import { patchEditorExt } from './ext.js';
@@ -98,6 +98,7 @@
98
98
  onError,
99
99
  diagnostics,
100
100
  enumOptionAllowed,
101
+ enumDisallowed = 'disable',
101
102
  strings,
102
103
  formatDiagnostic,
103
104
  class: className,
@@ -120,7 +121,7 @@
120
121
  // ── Reactivity + session identity ───────────────────────────────────────────
121
122
  let revision = $state(0);
122
123
  // 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
+ // leaf-key space is unique per editor, not per page: two editors mounted
124
125
  // side-by-side both hold `main:subject`, and a duplicate `id` makes `for` resolve
125
126
  // to whichever mounted first. `$props.id()` is stable across SSR and hydration,
126
127
  // which a module counter is not.
@@ -148,13 +149,13 @@
148
149
  /**
149
150
  * Re-derive the card tree and report the edit. Called for the two lanes that go
150
151
  * 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
+ * `proseChanged`, which does not bump the revision (bumping it would re-derive
152
153
  * and remount every leaf, costing the caret on every keystroke).
153
154
  *
154
155
  * `cardId` is passed rather than derived from `at`: the sites below hold the id
155
156
  * 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
157
+ * a leaf and a card index for a structure op, whose subject is the card rather than
158
+ * anything inside it; either way the path is minted here, after the bump, because
158
159
  * an op's own index is only addressable against the tree the bump re-derived (an
159
160
  * insert's card does not exist in the previous one, and a retype's kind is the
160
161
  * previous kind).
@@ -179,7 +180,7 @@
179
180
  }
180
181
 
181
182
  // ── 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
+ // A document swap is a remount (see the remount contract above), so a destroy is
183
184
  // the only way this surface ends and one span covers both. Cancellers register
184
185
  // below in the order they run: the registry that resolves new work first, then
185
186
  // the work already scheduled. The document handle is the consumer's to free, so
@@ -187,8 +188,11 @@
187
188
  const span = createLifespan();
188
189
  onDestroy(() => span.end());
189
190
 
190
- // ── Leaf registry (setCaret target lookup + the active-leaf seam) ───────────
191
- const leaves = new Map<string, FieldController>();
191
+ // ── Leaf registry (landing target lookup + the active-leaf seam) ────────────
192
+ // Every mounted field is in it, prose leaf and form control alike (`leaves.ts`):
193
+ // a landing needs a focus and a box to bloom, which every control has, and the
194
+ // codec lane is asked for only where there is an offset to place.
195
+ const leaves = createLeafRegistry();
192
196
  // Each leaf unregisters itself on its own teardown; this covers the surface
193
197
  // going away as a whole, where a leaf's cleanup order relative to the parent's
194
198
  // is Svelte's business and not something to depend on.
@@ -202,14 +206,8 @@
202
206
  let mainCard = $state<CardHandle | undefined>(undefined);
203
207
  let cardRefs = $state<(CardHandle | undefined)[]>([]);
204
208
  /** 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. */
209
+ * menu portals into, the way each leaf's surface resolves its nearest root. */
206
210
  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
211
 
214
212
  // ── Focus + bridge outputs ──────────────────────────────────────────────────
215
213
  // `activeCardId` is the id-keyed half of `activeAddr` (whose `card` is positional),
@@ -235,7 +233,7 @@
235
233
  * common case allocates nothing. */
236
234
  const NO_KINDS: readonly string[] = [];
237
235
  /**
238
- * The live kinds by content index, off the DERIVED card tree rather than
236
+ * The live kinds by content index, off the derived card tree rather than
239
237
  * `doc.cards`: they are already in hand, and `doc.cards` serializes every card on
240
238
  * each read, which is not a thing to do per keystroke.
241
239
  */
@@ -249,13 +247,13 @@
249
247
  function pathFor(addr: Addr): DocPath | undefined {
250
248
  return fieldPathForAddr(addr, addr.card == null ? NO_KINDS : liveKinds());
251
249
  }
252
- // The last PLACE reported, so a caret that did not move is not news. A leaf fires
250
+ // The last place reported, so a caret that did not move is not news. A leaf fires
253
251
  // one caret signal per transaction, and a transaction need not have moved the
254
252
  // caret to exist: a mark toggle over the same range, an anchor op, a plugin's own
255
253
  // bookkeeping. The memo is the editor's rather than each leaf's, because a place
256
254
  // spans leaves: a caret that leaves `main.body` at 5 for a card leaf and comes
257
255
  // 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,
256
+ // that reflows the page under a caret holding its offset is the case this drops,
259
257
  // and a preview does not need the signal to answer it: the place is unchanged, so
260
258
  // the one it already followed is the one its recompile re-locates.
261
259
  let lastCaretField: DocPath | undefined;
@@ -277,18 +275,18 @@
277
275
 
278
276
  // ── Commit routing ──────────────────────────────────────────────────────────
279
277
  // Scalars / arrays / objects → the typed writer (schema-checked). Prose leaves
280
- // commit themselves via the codec (applyChange) and do NOT pass through here.
278
+ // commit themselves via the codec (applyChange) and do not pass through here.
281
279
  // A scalar control commits `undefined` for a cleared entry: the unset lane
282
280
  // below (`doc.removeField`), not a write.
283
281
  //
284
- // A bad value makes `writer.set` THROW a `QuillmarkError`, whose
282
+ // A bad value makes `writer.set` throw a `QuillmarkError`, whose
285
283
  // `diagnostics[0]` carries a `code` and a canonical `path` (e.g.
286
284
  // `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
285
+ // editor already knows the field/card being committed, so it keys the entry
286
+ // from that address: id-keyed so it survives a later card reorder, never
289
287
  // parsed from the positional path; while surfacing the thrown diagnostic
290
288
  // verbatim (its `code`/`message`) as the payload (VISUAL_EDITOR §Diagnostics,
291
- // producer #2). It is stashed in `commitErrors`; a subsequent SUCCESSFUL commit
289
+ // producer #2). It is stashed in `commitErrors`; a subsequent successful commit
292
290
  // for the same field clears it. Nothing here gates: the value is not written
293
291
  // (the document is unchanged on throw, per the boundary's own transactional
294
292
  // contract) and editing continues.
@@ -297,14 +295,14 @@
297
295
  const keyStr = fieldKeyToString(key);
298
296
  try {
299
297
  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
298
+ // The unset rung of the commitment ladder (a cleared scalar control,
299
+ // VISUAL_EDITOR §"Structure mirrors the schema"): remove the field so the
302
300
  // engine's authored › `default:` › zero-fill resolve renders the
303
301
  // default, rather than baking a snapshot the schema can't track
304
302
  // (canon SCHEMAS.md: the engine never persists a default; nor do we).
305
303
  // Removal writes no value, so there is nothing for a schema to conform:
306
304
  // it goes through the quill-free `doc.removeField` (bare string = main
307
- // `{ field }`; `{ card, field }` for a card), NOT the typed writer.
305
+ // `{ field }`; `{ card, field }` for a card), not the typed writer.
308
306
  if (isMain) {
309
307
  doc.removeField(name);
310
308
  } else {
@@ -437,7 +435,7 @@
437
435
  });
438
436
  // No addr: the removed card has no address left, and the surviving cards'
439
437
  // 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
438
+ // which card went, which is the one thing a host keying on it needs, and the
441
439
  // only handle the removal leaves it.
442
440
  bump('structure', id);
443
441
  }
@@ -463,7 +461,7 @@
463
461
  bump('structure', id, i);
464
462
  }
465
463
  /**
466
- * Clear the tips channel: the dismissal write, and the ONLY write
464
+ * Clear the tips channel: the dismissal write, and the only write
467
465
  * tips make. `undefined` drops the key while `title` and any later sibling ride
468
466
  * through; `ext.ts` holds why that matters.
469
467
  */
@@ -476,7 +474,7 @@
476
474
  }
477
475
 
478
476
  // ── Addressing + per-card op bundle ─────────────────────────────────────────
479
- /** A LIVE card address: `card` is a getter, so a reorder re-targets in place. */
477
+ /** A live card address: `card` is a getter, so a reorder re-targets in place. */
480
478
  function cardAddr(id: string, field?: string): Addr {
481
479
  return field != null
482
480
  ? ({
@@ -497,9 +495,7 @@
497
495
  }
498
496
 
499
497
  // ── 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).
498
+ // Producer #1: quill.validate(doc), re-run every revision.
503
499
  const validation = $derived.by(() => {
504
500
  revision; // re-run on every mutation, per VISUAL_EDITOR §Diagnostics
505
501
  try {
@@ -526,7 +522,7 @@
526
522
  return mergeDiagnostics(fromValidate, fromExternal, [...commitErrors.values()]);
527
523
  });
528
524
  function opsFor(id: string, isMain: boolean) {
529
- // ONE identity per field: the leaf registry, the DOM's three names, and the
525
+ // One identity per field: the leaf registry, the DOM's three names, and the
530
526
  // diagnostics map all key off this string, so a leaf, its label and its
531
527
  // diagnostics can only ever resolve together.
532
528
  const leafKey = (field?: string) => fieldKeyToString({ card: isMain ? undefined : id, field });
@@ -548,33 +544,6 @@
548
544
  };
549
545
  }
550
546
 
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
547
  // ── The derived card tree (schema × payload join) ───────────────────────────
579
548
  function buildCard(
580
549
  id: string,
@@ -613,12 +582,14 @@
613
582
  // same text-ghost projection `<Field>` applies to a field's row; and falls
614
583
  // back to an invitation where a scalar shows nothing, because an empty body
615
584
  // 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.
585
+ // Asked only for a card that has a body, so the hook is never consulted
586
+ // about one that renders none. The hook is pure by contract, so it is called
587
+ // straight from the derive and its answer kept nowhere: a ghost holds still
588
+ // across a re-derive because the function does.
618
589
  bodyGhost: hasBody
619
590
  ? resolveBodyGhost(
620
591
  stringifyGhost(ghostDefault(rows.body ?? undefined)),
621
- customBodyGhost(kind, isMain),
592
+ merged.bodyPlaceholder?.({ cardId: id, kind, isMain }),
622
593
  merged.bodyGhost
623
594
  )
624
595
  : undefined
@@ -670,64 +641,93 @@
670
641
 
671
642
  // ── Public entry points ─────────────────────────────────────────────────────
672
643
  /**
673
- * Resolve a preview `ContentHit` to a mounted leaf and place its caret.
644
+ * Resolve a preview {@link Landing} to a mounted leaf and land in it.
674
645
  *
675
- * Async because the reveal has to RENDER before the landing: a collapsed group
646
+ * Async because the reveal has to render before the landing: a collapsed group
676
647
  * is `inert`, which swallows a focus silently, so a caret placed in the same
677
648
  * 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
649
+ * `onPick` ignores the promise: awaiting it is for a caller that wants to
679
650
  * observe where the caret went.
680
651
  *
681
- * A destroy lands INSIDE this one: `leaf` is looked up before the flush and
652
+ * A destroy lands inside this one: `leaf` is looked up before the flush and
682
653
  * 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.
654
+ * dispatch. A consumer's `onPick` outlives the surface it points at too, so the
655
+ * span is asked on the way in as well as after the await.
685
656
  */
686
- export async function setCaret(hit: ContentHit): Promise<void> {
687
- const leaf = await revealLeaf(hit.field);
688
- if (!leaf) return;
657
+ export async function setCaret(at: Landing): Promise<void> {
658
+ const found = await revealLeaf(at.field);
659
+ if (!found) return missed(`no mounted field at ${at.field}`, at.field);
689
660
  // 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);
661
+ // interior): `pos` is the segment start, not a cluster-exact caret
662
+ // (HitGranularity), so it is dropped rather than snapped to a spot the click
663
+ // did not resolve. `'cluster'` (and an absent granularity: the backend did not
664
+ // report it, treat as exact) places the caret. An absent `pos` is the placement
665
+ // rung and reaches the same floor.
666
+ land(found, at.granularity === 'segment' ? undefined : at.pos);
696
667
  // The arrival cue. Unconditional, unlike the preview side's change-guarded
697
668
  // 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
669
+ // leaf already focused (where landing a caret changes nothing on screen) or
699
670
  // one off-screen, where the browser's focus-scroll moves the page and leaves
700
671
  // the caret to be hunted for in a long form.
701
- bloomInside(leaf.el);
672
+ bloomInside(found.control.el);
702
673
  }
703
- /** The active leaf's controller: the formatting popover's observation seam. */
674
+ /** The active leaf's controller: the formatting popover's observation seam.
675
+ * `undefined` for a focused form control, which holds no marks to toggle. */
704
676
  export function getActiveLeaf(): FieldController | undefined {
705
677
  if (!activeAddr) return undefined;
706
678
  const card = activeAddr.card != null ? activeCardId : undefined;
707
- return leaves.get(fieldKeyToString({ card, field: activeAddr.field }));
679
+ return leaves.prose(fieldKeyToString({ card, field: activeAddr.field }));
708
680
  }
709
681
 
710
682
  /**
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.
683
+ * Reveal the field at `field` and hand back its landing handle plus the target it
684
+ * resolved to, once the reveal has rendered. The shared half of the two landing
685
+ * verbs: a collapsed group is clipped to zero height and sits inside an `inert`
686
+ * panel, which swallows a focus silently, so a caret placed in the same tick as the
687
+ * reveal goes nowhere and reports nothing. Exactly one card holds the key; the rest
688
+ * do nothing.
689
+ *
690
+ * The target comes back with the handle because the caller needs its key to ask for
691
+ * the codec lane (`leaves.prose`), and minting it twice would be two parses of one
692
+ * path. The reveal itself is the key's: a group holds the field, and an element
693
+ * address opens the same group its array sits in.
716
694
  *
717
- * A destroy lands INSIDE this: the leaf is looked up before the flush and
695
+ * A destroy lands inside this: the field is looked up before the flush and
718
696
  * dispatched into after it, and a PM view destroyed in that window throws on the
719
697
  * dispatch. A consumer's call outlives the surface it points at too, so the span is
720
698
  * asked on the way in as well as after the await.
721
699
  */
722
- async function revealLeaf(field: DocPath): Promise<FieldController | undefined> {
700
+ async function revealLeaf(field: DocPath): Promise<Landed | undefined> {
723
701
  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!);
702
+ const target = leafTargetFor(field);
703
+ const control = target && leaves.control(target.key);
704
+ if (!target || !control) return undefined;
705
+ mainCard?.revealLeaf(target.key);
706
+ for (const card of cardRefs) card?.revealLeaf(target.key);
729
707
  if (!(await span.resumes(tick()))) return undefined;
730
- return leaf;
708
+ return { ...target, control };
709
+ }
710
+
711
+ /**
712
+ * Put the caret in a revealed target, at the finest grain it can take.
713
+ *
714
+ * - An element address focuses that row, with no caret inside it: an array element
715
+ * is no `createField` leaf and its handle is a bare focus.
716
+ * - A prose leaf with a `pos` takes the caret at that USV offset.
717
+ * - Everything else takes the focus alone: the offset is a position in rendered
718
+ * content and a form control has no coordinate to spend it in (an
719
+ * `<input type="number">` refuses a selection outright), which is also the whole
720
+ * of what a click on plate-placed ink can mean.
721
+ */
722
+ function land(found: Landed, pos: number | undefined): void {
723
+ if (found.element != null && found.control.focusElement) {
724
+ return found.control.focusElement(found.element);
725
+ }
726
+ if (pos != null) {
727
+ const prose = leaves.prose(found.key);
728
+ if (prose) return prose.setCaret(pos);
729
+ }
730
+ found.control.focus();
731
731
  }
732
732
 
733
733
  // ── The verbs, as instance exports ──────────────────────────────────────────
@@ -737,16 +737,25 @@
737
737
  // They speak the public vocabulary — a `DocPath` for a place, a `CardId` for a
738
738
  // card — so a host drives them with what the hooks handed it.
739
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.
740
+ // A target the surface does not hold is a no-op that reports `target-unknown` at
741
+ // `dev`: a key from a previous session or a card already removed, or a path naming
742
+ // no declared field — an element path (`main.references.0`) whose array is
743
+ // undeclared or unmounted included, that being the only way one misses. `setCaret`
744
+ // reports it too, being the verb a preview click drives: a landing that resolved
745
+ // nothing is indistinguishable from one that landed, and a consumer wiring the
746
+ // bridge reads the difference off nothing else.
743
747
 
744
- /** Reveal and focus the leaf at `field`, without placing a caret inside it. */
748
+ /**
749
+ * Reveal and focus the field at `field`, without placing a caret inside it. Any
750
+ * mounted field: a prose leaf takes its view's focus, a form control takes the same
751
+ * handoff a click on its label does (`Field`, `leaves.ts`), and an element address
752
+ * takes its own row.
753
+ */
745
754
  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);
755
+ const found = await revealLeaf(field);
756
+ if (!found) return missed(`no mounted field at ${field}`, field);
757
+ land(found, undefined);
758
+ bloomInside(found.control.el);
750
759
  }
751
760
  /** Seed a card of `kind` and insert it at `at` (default: the end). Returns the new
752
761
  * card's session key, or `undefined` when the quill seeds no card of that kind. */
@@ -777,19 +786,56 @@
777
786
  reportError(onError, { code: 'target-unknown', severity: 'dev', message, path });
778
787
  }
779
788
 
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;
789
+ /** What a `DocPath` resolves to in the mounted tree: a leaf key, and the array
790
+ * element within it when the address names one. */
791
+ interface LeafTarget {
792
+ key: string;
793
+ element?: number;
794
+ }
795
+ /** A resolved target with the handle the registry holds for it. */
796
+ type Landed = LeafTarget & { control: FieldControl };
797
+
798
+ /**
799
+ * Map a landing's `field` (a canonical `DocPath`) to a mounted target: the
800
+ * `addrForFieldPath` route the diagnostics take, the absolute card index resolved
801
+ * to its live stable id, then the shared `fieldKeyToString` form.
802
+ *
803
+ * Two rungs, because the boundary mints addresses at a finer granularity than
804
+ * `Addr` can name: a `richtext[]` element surfaces as `main.references.0`, which
805
+ * the grammar reads as a field literally named `"0"`. The second rung reads that
806
+ * trailing segment as an index, under a field the schema declares an array — which
807
+ * is why the ladder is the editor's (VISUAL_EDITOR.md §Surface): the preview
808
+ * carries no schema, and truncating the address there is worse than guessing, since
809
+ * `pos` is an offset into the element's own content.
810
+ */
811
+ function leafTargetFor(field: DocPath): LeafTarget | undefined {
812
+ const direct = addrForFieldPath(field);
813
+ if (direct) {
814
+ const resolved = resolveCardKey(direct, cardIds);
815
+ return resolved ? { key: fieldKeyToString(resolved) } : undefined;
816
+ }
817
+ const element = elementAddrForFieldPath(field);
818
+ if (!element || !isArrayField(element.field)) return undefined;
819
+ const resolved = resolveCardKey(element.field, cardIds);
820
+ return resolved ? { key: fieldKeyToString(resolved), element: element.index } : undefined;
821
+ }
822
+
823
+ /** Whether `addr` names a field this document renders as an array repeater: the
824
+ * guard the element rung stands on. Asked of `controlKind`, so what the ladder
825
+ * tests is the control the tree mounted — the one holding a `focusElement` — and
826
+ * not a second reading of the schema beside it. */
827
+ function isArrayField(addr: Addr): boolean {
828
+ if (addr.field == null) return false;
829
+ const kind = addr.card == null ? undefined : model.cards[addr.card]?.kind;
830
+ const schema = quill.schema;
831
+ const card = addr.card == null ? schema.main : kind ? schema.card_kinds?.[kind] : undefined;
832
+ const declared = card?.fields?.[addr.field];
833
+ return !!declared && controlKind(declared) === 'array';
788
834
  }
789
835
  </script>
790
836
 
791
837
  <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
838
+ <!-- `main` and the tips card are one block in the stack: the tips card tucks under
793
839
  `main`'s bottom corners, so the two share a seam rather than a gutter and the
794
840
  wrapper is what holds them to it. -->
795
841
  <div class="qm-primary">
@@ -803,12 +849,12 @@
803
849
  isLast={true}
804
850
  {kinds}
805
851
  ops={opsFor('main', true)}
852
+ {enumDisallowed}
806
853
  onFocus={handleFocus}
807
854
  onCaretMove={handleCaret}
808
855
  onChange={proseChanged}
809
856
  {onError}
810
- {register}
811
- {unregister}
857
+ {leaves}
812
858
  />
813
859
 
814
860
  <!-- The tips card: a fixed slot after `main`, ahead of the cards, so
@@ -821,7 +867,7 @@
821
867
  </div>
822
868
 
823
869
  {@render addAffordance(0)}
824
- <!-- A card and the gap under it are one SLOT, which is what a reorder moves: the
870
+ <!-- A card and the gap under it are one slot, which is what a reorder moves: the
825
871
  strip below a card is the same strip wherever the card lands, so it rides along
826
872
  rather than being slid across. It is also the shape `animate:` asks for, being the
827
873
  keyed block's only child. -->
@@ -837,12 +883,12 @@
837
883
  isLast={i === model.cards.length - 1}
838
884
  {kinds}
839
885
  ops={opsFor(c.id, false)}
886
+ {enumDisallowed}
840
887
  onFocus={handleFocus}
841
888
  onCaretMove={handleCaret}
842
889
  onChange={proseChanged}
843
890
  {onError}
844
- {register}
845
- {unregister}
891
+ {leaves}
846
892
  />
847
893
  {@render addAffordance(i + 1)}
848
894
  </div>
@@ -851,47 +897,46 @@
851
897
 
852
898
  <FormatPopover {getActiveLeaf} />
853
899
 
854
- <!-- Cards always render; the ADD affordance is gated on the schema declaring
900
+ <!-- Cards always render; the add affordance is gated on the schema declaring
855
901
  `card_kinds`, since there is nothing to seed otherwise. A card already in the
856
902
  document shows regardless of its kind: a kind with no schema (foreign, or a
857
903
  schema with no `card_kinds` at all) degrades to a recovery shell inside <Card>
858
904
  (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
905
+ trapped. The gate lives here rather than at each call site: the strip is one
860
906
  decision, and two copies of it drift into a stack with a gap at one end. -->
861
907
  {#snippet addAffordance(atIndex: number)}
862
908
  {#if kinds.length}
863
- <!-- The strip past the last card is the APPEND point, the one a reader looks for
909
+ <!-- The strip past the last card is the append point, the one a reader looks for
864
910
  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
911
+ gaps between cards stay bare: the strip is the gap, and the pill each fills on
866
912
  hover draws what a label would state, the space the new card takes. -->
867
913
  {@const marked = atIndex === model.cards.length}
868
914
  <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. -->
915
+ <!-- Marked, the words are the accessible name and no `aria-label` doubles
916
+ them; bare, the same words land as the label, the strip having no
917
+ geometry to carry them. -->
872
918
  {#if kinds.length === 1}
873
919
  <button
874
920
  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
921
+ class="qm-add-btn qm-add-affordance qm-tap-floor"
922
+ aria-label={marked ? undefined : merged.addCard}
923
+ onclick={() => addCard(atIndex, kinds[0])}>{marked ? merged.addCard : ''}</button
879
924
  >
880
925
  {:else}
881
- <!-- Multi-kind add: pick the kind, then seed + insert. A MENU rather than a
926
+ <!-- Multi-kind add: pick the kind, then seed + insert. A menu rather than a
882
927
  disclosure: it floats out of the stack, so raising it moves no card, and
883
928
  it dismisses on pick, on Escape and on a click outside, none of which a
884
929
  `<details>` does. The trigger is bits-ui's `<button>`, which is why the
885
930
  rules below reach it through `:global`. -->
886
931
  <DropdownMenu.Root>
887
932
  <DropdownMenu.Trigger
888
- class="qm-add-btn qm-add-affordance"
933
+ class="qm-add-btn qm-add-affordance qm-tap-floor"
889
934
  aria-label={marked ? undefined : merged.addCard}
890
- >{marked ? merged.addCardMarkAny : ''}</DropdownMenu.Trigger
935
+ >{marked ? merged.addCard : ''}</DropdownMenu.Trigger
891
936
  >
892
937
  <DropdownMenu.Portal to={rootEl}>
893
938
  <DropdownMenu.Content sideOffset={4}>
894
- <!-- Portalled out of the row but INTO the stack's root, and carrying the
939
+ <!-- Portalled out of the row but into the stack's root, and carrying the
895
940
  marker itself: floating is still a detached subtree to the
896
941
  derivation, like FormatPopover and the enum listbox. -->
897
942
  <div class="qm-menu-surface" data-qm-root>
@@ -911,33 +956,40 @@
911
956
 
912
957
  <style>
913
958
  /* 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
959
+ a detached root, one of those core/theme.css applies the derivation to. The
915
960
  root rule also carries the baseline font and colour, so nothing here restates
916
961
  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
962
+ /* The column, not only the cards in it: the gutter the stack sits in and the tone
918
963
  behind it are the surface's own, so a bare `<div>` is a mounting site and
919
964
  nothing is owed before the editor looks right. Consumer CSS is unlayered and
920
965
  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. */
966
+ `border-box`, so a height from the caller is the height this draws.
967
+
968
+ The sunken rung, which is what makes a card an island: the cards are the base
969
+ plane and this is the one they float on, so the gutter reads as ground rather
970
+ than as more card (ARCHITECTURE §Styling). The inset is the widest rung on the
971
+ ramp, which is what gives the ground enough of itself to read as ground: at a
972
+ narrower one the stack meets whatever frames the surface and the islands are
973
+ back to being one block clipped at its edges. */
922
974
  .qm-editor {
923
975
  box-sizing: border-box;
924
976
  display: flex;
925
977
  flex-direction: column;
926
978
  gap: var(--_qm-space-2);
927
- padding: var(--_qm-space-2);
928
- background: var(--_qm-surface);
979
+ padding: var(--_qm-space-4);
980
+ background: var(--_qm-surface-sunken);
929
981
  color: var(--_qm-ink);
930
982
  }
931
- /* A fixed-height pane, which the stack is NOT by default, and the four popovers
983
+ /* A fixed-height pane, which the stack is not by default, and the four popovers
932
984
  are the reason: each resolves its portal target with
933
985
  `closest('[data-qm-root]')`, which is this element, so an `overflow` here
934
986
  clips the menu a leaf raised. Mounted in a page that scrolls, nothing clips;
935
987
  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
988
+ have had from its own scrolling frame either way. The tail rides with the
937
989
  scroller because it is only meaningful under one: dead space below the last
938
990
  card, so it can be read mid-pane rather than against the bottom edge.
939
991
 
940
- The HEIGHT is what makes the rest of the rule mean anything, and it is the
992
+ The height is what makes the rest of the rule mean anything, and it is the
941
993
  pane's rather than a number of ours: `overflow` on a box that grows to its
942
994
  content never has anything to scroll, so the stack would run past the pane
943
995
  and be clipped by whatever the host frames it with. Taking the pane's height
@@ -955,7 +1007,7 @@
955
1007
  padding-block-end: var(--_qm-tail);
956
1008
  }
957
1009
  /* 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
1010
+ blocks to. `main` has to paint over the tip: a later sibling paints over an earlier
959
1011
  one otherwise, and the tip's background would cover the corners it is there to
960
1012
  fill. `isolation` keeps that z-index local, so one wrapper owns the ordering and no
961
1013
  card below inherits an argument about it. The wrapper is a single flex item, so the
@@ -977,56 +1029,71 @@
977
1029
  gap: var(--_qm-space-2);
978
1030
  }
979
1031
  /* 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. */
1032
+ by position, so reveal and hit region are the full-bleed row rather than a word
1033
+ to aim at in the middle of it. It takes the editor's gap back on both sides, so
1034
+ it is not a control sitting in the gutter; it is the gutter: what separates two
1035
+ cards is the trigger's own height and nothing else, and the pill it fills on hover
1036
+ is edge to edge the space the new card opens into. No band is held back as
1037
+ miss-tolerance: gutter that reads as the trigger and inserts nothing unsays what
1038
+ the fill claims, so a press anywhere between two cards inserts. Absorbed rather
1039
+ than removed, because `gap` is also what separates the one seam no strip sits in:
1040
+ every card from the next under a quill declaring no kinds, where the affordance
1041
+ does not render at all. */
990
1042
  .qm-add-card {
991
1043
  display: flex;
992
1044
  margin-top: calc(var(--_qm-space-2) * -1);
993
1045
  margin-bottom: calc(var(--_qm-space-2) * -1);
994
1046
  }
995
1047
  /* Unboxed, like every button, and not dashed: a
996
- dashed edge is the PLACEHOLDER idiom ("nothing is here yet") which on a button
1048
+ dashed edge is the placeholder idiom ("nothing is here yet") which on a button
997
1049
  reads as disabled or as a drop target. It stays honest in one place, the
998
1050
  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
1051
+ trigger rests invisible, so the fill is the whole of what it shows: the pill fills
1000
1052
  the strip because the strip is what was pressed, and the strip is the gap, so the
1001
1053
  fill is the card-to-be drawn where it will land. Every gap is an equal entry point,
1002
1054
  reached by the name rather than by a mark: a card goes anywhere in the stack, not
1003
1055
  just after the last one.
1004
1056
 
1057
+ No inset of its own: the pill and the gap are the same rectangle, and a bare strip is
1058
+ a band of gutter, so its height is a rhythm rung. The press floor the box gives up is
1059
+ `.qm-tap-floor`'s (controls.css); what it overhangs is the card's own inset above and
1060
+ below, which holds no target, so the floor costs a neighbour nothing.
1061
+
1062
+ Ink is pinned to the label tone in every state, taking back the family's hover step:
1063
+ the trigger arrives and leaves on opacity, and a tone moving under an opacity already
1064
+ moving reads as two trips at different speeds.
1065
+
1005
1066
  `:global`, because the multi-kind trigger is bits-ui's own element and a `class`
1006
1067
  passed to a primitive is a plain string that never picks up the scoping hash:
1007
1068
  the same seam the enum trigger is styled through. */
1008
1069
  .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
1070
  display: flex;
1013
1071
  align-items: center;
1014
1072
  justify-content: center;
1015
1073
  width: 100%;
1074
+ height: var(--_qm-space-4);
1075
+ color: var(--_qm-ink-label);
1076
+ opacity: 0;
1077
+ transition:
1078
+ opacity var(--_qm-reveal),
1079
+ background-color var(--_qm-reveal);
1016
1080
  }
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. */
1081
+ /* The strip that holds words at rest takes the height that centres them, and rests dim
1082
+ rather than absent, having something to be seen. */
1020
1083
  .qm-add-card-marked :global(.qm-add-btn) {
1084
+ height: var(--_qm-tap-min);
1021
1085
  font-size: var(--_qm-text-label);
1086
+ font-weight: var(--_qm-weight-mid);
1087
+ opacity: var(--_qm-opacity-idle);
1022
1088
  }
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
1089
+ /* One arrival, three ways in. Keyboard focus, because the fill is the only thing that
1090
+ reads as the trigger on a bare strip; and an open menu, because it portals out of
1091
+ the strip, so a pointer moving onto an item has left the row whose hover was drawing
1027
1092
  the pill the menu hangs from. */
1093
+ .qm-add-card :global(.qm-add-btn:hover),
1028
1094
  .qm-add-card :global(.qm-add-btn:focus-visible),
1029
1095
  .qm-add-card :global(.qm-add-btn[data-state='open']) {
1030
- background: var(--_qm-surface-hover);
1096
+ opacity: 1;
1097
+ background: var(--_qm-add-fill);
1031
1098
  }
1032
1099
  </style>