@quillmark/svelte 0.10.1 → 0.11.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 (58) hide show
  1. package/README.md +1 -1
  2. package/THEMING.md +2 -2
  3. package/dist/core/codec/blocks.js +21 -1
  4. package/dist/core/codec/breaks.d.ts +10 -2
  5. package/dist/core/codec/breaks.js +65 -9
  6. package/dist/core/codec/code.d.ts +6 -4
  7. package/dist/core/codec/code.js +7 -4
  8. package/dist/core/codec/decode.js +91 -62
  9. package/dist/core/codec/encode.d.ts +5 -8
  10. package/dist/core/codec/encode.js +55 -85
  11. package/dist/core/codec/field.js +43 -24
  12. package/dist/core/codec/index.d.ts +2 -1
  13. package/dist/core/codec/index.js +6 -3
  14. package/dist/core/codec/islands.d.ts +13 -9
  15. package/dist/core/codec/islands.js +75 -49
  16. package/dist/core/codec/keymap.js +2 -1
  17. package/dist/core/codec/marks.d.ts +17 -13
  18. package/dist/core/codec/marks.js +39 -42
  19. package/dist/core/codec/positions.d.ts +4 -1
  20. package/dist/core/codec/positions.js +5 -1
  21. package/dist/core/codec/reconcile.d.ts +6 -0
  22. package/dist/core/codec/reconcile.js +19 -0
  23. package/dist/core/codec/schema.d.ts +5 -8
  24. package/dist/core/codec/schema.js +30 -156
  25. package/dist/core/codec/table-view.js +77 -26
  26. package/dist/core/codec/table.d.ts +6 -5
  27. package/dist/core/codec/table.js +14 -31
  28. package/dist/core/codec/urls.d.ts +20 -0
  29. package/dist/core/codec/urls.js +45 -0
  30. package/dist/preset/recipes.css +1 -1
  31. package/dist/preset/scale.css +7 -4
  32. package/dist/preview/bridge.d.ts +8 -1
  33. package/dist/preview/bridge.js +31 -25
  34. package/dist/preview/controller.js +13 -13
  35. package/dist/preview/geometry.d.ts +12 -1
  36. package/dist/preview/geometry.js +14 -1
  37. package/dist/preview/paint.js +6 -4
  38. package/dist/preview/strings.d.ts +0 -2
  39. package/dist/preview/strings.js +5 -6
  40. package/dist/visual/ArrayField.svelte +11 -13
  41. package/dist/visual/Card.svelte +5 -8
  42. package/dist/visual/DateField.svelte +2 -2
  43. package/dist/visual/Field.svelte +7 -3
  44. package/dist/visual/FieldHint.svelte +1 -1
  45. package/dist/visual/ObjectField.svelte +6 -1
  46. package/dist/visual/VisualEditor.svelte +1 -1
  47. package/dist/visual/VisualEditorInner.svelte +65 -18
  48. package/dist/visual/controls.css +8 -9
  49. package/dist/visual/ext.d.ts +12 -13
  50. package/dist/visual/ext.js +22 -19
  51. package/dist/visual/index.js +2 -3
  52. package/dist/visual/links.d.ts +4 -0
  53. package/dist/visual/links.js +15 -4
  54. package/dist/visual/strings.d.ts +7 -4
  55. package/dist/visual/strings.js +1 -0
  56. package/dist/visual/structure.d.ts +9 -1
  57. package/dist/visual/structure.js +26 -4
  58. package/package.json +2 -2
@@ -35,4 +35,11 @@ export interface VerticalSpan {
35
35
  * the port: no scroll shows more of it.
36
36
  */
37
37
  export declare function clearOfTheFold(target: VerticalSpan, port: VerticalSpan): boolean;
38
- export declare function createBridge(session: LiveSession, container: HTMLElement, slots: readonly PageSlot[], onPick: ((at: Landing) => void) | undefined): BridgeController;
38
+ /** The trip a pane with no box could not run, held for the box coming back. One, and the
39
+ * last one asked for: a caret hop supersedes the hop before it on a showing pane too.
40
+ * A cell rather than closure state, so a bridge rebuilt under a changed page count
41
+ * inherits what the one it replaced was holding (`controller.ts`). */
42
+ export interface HeldTrip {
43
+ run?: () => void;
44
+ }
45
+ export declare function createBridge(session: LiveSession, container: HTMLElement, slots: readonly PageSlot[], onPick: ((at: Landing) => void) | undefined, held?: HeldTrip): BridgeController;
@@ -1,4 +1,4 @@
1
- import { boxesForField, rectToPercent, clickToPdfPt, applyPercentRect } from './geometry.js';
1
+ import { boxesForField, rectToPercent, clickToPdfPt, pxToPt, applyPercentRect } from './geometry.js';
2
2
  /** A pane hidden by the shell's narrow switch (`preset/recipes.css`) measures 0×0, and so
3
3
  * does every box on it: the clearance arithmetic reads that as a caret clear of both
4
4
  * edges, and a centring computes its trip out of zeros. Neither is an answer about where
@@ -9,6 +9,11 @@ function hasBox(port) {
9
9
  /** What a rect reporting no height clears an edge by, in CSS px: a caret's own at the
10
10
  * smallest zoom a page still reads at. */
11
11
  const MIN_CLEARANCE = 8;
12
+ /** What a click may miss a field's ink by and still land on it, in CSS px of the page as
13
+ * drawn, so it converts per click at the scale the slot is showing. Sized as the
14
+ * pointer's imprecision and not as the leading it partly closes, which is a trade
15
+ * PREVIEW.md §"Click bridge" carries. */
16
+ const CLICK_SLACK_PX = 2;
12
17
  /**
13
18
  * Whether `target` sits clear of `port`'s edges by the fold's clearance, which is the
14
19
  * target's own height: a caret rect flush against an edge is visible and unusable, and
@@ -29,14 +34,15 @@ export function clearOfTheFold(target, port) {
29
34
  const clearance = Math.min(Math.max(target.height, MIN_CLEARANCE), (port.height - target.height) / 3);
30
35
  return target.top - clearance >= port.top && target.bottom + clearance <= port.bottom;
31
36
  }
32
- export function createBridge(session, container, slots, onPick) {
37
+ export function createBridge(session, container, slots, onPick, held = {}) {
33
38
  const unlisten = [];
34
39
  // Two rungs: `positionAt` over span-tracked content, `fieldAt` over every placement
35
40
  // the compile tracks, a strict superset of it (PREVIEW.md §"Click bridge" carries
36
41
  // the measurement). The second fires where a field is placed without its content
37
42
  // being tracked, and the pick carries no `pos` there: a fabricated `0` would be an
38
43
  // invented offset wearing a real one's type. No third rung hit-tests `regions()`,
39
- // whose rects bound ink the field does not fill.
44
+ // whose rects bound ink the field does not fill; the slack both rungs carry is what
45
+ // that rung would have been reached for.
40
46
  if (onPick) {
41
47
  for (const { page, el } of slots) {
42
48
  const handleClick = (ev) => {
@@ -50,10 +56,11 @@ export function createBridge(session, container, slots, onPick) {
50
56
  const px = ev.clientX - box.left;
51
57
  const py = ev.clientY - box.top;
52
58
  const pt = clickToPdfPt(px, py, box.width, box.height, slot.size);
53
- const hit = session.positionAt(slot.page, pt.x, pt.y);
59
+ const tol = pxToPt(CLICK_SLACK_PX, box.width, slot.size);
60
+ const hit = session.positionAt(slot.page, pt.x, pt.y, tol);
54
61
  if (hit)
55
62
  return void onPick(hit);
56
- const field = session.fieldAt(slot.page, pt.x, pt.y);
63
+ const field = session.fieldAt(slot.page, pt.x, pt.y, tol);
57
64
  if (field)
58
65
  onPick({ field });
59
66
  };
@@ -72,52 +79,50 @@ export function createBridge(session, container, slots, onPick) {
72
79
  marker.remove();
73
80
  return rect;
74
81
  }
75
- // Centre `target` vertically in the scrollport; the cross axis takes the minimum
76
- // trip instead, since the container is a vertical scroller and only a page wider
77
- // than the port has anything to move there.
78
- function centre(target) {
79
- const port = container.getBoundingClientRect();
82
+ // Centre `target` vertically in `port`, the scrollport's box; the cross axis takes the
83
+ // minimum trip instead, since the container is a vertical scroller and only a page
84
+ // wider than the port has anything to move there. `port` is the caller's own read: the
85
+ // marker `measure` places is absolute inside the slot, so nothing between that read
86
+ // and this one moves the container's box, and one forced layout serves the whole hop.
87
+ function centre(target, port) {
80
88
  container.scrollTop += target.top + target.height / 2 - (port.top + port.height / 2);
81
89
  if (target.left < port.left)
82
90
  container.scrollLeft += target.left - port.left;
83
91
  else if (target.right > port.right)
84
92
  container.scrollLeft += target.right - port.right;
85
93
  }
86
- // The trip a pane with no box could not run, held for the box coming back. One, and
87
- // the last one asked for: a caret hop supersedes the hop before it on a showing pane
88
- // too.
89
- let held;
90
94
  // The re-assert. Nothing else asks on the way back: `refresh` re-asks only on a
91
95
  // recompile, and the reader who has just tapped Preview is not typing, so the pane
92
96
  // would open where the caret was several edits ago. Guarded for jsdom, which ships no
93
97
  // ResizeObserver and lays out nothing for one to report.
94
98
  if (typeof ResizeObserver !== 'undefined') {
95
99
  const observer = new ResizeObserver(() => {
96
- if (!held || !hasBox(container.getBoundingClientRect()))
100
+ if (!held.run || !hasBox(container.getBoundingClientRect()))
97
101
  return;
98
- const trip = held;
99
- held = undefined;
102
+ const trip = held.run;
103
+ held.run = undefined;
100
104
  trip();
101
105
  });
102
106
  observer.observe(container);
103
107
  unlisten.push(() => observer.disconnect());
104
108
  }
105
109
  function scrollToField(field) {
106
- const box = boxesForField(field, session.fieldBoxes(field), session.regions())[0];
110
+ const box = boxesForField(field, session.fieldBoxes(field), () => session.regions())[0];
107
111
  if (!box)
108
112
  return false;
109
113
  const slot = slots[box.page];
110
114
  if (!slot)
111
115
  return false;
116
+ const port = container.getBoundingClientRect();
112
117
  // The address is placed, which is the whole of what the `boolean` answers; a pane
113
118
  // with no box yet is not a second no.
114
- if (!hasBox(container.getBoundingClientRect())) {
115
- held = () => void scrollToField(field);
119
+ if (!hasBox(port)) {
120
+ held.run = () => void scrollToField(field);
116
121
  return true;
117
122
  }
118
123
  // A discrete act ("show me this field"), so it centres every time, unlike
119
124
  // the continuous hop below.
120
- centre(measure(slot, rectToPercent(box.rect, slot.size)));
125
+ centre(measure(slot, rectToPercent(box.rect, slot.size)), port);
121
126
  return true;
122
127
  }
123
128
  function focusPosition(field, pos) {
@@ -131,8 +136,9 @@ export function createBridge(session, container, slots, onPick) {
131
136
  // for every keystroke of (`hasBox`): the follow would die silently for as long as
132
137
  // a track is hidden. What is held is the request, not the trip, so it re-locates
133
138
  // against whatever compile is current when the pane comes back.
134
- if (!hasBox(container.getBoundingClientRect())) {
135
- held = () => focusPosition(field, pos);
139
+ const port = container.getBoundingClientRect();
140
+ if (!hasBox(port)) {
141
+ held.run = () => focusPosition(field, pos);
136
142
  return;
137
143
  }
138
144
  // The continuous hop: one call per keystroke and per arrow key, so it moves
@@ -140,9 +146,9 @@ export function createBridge(session, container, slots, onPick) {
140
146
  // takes the scrollport back from the user on every one of them, including
141
147
  // the ones that changed nothing about where the caret already was.
142
148
  const target = measure(slot, rectToPercent(region.rect, slot.size));
143
- if (clearOfTheFold(target, container.getBoundingClientRect()))
149
+ if (clearOfTheFold(target, port))
144
150
  return;
145
- centre(target);
151
+ centre(target, port);
146
152
  }
147
153
  return {
148
154
  scrollToField,
@@ -7,14 +7,13 @@ const CONTAINER_CLASS = 'qm-preview';
7
7
  // class, the hooks a consumer and the tests target.
8
8
  const MESSAGE_CLASS = 'qm-preview-message';
9
9
  const EMPTY_CLASS = 'qm-preview-empty';
10
- const UNSUPPORTED_CLASS = 'qm-preview-unsupported';
11
10
  const ERROR_CLASS = 'qm-preview-error';
12
11
  export function createPreview(session, opts) {
13
12
  const container = opts.container;
14
13
  const margin = opts.margin ?? 1;
15
14
  const t = mergePreviewStrings(opts.strings);
16
15
  container.classList.add(CONTAINER_CLASS);
17
- // One element, restamped: the non-paint states (empty, unsupported, a paint that
16
+ // One element, restamped: the non-paint states (empty, a paint that
18
17
  // threw) are mutually exclusive.
19
18
  let message;
20
19
  function showMessage(text, state) {
@@ -33,8 +32,8 @@ export function createPreview(session, opts) {
33
32
  // slots and never calls the `paint`/`pageSize` verbs the boundary refuses
34
33
  // there. A paint that unexpectedly throws is caught per-slot and surfaced
35
34
  // through the shared message rather than aborting the observer callback
36
- // mid-sweep (runtime.d.ts: even a `supportsCanvas` compile can hit a paint
37
- // the boundary refuses; brittle to leave uncaught).
35
+ // mid-sweep (a paint is the one surface that can still refuse a counted page;
36
+ // brittle to leave uncaught).
38
37
  const paintLoop = createPaintLoop(session, container, margin, (page, err) => {
39
38
  reportError(opts.onError, {
40
39
  code: 'paint-failed',
@@ -50,24 +49,25 @@ export function createPreview(session, opts) {
50
49
  // slots array is a stable identity but its members are swapped on reconcile, and
51
50
  // the listeners are per slot.
52
51
  let bridge;
52
+ // Outlives that rebuild, so a trip held for a pane with no box crosses it. Only the
53
+ // follow is re-asserted after a recompile, and a `scrollToField` the pane could not
54
+ // run has nothing else that would ask again for it.
55
+ const held = {};
53
56
  function attach() {
54
- bridge = createBridge(session, container, paintLoop.slots, opts.onPick);
57
+ bridge = createBridge(session, container, paintLoop.slots, opts.onPick, held);
55
58
  }
56
59
  function detach() {
57
60
  bridge?.destroy();
58
61
  bridge = undefined;
59
62
  }
60
- // Called at construction and after every `apply`, so `supportsCanvas` is re-read
61
- // per compile (runtime.d.ts: re-check after `open`) and a 0-page or non-canvas
62
- // compile that later gains paintable pages recovers; the check spans the paint
63
- // capability generally, not just the page count.
63
+ // Called at construction and after every `apply`, so a 0-page compile that later
64
+ // gains pages recovers. A counted page is the whole condition: every backend
65
+ // paints, and `paint`/`pageSize` refuse only a page the count excludes.
64
66
  function render(pageCount, dirtyPages) {
65
- if (!session.supportsCanvas || pageCount === 0) {
66
- // A 0-page compile is a recoverable empty; pages the boundary cannot
67
- // raster are a genuine unsupported.
67
+ if (pageCount === 0) {
68
68
  paintLoop.refresh([], 0);
69
69
  detach();
70
- showMessage(pageCount === 0 ? t.noPages : t.unsupported, pageCount === 0 ? EMPTY_CLASS : UNSUPPORTED_CLASS);
70
+ showMessage(t.noPages, EMPTY_CLASS);
71
71
  return;
72
72
  }
73
73
  // Read the count against the current slots before reconcile moves it, and
@@ -27,8 +27,11 @@ export interface PdfPoint {
27
27
  * boundary character (`.` for a nested field, `[` for an array element) keeps the
28
28
  * prefix a path boundary: `main.keywords` matches `main.keywords[0]`, not
29
29
  * `main.keywords_note`.
30
+ *
31
+ * `regions` is a thunk because the first rung answers without it, and taking it
32
+ * marshals every placement in the compile across the WASM boundary.
30
33
  */
31
- export declare function boxesForField(field: string, boxes: readonly FieldRegion[], regions: readonly FieldRegion[]): readonly FieldRegion[];
34
+ export declare function boxesForField(field: string, boxes: readonly FieldRegion[], regions: () => readonly FieldRegion[]): readonly FieldRegion[];
32
35
  /**
33
36
  * Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
34
37
  * the page's displayed size across DPR and container resize for free, so the
@@ -41,6 +44,14 @@ export declare function rectToPercent(rect: PdfRect, pageSize: PageSize): Percen
41
44
  * the transform that produces it rather than at the caller placing a marker.
42
45
  */
43
46
  export declare function applyPercentRect(el: HTMLElement, pct: PercentRect): void;
47
+ /**
48
+ * A CSS-px length on a page element of CSS width `cssW` -> the PDF-pt length it spans.
49
+ * Width alone, because that is the scale the slot is drawn at: `paint.ts` sizes a slot
50
+ * by width under a locked aspect ratio, so {@link clickToPdfPt}'s two ratios are one
51
+ * number and a length has one to convert through. What the click bridge reads its
52
+ * pointer slack in points through.
53
+ */
54
+ export declare function pxToPt(px: number, cssW: number, pageSize: PageSize): number;
44
55
  /**
45
56
  * Inverse: a click at CSS px `(px, py)` within a page element of CSS size
46
57
  * `(cssW, cssH)` -> PDF-pt. The exact inverse of {@link rectToPercent}: the
@@ -12,11 +12,14 @@
12
12
  * boundary character (`.` for a nested field, `[` for an array element) keeps the
13
13
  * prefix a path boundary: `main.keywords` matches `main.keywords[0]`, not
14
14
  * `main.keywords_note`.
15
+ *
16
+ * `regions` is a thunk because the first rung answers without it, and taking it
17
+ * marshals every placement in the compile across the WASM boundary.
15
18
  */
16
19
  export function boxesForField(field, boxes, regions) {
17
20
  if (boxes.length)
18
21
  return boxes;
19
- return regions.filter((r) => r.field === field || r.field.startsWith(`${field}.`) || r.field.startsWith(`${field}[`));
22
+ return regions().filter((r) => r.field === field || r.field.startsWith(`${field}.`) || r.field.startsWith(`${field}[`));
20
23
  }
21
24
  /**
22
25
  * Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
@@ -47,6 +50,16 @@ export function applyPercentRect(el, pct) {
47
50
  height: `${pct.height}%`
48
51
  });
49
52
  }
53
+ /**
54
+ * A CSS-px length on a page element of CSS width `cssW` -> the PDF-pt length it spans.
55
+ * Width alone, because that is the scale the slot is drawn at: `paint.ts` sizes a slot
56
+ * by width under a locked aspect ratio, so {@link clickToPdfPt}'s two ratios are one
57
+ * number and a length has one to convert through. What the click bridge reads its
58
+ * pointer slack in points through.
59
+ */
60
+ export function pxToPt(px, cssW, pageSize) {
61
+ return (px / cssW) * pageSize.widthPt;
62
+ }
50
63
  /**
51
64
  * Inverse: a click at CSS px `(px, py)` within a page element of CSS size
52
65
  * `(cssW, cssH)` -> PDF-pt. The exact inverse of {@link rectToPercent}: the
@@ -41,7 +41,11 @@ onPaintError) {
41
41
  // responsibilities"). `border-box` costs nothing here and keeps a slot a
42
42
  // consumer insets from measuring wider than its paper.
43
43
  const el = document.createElement('div');
44
- el.className = 'qm-page';
44
+ // Not `.qm-page`: that name is the preset's page baseline, an unlayered rule at
45
+ // ordinary specificity a host puts on `<body>`, and a slot wearing it takes that
46
+ // rule's `color-scheme` inside a mounted root, which the derivation leaves to the
47
+ // host (`core/theme.css`).
48
+ el.className = 'qm-page-slot';
45
49
  // The page's index, in the DOM. `pageByEl` is this module's and a consumer
46
50
  // drawing an overlay of its own needs the number: position among siblings is
47
51
  // the only handle without it, which is right today and is not a contract.
@@ -157,9 +161,7 @@ onPaintError) {
157
161
  }
158
162
  // Slots are built on the first `refresh` (the controller calls it immediately),
159
163
  // not here: reconcile → `session.pageSize` must run after the controller's
160
- // `supportsCanvas`/`pageCount` gate, since a `supportsCanvas: false` compile with
161
- // pages would throw on `pageSize` (runtime.d.ts: it succeeds iff `supportsCanvas`)
162
- // before the controller can surface its "unsupported" message.
164
+ // `pageCount` gate, since `pageSize` throws for a page the count excludes.
163
165
  // ── Keep mounted rasters in step with the display ───────────────────────────
164
166
  // Every paint freezes `canvas.style.width/height` to the box width at that
165
167
  // paint (paintSlot). A page that stays mounted while the container's CSS width
@@ -3,8 +3,6 @@ export interface PreviewStrings {
3
3
  renderFailed: string;
4
4
  /** A compile with zero pages: recoverable, and the commonest state of a fresh seed. */
5
5
  noPages: string;
6
- /** A compile whose pages this backend cannot raster at all. */
7
- unsupported: string;
8
6
  }
9
7
  /** The package's English. Every key, so the merge below is total. */
10
8
  export declare const DEFAULT_PREVIEW_STRINGS: PreviewStrings;
@@ -1,5 +1,5 @@
1
- // Every word the preview says. Three, because the surface is a canvas and its
2
- // only prose is the message states that stand in when there is nothing to paint.
1
+ // Every word the preview says: the surface is a canvas, and its only prose is the
2
+ // message states that stand in when there is nothing to paint.
3
3
  //
4
4
  // Separate from the visual surface's set rather than pooled with it: `/preview`
5
5
  // reaches `/core` and nothing editor-side, which is what lets it promote to its own
@@ -8,13 +8,12 @@
8
8
  // twice rather than learning two idioms.
9
9
  //
10
10
  // A plain options field rather than a context channel, unlike the visual surface's:
11
- // these three are read in one module by one function, so there is no tree to make
12
- // them ambient for.
11
+ // they are read in one module by one function, so there is no tree to make them
12
+ // ambient for.
13
13
  /** The package's English. Every key, so the merge below is total. */
14
14
  export const DEFAULT_PREVIEW_STRINGS = {
15
15
  renderFailed: 'Preview failed to render.',
16
- noPages: 'No pages to preview.',
17
- unsupported: 'Preview is not available for this document.'
16
+ noPages: 'No pages to preview.'
18
17
  };
19
18
  /** Fill a partial override to the whole key set. */
20
19
  export function mergePreviewStrings(custom) {
@@ -147,7 +147,6 @@
147
147
  // element shapes register alike.
148
148
  const rowEls: Record<string, HTMLElement | undefined> = $state({});
149
149
  let addEl: HTMLButtonElement | undefined = $state();
150
- let rootEl: HTMLElement | undefined = $state();
151
150
  let rowsEl: HTMLElement | undefined = $state();
152
151
 
153
152
  // ── Object elements: one open at a time ──────────────────────────────────────
@@ -268,8 +267,8 @@
268
267
  * compile the document has moved past.
269
268
  *
270
269
  * An absent `pos` is the placement rung, exactly as on `Landing`. A row that takes
271
- * no offset gets the bare focus: the JSON element, whose textarea has no coordinate
272
- * to spend one in, and a `string` element, which the compile never addresses.
270
+ * no offset gets the bare focus: a `string` element, whose input has no coordinate
271
+ * to spend one in and which the compile never addresses.
273
272
  *
274
273
  * The row's box comes back with the landing: the arrival wash is the address's own
275
274
  * granularity, and one row is what an element address named. A fallback to the
@@ -325,8 +324,8 @@
325
324
  /**
326
325
  * The element keyboard contract. Both keys ride the element control's own keydown
327
326
  * (the input's, or the PM view's through `handleDOMEvents`) since neither
328
- * surface is a place a keymap of this component's could sit. The JSON element is
329
- * out: Enter is a newline in a textarea.
327
+ * surface is a place a keymap of this component's could sit. An `object` element
328
+ * wires neither: its row is a summary button, whose Enter is the disclosure's.
330
329
  */
331
330
  function onElementKey(e: KeyboardEvent, k: number): void {
332
331
  if (control === 'object' || e.isComposing) return;
@@ -345,7 +344,6 @@
345
344
  </script>
346
345
 
347
346
  <div
348
- bind:this={rootEl}
349
347
  class="qm-array"
350
348
  class:empty={ids.length === 0}
351
349
  role="group"
@@ -484,10 +482,10 @@
484
482
  .qm-array-rows.empty {
485
483
  display: none;
486
484
  }
487
- /* A grid rather than a block: an `<input>` and a `<textarea>` are inline-level and
488
- would sit on a baseline, standing the row a descender taller than the box the slab
489
- measures itself against. `minmax(0, …)` because a long unbroken value grows an `auto`
490
- track, and the edge with it. */
485
+ /* A grid rather than a block: an `<input>` is inline-level and would sit on a
486
+ baseline, standing the row a descender taller than the box the slab measures itself
487
+ against. `minmax(0, …)` because a long unbroken value grows an `auto` track, and the
488
+ edge with it. */
491
489
  .qm-array-row {
492
490
  position: relative;
493
491
  display: grid;
@@ -498,9 +496,9 @@
498
496
  border-radius: var(--_qm-radius-inner);
499
497
  }
500
498
  /* The end inset the slab stands in, taken off whichever box the element drew:
501
- `.qm-input` is the text element and the JSON textarea, `.qm-control-box` the prose
502
- one. `:global`, because the box belongs to the child component's markup and the scope
503
- class stops at this component's. The longhand beats the family's `padding` shorthand
499
+ `.qm-input` is the text element, `.qm-control-box` the prose one and an object row's
500
+ summary. `:global`, because the box belongs to the child component's markup and the
501
+ scope class stops at this component's. The longhand beats the family's `padding` shorthand
504
502
  without a specificity fight: this block is unlayered and `controls.css` is not. */
505
503
  .qm-array-row :global(.qm-input),
506
504
  .qm-array-row :global(.qm-control-box) {
@@ -315,7 +315,7 @@
315
315
  {quill}
316
316
  addr={ops.makeAddr(undefined)}
317
317
  unframed
318
- label="Body"
318
+ label={t.strings.bodyLabel}
319
319
  placeholder={card.bodyGhost}
320
320
  leafKey={ops.leafKey(undefined)}
321
321
  {onFocus}
@@ -359,9 +359,9 @@
359
359
  {/snippet}
360
360
 
361
361
  <!-- A card whose `kind` has no schema stays visible and removable, its fields, body and
362
- `$ext` intact in the Document, so it is never a data trap. `setCardKind` swaps the kind
363
- and leaves payload and body untouched, so retyping drops no field; with no kinds to
364
- offer, delete is the only exit. -->
362
+ `$ext` intact in the Document, so it is never a data trap. A retype swaps the kind and
363
+ leaves payload and body untouched, so it drops no field; with no kinds to offer, delete
364
+ is the only exit. -->
365
365
  {#snippet recoveryShell()}
366
366
  <header class="qm-card-header">
367
367
  <span class="qm-card-title-static">{humanize(card.kind)}</span>
@@ -393,7 +393,7 @@
393
393
  </select>
394
394
  </label>
395
395
  {:else}
396
- <p class="qm-recovery-note qm-recovery-muted">
396
+ <p class="qm-recovery-note">
397
397
  {t.strings.noCardKinds}
398
398
  </p>
399
399
  {/if}
@@ -780,9 +780,6 @@
780
780
  font-size: var(--_qm-text-label);
781
781
  color: var(--_qm-ink-label);
782
782
  }
783
- .qm-recovery-muted {
784
- color: var(--_qm-ink-label);
785
- }
786
783
  .qm-recovery-retype {
787
784
  display: inline-flex;
788
785
  align-items: center;
@@ -11,7 +11,7 @@
11
11
  calendar) rather than `DatePicker`: the segments are the entry affordance, and a
12
12
  calendar is a second surface this field does not need.
13
13
 
14
- The boundary is A string, and the local is too. The primitive speaks
14
+ The boundary is a string, and the local is too. The primitive speaks
15
15
  `CalendarDate`; the document speaks `YYYY-MM-DD`. `CalendarDate` carries no time
16
16
  and no zone, so the round-trip is lossless and no local-midnight shift can occur:
17
17
  the hazard that makes `new Date('2026-07-25')` the wrong tool here. Authored
@@ -22,7 +22,7 @@
22
22
  parsed value: a fresh `CalendarDate` is never `===` the last one, which would
23
23
  make every reconcile fire and re-render all seven segments on each commit.
24
24
 
25
- The ghost is the default'S digits, not A format hint. An unset field
25
+ The ghost is the default's digits, not a format hint. An unset field
26
26
  carrying a `default:` prints the default's digits in the segments, ghost-toned,
27
27
  instead of the primitive's `mm`/`dd`/`yyyy` hints, which say "empty" where the
28
28
  rung says "will render 2026-01-01". The ghost is painted in the segment snippet,
@@ -128,10 +128,13 @@
128
128
  * array's elements, an object's properties, a variant's cells, and a subform's cells
129
129
  * under an open element. One door rather than one per depth, since `getContentAt`
130
130
  * takes the whole path and the walk down is each container prefixing its own step.
131
- * Read per call and never held: a leaf takes its state at mount, and a control asks
132
- * once. */
131
+ *
132
+ * The reader is held, as the codec's is (`field.ts`): a `{quill, doc}` pair reads
133
+ * live, so every call through it sees the commit before it, and an array of prose
134
+ * elements is one handle across the boundary rather than one per element. */
135
+ const reader = $derived(quill.reader(doc));
133
136
  function contentAt(path: PathStep[]): Content | undefined {
134
- return quill.reader(doc).getContentAt(addr, path);
137
+ return reader.getContentAt(addr, path);
135
138
  }
136
139
 
137
140
  // ── Focus: one answer, two callers (`leaves.ts`) ─────────────────────────────
@@ -239,6 +242,7 @@
239
242
  {addr}
240
243
  inline={field.inline}
241
244
  plaintext={field.plaintext}
245
+ placeholder={defaultStr}
242
246
  labelledBy={domIds.label}
243
247
  {describedBy}
244
248
  {leafKey}
@@ -131,7 +131,7 @@
131
131
  }}><Icon name="info" size={13} /></button
132
132
  >
133
133
 
134
- <!-- Placement is the description'S height. One line rides the label rung beside its own
134
+ <!-- Placement is the description's height. One line rides the label rung beside its own
135
135
  glyph, where the overhang past the rung's line box falls in the label-to-control gaps.
136
136
  Anything taller stands over the field: beside the glyph it would hang onto the control
137
137
  it describes, the one overlap a reader cannot clear by reaching for what is hidden,
@@ -122,7 +122,11 @@
122
122
  prose.focus();
123
123
  return true;
124
124
  }
125
- const el = cell.querySelector<HTMLElement>(FOCUSABLE);
125
+ // Past the label row: a property carrying a `description` opens with the hint
126
+ // trigger, which is focusable and stands before the control it describes.
127
+ const el = [...cell.querySelectorAll<HTMLElement>(FOCUSABLE)].find(
128
+ (n) => !n.closest('.qm-field-label-row')
129
+ );
126
130
  el?.focus();
127
131
  return !!el;
128
132
  }
@@ -205,6 +209,7 @@
205
209
  labelledBy={ids?.label}
206
210
  describedBy={describes}
207
211
  value={obj[key] as string | undefined}
212
+ fallback={sub.default != null ? String(sub.default) : undefined}
208
213
  onCommit={(v) => commitProp(key, v)}
209
214
  />
210
215
  {:else if kind === 'text'}
@@ -3,7 +3,7 @@
3
3
  `doc` prop remounts the editor under it, so a consumer holding one mount across
4
4
  two documents gets a surface that names the live handle.
5
5
 
6
- It is A `{#key}`, not A reseed. The state a swap invalidates is not one field:
6
+ It is a `{#key}`, not a reseed. The state a swap invalidates is not one field:
7
7
  composable cards key on session id, the main card is keyed on nothing, and each
8
8
  prose leaf mounts once per stable leaf key with `createField` closing over the
9
9
  `doc` it mounted against — so after an in-place swap the main card's leaves