@quillmark/svelte 0.16.0 → 0.18.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 (54) hide show
  1. package/README.md +4 -2
  2. package/dist/core/codec/index.d.ts +0 -3
  3. package/dist/core/rebind.svelte.js +5 -8
  4. package/dist/core/theme.css +12 -1
  5. package/dist/preset/index.d.ts +1 -0
  6. package/dist/preview/Preview.svelte +14 -19
  7. package/dist/preview/Preview.svelte.d.ts +3 -3
  8. package/dist/preview/index.d.ts +0 -1
  9. package/dist/visual/ArrayField.svelte +44 -41
  10. package/dist/visual/ArrayField.svelte.d.ts +0 -1
  11. package/dist/visual/BooleanField.svelte +16 -14
  12. package/dist/visual/BooleanField.svelte.d.ts +0 -1
  13. package/dist/visual/Card.svelte +65 -35
  14. package/dist/visual/CardControls.svelte.d.ts +0 -1
  15. package/dist/visual/DateField.svelte +14 -17
  16. package/dist/visual/DateField.svelte.d.ts +0 -1
  17. package/dist/visual/EnumField.svelte +9 -12
  18. package/dist/visual/EnumField.svelte.d.ts +0 -1
  19. package/dist/visual/Field.svelte +17 -10
  20. package/dist/visual/Field.svelte.d.ts +3 -0
  21. package/dist/visual/FieldHint.svelte.d.ts +0 -1
  22. package/dist/visual/FieldLabel.svelte +1 -2
  23. package/dist/visual/FormatPopover.svelte +22 -27
  24. package/dist/visual/FormatPopover.svelte.d.ts +0 -1
  25. package/dist/visual/MatrixAdd.svelte +210 -0
  26. package/dist/visual/MatrixAdd.svelte.d.ts +15 -0
  27. package/dist/visual/MatrixField.svelte +329 -84
  28. package/dist/visual/MatrixField.svelte.d.ts +8 -6
  29. package/dist/visual/NumberField.svelte +6 -10
  30. package/dist/visual/NumberField.svelte.d.ts +0 -1
  31. package/dist/visual/ObjectField.svelte +30 -17
  32. package/dist/visual/ObjectField.svelte.d.ts +3 -1
  33. package/dist/visual/ProseField.svelte +42 -41
  34. package/dist/visual/ProseField.svelte.d.ts +0 -1
  35. package/dist/visual/ProseValue.svelte +56 -56
  36. package/dist/visual/ProseValue.svelte.d.ts +0 -1
  37. package/dist/visual/SlashMenu.svelte +5 -15
  38. package/dist/visual/SlashMenu.svelte.d.ts +0 -1
  39. package/dist/visual/TextField.svelte +5 -9
  40. package/dist/visual/TextField.svelte.d.ts +0 -1
  41. package/dist/visual/TipsCard.svelte +10 -11
  42. package/dist/visual/TipsCard.svelte.d.ts +0 -1
  43. package/dist/visual/VisualEditorInner.svelte +21 -14
  44. package/dist/visual/VisualEditorInner.svelte.d.ts +0 -1
  45. package/dist/visual/checklists.d.ts +82 -0
  46. package/dist/visual/checklists.js +171 -0
  47. package/dist/visual/index.d.ts +0 -1
  48. package/dist/visual/strings.d.ts +15 -1
  49. package/dist/visual/strings.js +6 -0
  50. package/dist/visual/structure.d.ts +63 -31
  51. package/dist/visual/structure.js +122 -44
  52. package/package.json +20 -16
  53. package/dist/visual/synced.svelte.d.ts +0 -15
  54. package/dist/visual/synced.svelte.js +0 -34
package/README.md CHANGED
@@ -8,7 +8,7 @@ Editor + live-preview components for [Quillmark](https://github.com/borb-sh/quil
8
8
  npm install @quillmark/svelte
9
9
  ```
10
10
 
11
- `svelte@^5` and `@quillmark/wasm` are peer dependencies: the session's handles cross the package boundary, so the consumer supplies the one copy both sides mint them from.
11
+ `svelte@^5.33` and `@quillmark/wasm` are peer dependencies: the session's handles cross the package boundary, so the consumer supplies the one copy both sides mint them from.
12
12
 
13
13
  ## Subpaths
14
14
 
@@ -46,6 +46,8 @@ const session = await new Engine().open(quill, doc);
46
46
  // free() on teardown the handles you MINTED: this quill, this doc, this session.
47
47
  ```
48
48
 
49
+ `<VisualEditor>` needs no session, and the first `open` loads the render build, so a host that mounts the editor over `doc` and `quill` first shows it without waiting on that load. The session opens behind it: `await engine.load(quill)`, then `engine.open(quill, doc)`, so its first compile reads what was typed meanwhile ([HOSTING.md](prose/canon/HOSTING.md)).
50
+
49
51
  ## The quill resolves from the document
50
52
 
51
53
  A stored document carries none of its quill's bytes, only a reference: `doc.quillRef` is `name@version`, persisted in the markdown itself. Resolution is **host code**: read the ref, map it to a `Quill`, open. No surface resolves, so one resolution per document holds by construction.
@@ -58,7 +60,7 @@ const session = await new Engine().open(quill, doc);
58
60
 
59
61
  `@quillmark/quiver` is one such registry, and the quill it hands back is **borrowed**: it is cached per ref and shared with every caller, so freeing it strands the next one.
60
62
 
61
- Opening a document that names a **different** quill is the same sequence, in order: resolve the new ref, `engine.open(quill, next)`, swap the props, then free the replaced handles that were yours to free. `<VisualEditor>` re-keys itself on the new `doc` (see below); `<Preview>` swaps by remount (`{#key session}`).
63
+ Opening a document that names a **different** quill is the same sequence, in order: resolve the new ref, swap the editor's props, free the replaced handles that were yours to free, then open the next session behind the editor. `<VisualEditor>` re-keys itself on the new `doc` (see below); `<Preview>` swaps by remount (`{#key session}`).
62
64
 
63
65
  ## What a document is trusted to be
64
66
 
@@ -1,6 +1,3 @@
1
- import 'prosemirror-view/style/prosemirror.css';
2
- import 'prosemirror-gapcursor/style/gapcursor.css';
3
- import './prose.css';
4
1
  export { rangeAnchor } from './anchor.js';
5
2
  export type { RangeAnchor } from './anchor.js';
6
3
  export { createField, emptyContent, heldAttributes, proseAttributes, proseLeafPlugins } from './field.js';
@@ -1,16 +1,13 @@
1
- // The remount contract, enforced. `Preview` binds its props once, in `onMount`, and
2
- // observes nothing after: the vanilla core below it owns scroll position, mounted
3
- // slots and observer sets that a remount would discard on every apply. So a prop
4
- // swapped in place is reported, not dropped in silence, and thrown in a dev build.
1
+ // The remount contract, enforced. `Preview` binds its props once, when its container
2
+ // mounts, and observes nothing after: the vanilla core below it owns scroll position,
3
+ // mounted slots and observer sets that a remount would discard on every apply. So a
4
+ // prop swapped in place is reported, not dropped in silence, and thrown in a dev build.
5
5
  //
6
6
  // A guard over one prop of a set is worse than none: a consumer who learns the
7
7
  // surface complains when it ignores a prop reads silence on the rest as reactivity.
8
8
  // The snapshot is every once-bound prop, and one report names whichever went stale.
9
+ import { DEV } from 'esm-env';
9
10
  import { reportError } from './errors.js';
10
- // True in a dev build whose bundler injects `import.meta.env`. Cast, since the package
11
- // declares no bundler's ambient types; false where nothing injects it, so a toolchain
12
- // that does not answer gets the report alone.
13
- const DEV = import.meta.env?.DEV === true;
14
11
  /**
15
12
  * Report the violation, then throw it in a dev build. Both surfaces raise it here; the
16
13
  * guards themselves stay separate, since the preview snapshots its props once and the
@@ -316,7 +316,14 @@
316
316
  for the type half. Asymmetric because the vertical is what leading leaves once
317
317
  it has taken the line box, and the horizontal is how far in the text starts,
318
318
  which nothing else fixes. */
319
- --_qm-inset-control: var(--_qm-space) var(--_qm-space-3);
319
+ --_qm-inset-control-block: var(--_qm-space);
320
+ --_qm-inset-control: var(--_qm-inset-control-block) var(--_qm-space-3);
321
+ /* A box's first line, drawn: its line box and the inset over and under it. What a
322
+ row of boxes lines up on however far one of them wraps, and so what a mark with no
323
+ line of its own centres on to stand in that row (`BooleanField`). */
324
+ --_qm-box-line: calc(
325
+ var(--_qm-text-body) * var(--_qm-leading-body) + 2 * var(--_qm-inset-control-block)
326
+ );
320
327
  /* The nesting step: the distance from one structural stroke to the next, and what
321
328
  makes the depths read as a ladder rather than as indents that happen to look
322
329
  alike. A rung because the boxes holding that distance are in two components and
@@ -349,6 +356,10 @@
349
356
  name it and no label of its own to fit. A threshold rather than a rhythm choice,
350
357
  so it does not move with `--qm-space`. */
351
358
  --_qm-track-min: 7rem;
359
+ /* A prose column's floor: its cell wraps, and at `--_qm-track-min` a line holds a
360
+ word, standing the row many lines tall beside cells one line tall. A multiple of
361
+ the floor, so the two move together. */
362
+ --_qm-track-prose: calc(var(--_qm-track-min) * 2);
352
363
 
353
364
  /* ── Type ────────────────────────────────────────────────────────────────
354
365
  `--qm-font-size` anchors the chrome rung; 1.125 is the ratio between adjacent
@@ -0,0 +1 @@
1
+ export {};
@@ -7,7 +7,7 @@
7
7
  imperatively (e.g. after `session.apply` elsewhere).
8
8
  -->
9
9
  <script lang="ts">
10
- import { onMount } from 'svelte';
10
+ import { untrack } from 'svelte';
11
11
  import { createPreview, type PreviewController } from './controller.js';
12
12
  import { guardRebind } from '../core/rebind.svelte.js';
13
13
  import type { PreviewStringsInput } from './strings.js';
@@ -16,9 +16,9 @@
16
16
  import type { EditorErrorHandler } from '../core/errors.js';
17
17
 
18
18
  /**
19
- * Remount contract. `createPreview` binds once in `onMount`; a later change to any
20
- * prop it closed over (`session`, `margin`, `onPick`, `onError`, `strings`) is not
21
- * observed, and each reports `rebind-ignored` when swapped. Swap
19
+ * Remount contract. `createPreview` binds once, when its container mounts; a later
20
+ * change to any prop it closed over (`session`, `margin`, `onPick`, `onError`,
21
+ * `strings`) is not observed, and each reports `rebind-ignored` when swapped. Swap
22
22
  * the session by remounting (`{#key session}`, as the playground does); drive
23
23
  * in-place edits through the `refresh(change)` method, not a prop change.
24
24
  *
@@ -44,7 +44,6 @@
44
44
 
45
45
  let { session, margin, onPick, onError, strings, class: className, style }: Props = $props();
46
46
 
47
- let containerEl: HTMLDivElement | undefined = $state();
48
47
  let controller: PreviewController | undefined;
49
48
 
50
49
  guardRebind(
@@ -52,20 +51,16 @@
52
51
  'Remount the preview ({#key session}) to rebind.'
53
52
  );
54
53
 
55
- onMount(() => {
56
- if (!containerEl) return;
57
- controller = createPreview(session, {
58
- container: containerEl,
59
- margin,
60
- onPick,
61
- onError,
62
- strings
54
+ function mountPreview(container: HTMLDivElement): () => void {
55
+ return untrack(() => {
56
+ const mounted = createPreview(session, { container, margin, onPick, onError, strings });
57
+ controller = mounted;
58
+ return () => {
59
+ mounted.destroy();
60
+ controller = undefined;
61
+ };
63
62
  });
64
- return () => {
65
- controller?.destroy();
66
- controller = undefined;
67
- };
68
- });
63
+ }
69
64
 
70
65
  export function refresh(change: ChangeSet): void {
71
66
  controller?.refresh(change);
@@ -88,7 +83,7 @@
88
83
  }
89
84
  </script>
90
85
 
91
- <div bind:this={containerEl} class="qm-preview {className ?? ''}" {style} data-qm-root></div>
86
+ <div {@attach mountPreview} class="qm-preview {className ?? ''}" {style} data-qm-root></div>
92
87
 
93
88
  <style>
94
89
  /* A detached root: the preview is not a descendant of the editor, so it carries
@@ -3,9 +3,9 @@ import type { LiveSession, ChangeSet } from '@quillmark/wasm';
3
3
  import type { DocPath, Landing, Place } from '../core/address.js';
4
4
  import type { EditorErrorHandler } from '../core/errors.js';
5
5
  /**
6
- * Remount contract. `createPreview` binds once in `onMount`; a later change to any
7
- * prop it closed over (`session`, `margin`, `onPick`, `onError`, `strings`) is not
8
- * observed, and each reports `rebind-ignored` when swapped. Swap
6
+ * Remount contract. `createPreview` binds once, when its container mounts; a later
7
+ * change to any prop it closed over (`session`, `margin`, `onPick`, `onError`,
8
+ * `strings`) is not observed, and each reports `rebind-ignored` when swapped. Swap
9
9
  * the session by remounting (`{#key session}`, as the playground does); drive
10
10
  * in-place edits through the `refresh(change)` method, not a prop change.
11
11
  *
@@ -1,4 +1,3 @@
1
- import '../core/theme.css';
2
1
  export { createPreview } from './controller.js';
3
2
  export type { PreviewOptions, PreviewController } from './controller.js';
4
3
  export { default as Preview } from './Preview.svelte';
@@ -12,12 +12,12 @@
12
12
  neither a screen reader nor a touch user, and the count is the carrier.
13
13
 
14
14
  An `object` element collapses: the row is its own summary — a box, titled by the
15
- row's first short text cell ({@link rowSummary}) — and opens onto {@link ObjectField},
16
- one at a time. Stacking the subforms instead would nest a field one level past the
17
- depth the subform's own vertical draws, once per row.
18
- Opening is therefore part of a landing rather than something the user does first:
15
+ row's first short text cell ({@link rowSummary}) — and opens onto {@link ObjectField}.
16
+ Each row opens and closes on its own: a press on a summary toggles that row, and
17
+ opening one leaves every other where it stands, so a landing never folds the row the
18
+ author was in. Opening is part of a landing rather than something the user does first:
19
19
  `focusPath` opens the row it is aimed at before it focuses, and hands the rest of the
20
- walk to the subform it opened, which is how a row two closed boxes down is reached.
20
+ walk to that row's subform, which is how a row two closed boxes down is reached.
21
21
 
22
22
  The table (`layout: 'table'`, VISUAL_EDITOR §"Structure mirrors the schema") is the
23
23
  same row machine in another presentation: the same ids, the same splices, the same
@@ -54,6 +54,7 @@
54
54
  Removing the last row commits `[]`, the empty answer.
55
55
  -->
56
56
  <script lang="ts">
57
+ import { SvelteSet } from 'svelte/reactivity';
57
58
  import { wording } from './strings.js';
58
59
 
59
60
  // The surface's words, ambient from the editor root; the package's English
@@ -76,7 +77,6 @@
76
77
  } from './structure.js';
77
78
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
78
79
  import type { LandingBox } from './leaves.js';
79
- import { holdInView } from './hold.js';
80
80
  import { reorder, reorderArm } from './motion.js';
81
81
  import Icon from './icons/Icon.svelte';
82
82
  import TextField from './TextField.svelte';
@@ -214,7 +214,7 @@
214
214
  const rowEls: Record<string, HTMLElement | undefined> = $state({});
215
215
  // A table row's cells, on the same key: the subform a landing and the column-keeping
216
216
  // Enter hop reach into. Every table row is open, so there is one per row where the
217
- // list holds one for the open row alone.
217
+ // list holds one per open row.
218
218
  type Subform = {
219
219
  focus: () => void;
220
220
  focusPath: (steps: readonly PathStep[], pos?: number) => LandingBox;
@@ -226,14 +226,14 @@
226
226
  delete els[id];
227
227
  delete rowEls[id];
228
228
  delete cellEls[id];
229
+ delete objEls[id];
230
+ open.delete(id);
229
231
  }
230
232
 
231
- // ── Object elements: one open at a time ──────────────────────────────────────
232
- // So an array of ten records is ten lines and one figure, whatever its length.
233
- let openId = $state<string | undefined>(undefined);
234
- // The open row's subform, for the landing below. One entry, never a map: only one
235
- // row is open, so the ref is singular by the same rule the state is.
236
- let openObjEl = $state<Subform | undefined>();
233
+ // ── Object elements: each row open on its own ────────────────────────────────
234
+ const open = new SvelteSet<string>();
235
+ // An open row's subform, on the row's id: the landing below walks into it.
236
+ const objEls: Record<string, Subform | undefined> = $state({});
237
237
 
238
238
  /** A collapsed row's own words, or `undefined` while it has none. */
239
239
  function elementTitle(k: number): string | undefined {
@@ -244,12 +244,9 @@
244
244
  function untitled(k: number): string {
245
245
  return label != null ? t.strings.elementUntitled(label, k + 1) : String(k + 1);
246
246
  }
247
- /** The summary is the anchor: a row closing above it is what would carry it off the fold,
248
- * and a row's subform hangs under its own summary (`hold.ts`). */
249
- function toggleRow(id: string, summary: HTMLElement): void {
250
- holdInView(summary, () => {
251
- openId = openId === id ? undefined : id;
252
- });
247
+ function toggleRow(id: string): void {
248
+ if (open.has(id)) open.delete(id);
249
+ else open.add(id);
253
250
  }
254
251
  const rowName = (k: number): string | undefined =>
255
252
  label != null ? `${label} ${k + 1}` : undefined;
@@ -300,7 +297,7 @@
300
297
  onCommit(next);
301
298
  // A row added is a row to fill in, so an object element arrives open: landing on
302
299
  // a collapsed empty summary would make adding one a two-press gesture.
303
- if (control === 'object' && !table) openId = id;
300
+ if (control === 'object' && !table) open.add(id);
304
301
  void focusAfterFlush(id, column);
305
302
  }
306
303
  function add(): void {
@@ -319,9 +316,6 @@
319
316
  const next = ids.filter((_, i) => i !== k);
320
317
  ids = next;
321
318
  drop(dropped);
322
- // The open row can be the one removed; `openId` is cleared with it rather than
323
- // left naming an element that has gone.
324
- if (openId === dropped) openId = undefined;
325
319
  onCommit(arr.filter((_, i) => i !== k));
326
320
  void focusAfterFlush(next[Math.max(k - 1, 0)], column, fromRemove);
327
321
  }
@@ -370,7 +364,8 @@
370
364
  /** An object row's landing: inside the subform when that row is the open one, on
371
365
  * the row's own summary otherwise — a collapsed row's control is its summary. */
372
366
  function focusObjectRow(id: string): void {
373
- if (id === openId && openObjEl) return openObjEl.focus();
367
+ const sub = objEls[id];
368
+ if (open.has(id) && sub) return sub.focus();
374
369
  rowEls[id]?.querySelector<HTMLElement>('.qm-element-summary')?.focus();
375
370
  }
376
371
  /** The box an arrival wash blooms in (`leaves.ts`, `core/bloom.ts`): the elements,
@@ -413,10 +408,11 @@
413
408
  return rowEls[id];
414
409
  }
415
410
  if (control === 'object') {
416
- openId = id;
411
+ open.add(id);
417
412
  return (async () => {
418
413
  if (!(await span.resumes(tick()))) return undefined;
419
- if (rest.length && openObjEl) return (await openObjEl.focusPath(rest, pos)) ?? rowEls[id];
414
+ const sub = objEls[id];
415
+ if (rest.length && sub) return (await sub.focusPath(rest, pos)) ?? rowEls[id];
420
416
  focusObjectRow(id);
421
417
  return rowEls[id];
422
418
  })();
@@ -539,8 +535,7 @@
539
535
  </script>
540
536
 
541
537
  <div
542
- class="qm-array"
543
- class:empty={ids.length === 0}
538
+ class={['qm-array', { empty: ids.length === 0 }]}
544
539
  role="group"
545
540
  aria-labelledby={label != null ? labelId : undefined}
546
541
  aria-describedby={description ? descriptionId : undefined}
@@ -579,8 +574,7 @@
579
574
  each block's one child: a branch inside the block would stand between them. -->
580
575
  {#if table}
581
576
  <div
582
- class="qm-array-rows qm-array-table"
583
- class:empty={ids.length === 0}
577
+ class={['qm-array-rows qm-array-table', { empty: ids.length === 0 }]}
584
578
  data-default={defaulted ? '' : undefined}
585
579
  bind:this={rowsEl}
586
580
  >
@@ -591,7 +585,12 @@
591
585
  column's rather than any one cell's. -->
592
586
  <div class="qm-array-table-head">
593
587
  {#each columns as [key, sub] (key)}
594
- <div class="qm-array-table-col" class:mark={controlKind(sub) === 'boolean'}>
588
+ <div
589
+ class={[
590
+ 'qm-array-table-col',
591
+ { mark: controlKind(sub) === 'boolean', prose: controlKind(sub) === 'prose' }
592
+ ]}
593
+ >
595
594
  <FieldLabel
596
595
  label={columnTitle(key, sub)}
597
596
  required={obliged(sub)}
@@ -621,8 +620,7 @@
621
620
  </div>
622
621
  {:else if control === 'object'}
623
622
  <div
624
- class="qm-array-rows"
625
- class:empty={ids.length === 0}
623
+ class={['qm-array-rows', { empty: ids.length === 0 }]}
626
624
  data-default={defaulted ? '' : undefined}
627
625
  bind:this={rowsEl}
628
626
  >
@@ -631,8 +629,7 @@
631
629
  <!-- The handler catches a key from the controls inside the row and adds no
632
630
  interaction of the row's own: the row is no tab stop. -->
633
631
  <div
634
- class="qm-array-row qm-element"
635
- class:open={openId === id}
632
+ class={['qm-array-row qm-element', { open: open.has(id) }]}
636
633
  bind:this={rowEls[id]}
637
634
  animate:reorder={arm.armed}
638
635
  onkeydown={(e) => onRowKey(e, k)}
@@ -645,8 +642,8 @@
645
642
  <button
646
643
  type="button"
647
644
  class="qm-control-box qm-focus-ring qm-element-summary"
648
- aria-expanded={openId === id}
649
- onclick={(e) => toggleRow(id, e.currentTarget)}
645
+ aria-expanded={open.has(id)}
646
+ onclick={() => toggleRow(id)}
650
647
  >
651
648
  <!-- Leading, and it rotates: trailing is the figure for pushing a new
652
649
  screen, where this unfolds in place. Same glyph, same rotation and
@@ -662,9 +659,9 @@
662
659
  {@render rowActions(k)}
663
660
  </div>
664
661
  </div>
665
- {#if openId === id}
662
+ {#if open.has(id)}
666
663
  <ObjectField
667
- bind:this={openObjEl}
664
+ bind:this={objEls[id]}
668
665
  value={(arr[k] ?? {}) as Record<string, unknown>}
669
666
  properties={items?.properties}
670
667
  label={rowName(k)}
@@ -683,8 +680,7 @@
683
680
  </div>
684
681
  {:else}
685
682
  <div
686
- class="qm-array-rows"
687
- class:empty={ids.length === 0}
683
+ class={['qm-array-rows', { empty: ids.length === 0 }]}
688
684
  data-default={defaulted ? '' : undefined}
689
685
  bind:this={rowsEl}
690
686
  >
@@ -1044,6 +1040,9 @@
1044
1040
  .qm-array-table-col.mark {
1045
1041
  min-width: 0;
1046
1042
  }
1043
+ .qm-array-table-col.prose {
1044
+ min-width: var(--_qm-track-prose);
1045
+ }
1047
1046
  /* The remove is the row's last track, pinned to the end edge of the box the table
1048
1047
  scrolls in: sticky, so it rests in its track while the table fits and rides over
1049
1048
  the cells once the box scrolls, on the card's plane so what passes under it is
@@ -1066,8 +1065,12 @@
1066
1065
  background: var(--_qm-surface);
1067
1066
  box-shadow: 0 0 0 var(--_qm-ring-reach) var(--_qm-surface);
1068
1067
  }
1068
+ /* The glyph stands on the row's first line with the cells, however far the slab
1069
+ stretches under it. */
1069
1070
  .qm-array-table-row > .qm-remove {
1070
1071
  align-self: stretch;
1072
+ align-items: start;
1073
+ padding-block-start: calc((var(--_qm-box-line) - var(--_qm-glyph-control)) / 2);
1071
1074
  height: auto;
1072
1075
  border-radius: var(--_qm-radius-inner);
1073
1076
  color: var(--_qm-ink-label);
@@ -2,7 +2,6 @@ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  import { type ArrayLayout } from './structure.js';
3
3
  import { type DeepDiagnostic } from './diagnostics.js';
4
4
  import type { LandingBox } from './leaves.js';
5
- import './controls.css';
6
5
  interface Props {
7
6
  value: unknown[] | undefined;
8
7
  /** The declared `default:`: the rows an unset array draws, which a gesture takes. */
@@ -13,7 +13,6 @@
13
13
  -->
14
14
  <script lang="ts">
15
15
  import { Switch } from 'bits-ui';
16
- import { syncedLocal } from './synced.svelte.js';
17
16
  import './controls.css';
18
17
 
19
18
  interface Props {
@@ -36,18 +35,14 @@
36
35
  }
37
36
  let { value, fallback, optional = false, label, id, describedBy, onCommit }: Props = $props();
38
37
 
39
- // Local toggle state synced to `value`; own-toggles stay local, only an external
40
- // change reconciles back in (see `syncedLocal`). The primitive is driven
41
- // controlled (`checked` + `onCheckedChange`, never `bind:`) so reconciliation
42
- // stays the package's: a two-way bind hands the primitive a lane around it,
43
- // which repeats the reconciliation hazard in miniature. `undefined` is the unset
44
- // optional cell, the one state with no default to stand in for it.
45
- const local = syncedLocal<boolean | undefined>(
46
- () => value ?? fallback ?? (optional ? undefined : false)
47
- );
48
- const unset = $derived(local.value === undefined);
38
+ // The primitive is driven controlled (`checked` + `onCheckedChange`, never `bind:`)
39
+ // so reconciliation stays the package's: a two-way bind hands the primitive a lane
40
+ // around it, which repeats the reconciliation hazard in miniature. `undefined` is
41
+ // the unset optional cell, the one state with no default to stand in for it.
42
+ let local = $derived<boolean | undefined>(value ?? fallback ?? (optional ? undefined : false));
43
+ const unset = $derived(local === undefined);
49
44
  const tristate = $derived(
50
- optional ? { role: 'checkbox', 'aria-checked': unset ? ('mixed' as const) : !!local.value } : {}
45
+ optional ? { role: 'checkbox', 'aria-checked': unset ? ('mixed' as const) : !!local } : {}
51
46
  );
52
47
  </script>
53
48
 
@@ -57,13 +52,13 @@
57
52
  <span class="qm-toggle-wrap" data-unset={unset ? '' : undefined}>
58
53
  <Switch.Root
59
54
  class="qm-toggle qm-focus-ring qm-tap-floor"
60
- checked={!!local.value}
55
+ checked={!!local}
61
56
  {id}
62
57
  aria-label={id ? undefined : label}
63
58
  aria-describedby={describedBy}
64
59
  onCheckedChange={(v) => {
65
60
  // From unset the primitive reads the unchecked face, so its toggle is `true`.
66
- local.value = v;
61
+ local = v;
67
62
  onCommit(v);
68
63
  }}
69
64
  >
@@ -74,6 +69,13 @@
74
69
  </span>
75
70
 
76
71
  <style>
72
+ /* The switch stands on the first line of the boxes beside it: a row of controls tops
73
+ out together, and a mark carries no line of its own to sit on. */
74
+ .qm-toggle-wrap {
75
+ display: flex;
76
+ align-items: center;
77
+ min-height: var(--_qm-box-line);
78
+ }
77
79
  /* A primitive renders its own element, which a scoped selector cannot reach:
78
80
  styled through the wrapper with `:global`. */
79
81
  /* The track is the drawn box and the target is `.qm-tap-floor`'s (controls.css): a
@@ -1,4 +1,3 @@
1
- import './controls.css';
2
1
  interface Props {
3
2
  value: boolean | undefined;
4
3
  fallback?: boolean;