@quillmark/svelte 0.11.0 → 0.13.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 (62) hide show
  1. package/dist/core/address.d.ts +17 -10
  2. package/dist/core/address.js +26 -12
  3. package/dist/core/bloom.js +4 -3
  4. package/dist/core/codec/decode.d.ts +17 -0
  5. package/dist/core/codec/decode.js +78 -19
  6. package/dist/core/codec/encode.js +1 -1
  7. package/dist/core/codec/field.d.ts +64 -15
  8. package/dist/core/codec/field.js +201 -65
  9. package/dist/core/codec/index.d.ts +3 -3
  10. package/dist/core/codec/index.js +3 -3
  11. package/dist/core/codec/islands.d.ts +5 -4
  12. package/dist/core/codec/islands.js +23 -38
  13. package/dist/core/codec/keymap.d.ts +5 -4
  14. package/dist/core/codec/keymap.js +5 -4
  15. package/dist/core/codec/prose.css +42 -0
  16. package/dist/core/codec/schema.d.ts +24 -5
  17. package/dist/core/codec/schema.js +40 -9
  18. package/dist/core/codec/slash.js +0 -0
  19. package/dist/core/errors.d.ts +3 -2
  20. package/dist/core/rungs.d.ts +8 -0
  21. package/dist/core/rungs.js +24 -0
  22. package/dist/core/theme.css +19 -6
  23. package/dist/preview/paint.js +21 -17
  24. package/dist/visual/ArrayField.svelte +649 -176
  25. package/dist/visual/ArrayField.svelte.d.ts +16 -1
  26. package/dist/visual/BooleanField.svelte +36 -11
  27. package/dist/visual/BooleanField.svelte.d.ts +2 -0
  28. package/dist/visual/Card.svelte +6 -25
  29. package/dist/visual/DateField.svelte +66 -31
  30. package/dist/visual/EnumField.svelte +27 -9
  31. package/dist/visual/EnumField.svelte.d.ts +5 -0
  32. package/dist/visual/Field.svelte +113 -38
  33. package/dist/visual/FormatPopover.svelte +4 -3
  34. package/dist/visual/MatrixField.svelte +353 -0
  35. package/dist/visual/MatrixField.svelte.d.ts +36 -0
  36. package/dist/visual/NumberField.svelte +31 -14
  37. package/dist/visual/NumberField.svelte.d.ts +5 -1
  38. package/dist/visual/ObjectField.svelte +404 -139
  39. package/dist/visual/ObjectField.svelte.d.ts +17 -2
  40. package/dist/visual/ProseField.svelte +53 -23
  41. package/dist/visual/ProseField.svelte.d.ts +15 -3
  42. package/dist/visual/ProseValue.svelte +102 -46
  43. package/dist/visual/ProseValue.svelte.d.ts +17 -5
  44. package/dist/visual/TextField.svelte +35 -18
  45. package/dist/visual/TextField.svelte.d.ts +7 -0
  46. package/dist/visual/VariantField.svelte +56 -2
  47. package/dist/visual/VariantField.svelte.d.ts +10 -1
  48. package/dist/visual/VisualEditorInner.svelte +77 -73
  49. package/dist/visual/controls.css +47 -5
  50. package/dist/visual/diagnostics.d.ts +26 -2
  51. package/dist/visual/diagnostics.js +52 -5
  52. package/dist/visual/hold.js +2 -2
  53. package/dist/visual/icons/nodes.d.ts +3 -0
  54. package/dist/visual/icons/nodes.js +2 -1
  55. package/dist/visual/leaves.d.ts +25 -12
  56. package/dist/visual/motion.d.ts +14 -5
  57. package/dist/visual/motion.js +13 -11
  58. package/dist/visual/strings.d.ts +27 -10
  59. package/dist/visual/strings.js +8 -2
  60. package/dist/visual/structure.d.ts +159 -40
  61. package/dist/visual/structure.js +337 -45
  62. package/package.json +2 -2
@@ -10,12 +10,16 @@
10
10
  container with one cell written — and cells outside the drawn world ride through
11
11
  untouched rather than needing to be merged back. A content cell reads at the key it
12
12
  declares: the boundary's schema walk unions the worlds, so a dormant cell reads absent
13
- rather than raising.
13
+ rather than raising. A cell may be a container of its own, which the subform draws at
14
+ the next rung.
14
15
  -->
15
16
  <script lang="ts">
16
17
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
17
18
  import EnumField from './EnumField.svelte';
18
19
  import ObjectField from './ObjectField.svelte';
20
+ import DiagnosticList from './DiagnosticList.svelte';
21
+ import type { DeepDiagnostic } from './diagnostics.js';
22
+ import type { LandingBox } from './leaves.js';
19
23
  import {
20
24
  VARIANT_DISCRIMINANT,
21
25
  commitDiscriminant,
@@ -43,6 +47,10 @@
43
47
  onCommit: (v: Record<string, unknown> | undefined) => void;
44
48
  optionAllowed?: (value: string) => boolean;
45
49
  enumDisallowed?: 'hide' | 'disable';
50
+ /** The diagnostics routed into this field, each with its steps still to walk: one
51
+ * naming a live cell draws under it; the rest — the discriminant's, a dormant
52
+ * world's — draw at the field's foot. */
53
+ diagnostics?: readonly DeepDiagnostic[];
46
54
  }
47
55
  let {
48
56
  value,
@@ -55,12 +63,52 @@
55
63
  contentAt,
56
64
  onCommit,
57
65
  optionAllowed,
58
- enumDisallowed
66
+ enumDisallowed,
67
+ diagnostics
59
68
  }: Props = $props();
60
69
 
61
70
  const member = $derived(variantMember(value, ghostMember));
62
71
  const cells = $derived(variantCells(schema, member));
63
72
  const discriminant = $derived(value?.[VARIANT_DISCRIMINANT] as string | undefined);
73
+
74
+ type Subform = {
75
+ focus: () => void;
76
+ focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
77
+ };
78
+ let cellsEl = $state<Subform | undefined>();
79
+
80
+ /** Whether a step names a cell the live world draws. The one test the landing and
81
+ * the diagnostics both read, so the cell a caret lands in is the cell a message
82
+ * draws under. The discriminant is the field's own control and no world's cell. */
83
+ function live(step: PathStep | undefined): boolean {
84
+ return (
85
+ typeof step === 'string' &&
86
+ step !== VARIANT_DISCRIMINANT &&
87
+ !!cells &&
88
+ Object.hasOwn(cells, step)
89
+ );
90
+ }
91
+ // The cells' own, and the field's: a diagnostic naming the discriminant or a world
92
+ // that is not drawn has no cell to sit under, and the subform's foot is inside a box
93
+ // holding the live world alone. So it draws under the field, where the control it is
94
+ // about is.
95
+ const inside = $derived((diagnostics ?? []).filter((d) => live(d.steps[0])));
96
+ const foot = $derived(
97
+ (diagnostics ?? []).filter((d) => !live(d.steps[0])).map((d) => d.diagnostic)
98
+ );
99
+
100
+ /** Take the caret: the discriminant's trigger, the field's own control. */
101
+ export function focus(): void {
102
+ if (id) document.getElementById(id)?.focus();
103
+ }
104
+ /** Land at `steps`: a live cell, and the rest of the walk inside it where the cell is
105
+ * a container; the discriminant, a dormant world's cell and a key no world declares
106
+ * land on the trigger ({@link FieldControl.focusPath}). */
107
+ export function focusPath(steps: readonly PathStep[], pos?: number): LandingBox {
108
+ if (live(steps[0]) && cellsEl) return cellsEl.focusPath(steps, pos);
109
+ focus();
110
+ return undefined;
111
+ }
64
112
  </script>
65
113
 
66
114
  <div class="qm-variant">
@@ -68,6 +116,7 @@
68
116
  value={discriminant}
69
117
  values={schema.values ?? []}
70
118
  fallback={ghostMember}
119
+ blankTitle={schema.ui?.blank_title}
71
120
  {id}
72
121
  {describedBy}
73
122
  onCommit={(v) => onCommit(commitDiscriminant(value, v))}
@@ -81,6 +130,7 @@
81
130
  {#if cells}
82
131
  {#key member}
83
132
  <ObjectField
133
+ bind:this={cellsEl}
84
134
  value={value ?? {}}
85
135
  properties={cells}
86
136
  {label}
@@ -89,9 +139,13 @@
89
139
  {describedBy}
90
140
  {contentAt}
91
141
  onCommit={(obj) => onCommit(obj)}
142
+ diagnostics={inside}
92
143
  />
93
144
  {/key}
94
145
  {/if}
146
+ <!-- The field's own foot, drawn whether or not a world is: what the live world
147
+ cannot hold belongs under the field rather than inside the box its cells sit in. -->
148
+ <DiagnosticList diagnostics={foot} />
95
149
  </div>
96
150
 
97
151
  <style>
@@ -1,4 +1,6 @@
1
1
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
+ import type { DeepDiagnostic } from './diagnostics.js';
3
+ import type { LandingBox } from './leaves.js';
2
4
  interface Props {
3
5
  /** The stored container, or undefined while the field is unset. */
4
6
  value: Record<string, unknown> | undefined;
@@ -19,7 +21,14 @@ interface Props {
19
21
  onCommit: (v: Record<string, unknown> | undefined) => void;
20
22
  optionAllowed?: (value: string) => boolean;
21
23
  enumDisallowed?: 'hide' | 'disable';
24
+ /** The diagnostics routed into this field, each with its steps still to walk: one
25
+ * naming a live cell draws under it; the rest — the discriminant's, a dormant
26
+ * world's — draw at the field's foot. */
27
+ diagnostics?: readonly DeepDiagnostic[];
22
28
  }
23
- declare const VariantField: import("svelte").Component<Props, {}, "">;
29
+ declare const VariantField: import("svelte").Component<Props, {
30
+ focus: () => void;
31
+ focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
32
+ }, "">;
24
33
  type VariantField = ReturnType<typeof VariantField>;
25
34
  export default VariantField;
@@ -24,7 +24,7 @@
24
24
  import {
25
25
  addrForFieldPath,
26
26
  cardPath,
27
- elementAddrForFieldPath,
27
+ nestedAddrForFieldPath,
28
28
  fieldPathForAddr,
29
29
  type DocPath,
30
30
  type Landing
@@ -36,7 +36,9 @@
36
36
  Addr,
37
37
  CardAddr,
38
38
  Diagnostic,
39
+ PathStep,
39
40
  PayloadItem,
41
+ QuillFieldSchema,
40
42
  Resolved,
41
43
  ResolvedField
42
44
  } from '@quillmark/wasm';
@@ -47,19 +49,22 @@
47
49
  import {
48
50
  IdSeq,
49
51
  controlKind,
52
+ isContainer,
53
+ shortCell,
54
+ schemaAt,
50
55
  fieldModels,
51
56
  groupOrder,
52
57
  groupSections,
53
58
  groupLabel,
54
59
  cardTitle,
55
- titleFields,
60
+ kindTitle,
56
61
  fieldValues,
57
62
  bodyEnabled,
58
- humanize,
59
63
  provenanceMap,
60
64
  resolvedByCardIndex,
61
65
  ghostDefault,
62
- stringifyGhost,
66
+ declaredGhost,
67
+ titleText,
63
68
  resolveBodyGhost,
64
69
  NO_RESOLVED_ROWS,
65
70
  type CardModel,
@@ -75,7 +80,7 @@
75
80
  } from './diagnostics.js';
76
81
  import { fieldDomIds, groupPanelId } from './domid.js';
77
82
  import { createLeafRegistry, type FieldControl } from './leaves.js';
78
- import { reorder, reorderTrips } from './motion.js';
83
+ import { reorder, reorderArm, reorderTrips } from './motion.js';
79
84
  import { tipsChannel } from './tips.js';
80
85
  import { patchEditorExt } from './ext.js';
81
86
  import Card from './Card.svelte';
@@ -282,24 +287,22 @@
282
287
  onChange?.({ source: 'prose', cardId, path: pathFor(plain) });
283
288
  }
284
289
 
285
- /** A card's header after a prose commit to a field its `{field}` title names: the
286
- * one piece of the model that lane changes, held beside the model and dropped on
287
- * the next bump, which rebuilds it from the document. The kind is the derived
288
- * tree's ({@link liveKinds}), so the document is read only for a title that
289
- * names the field. */
290
+ /** A header named by the card's own values, after a prose commit to one that can name
291
+ * it: the one piece of the model that lane moves, held beside the model and dropped
292
+ * on the next bump, which rebuilds it from the document. The card is read only for a
293
+ * one-line prose field of a kind declaring no `title` (`cardTitle`). */
290
294
  let liveTitles = $state<Record<CardId, string>>({});
291
295
  function refreshTitle(addr: Addr, cardId: CardId): void {
292
296
  const { card: at, field } = addr;
293
- if (field == null) return;
294
- const shown = at == null ? model.main : model.cards[at];
295
- if (!shown) return;
296
- const cardSchema = at == null ? quill.schema.main : quill.schema.card_kinds?.[shown.kind];
297
- if (!titleFields(cardSchema?.ui?.title).includes(field)) return;
298
- const live = at == null ? doc.main : doc.cards[at];
299
- if (!live) return;
297
+ if (at == null || field == null) return;
298
+ const shown = model.cards[at];
299
+ const cardSchema = shown && quill.schema.card_kinds?.[shown.kind];
300
+ if (!cardSchema || cardSchema.title?.trim()) return;
301
+ const sub = Object.hasOwn(cardSchema.fields, field) ? cardSchema.fields[field] : undefined;
302
+ if (!sub || !shortCell(sub)) return;
300
303
  liveTitles = {
301
304
  ...liveTitles,
302
- [cardId]: cardTitle(cardSchema, shown.kind, fieldValues(live.payloadItems), undefined)
305
+ [cardId]: cardTitle(cardSchema, shown.kind, fieldValues(doc.card(at).payloadItems), undefined)
303
306
  };
304
307
  }
305
308
  function titled(c: CardModel): CardModel {
@@ -324,11 +327,11 @@
324
327
  const keyStr = fieldKeyToString(key);
325
328
  try {
326
329
  if (value === undefined) {
327
- // The unset rung of the commitment ladder (VISUAL_EDITOR §"Structure mirrors
328
- // the schema"): removing the field leaves the engine's authored › `default:`
329
- // › blank-fill resolve to render the default, where writing one bakes a
330
- // snapshot the schema cannot track (canon SCHEMAS.md: the engine never
331
- // persists a default; nor do we). Removal writes no value, so there is
330
+ // The unset rung (VISUAL_EDITOR §"The commitment ladder"): removing the field
331
+ // leaves the engine's authored › `default:` › blank-fill resolve to render the
332
+ // default, where writing one bakes a snapshot the schema cannot track (canon
333
+ // SCHEMAS.md: the engine never writes a default; the editor writes one only
334
+ // as the edit that takes it). Removal writes no value, so there is
332
335
  // nothing for a schema to conform and the lane is the quill-free one.
333
336
  if (isMain) {
334
337
  doc.removeField(name);
@@ -424,26 +427,17 @@
424
427
  return undefined;
425
428
  }
426
429
  }
427
- // The reorder gesture's arming window: the reconcile that moves a
428
- // slot is the trip, and every other reconcile that happens to move one is not. Read
429
- // through a getter rather than passed as a value, so the flag stays out of the
430
- // template and needs no reactivity for it: `animate:` asks at apply time, which is a
431
- // microtask after the mutation and well inside the frame that disarms it.
432
- let reordering = false;
433
- let reorderFrame = 0;
434
- function armReorder(): void {
435
- reordering = true;
436
- reorderFrame = requestAnimationFrame(() => (reordering = false));
437
- }
438
- span.onEnd(() => cancelAnimationFrame(reorderFrame));
439
- const isReordering = (): boolean => reordering;
430
+ // The reorder gesture's arming window (`motion.ts`): the reconcile that moves a slot
431
+ // is the trip, and every other reconcile that happens to move one is not.
432
+ const arm = reorderArm();
433
+ span.onEnd(arm.cancel);
440
434
 
441
435
  function moveCardById(id: string, dir: -1 | 1): void {
442
436
  const from = cardIndexOf(id);
443
437
  if (from < 0) return;
444
438
  const to = from + dir;
445
439
  if (to < 0 || to >= doc.cardCount) return;
446
- armReorder();
440
+ arm.arm();
447
441
  doc.moveCard(from, to);
448
442
  const w = cardIds.slice();
449
443
  const [x] = w.splice(from, 1);
@@ -617,7 +611,8 @@
617
611
  // still across a re-derive because the function does.
618
612
  bodyGhost: hasBody
619
613
  ? resolveBodyGhost(
620
- stringifyGhost(ghostDefault(rows.body ?? undefined)),
614
+ titleText(ghostDefault(rows.body ?? undefined)) || undefined,
615
+ declaredGhost(cardSchema?.body?.example, true),
621
616
  merged.bodyPlaceholder?.({ cardId: id, kind, isMain }),
622
617
  merged.bodyGhost
623
618
  )
@@ -691,7 +686,8 @@
691
686
  // click is one discrete act, and its commonest target is the leaf already focused
692
687
  // (where landing a caret changes nothing on screen) or one off-screen, where the
693
688
  // browser's focus-scroll moves the page and leaves the caret to be hunted for.
694
- bloomInside(land(found, at.granularity === 'segment' ? undefined : at.pos));
689
+ const box = await land(found, at.granularity === 'segment' ? undefined : at.pos);
690
+ if (span.alive) bloomInside(box);
695
691
  }
696
692
  /** The active leaf's controller: the formatting popover's observation seam.
697
693
  * `undefined` for a focused form control, which holds no marks to toggle. */
@@ -730,20 +726,22 @@
730
726
 
731
727
  /**
732
728
  * Put the caret in a revealed target, at the finest grain it can take: a USV offset
733
- * in a prose leaf or an array element, a bare focus everywhere else. A form control
734
- * has no coordinate to spend an offset in (an `<input type="number">` refuses a
735
- * selection outright), which is also the whole of what a click on plate-placed ink
736
- * can mean. An element is no `createField` leaf, so the offset goes down the element
737
- * lane instead, where what it means is the row control's (`leaves.ts`).
729
+ * in a prose leaf or a nested content cell, a bare focus everywhere else. A form
730
+ * control has no coordinate to spend an offset in (an `<input type="number">`
731
+ * refuses a selection outright), which is also the whole of what a click on
732
+ * plate-placed ink can mean. A cell is no `createField` leaf, so the offset goes
733
+ * down the container's walk instead, where what it means is the leaf's (`leaves.ts`).
738
734
  *
739
735
  * Hands back the box the arrival wash blooms in, which is the landing's own
740
- * granularity rather than the registry's: an element lands in one row and says so
741
- * over that row, and everything else — the field, and a row the document has since
742
- * dropped — over the field's box.
736
+ * granularity rather than the registry's: a walk lands in one row and says so over
737
+ * the innermost row it opened, and everything else — the field, a property under a
738
+ * field-level subform, and a row the document has since dropped — over the field's
739
+ * box. Async because a row two closed boxes down exists only once the row above it
740
+ * has opened and flushed.
743
741
  */
744
- function land(found: Landed, pos: number | undefined): HTMLElement {
745
- if (found.element != null && found.control.focusElement) {
746
- return found.control.focusElement(found.element, pos) ?? found.control.el;
742
+ async function land(found: Landed, pos: number | undefined): Promise<HTMLElement> {
743
+ if (found.steps && found.control.focusPath) {
744
+ return (await found.control.focusPath(found.steps, pos)) ?? found.control.el;
747
745
  }
748
746
  if (pos != null) {
749
747
  const prose = leaves.prose(found.key);
@@ -772,7 +770,8 @@
772
770
  export async function focusField(field: DocPath): Promise<void> {
773
771
  const found = await revealLeaf(field);
774
772
  if (!found) return missed(`no mounted field at ${field}`, field);
775
- bloomInside(land(found, undefined));
773
+ const box = await land(found, undefined);
774
+ if (span.alive) bloomInside(box);
776
775
  }
777
776
  /** Seed a card of `kind` and insert it at `at` (default: the end). Returns the new
778
777
  * card's session key, or `undefined` when the quill seeds no card of that kind. */
@@ -802,11 +801,11 @@
802
801
  reportError(onError, { code: 'target-unknown', severity: 'dev', message, path });
803
802
  }
804
803
 
805
- /** What a `DocPath` resolves to in the mounted tree: a leaf key, and the array
806
- * element within it when the address names one. */
804
+ /** What a `DocPath` resolves to in the mounted tree: a leaf key, and the steps from
805
+ * that field to the leaf when the address names one inside it. */
807
806
  interface LeafTarget {
808
807
  key: string;
809
- element?: number;
808
+ steps?: PathStep[];
810
809
  }
811
810
  /** A resolved target with the handle the registry holds for it. */
812
811
  type Landed = LeafTarget & { control: FieldControl };
@@ -816,11 +815,13 @@
816
815
  * diagnostics take.
817
816
  *
818
817
  * Two rungs, because the boundary mints addresses at a finer granularity than
819
- * `Addr` can name: a `richtext[]` element surfaces as `main.keywords[0]`. The
820
- * second rung reads that trailing index segment under a field the schema declares
821
- * an array — which is why the ladder is the editor's (VISUAL_EDITOR.md §Surface):
822
- * the preview carries no schema, and truncating the address there is worse than
823
- * guessing, since `pos` is an offset into the element's own content.
818
+ * `Addr` can name: a `richtext[]` element surfaces as `main.keywords[0]`, a nested
819
+ * row's cell as `main.vectors[0].tours[2].title`. The second rung reads the steps
820
+ * past the field against the schema, rung by rung — an index under a declared
821
+ * array, a key under a declared object, variant or matrix — which is why the ladder
822
+ * is the editor's (VISUAL_EDITOR.md §Surface): the preview carries no schema, and
823
+ * truncating the address there is worse than guessing, since `pos` is an offset
824
+ * into the leaf's own content.
824
825
  */
825
826
  function leafTargetFor(field: DocPath): LeafTarget | undefined {
826
827
  const direct = addrForFieldPath(field);
@@ -828,22 +829,25 @@
828
829
  const resolved = resolveCardKey(direct, cardIds);
829
830
  return resolved ? { key: fieldKeyToString(resolved) } : undefined;
830
831
  }
831
- const element = elementAddrForFieldPath(field);
832
- if (!element || !isArrayField(element.field)) return undefined;
833
- const resolved = resolveCardKey(element.field, cardIds);
834
- return resolved ? { key: fieldKeyToString(resolved), element: element.index } : undefined;
832
+ const nested = nestedAddrForFieldPath(field);
833
+ if (!nested) return undefined;
834
+ const declared = declaredField(nested.field);
835
+ if (!declared || !isContainer(controlKind(declared))) return undefined;
836
+ if (!schemaAt(declared, nested.steps)) return undefined;
837
+ const resolved = resolveCardKey(nested.field, cardIds);
838
+ return resolved ? { key: fieldKeyToString(resolved), steps: nested.steps } : undefined;
835
839
  }
836
840
 
837
- /** The guard the element rung stands on. Asked of `controlKind`, so what the ladder
838
- * tests is the control the tree mounted — the one holding a `focusElement` — and
839
- * not a second reading of the schema beside it. */
840
- function isArrayField(addr: Addr): boolean {
841
- if (addr.field == null) return false;
841
+ /** The schema the field at `addr` declares, off the live card's kind: the guard the
842
+ * nested rung stands on. Asked of `controlKind` and `schemaAt`, so what the ladder
843
+ * tests is the control the tree mounted — the one holding a `focusPath` — and not a
844
+ * second reading of the schema beside it. */
845
+ function declaredField(addr: Addr): QuillFieldSchema | undefined {
846
+ if (addr.field == null) return undefined;
842
847
  const kind = addr.card == null ? undefined : model.cards[addr.card]?.kind;
843
848
  const schema = quill.schema;
844
849
  const card = addr.card == null ? schema.main : kind ? schema.card_kinds?.[kind] : undefined;
845
- const declared = card?.fields?.[addr.field];
846
- return !!declared && controlKind(declared) === 'array';
850
+ return card?.fields?.[addr.field];
847
851
  }
848
852
  </script>
849
853
 
@@ -854,7 +858,7 @@
854
858
  <div class="qm-primary">
855
859
  <Card
856
860
  bind:this={mainCard}
857
- card={titled(model.main)}
861
+ card={model.main}
858
862
  {doc}
859
863
  {quill}
860
864
  isFirst={true}
@@ -883,7 +887,7 @@
883
887
  rather than being slid across. It is also the shape `animate:` asks for, being the
884
888
  keyed block's only child. -->
885
889
  {#each model.cards as c, i (c.id)}
886
- <div class="qm-card-slot" bind:this={slotEls[i]} animate:reorder={isReordering}>
890
+ <div class="qm-card-slot" bind:this={slotEls[i]} animate:reorder={arm.armed}>
887
891
  <Card
888
892
  bind:this={cardRefs[i]}
889
893
  card={titled(c)}
@@ -953,7 +957,7 @@
953
957
  <div class="qm-menu-surface" data-qm-root>
954
958
  {#each kinds as k (k)}
955
959
  <DropdownMenu.Item class="qm-menu-item" onSelect={() => addCard(atIndex, k)}
956
- >{humanize(k)}</DropdownMenu.Item
960
+ >{kindTitle(quill.schema.card_kinds?.[k], k)}</DropdownMenu.Item
957
961
  >
958
962
  {/each}
959
963
  </div>
@@ -105,14 +105,25 @@
105
105
  width: 100%;
106
106
  box-sizing: border-box;
107
107
  }
108
- /* The native placeholder is the label rung, not the UA's own grey: a text or
109
- number control's placeholder is the resolved `default:` (Field's `defaultStr`),
110
- which is content and reads at a content's contrast. What says shown-never-written
111
- is the step from the `ink` a written value takes, the same step the prose leaf's
112
- empty state reads (`ProseField`). */
108
+ /* An unset field's text where its `default:` prints (`TextField`, `NumberField`, a
109
+ prose leaf): the value, at the default rung, so it reads as an answer and never as a
110
+ written one. The first edit writes it and the rung goes with the attribute. */
111
+ :where(.qm-input[data-default], .ProseMirror[data-default]) {
112
+ color: var(--_qm-ink-default);
113
+ }
114
+ /* The native placeholder is the label rung, not the UA's own grey: what an input
115
+ ghosts is words about a value it does not hold — an `example:`, `None` — which is
116
+ content and reads at a content's contrast, the same step the prose leaf's ghost
117
+ reads (`prose.css`). */
113
118
  :where(.qm-input)::placeholder {
114
119
  color: var(--_qm-ink-label);
115
120
  }
121
+ /* The note a held prose leaf draws inside its box, under the text it is about. */
122
+ :where(.qm-prose-held-note) {
123
+ display: block;
124
+ font-size: var(--_qm-text-label);
125
+ color: var(--_qm-ink-label);
126
+ }
116
127
 
117
128
  /* The second recipe, and it is not the box above: a field is a box because you
118
129
  type into it. A button rests with no fill; the surface arrives on hover, off the
@@ -255,6 +266,37 @@
255
266
  transform: translate(-50%, -50%);
256
267
  }
257
268
 
269
+ /* The capacity ladder: how many tracks a grid of fields steps to, off the width of
270
+ the query container it stands in. One recipe, because three grids read it — a section's
271
+ fields (`Card.svelte`), a subform's properties (`ObjectField`), each over its own
272
+ container, and a compact matrix's members (`MatrixField`) — and a ladder kept in
273
+ several files agrees until one is retuned. Capacity is
274
+ the container's, not JavaScript's: nothing measures, so there is no observer to loop
275
+ and no re-packing to restructure the DOM under a prose leaf.
276
+
277
+ It steps 1 → 2 → 4, skipping 3: each rung is the width at which a track still clears
278
+ the comfortable field minimum, and an even capacity is what lets the half a subform
279
+ of one takes (`--cols-half`) land on a track boundary. A field ends on its track's
280
+ own edge, so a rung is `n * 220px + gaps`. A subform three rungs in stands three
281
+ `--_qm-nest` and three verticals narrower than its section, and steps down where the
282
+ section does not: the count is the width the tracks actually get. */
283
+ :where(.qm-tracks) {
284
+ --cols: 1;
285
+ --cols-half: 1;
286
+ }
287
+ @container (min-width: 28rem) {
288
+ :where(.qm-tracks) {
289
+ --cols: 2;
290
+ --cols-half: 1;
291
+ }
292
+ }
293
+ @container (min-width: 57rem) {
294
+ :where(.qm-tracks) {
295
+ --cols: 4;
296
+ --cols-half: 2;
297
+ }
298
+ }
299
+
258
300
  /* A floating list of choices: the enum's listbox and the card stack's kind menu.
259
301
  Both portal into the nearest `[data-qm-root]`, and both are picked from, so the
260
302
  surface (its edge, its inset, its ink) and the item (its pad, its radius, the
@@ -1,4 +1,4 @@
1
- import type { Diagnostic } from '@quillmark/wasm';
1
+ import type { Diagnostic, PathStep } from '@quillmark/wasm';
2
2
  /** A field's routing address, and `/core`'s `Addr` structurally: an `Addr` is a
3
3
  * positional `FieldKey`, so `nearestAddrForFieldPath` is the path→key walk and
4
4
  * routing carries no second copy of the grammar. `card` is `undefined` for the main
@@ -40,10 +40,34 @@ export interface RoutedDiagnostic {
40
40
  * Completeness stays a read the host makes on `quill.validate(doc)`.
41
41
  */
42
42
  export declare function routeAndResolve(diagnostics: Diagnostic[] | undefined, cardIds: readonly string[]): RoutedDiagnostic[];
43
+ /** A diagnostic with the steps still to walk from the control holding it. */
44
+ export interface DeepDiagnostic {
45
+ steps: readonly PathStep[];
46
+ diagnostic: Diagnostic;
47
+ }
48
+ /** A field's routed diagnostics, each with its steps past the field: none for a
49
+ * diagnostic anchored at the field itself, or one whose path is not a nested one
50
+ * (a local commit error carries the field's own path). */
51
+ export declare function deepen(diagnostics: Diagnostic[] | undefined): DeepDiagnostic[];
52
+ /** One rung of the walk: the diagnostics anchored here, and the rest bucketed by the
53
+ * step they take next, each with that step consumed. */
54
+ export interface DeepSplit {
55
+ here: Diagnostic[];
56
+ below: Map<PathStep, DeepDiagnostic[]>;
57
+ }
58
+ export declare function splitDeep(list: readonly DeepDiagnostic[] | undefined): DeepSplit;
59
+ /** The diagnostics a rung cannot hand down: the ones anchored here, plus every one
60
+ * whose next step names nothing in `held`. Drawn at the rung's own foot, so a leaf the
61
+ * tree does not draw still has its message read somewhere the user can see. */
62
+ export declare function unrouted(split: DeepSplit, held: (step: PathStep) => boolean): Diagnostic[];
43
63
  /**
44
64
  * Merge N routed groups into `Map<fieldKeyToString(key), Diagnostic[]>`.
45
- * Dedupes an identical `(severity, message)` pair landing on the same key from
65
+ * Dedupes an identical `(path, severity, message)` quad landing on the same key from
46
66
  * more than one group (e.g. the same error present in both `validate()` and
47
67
  * an external feed). Producer order within a key.
68
+ *
69
+ * The key is the field, but the walk past it is the `path`'s ({@link deepen}), so two
70
+ * cells of one container share a key and differ only there: without `path` in the
71
+ * quad, one message failing two rows draws on the first row alone.
48
72
  */
49
73
  export declare function mergeDiagnostics(...groups: RoutedDiagnostic[][]): Map<string, Diagnostic[]>;
@@ -1,4 +1,4 @@
1
- import { nearestAddrForFieldPath } from '../core/address.js';
1
+ import { nearestAddrForFieldPath, nestedAddrForFieldPath } from '../core/address.js';
2
2
  /** Canonical string key for a `FieldKey`: the routing `Map`'s key space. */
3
3
  export function fieldKeyToString(k) {
4
4
  return `${k.card ?? 'main'}:${k.field ?? '$body'}`;
@@ -42,11 +42,53 @@ export function routeAndResolve(diagnostics, cardIds) {
42
42
  }
43
43
  return out;
44
44
  }
45
+ /** A field's routed diagnostics, each with its steps past the field: none for a
46
+ * diagnostic anchored at the field itself, or one whose path is not a nested one
47
+ * (a local commit error carries the field's own path). */
48
+ export function deepen(diagnostics) {
49
+ return (diagnostics ?? []).map((d) => ({
50
+ steps: (d.path && nestedAddrForFieldPath(d.path)?.steps) || [],
51
+ diagnostic: d
52
+ }));
53
+ }
54
+ export function splitDeep(list) {
55
+ const here = [];
56
+ const below = new Map();
57
+ for (const d of list ?? []) {
58
+ if (d.steps.length === 0) {
59
+ here.push(d.diagnostic);
60
+ continue;
61
+ }
62
+ const [step, ...rest] = d.steps;
63
+ const bucket = below.get(step);
64
+ const next = { steps: rest, diagnostic: d.diagnostic };
65
+ if (bucket)
66
+ bucket.push(next);
67
+ else
68
+ below.set(step, [next]);
69
+ }
70
+ return { here, below };
71
+ }
72
+ /** The diagnostics a rung cannot hand down: the ones anchored here, plus every one
73
+ * whose next step names nothing in `held`. Drawn at the rung's own foot, so a leaf the
74
+ * tree does not draw still has its message read somewhere the user can see. */
75
+ export function unrouted(split, held) {
76
+ const out = [...split.here];
77
+ for (const [step, list] of split.below)
78
+ if (!held(step))
79
+ for (const d of list)
80
+ out.push(d.diagnostic);
81
+ return out;
82
+ }
45
83
  /**
46
84
  * Merge N routed groups into `Map<fieldKeyToString(key), Diagnostic[]>`.
47
- * Dedupes an identical `(severity, message)` pair landing on the same key from
85
+ * Dedupes an identical `(path, severity, message)` quad landing on the same key from
48
86
  * more than one group (e.g. the same error present in both `validate()` and
49
87
  * an external feed). Producer order within a key.
88
+ *
89
+ * The key is the field, but the walk past it is the `path`'s ({@link deepen}), so two
90
+ * cells of one container share a key and differ only there: without `path` in the
91
+ * quad, one message failing two rows draws on the first row alone.
50
92
  */
51
93
  export function mergeDiagnostics(...groups) {
52
94
  const byKey = new Map();
@@ -54,9 +96,14 @@ export function mergeDiagnostics(...groups) {
54
96
  for (const group of groups) {
55
97
  for (const { key, diagnostic } of group) {
56
98
  const k = fieldKeyToString(key);
57
- // Collision-proof key: JSON-encode the triple so arbitrary `message` text
58
- // can never merge two distinct triples into one (a real diagnostic dropped).
59
- const dedupeKey = JSON.stringify([k, diagnostic.severity, diagnostic.message]);
99
+ // Collision-proof key: JSON-encode the quad so arbitrary `message` text
100
+ // can never merge two distinct quads into one (a real diagnostic dropped).
101
+ const dedupeKey = JSON.stringify([
102
+ k,
103
+ diagnostic.path ?? null,
104
+ diagnostic.severity,
105
+ diagnostic.message
106
+ ]);
60
107
  if (seen.has(dedupeKey))
61
108
  continue;
62
109
  seen.add(dedupeKey);
@@ -2,7 +2,7 @@
2
2
  // the layout, and what fills the viewport as a section collapses is that section's own
3
3
  // content, whose top never moves.
4
4
  import { flushSync } from 'svelte';
5
- import { rungMs } from './motion.js';
5
+ import { rungMs, rungStyle } from '../core/rungs.js';
6
6
  /**
7
7
  * Run `change` and keep `anchor` inside the fold for the length of the move.
8
8
  *
@@ -26,7 +26,7 @@ export function holdInView(anchor, change) {
26
26
  flushSync();
27
27
  const reveal = () => anchor.scrollIntoView({ block: 'nearest', behavior: 'instant' });
28
28
  reveal();
29
- const rung = rungMs(getComputedStyle(anchor), '--_qm-duration-slow');
29
+ const rung = rungMs(rungStyle(anchor), '--_qm-duration-slow');
30
30
  if (rung === undefined)
31
31
  return;
32
32
  const until = performance.now() + rung;
@@ -116,6 +116,9 @@ export declare const ICONS: {
116
116
  readonly minus: readonly [readonly ["path", {
117
117
  readonly d: "M5 12h14";
118
118
  }]];
119
+ readonly check: readonly [readonly ["path", {
120
+ readonly d: "M20 6 9 17l-5-5";
121
+ }]];
119
122
  };
120
123
  /** The closed set. A name that is not here is a compile error at the call site,
121
124
  * which is what a string key buys over thirteen imported components. */