@quillmark/svelte 0.11.0 → 0.12.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 (37) 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.js +21 -15
  5. package/dist/core/codec/encode.js +1 -1
  6. package/dist/core/codec/islands.d.ts +5 -4
  7. package/dist/core/codec/islands.js +23 -38
  8. package/dist/core/codec/slash.js +0 -0
  9. package/dist/core/rungs.d.ts +8 -0
  10. package/dist/core/rungs.js +24 -0
  11. package/dist/core/theme.css +10 -0
  12. package/dist/visual/ArrayField.svelte +596 -167
  13. package/dist/visual/ArrayField.svelte.d.ts +14 -1
  14. package/dist/visual/BooleanField.svelte +10 -7
  15. package/dist/visual/Card.svelte +3 -22
  16. package/dist/visual/Field.svelte +69 -29
  17. package/dist/visual/MatrixField.svelte +352 -0
  18. package/dist/visual/MatrixField.svelte.d.ts +36 -0
  19. package/dist/visual/ObjectField.svelte +351 -133
  20. package/dist/visual/ObjectField.svelte.d.ts +17 -2
  21. package/dist/visual/VariantField.svelte +55 -2
  22. package/dist/visual/VariantField.svelte.d.ts +10 -1
  23. package/dist/visual/VisualEditorInner.svelte +53 -49
  24. package/dist/visual/controls.css +31 -0
  25. package/dist/visual/diagnostics.d.ts +26 -2
  26. package/dist/visual/diagnostics.js +52 -5
  27. package/dist/visual/hold.js +2 -2
  28. package/dist/visual/icons/nodes.d.ts +3 -0
  29. package/dist/visual/icons/nodes.js +2 -1
  30. package/dist/visual/leaves.d.ts +25 -12
  31. package/dist/visual/motion.d.ts +14 -5
  32. package/dist/visual/motion.js +13 -11
  33. package/dist/visual/strings.d.ts +19 -9
  34. package/dist/visual/strings.js +6 -2
  35. package/dist/visual/structure.d.ts +94 -9
  36. package/dist/visual/structure.js +229 -12
  37. 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">
@@ -81,6 +129,7 @@
81
129
  {#if cells}
82
130
  {#key member}
83
131
  <ObjectField
132
+ bind:this={cellsEl}
84
133
  value={value ?? {}}
85
134
  properties={cells}
86
135
  {label}
@@ -89,9 +138,13 @@
89
138
  {describedBy}
90
139
  {contentAt}
91
140
  onCommit={(obj) => onCommit(obj)}
141
+ diagnostics={inside}
92
142
  />
93
143
  {/key}
94
144
  {/if}
145
+ <!-- The field's own foot, drawn whether or not a world is: what the live world
146
+ cannot hold belongs under the field rather than inside the box its cells sit in. -->
147
+ <DiagnosticList diagnostics={foot} />
95
148
  </div>
96
149
 
97
150
  <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,6 +49,8 @@
47
49
  import {
48
50
  IdSeq,
49
51
  controlKind,
52
+ isContainer,
53
+ schemaAt,
50
54
  fieldModels,
51
55
  groupOrder,
52
56
  groupSections,
@@ -75,7 +79,7 @@
75
79
  } from './diagnostics.js';
76
80
  import { fieldDomIds, groupPanelId } from './domid.js';
77
81
  import { createLeafRegistry, type FieldControl } from './leaves.js';
78
- import { reorder, reorderTrips } from './motion.js';
82
+ import { reorder, reorderArm, reorderTrips } from './motion.js';
79
83
  import { tipsChannel } from './tips.js';
80
84
  import { patchEditorExt } from './ext.js';
81
85
  import Card from './Card.svelte';
@@ -424,26 +428,17 @@
424
428
  return undefined;
425
429
  }
426
430
  }
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;
431
+ // The reorder gesture's arming window (`motion.ts`): the reconcile that moves a slot
432
+ // is the trip, and every other reconcile that happens to move one is not.
433
+ const arm = reorderArm();
434
+ span.onEnd(arm.cancel);
440
435
 
441
436
  function moveCardById(id: string, dir: -1 | 1): void {
442
437
  const from = cardIndexOf(id);
443
438
  if (from < 0) return;
444
439
  const to = from + dir;
445
440
  if (to < 0 || to >= doc.cardCount) return;
446
- armReorder();
441
+ arm.arm();
447
442
  doc.moveCard(from, to);
448
443
  const w = cardIds.slice();
449
444
  const [x] = w.splice(from, 1);
@@ -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
 
@@ -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)}
@@ -255,6 +255,37 @@
255
255
  transform: translate(-50%, -50%);
256
256
  }
257
257
 
258
+ /* The capacity ladder: how many tracks a grid of fields steps to, off the width of
259
+ the query container it stands in. One recipe, because three grids read it — a section's
260
+ fields (`Card.svelte`), a subform's properties (`ObjectField`), each over its own
261
+ container, and a compact matrix's members (`MatrixField`) — and a ladder kept in
262
+ several files agrees until one is retuned. Capacity is
263
+ the container's, not JavaScript's: nothing measures, so there is no observer to loop
264
+ and no re-packing to restructure the DOM under a prose leaf.
265
+
266
+ It steps 1 → 2 → 4, skipping 3: each rung is the width at which a track still clears
267
+ the comfortable field minimum, and an even capacity is what lets the half a subform
268
+ of one takes (`--cols-half`) land on a track boundary. A field ends on its track's
269
+ own edge, so a rung is `n * 220px + gaps`. A subform three rungs in stands three
270
+ `--_qm-nest` and three verticals narrower than its section, and steps down where the
271
+ section does not: the count is the width the tracks actually get. */
272
+ :where(.qm-tracks) {
273
+ --cols: 1;
274
+ --cols-half: 1;
275
+ }
276
+ @container (min-width: 28rem) {
277
+ :where(.qm-tracks) {
278
+ --cols: 2;
279
+ --cols-half: 1;
280
+ }
281
+ }
282
+ @container (min-width: 57rem) {
283
+ :where(.qm-tracks) {
284
+ --cols: 4;
285
+ --cols-half: 2;
286
+ }
287
+ }
288
+
258
289
  /* A floating list of choices: the enum's listbox and the card stack's kind menu.
259
290
  Both portal into the nearest `[data-qm-root]`, and both are picked from, so the
260
291
  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. */
@@ -63,5 +63,6 @@ export const ICONS = {
63
63
  ['path', { d: 'M3 6h18' }],
64
64
  ['path', { d: 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' }]
65
65
  ],
66
- minus: [['path', { d: 'M5 12h14' }]]
66
+ minus: [['path', { d: 'M5 12h14' }]],
67
+ check: [['path', { d: 'M20 6 9 17l-5-5' }]]
67
68
  };
@@ -1,4 +1,12 @@
1
+ import type { PathStep } from '@quillmark/wasm';
1
2
  import type { FieldController } from '../core/codec/index.js';
3
+ /**
4
+ * What a landing at depth hands back: the box the arrival wash blooms in, now or once
5
+ * the row it had to open has rendered. A row two closed boxes down does not exist
6
+ * until the row above it opens and flushes, so the innermost box is a promise wherever
7
+ * the walk had to open something on the way.
8
+ */
9
+ export type LandingBox = HTMLElement | undefined | Promise<HTMLElement | undefined>;
2
10
  /**
3
11
  * A mounted field's landing handle: focus it, and the element the arrival wash blooms
4
12
  * inside ({@link import('../core/bloom.js').bloomInside}).
@@ -10,22 +18,27 @@ import type { FieldController } from '../core/codec/index.js';
10
18
  export interface FieldControl {
11
19
  focus(): void;
12
20
  /**
13
- * Land in element `index` of a control that has elements, at USV `pos` where the
14
- * row's control can take one; resolving the index to the element's session id at
15
- * the call, with {@link focus}'s own answer for an index past the live list. Absent
16
- * on every control without elements.
21
+ * Land at `steps` inside a control that has an inside — an array's element, an
22
+ * object's property, a variant's cell, a matrix's member, and any of them under any
23
+ * other — at USV `pos` where the leaf the walk ends in can take one. Each rung
24
+ * resolves its own step at the call (an index to a session id, a key to a mounted
25
+ * cell) and hands the rest to the control it opened, so nothing positional is
26
+ * carried. A step the tree does not hold falls back to that rung's own
27
+ * {@link focus}: the field is right and the row is gone. Absent on every control
28
+ * with no inside.
17
29
  *
18
30
  * A landing, not a focus: an absent `pos` is the placement rung, exactly as on
19
- * `Landing`, and what a `pos` means is the row control's, the same way focusing is.
20
- * The place rides the call because the registry stays parent-keyed: a per-element
21
- * key is positional, in a registry whose doctrine is dodging positional churn.
31
+ * `Landing`, and what a `pos` means is the leaf's, the same way focusing is. The
32
+ * place rides the call because the registry stays parent-keyed: a per-element key is
33
+ * positional, in a registry whose doctrine is dodging positional churn.
22
34
  *
23
- * Returns the element's own box, which is what the wash blooms in: the address
24
- * named one row, and a wash over the whole repeater says the field where the click
25
- * said the row. `undefined` is the fallback the index past the live list took, and
26
- * reads as {@link el}.
35
+ * Returns the innermost row the address named, which is what the wash blooms in: a
36
+ * box wider than the one the click resolved says the field where the pick said the
37
+ * row. `undefined` is a landing that named no row — a property under a field-level
38
+ * subform, or the fallback an index past the live list took — and reads as
39
+ * {@link el}.
27
40
  */
28
- focusElement?(index: number, pos?: number): HTMLElement | undefined;
41
+ focusPath?(steps: readonly PathStep[], pos?: number): LandingBox;
29
42
  readonly el: HTMLElement;
30
43
  }
31
44
  /**
@@ -3,11 +3,20 @@ import type { AnimationConfig } from 'svelte/animate';
3
3
  * position for its whole length, so anything that measures the node's box waits on
4
4
  * these rather than reading the one a transform is holding. */
5
5
  export declare function reorderTrips(node: HTMLElement): Animation[];
6
- /** A duration rung off `style`, in ms; `undefined` where the derivation is out of reach
7
- * (an unstyled root, or jsdom, where `getComputedStyle` reports custom properties as
8
- * empty). No rung, no motion: a fallback here would be the scale restated in the one
9
- * place `check:style` cannot read. */
10
- export declare function rungMs(style: CSSStyleDeclaration, rung: string): number | undefined;
6
+ /**
7
+ * The reorder gesture's arming window, for a keyed list that takes the trip. The
8
+ * reconcile that moves a slot is the trip, and every other reconcile that happens to
9
+ * move one is not; so the command arms the gesture and the frame it lands in disarms
10
+ * it, `animate:` asking at apply time, a microtask after the mutation and well inside
11
+ * that frame. `armed` is a getter rather than a value, so the flag stays out of the
12
+ * template and needs no reactivity for it. `cancel` is for the surface's teardown.
13
+ */
14
+ export interface ReorderArm {
15
+ arm(): void;
16
+ armed(): boolean;
17
+ cancel(): void;
18
+ }
19
+ export declare function reorderArm(): ReorderArm;
11
20
  /**
12
21
  * Slide a keyed slot from where it was to where it now is.
13
22
  *