@quillmark/svelte 0.1.0 → 0.2.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 (64) hide show
  1. package/README.md +15 -11
  2. package/THEMING.md +42 -23
  3. package/dist/core/address.d.ts +39 -4
  4. package/dist/core/address.js +47 -19
  5. package/dist/core/codec/encode.js +4 -1
  6. package/dist/core/codec/field.d.ts +1 -1
  7. package/dist/core/codec/prose.css +41 -18
  8. package/dist/core/codec/table.js +3 -1
  9. package/dist/core/errors.d.ts +15 -5
  10. package/dist/core/index.d.ts +1 -1
  11. package/dist/core/index.js +5 -4
  12. package/dist/core/lifecycle.d.ts +21 -5
  13. package/dist/core/lifecycle.js +34 -8
  14. package/dist/core/rebind.svelte.d.ts +16 -2
  15. package/dist/core/rebind.svelte.js +26 -8
  16. package/dist/core/theme.css +50 -18
  17. package/dist/preset/recipes.css +55 -8
  18. package/dist/preset/scale.css +5 -4
  19. package/dist/preview/Preview.svelte +10 -9
  20. package/dist/preview/Preview.svelte.d.ts +5 -5
  21. package/dist/preview/bridge.d.ts +6 -4
  22. package/dist/preview/bridge.js +17 -7
  23. package/dist/preview/controller.d.ts +13 -6
  24. package/dist/preview/controller.js +5 -2
  25. package/dist/preview/geometry.d.ts +16 -1
  26. package/dist/preview/geometry.js +20 -0
  27. package/dist/preview/index.d.ts +1 -0
  28. package/dist/preview/index.js +7 -0
  29. package/dist/preview/overlay.d.ts +2 -1
  30. package/dist/preview/overlay.js +24 -9
  31. package/dist/preview/paint.js +4 -0
  32. package/dist/visual/ArrayField.svelte +26 -20
  33. package/dist/visual/ArrayField.svelte.d.ts +4 -3
  34. package/dist/visual/Card.svelte +21 -19
  35. package/dist/visual/Card.svelte.d.ts +8 -3
  36. package/dist/visual/DiagnosticList.svelte +1 -1
  37. package/dist/visual/EnumField.svelte +34 -14
  38. package/dist/visual/EnumField.svelte.d.ts +4 -3
  39. package/dist/visual/Field.svelte +87 -25
  40. package/dist/visual/Field.svelte.d.ts +11 -7
  41. package/dist/visual/FieldHint.svelte +59 -10
  42. package/dist/visual/FormatPopover.svelte +3 -1
  43. package/dist/visual/ObjectField.svelte +21 -2
  44. package/dist/visual/ObjectField.svelte.d.ts +3 -1
  45. package/dist/visual/ProseArrayElement.svelte +1 -6
  46. package/dist/visual/ProseArrayElement.svelte.d.ts +0 -1
  47. package/dist/visual/ProseField.svelte +20 -12
  48. package/dist/visual/ProseField.svelte.d.ts +5 -3
  49. package/dist/visual/TipsCard.svelte +1 -1
  50. package/dist/visual/VisualEditor.svelte +12 -13
  51. package/dist/visual/VisualEditor.svelte.d.ts +2 -3
  52. package/dist/visual/VisualEditorInner.svelte +140 -66
  53. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
  54. package/dist/visual/controls.css +14 -11
  55. package/dist/visual/diagnostics.d.ts +12 -20
  56. package/dist/visual/diagnostics.js +6 -18
  57. package/dist/visual/index.d.ts +1 -0
  58. package/dist/visual/index.js +7 -0
  59. package/dist/visual/leaves.d.ts +47 -0
  60. package/dist/visual/leaves.js +31 -0
  61. package/dist/visual/props.d.ts +27 -6
  62. package/dist/visual/tips.d.ts +3 -1
  63. package/dist/visual/tips.js +5 -2
  64. package/package.json +3 -5
@@ -1,21 +1,47 @@
1
1
  // `@quillmark/wasm` ships wasm-bindgen's web target: the core build is
2
- // instantiated by an explicit `await init()`, and every export throws
3
- // `runtime::not_initialized` until it resolves. Instantiation carries the panic
4
- // hook on its start section, so a Rust panic reaches JS as a stack trace.
2
+ // instantiated by an explicit `await init()`, which RESOLVES TO the classes and
3
+ // free functions — the artifact exports none of them statically, so awaiting is
4
+ // the only way to hold one. Instantiation carries the panic hook on its start
5
+ // section, so a Rust panic reaches JS as a stack trace.
5
6
  import { init as initWasm } from '@quillmark/wasm';
6
7
  let started;
8
+ /** The resolved surface, for the sync verbs below that cannot await one. */
9
+ let latched;
7
10
  /**
8
- * Instantiate the core WASM build. Await once before any other boundary verb,
9
- * from any surface's setup path; the promise is memoized, so concurrent callers
10
- * share one instantiation and repeat calls are free. A failed init clears the
11
- * memo, leaving a retry possible.
11
+ * Instantiate the core WASM build and resolve to its surface. Await once before
12
+ * any other boundary verb, from any surface's setup path; the promise is memoized,
13
+ * so concurrent callers share one instantiation and repeat calls are free. A failed
14
+ * init clears the memo, leaving a retry possible.
15
+ *
16
+ * The surface is the artifact's own, handed back unchanged: `const { Quill, Document }
17
+ * = await init()` is the whole of a host's access to the classes, and this package
18
+ * re-declares none of it.
12
19
  */
13
20
  export function init() {
14
21
  if (started)
15
22
  return started;
16
- started = initWasm().catch((err) => {
23
+ started = initWasm().then((surface) => {
24
+ latched = surface;
25
+ return surface;
26
+ }, (err) => {
17
27
  started = undefined;
18
28
  throw err;
19
29
  });
20
30
  return started;
21
31
  }
32
+ /**
33
+ * The surface synchronously, for this package's pure verbs: the `DocPath` formatter
34
+ * on the public API and the codec inside a ProseMirror transaction, neither of which
35
+ * can await. Package-internal, and read at the call rather than destructured at
36
+ * module scope, so no module's import graph carries an ordering constraint.
37
+ *
38
+ * Reaching one before {@link init} resolves throws, naming the fix. This is the seam
39
+ * that turns an awaited surface back into sync verbs, so it is the one place the
40
+ * precondition the gate made structural has to be stated again.
41
+ */
42
+ export function core() {
43
+ if (!latched) {
44
+ throw new Error('@quillmark/svelte: the WASM core is not initialized. Await `init()` from `@quillmark/svelte/core` before mounting a surface or calling a boundary verb.');
45
+ }
46
+ return latched;
47
+ }
@@ -3,10 +3,24 @@ import { type EditorErrorHandler } from './errors.js';
3
3
  export type Bound = Record<string, unknown> & {
4
4
  onError?: EditorErrorHandler;
5
5
  };
6
+ /**
7
+ * Report the violation, then throw it in a dev build. Both surfaces raise it here; the
8
+ * guards themselves stay separate, since the preview snapshots its props once and the
9
+ * editor re-reads its pair on every re-key.
10
+ *
11
+ * The report alone reaches whoever bound `onError` at mount, and a consumer swapping
12
+ * that handler beside the handle it belongs to is the case this exists for: they are
13
+ * told through the sink they just abandoned. A throw needs no handler to arrive.
14
+ *
15
+ * It costs the surface nothing: an effect that throws neither unmounts its tree nor
16
+ * stops the ones beside it, so the surface goes on serving what it bound, which is the
17
+ * very state being reported.
18
+ */
19
+ export declare function reportRebindIgnored(onError: EditorErrorHandler | undefined, message: string): void;
6
20
  /**
7
21
  * Report `rebind-ignored` once, at `dev`, when any prop `snapshot` names is swapped
8
- * in place. `remount` closes the message with the surface's own remount spelling
9
- * (`Remount the preview ({#key session}) to rebind.`).
22
+ * in place, and in a dev build throw it after. `remount` closes the message with the
23
+ * surface's own remount spelling (`Remount the preview ({#key session}) to rebind.`).
10
24
  *
11
25
  * Mounted identities are recorded on the effect's FIRST run, the flush the surface
12
26
  * binds in; every later comparison reads them through the same `snapshot`.
@@ -1,16 +1,38 @@
1
1
  // The remount contract, enforced. `Preview` binds its props ONCE, in `onMount`, and
2
2
  // observes nothing after: the vanilla core below it owns scroll position, mounted
3
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.
4
+ // 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
9
  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
+ /**
15
+ * Report the violation, then throw it in a dev build. Both surfaces raise it here; the
16
+ * guards themselves stay separate, since the preview snapshots its props once and the
17
+ * editor re-reads its pair on every re-key.
18
+ *
19
+ * The report alone reaches whoever bound `onError` at mount, and a consumer swapping
20
+ * that handler beside the handle it belongs to is the case this exists for: they are
21
+ * told through the sink they just abandoned. A throw needs no handler to arrive.
22
+ *
23
+ * It costs the surface nothing: an effect that throws neither unmounts its tree nor
24
+ * stops the ones beside it, so the surface goes on serving what it bound, which is the
25
+ * very state being reported.
26
+ */
27
+ export function reportRebindIgnored(onError, message) {
28
+ reportError(onError, { code: 'rebind-ignored', severity: 'dev', message });
29
+ if (DEV)
30
+ throw new Error(`[quillmark/ui] rebind-ignored: ${message}`);
31
+ }
10
32
  /**
11
33
  * Report `rebind-ignored` once, at `dev`, when any prop `snapshot` names is swapped
12
- * in place. `remount` closes the message with the surface's own remount spelling
13
- * (`Remount the preview ({#key session}) to rebind.`).
34
+ * in place, and in a dev build throw it after. `remount` closes the message with the
35
+ * surface's own remount spelling (`Remount the preview ({#key session}) to rebind.`).
14
36
  *
15
37
  * Mounted identities are recorded on the effect's FIRST run, the flush the surface
16
38
  * binds in; every later comparison reads them through the same `snapshot`.
@@ -35,10 +57,6 @@ export function guardRebind(snapshot, remount) {
35
57
  if (stale.length === 0)
36
58
  return;
37
59
  reported = true;
38
- reportError(bound.onError, {
39
- code: 'rebind-ignored',
40
- severity: 'dev',
41
- message: `${stale.join(', ')} swapped in place; the mounted surface still holds what it bound at mount. ${remount}`
42
- });
60
+ reportRebindIgnored(bound.onError, `${stale.join(', ')} swapped in place; the mounted surface still holds what it bound at mount. ${remount}`);
43
61
  });
44
62
  }
@@ -12,6 +12,13 @@
12
12
  on the root rule (`font-family`) that every root then inherits, and a free
13
13
  `style` attribute for the consumer.
14
14
 
15
+ That import carries no binding, so it survives a consumer's bundler only because
16
+ the package claims `sideEffects: true`. Pruned, every rung below is undefined,
17
+ every `var()` reading one is invalid at computed-value time, and the declaration
18
+ holding it drops: a control with no border, no background and no padding, under
19
+ chrome that still reads as intact. `check:bundle` holds both ends, the claim and
20
+ the built bytes.
21
+
15
22
  Every value stays a `var()` reference, so the dials resolve against the CASCADE
16
23
  at each root: a consumer's `[data-theme="dark"]` ancestor rule flows in
17
24
  unchanged, the light/dark default follows the `color-scheme` the host declares
@@ -98,12 +105,13 @@
98
105
  --_qm-border: color-mix(in oklab, var(--_qm-bg), var(--_qm-fg) 17%);
99
106
  /* A rule that separates without structuring: the lines BETWEEN a table's cells,
100
107
  where the frame around them is the structure. Half the border's mix rather
101
- than a fourth surface rung, because it is a stroke and reads against the
108
+ than a surface rung of its own, because it is a stroke and reads against the
102
109
  surface behind it, not a fill stacked on one. */
103
110
  --_qm-border-faint: color-mix(in oklab, var(--_qm-bg), var(--_qm-fg) 9%);
111
+ /* A fourth step between `label` and `ghost` is a number with no fourth kind of
112
+ text under it. */
104
113
  --_qm-ink: var(--_qm-fg);
105
114
  --_qm-ink-label: color-mix(in oklab, var(--_qm-fg), var(--_qm-bg) 35%);
106
- --_qm-ink-meta: color-mix(in oklab, var(--_qm-fg), var(--_qm-bg) 45%);
107
115
  --_qm-ink-ghost: color-mix(in oklab, var(--_qm-fg), var(--_qm-bg) 60%);
108
116
  /* The correlation wash (`core/bloom.ts`). Its alpha is a PEAK, not a tint: the
109
117
  wash animates from and back to zero, never held, so it reads at pane scale
@@ -134,7 +142,12 @@
134
142
  only up to the point where it is assembled. ONE width: focus is the only
135
143
  state that draws a ring anywhere, so there is no held-back tier under it. */
136
144
  --_qm-ring-width: 2px;
137
- --_qm-ring-focus: var(--_qm-ring-width) solid var(--_qm-accent);
145
+ /* Every ring the surface draws, whatever the state under it: focus and selection
146
+ alike, which is why the name carries neither. Assembled here because the parts
147
+ spelled out at a use site pass every axis — each reads a rung — while drawing a
148
+ ring nothing else agrees with. The width stays separately reachable: a drop
149
+ indicator is the ring's thickness without being a ring. */
150
+ --_qm-ring: var(--_qm-ring-width) solid var(--_qm-accent);
138
151
  --_qm-ring-offset: 1px;
139
152
 
140
153
  /* ── Motion ──────────────────────────────────────────────────────────────
@@ -147,12 +160,11 @@
147
160
  animates over WAAPI and reads it from here. */
148
161
  --_qm-bloom-dwell: 1100ms;
149
162
 
150
- /* One curve per role, the role being what the trip's two endpoints ARE.
151
- `reverse` is the only one that has to be symmetric: it is the only one an
152
- interruption turns around. */
163
+ /* ONE curve, symmetric because an interruption turns these trips around: a hover
164
+ leaves before it arrives, a card collapses mid-expand. The one-way pair a scale
165
+ like this often carries is minted when a trip long enough to read one arrives;
166
+ the popover's pop is the nearest and at `fast` it is under that threshold. */
153
167
  --_qm-ease-reverse: cubic-bezier(0.4, 0, 0.6, 1);
154
- --_qm-ease-arrive: cubic-bezier(0, 0, 0.2, 1);
155
- --_qm-ease-depart: cubic-bezier(0.4, 0, 1, 1);
156
168
 
157
169
  /* ── Geometry ──────────────────────────────────────────────────────────*/
158
170
  /* ONE stroke width for every edge the chrome draws: a card's, a control's,
@@ -164,6 +176,9 @@
164
176
  against a token. */
165
177
  --_qm-border-width: 1px;
166
178
  --_qm-radius: var(--qm-radius, 8px);
179
+ /* The INTERIOR corner, and HALF is the relationship rather than the number: a
180
+ corner inside a corner reads as nested only while it is visibly tighter, and
181
+ halving holds that at every setting of the dial, zero included. */
167
182
  --_qm-radius-inner: calc(var(--_qm-radius) / 2);
168
183
  /* A fully-rounded end cap: a shape tier, not a step on the radius ramp, so
169
184
  it does not derive from the dial: a pill stays a pill at any radius. */
@@ -173,6 +188,13 @@
173
188
  --_qm-space-2: calc(var(--_qm-space) * 2);
174
189
  --_qm-space-3: calc(var(--_qm-space) * 3);
175
190
  --_qm-space-4: calc(var(--_qm-space) * 4);
191
+ /* A rung because the two axes are two decisions and the ladder alone cannot hold
192
+ them together: `check:style`'s rhythm axis sees each box read a rung, never that
193
+ they read the SAME one, which is the drift `visual/controls.css` already names
194
+ for the type half. Asymmetric because the vertical is what leading leaves once
195
+ it has taken the line box, and the horizontal is how far in the text starts,
196
+ which nothing else fixes. */
197
+ --_qm-inset-control: var(--_qm-space) var(--_qm-space-3);
176
198
  /* The scroll TAIL: dead space under the last card, so it can be read at the
177
199
  middle of a pane rather than at its bottom edge. A fraction of the VIEWPORT
178
200
  rather than a step on the ramp, because what it answers is how far the eye
@@ -196,26 +218,36 @@
196
218
  --_qm-glyph-control: 14px;
197
219
 
198
220
  /* ── Type ────────────────────────────────────────────────────────────────
199
- `--qm-font-size` anchors the body rung; 1.125 is the ratio between adjacent
200
- rungs: a step up (title) and two down (label, meta). The ratio and the
201
- weights are fixed conventions, not dials: no consumer tunes a typographic
202
- ratio, and a dial nobody turns is a name every reader still holds. */
221
+ `--qm-font-size` anchors the CHROME rung; 1.125 is the ratio between adjacent
222
+ rungs: a step up (title) and one down (label). The ratio and the
223
+ weight are fixed conventions, not dials: no consumer tunes a typographic
224
+ ratio, and a dial nobody turns is a name every reader still holds. THREE chrome
225
+ rungs: a second step below `label` lands near 11px, under a size anything here
226
+ wants to be read at, and the supporting text that would take it is what `label`
227
+ already is. */
203
228
  --_qm-text-body: var(--qm-font-size, 0.875rem);
204
229
  --_qm-text-title: calc(var(--_qm-text-body) * 1.125);
205
230
  --_qm-text-label: calc(var(--_qm-text-body) / 1.125);
206
- --_qm-text-meta: calc(var(--_qm-text-label) / 1.125);
231
+ /* PAPER: the document body answers to the preview's paper where every other
232
+ surface here answers to the control beside it, so a rung shared with the chrome
233
+ makes a consumer asking for larger prose take larger inputs with it. 16/15 where
234
+ the ramp steps at 9/8: small enough to read as a change of REGISTER rather than
235
+ a rung of hierarchy, and clear of the title rung, which one 9/8 step lands on
236
+ exactly. */
237
+ --_qm-text-paper: calc(var(--_qm-text-body) * 16 / 15);
238
+ /* ONE weight over the regular: every heading here is a name over the value under
239
+ it, so a second is a rung with no register beneath it. Hierarchy is the size
240
+ ramp's. */
207
241
  --_qm-weight-label: 600;
208
- --_qm-weight-soft: 500;
209
242
  /* Leading: the ramp's third axis, and it does not track the sizes one for one:
210
243
  two rungs, because a wrapped label and a wrapped paragraph want different
211
244
  rhythms and nothing wants four. `body` is reading rhythm (both prose leaves,
212
245
  every control beside them, the tips card's guidance); `tight` is a line that is
213
246
  a label rather than a passage. Unitless, so a rung inherits MULTIPLICATIVELY
214
247
  and holds against whichever size rung the surface reads: a `rem` leading
215
- would freeze at one size and disagree with the rest of the ramp. Leading is
216
- also the WHOLE of what separates paper from chrome: the body leaf reads
217
- `--_qm-text-body`, the same size as the input beside it, and there is no fifth
218
- size rung over it. `line-height: 1` is outside the axis: it collapses the
248
+ would freeze at one size and disagree with the rest of the ramp. It is the
249
+ axis paper and chrome SHARE: both read `body`, and the size rung is what
250
+ separates them. `line-height: 1` is outside the axis: it collapses the
219
251
  line box onto its content, a glyph or a button's one-line label. */
220
252
  --_qm-leading-body: 1.5;
221
253
  --_qm-leading-tight: 1.3;
@@ -86,11 +86,7 @@ html:has(.qm-workspace) {
86
86
  The GAP is the endorsed default and not a fact about splitting: two surfaces standing
87
87
  apart on a page read as two things, which is what a host showing its instruments wants.
88
88
  A host that spends the whole screen on the mounts closes it to nothing and carries a
89
- hairline on one pane instead: one property, and the tracks are untouched.
90
-
91
- Below the width that fits two mounts abreast the split stops being one: the tracks
92
- stack and each takes a pane's height. WHICH element scrolls then is the host's, since
93
- it is the host that knows where the room comes from. */
89
+ hairline on one pane instead: one property, and the tracks are untouched. */
94
90
  .qm-split {
95
91
  display: grid;
96
92
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -99,11 +95,62 @@ html:has(.qm-workspace) {
99
95
  min-height: 0;
100
96
  }
101
97
 
102
- @media (width < 60rem) {
98
+ /* THE BAND THAT SWITCHES THEM, and nothing at the widths where both tracks are showing
99
+ and there is nothing to switch. Where it stands is the host's, like any other band; what
100
+ goes in it is a pair of `.qm-control`s, which already carry the on-state a pressed
101
+ control takes. Its visibility is the PRESET's because the threshold is: a host hiding
102
+ its own tab row writes this file's number a second time, which is the one thing the
103
+ single source is for. */
104
+ .qm-switch {
105
+ display: none;
106
+ }
107
+
108
+ /* ONE MOUNT AT A TIME under the threshold, rather than two of them abreast. Half a narrow
109
+ viewport is under the width either surface reads at, so the split stops being one and
110
+ the tracks become views of the same room: the host says which with `data-qm-show`, and
111
+ the attribute names the TRACK rather than what is in it, so nothing here knows an editor
112
+ from a preview. No attribute is the first track.
113
+
114
+ The other track is `display: none` and not a clip, which is the whole of the hiding: it
115
+ leaves the tab order, hit-testing and the accessibility tree in one property, so the
116
+ pane needs no `inert` and the host needs no breakpoint in JS. Both stay MOUNTED (a
117
+ preview binds its handle once, an editor holds a caret, a history and a scroll), so
118
+ the switch is presentational, never conditional. A pane hidden this way keeps the
119
+ pixels it had (the paint loop's band sweep no-ops on an empty visible set) and repaints
120
+ from its live box when it comes back, which its resize observer sees as a width moving
121
+ off zero.
122
+
123
+ HEIGHT is the second clause because a landscape phone passes the first one: it clears
124
+ the width with room for two tracks and has no body left to put them in. A desktop window
125
+ that short wants one mount for the same reason. */
126
+ @media (width < 50rem), (height < 30rem) {
127
+ .qm-switch {
128
+ display: flex;
129
+ gap: var(--qmh-space);
130
+ }
131
+
132
+ /* Even halves of the band, at the height a THUMB needs. The floor is the recipe's and
133
+ not the host's, because the band exists only at the sizes where the pointer is a
134
+ finger: a host is free to stand the band deeper, and has nothing to gain from
135
+ standing it shallower than the thing it is tapping. */
136
+ .qm-switch > * {
137
+ flex: 1 1 0;
138
+ min-height: var(--qmh-tap);
139
+ }
140
+
141
+ /* Centred, which is what a control filling half a band reads as; `.qm-control` itself
142
+ reads left, for the menus it also dresses. */
143
+ .qm-switch > .qm-control {
144
+ text-align: center;
145
+ }
146
+
103
147
  .qm-split {
104
148
  grid-template-columns: minmax(0, 1fr);
105
- grid-template-rows: none;
106
- grid-auto-rows: var(--qmh-pane);
149
+ }
150
+
151
+ .qm-split:not([data-qm-show='2']) > :nth-child(2),
152
+ .qm-split[data-qm-show='2'] > :nth-child(1) {
153
+ display: none;
107
154
  }
108
155
  }
109
156
 
@@ -58,10 +58,11 @@
58
58
  so it does not derive from the spacing dial. */
59
59
  --qmh-measure: 34rem;
60
60
 
61
- /* What one mount takes once a split has stacked. Reading geometry too, and about
62
- the surface rather than the text: how much of a document is worth showing when it
63
- cannot stand beside the thing it answers to. */
64
- --qmh-pane: 34rem;
61
+ /* The floor under a control a THUMB lands on. Hand geometry rather than rhythm, which
62
+ is why it does not derive from the spacing dial either: 44px is where both platform
63
+ guidelines and WCAG put a touch target, and a control padded to a pointer's size is
64
+ under it whatever rung the padding reads. */
65
+ --qmh-tap: 2.75rem;
65
66
 
66
67
  /* ── Type ────────────────────────────────────────────────────────────────── */
67
68
  /* Two faces with a job each: prose is sans, chrome is mono. Both are system stacks,
@@ -11,13 +11,13 @@
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';
14
- import type { LiveSession, ContentHit, ChangeSet } from '@quillmark/wasm';
15
- import type { DocPath, Place } from '../core/address.js';
14
+ import type { LiveSession, ChangeSet } from '@quillmark/wasm';
15
+ import type { DocPath, Landing, Place } from '../core/address.js';
16
16
  import type { EditorErrorHandler } from '../core/errors.js';
17
17
 
18
18
  /**
19
19
  * REMOUNT CONTRACT. `createPreview` binds once in `onMount`; a later change to any
20
- * prop it closed over (`session`, `margin`, `overlays`, `onCaretPick`, `onError`,
20
+ * prop it closed over (`session`, `margin`, `overlays`, `onPick`, `onError`,
21
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.
@@ -36,7 +36,7 @@
36
36
  style?: string;
37
37
  margin?: number;
38
38
  overlays?: boolean;
39
- onCaretPick?: (hit: ContentHit) => void;
39
+ onPick?: (at: Landing) => void;
40
40
  /** A page paint the backend refused; the error message state shows either way. */
41
41
  onError?: EditorErrorHandler;
42
42
  /** The message-state wording, keyed and partial (`PreviewStrings`). */
@@ -47,7 +47,7 @@
47
47
  session,
48
48
  margin,
49
49
  overlays,
50
- onCaretPick,
50
+ onPick,
51
51
  onError,
52
52
  strings,
53
53
  class: className,
@@ -58,7 +58,7 @@
58
58
  let controller: PreviewController | undefined;
59
59
 
60
60
  guardRebind(
61
- () => ({ session, margin, overlays, onCaretPick, onError, strings }),
61
+ () => ({ session, margin, overlays, onPick, onError, strings }),
62
62
  'Remount the preview ({#key session}) to rebind.'
63
63
  );
64
64
 
@@ -68,7 +68,7 @@
68
68
  container: containerEl,
69
69
  margin,
70
70
  overlays,
71
- onCaretPick,
71
+ onPick,
72
72
  onError,
73
73
  strings
74
74
  });
@@ -81,8 +81,9 @@
81
81
  export function refresh(change: ChangeSet): void {
82
82
  controller?.refresh(change);
83
83
  }
84
- export function scrollToField(field: DocPath): void {
85
- controller?.scrollToField(field);
84
+ /** `false` when this compile places nothing at `field` ({@link PreviewController}). */
85
+ export function scrollToField(field: DocPath): boolean {
86
+ return controller?.scrollToField(field) ?? false;
86
87
  }
87
88
  /** Takes the editor's `onCaretMove` payload: `onCaretMove={preview.focusPosition}`. */
88
89
  export function focusPosition(at: Place): void {
@@ -1,10 +1,10 @@
1
1
  import type { PreviewStringsInput } from './strings.js';
2
- import type { LiveSession, ContentHit, ChangeSet } from '@quillmark/wasm';
3
- import type { DocPath, Place } from '../core/address.js';
2
+ import type { LiveSession, ChangeSet } from '@quillmark/wasm';
3
+ import type { DocPath, Landing, Place } from '../core/address.js';
4
4
  import type { EditorErrorHandler } from '../core/errors.js';
5
5
  /**
6
6
  * REMOUNT CONTRACT. `createPreview` binds once in `onMount`; a later change to any
7
- * prop it closed over (`session`, `margin`, `overlays`, `onCaretPick`, `onError`,
7
+ * prop it closed over (`session`, `margin`, `overlays`, `onPick`, `onError`,
8
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.
@@ -23,7 +23,7 @@ interface Props {
23
23
  style?: string;
24
24
  margin?: number;
25
25
  overlays?: boolean;
26
- onCaretPick?: (hit: ContentHit) => void;
26
+ onPick?: (at: Landing) => void;
27
27
  /** A page paint the backend refused; the error message state shows either way. */
28
28
  onError?: EditorErrorHandler;
29
29
  /** The message-state wording, keyed and partial (`PreviewStrings`). */
@@ -31,7 +31,7 @@ interface Props {
31
31
  }
32
32
  declare const Preview: import("svelte").Component<Props, {
33
33
  refresh: (change: ChangeSet) => void;
34
- scrollToField: (field: DocPath) => void;
34
+ scrollToField: (field: DocPath) => boolean;
35
35
  focusPosition: (at: Place) => void;
36
36
  setZoom: (scale: number) => void;
37
37
  }, "">;
@@ -1,8 +1,10 @@
1
- import type { LiveSession, ContentHit } from '@quillmark/wasm';
1
+ import type { LiveSession } from '@quillmark/wasm';
2
+ import type { Landing } from '../core/address.js';
2
3
  import type { PageSlot } from './paint.js';
3
4
  export interface BridgeController {
4
- /** Scroll `field`'s first content box to centre (a no-op if it has none). */
5
- scrollToField(field: string): void;
5
+ /** Scroll `field`'s first box to centre; `false` when this compile places none
6
+ * for that address, which is the whole of what the preview can say about it. */
7
+ scrollToField(field: string): boolean;
6
8
  /**
7
9
  * Bring the caret rect at `field`/`pos` into view (a no-op off-content, and a
8
10
  * no-op when it is already clear of the fold).
@@ -10,4 +12,4 @@ export interface BridgeController {
10
12
  focusPosition(field: string, pos: number): void;
11
13
  destroy(): void;
12
14
  }
13
- export declare function createBridge(session: LiveSession, container: HTMLElement, slots: readonly PageSlot[], onCaretPick: ((hit: ContentHit) => void) | undefined): BridgeController;
15
+ export declare function createBridge(session: LiveSession, container: HTMLElement, slots: readonly PageSlot[], onPick: ((at: Landing) => void) | undefined): BridgeController;
@@ -1,7 +1,13 @@
1
- import { rectToPercent, clickToPdfPt, applyPercentRect } from './geometry.js';
2
- export function createBridge(session, container, slots, onCaretPick) {
1
+ import { boxesForField, rectToPercent, clickToPdfPt, applyPercentRect } from './geometry.js';
2
+ export function createBridge(session, container, slots, onPick) {
3
3
  const unlisten = [];
4
- if (onCaretPick) {
4
+ // TWO RUNGS: `positionAt` over span-tracked content, `fieldAt` over every placement
5
+ // the compile tracks, a strict superset of it (PREVIEW.md §"Click bridge" carries
6
+ // the measurement). The second fires where a field is placed without its content
7
+ // being tracked, and the pick carries NO `pos` there: a fabricated `0` would be an
8
+ // invented offset wearing a real one's type. No third rung hit-tests `regions()`,
9
+ // whose rects bound ink the field does not fill.
10
+ if (onPick) {
5
11
  for (const { page, el } of slots) {
6
12
  const handleClick = (ev) => {
7
13
  // Re-read through the live array: a same-count `refresh` swaps the
@@ -16,7 +22,10 @@ export function createBridge(session, container, slots, onCaretPick) {
16
22
  const pt = clickToPdfPt(px, py, box.width, box.height, slot.size);
17
23
  const hit = session.positionAt(slot.page, pt.x, pt.y);
18
24
  if (hit)
19
- onCaretPick(hit);
25
+ return void onPick(hit);
26
+ const field = session.fieldAt(slot.page, pt.x, pt.y);
27
+ if (field)
28
+ onPick({ field });
20
29
  };
21
30
  el.addEventListener('click', handleClick);
22
31
  unlisten.push(() => el.removeEventListener('click', handleClick));
@@ -54,15 +63,16 @@ export function createBridge(session, container, slots, onCaretPick) {
54
63
  }
55
64
  return {
56
65
  scrollToField(field) {
57
- const box = session.fieldBoxes(field)[0];
66
+ const box = boxesForField(field, session.fieldBoxes(field), session.regions())[0];
58
67
  if (!box)
59
- return;
68
+ return false;
60
69
  const slot = slots[box.page];
61
70
  if (!slot)
62
- return;
71
+ return false;
63
72
  // A discrete act ("show me this field"), so it centres every time: the
64
73
  // asymmetry the bloom draws between a continuous signal and a one-shot one.
65
74
  centre(measure(slot, rectToPercent(box.rect, slot.size)));
75
+ return true;
66
76
  },
67
77
  focusPosition(field, pos) {
68
78
  const region = session.locate(field, pos);
@@ -1,5 +1,5 @@
1
- import type { LiveSession, ChangeSet, ContentHit } from '@quillmark/wasm';
2
- import type { DocPath, Place } from '../core/address.js';
1
+ import type { LiveSession, ChangeSet } from '@quillmark/wasm';
2
+ import type { DocPath, Landing, Place } from '../core/address.js';
3
3
  import { type EditorErrorHandler } from '../core/errors.js';
4
4
  import { type PreviewStringsInput } from './strings.js';
5
5
  export interface PreviewOptions {
@@ -9,8 +9,10 @@ export interface PreviewOptions {
9
9
  margin?: number;
10
10
  /** Draw field-box overlays. Default true. */
11
11
  overlays?: boolean;
12
- /** A click resolved to a content position; the hook does not fire off-ink. */
13
- onCaretPick?(hit: ContentHit): void;
12
+ /** A click resolved to an address ({@link Landing}): a caret where the compile
13
+ * tracks the content under the point, the field alone where it tracks only the
14
+ * placement. The hook does not fire where the compile tracks neither. */
15
+ onPick?(at: Landing): void;
14
16
  /** A page paint the backend refused ({@link EditorErrorHandler}). The preview
15
17
  * shows its error message state either way; this routes it to an app's sink. */
16
18
  onError?: EditorErrorHandler;
@@ -21,8 +23,13 @@ export interface PreviewOptions {
21
23
  export interface PreviewController {
22
24
  /** Repaint `dirtyPages ∩ visible` and re-read geometry; the only apply-driven hop. */
23
25
  refresh(change: ChangeSet): void;
24
- /** Scroll `field`'s first box into view and bloom it. */
25
- scrollToField(field: DocPath): void;
26
+ /**
27
+ * Scroll `field`'s first box into view and bloom it. `false` when this compile
28
+ * places nothing at that address — an answer, not a failure: the plate places
29
+ * plenty it does not track, and the preview carries no schema to tell that from a
30
+ * field the host misnamed. The editor's `focusField` is what distinguishes them.
31
+ */
32
+ scrollToField(field: DocPath): boolean;
26
33
  /**
27
34
  * Bring the caret at `at` into view and bloom its field on arrival. Both halves
28
35
  * are change-guarded: the pane moves only when the caret has left the fold, the
@@ -59,7 +59,7 @@ export function createPreview(session, opts) {
59
59
  // swapped on reconcile, and both attach DOM/listeners per slot.
60
60
  function attach() {
61
61
  overlay = overlaysEnabled ? createOverlay(session, paintLoop.slots) : undefined;
62
- bridge = createBridge(session, container, paintLoop.slots, opts.onCaretPick);
62
+ bridge = createBridge(session, container, paintLoop.slots, opts.onPick);
63
63
  }
64
64
  function detach() {
65
65
  overlay?.destroy();
@@ -120,8 +120,11 @@ export function createPreview(session, opts) {
120
120
  bridge?.focusPosition(followed.field, followed.pos);
121
121
  },
122
122
  scrollToField(field) {
123
- bridge?.scrollToField(field);
123
+ const found = bridge?.scrollToField(field) ?? false;
124
+ // Marked either way: a compile that gains the address later blooms it on the
125
+ // rebuild, since the flash carries its own start time (`core/bloom.ts`).
124
126
  overlay?.flashField(field);
127
+ return found;
125
128
  },
126
129
  focusPosition(at) {
127
130
  followed = at;
@@ -1,4 +1,4 @@
1
- import type { PageSize } from '@quillmark/wasm';
1
+ import type { FieldRegion, PageSize } from '@quillmark/wasm';
2
2
  /** A page rect in PDF points, `[x0, y0, x1, y1]`, bottom-left origin (`FieldRegion.rect`'s shape). */
3
3
  export type PdfRect = readonly [number, number, number, number];
4
4
  /** A rect as CSS percent of the page box (top-left origin; Y is flipped from PDF-pt). */
@@ -13,6 +13,21 @@ export interface PdfPoint {
13
13
  x: number;
14
14
  y: number;
15
15
  }
16
+ /**
17
+ * An address's boxes: `fieldBoxes`'s union, else its own `regions()` rects, else the
18
+ * rects of everything UNDER it. The one rule the overlay and the scroll both read, so
19
+ * an address `regions()` names never draws nothing (PREVIEW.md §"Three
20
+ * responsibilities").
21
+ *
22
+ * `fieldBoxes` answers `[]` for a scalar reference the plate places without tracking
23
+ * its content and for a `richtext[]` element; runtime.d.ts says what such a field's
24
+ * box is, a single `regions()` rect. The third rung is that sentence one granularity
25
+ * down — an array's ink is its elements' — and is a fallback, not a union, so an
26
+ * address with rects of its own never also draws its children's. The `.` keeps the
27
+ * prefix a path boundary: `main.references` matches `main.references.0`, not
28
+ * `main.references_note`.
29
+ */
30
+ export declare function boxesForField(field: string, boxes: readonly FieldRegion[], regions: readonly FieldRegion[]): readonly FieldRegion[];
16
31
  /**
17
32
  * Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
18
33
  * the page's displayed size across DPR and container resize for free; this is