@quillmark/svelte 0.6.0 → 0.7.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.
package/THEMING.md CHANGED
@@ -173,7 +173,7 @@ An app that mounts an editor beside a preview over one session needs a screen to
173
173
  | `.qm-switch` | The band that says which track is showing, and nothing at the widths where both are. Its controls read as even halves and stand at the touch floor (`--qmh-tap`, 44px); depth past that is yours. |
174
174
  | `.qm-frame` | The mounting site: an edge, a corner, and the clip that holds a surface to them. What is inside it is the surface's own. |
175
175
 
176
- The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and repaints itself when it comes back. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
176
+ The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and comes back to them — it rasters nothing at a box it has none of, and repaints only where it returns to a box of another width. A preview scroll asked for while it was hidden is held and run on the way back, so it opens at the caret rather than several edits behind it. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
177
177
 
178
178
  The shape is shared; the look of a band is not. Your wordmark, your nav and the depth of your head are the most app-specific things on the screen, and a preset that drew them would hand every tool built on this the same face, so they read `--qmh-*` like everything else and you write the rules. `.qm-split`'s gap is a default rather than a fact about splitting: close it to `0` and carry a hairline on one pane if you want the two mounts to meet instead of stand apart.
179
179
 
@@ -161,7 +161,8 @@ export function createField(opts) {
161
161
  // A focus event does not bubble, so the leaf's own `focus` handler
162
162
  // never fires for a cell: without this the active address would not
163
163
  // follow a caret clicked straight into a table.
164
- onCellFocus: () => opts.onFocus?.(addr)
164
+ onCellFocus: () => opts.onFocus?.(addr),
165
+ clearance
165
166
  })
166
167
  },
167
168
  dispatchTransaction: (tr) => {
@@ -40,6 +40,10 @@ export interface TableViewDeps {
40
40
  /** A cell took focus: the leaf's own `focus` handler never fires for one (a focus
41
41
  * event does not bubble), so the active address would not follow the caret. */
42
42
  onCellFocus: () => void;
43
+ /** The clearance a revealed caret keeps, in the leaf's own line box (`field.ts`), which
44
+ * is a cell's line box too. `undefined` where the derivation is out of reach (jsdom),
45
+ * which is PM's 5px default. */
46
+ clearance: number | undefined;
43
47
  }
44
48
  /** The `island_block` node view: a table island's editing surface, and the literal
45
49
  * placeholder for every other island type. */
@@ -869,6 +869,11 @@ class TableIslandView {
869
869
  plugins: cellPlugins(this.cellKeys(r, c))
870
870
  }),
871
871
  attributes: { 'aria-label': name, class: 'qm-table-cell-editor' },
872
+ // A cell is one of the leaf's lines, so a reveal here keeps the leaf's
873
+ // clearance: PM's 5px default is the caret visible and unusable that rung
874
+ // exists to refuse (`field.ts`).
875
+ scrollThreshold: this.deps.clearance,
876
+ scrollMargin: this.deps.clearance,
872
877
  dispatchTransaction: (tr) => {
873
878
  const next = view.state.apply(tr);
874
879
  view.updateState(next);
@@ -928,8 +933,12 @@ class TableIslandView {
928
933
  if (!mounted)
929
934
  return;
930
935
  const { view } = mounted;
936
+ // Flagged, for the reason the leaf's own landing is (`field.ts`, `setCaret`): PM
937
+ // focuses with `preventScroll`, so an unflagged dispatch lands the caret where no
938
+ // scroller has moved to — a Tab past the right edge of the horizontal scroller a
939
+ // wide table lives in, or the row an Enter on the last one appends below the fold.
931
940
  view.focus();
932
- view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc)));
941
+ view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc)).scrollIntoView());
933
942
  }
934
943
  /**
935
944
  * A cell's keys. Traversal is the island's link in the leaf's chain
@@ -16,8 +16,8 @@
16
16
  the package claims `sideEffects: true`. Pruned, every rung below is undefined,
17
17
  every `var()` reading one is invalid at computed-value time, and the declaration
18
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.
19
+ chrome that still reads as intact. `check:bundle` reads the built bytes for it;
20
+ the claim in the manifest is review's.
21
21
 
22
22
  Every value stays a `var()` reference, so the dials resolve against the cascade
23
23
  at each root: a consumer's `[data-theme="dark"]` ancestor rule flows in
@@ -232,9 +232,8 @@
232
232
  Offset-free is the whole of it: an offset states where the light is and no pole
233
233
  carries that, where a halo states occlusion. Toward black at both poles for the
234
234
  same reason — a crevice is darker than what surrounds it in a dark room too — so
235
- the cue means one thing whichever way the scale runs. Spent only on what portals;
236
- `check:style` holds the offsets at zero here and the rung as the only shadow
237
- anywhere. */
235
+ the cue means one thing whichever way the scale runs. Spent only on what portals,
236
+ and the one shadow any surface casts, which is review's to hold. */
238
237
  --_qm-lift: 0 0 12px color-mix(in oklab, #000 16%, transparent);
239
238
  /* The recede ladder: a hover-revealed or disabled affordance at rest, and a
240
239
  muted one. Opacity recedes toward whatever is behind, so these invert
@@ -282,12 +281,12 @@
282
281
 
283
282
  /* ── Geometry ──────────────────────────────────────────────────────────*/
284
283
  /* One stroke width for every edge the chrome draws: a card's, a control's,
285
- and the metadata bracket's three rules alike. A threshold rather than a
284
+ and the verticals nested inside it alike. A threshold rather than a
286
285
  rhythm choice, like the tap floor: a hairline is a hairline at any
287
286
  `--qm-space`, so it does not derive from a dial. It exists as a rung because
288
- the bracket's whole claim is that its vertical and its horizontals are the
289
- same stroke, and `check:style`'s border-width axis can only hold that
290
- against a token. */
287
+ the nesting ladder's whole claim is that a card's edge, a section's vertical
288
+ and a subform's are one stroke at three depths, and `check:style`'s
289
+ border-width axis can only hold that against a token. */
291
290
  --_qm-border-width: 1px;
292
291
  --_qm-radius: var(--qm-radius, 8px);
293
292
  /* The interior corner, and half is the relationship rather than the number: a
@@ -310,6 +309,12 @@
310
309
  it has taken the line box, and the horizontal is how far in the text starts,
311
310
  which nothing else fixes. */
312
311
  --_qm-inset-control: var(--_qm-space) var(--_qm-space-3);
312
+ /* The nesting step: the distance from one structural stroke to the next, and what
313
+ makes the depths read as a ladder rather than as indents that happen to look
314
+ alike. A rung because the boxes holding that distance are in two components and
315
+ `check:style`'s rhythm axis sees each of them read a rung, never that they read
316
+ the same one — the case `--_qm-inset-control` above is cut for. */
317
+ --_qm-nest: var(--_qm-space-3);
313
318
  /* The scroll tail: dead space under the last card, so it can be read at the
314
319
  middle of a pane rather than at its bottom edge. A fraction of the viewport
315
320
  rather than a step on the ramp, because what it answers is how far the eye
@@ -50,11 +50,19 @@
50
50
  root scroller counting the overflow its panes clip, and a wheel over the chrome drags
51
51
  the whole app off the top. Three bands (a head, the body, an optional foot), and the
52
52
  body takes what is left and may shrink below its content, so the panes in it own
53
- their overflow and hold still while their contents move. */
53
+ their overflow and hold still while their contents move.
54
+
55
+ The one column is stated for the same reason the rows are: an implicit track is `auto`,
56
+ which is sized by what a band holds, and a `.qm-split` under one contributes its items'
57
+ max-content through its `fr` tracks. The shell would take the width of the widest thing
58
+ mounted in it instead of the viewport's, and every band would be that wide with the
59
+ second pane past the edge. A host holding its content to a maximum never sees it; one
60
+ spending the whole screen sees it on the first surface it mounts. */
54
61
  .qm-workspace {
55
62
  position: fixed;
56
63
  inset: 0;
57
64
  display: grid;
65
+ grid-template-columns: minmax(0, 1fr);
58
66
  grid-template-rows: auto minmax(0, 1fr) auto;
59
67
  overflow: hidden;
60
68
  }
@@ -115,9 +123,12 @@ html:has(.qm-workspace) {
115
123
  pane needs no `inert` and the host needs no breakpoint in JS. Both stay mounted (a
116
124
  preview binds its handle once, an editor holds a caret, a history and a scroll), so
117
125
  the switch is presentational, never conditional. A pane hidden this way keeps the
118
- pixels it had (the paint loop's band sweep no-ops on an empty visible set) and repaints
119
- from its live box when it comes back, which its resize observer sees as a width moving
120
- off zero.
126
+ pixels it had: the paint loop's band sweep no-ops on an empty visible set, and its
127
+ resize observer holds the last real width rather than rastering at the 0×0 box a hidden
128
+ pane reports, so what comes back at the width it left needs no paint at all. The
129
+ preview's caret follow spans the same window — a trip asked for against a pane with no
130
+ box is held and run when it has one (`preview/bridge.ts`), the pane opening at the
131
+ caret rather than several edits behind it.
121
132
 
122
133
  Height is the second clause because a landscape phone passes the first one: it clears
123
134
  the width with room for two tracks and has no body left to put them in. A desktop window
@@ -1,6 +1,8 @@
1
1
  import type { LiveSession } from '@quillmark/wasm';
2
2
  import type { Landing } from '../core/address.js';
3
3
  import type { PageSlot } from './paint.js';
4
+ /** Both trips are held while the pane has no box to measure against, and the last one
5
+ * held runs when it has one again. */
4
6
  export interface BridgeController {
5
7
  /** Scroll `field`'s first box to centre; `false` when this compile places none
6
8
  * for that address, which is the whole of what the preview can say about it. */
@@ -12,4 +14,25 @@ export interface BridgeController {
12
14
  focusPosition(field: string, pos: number): void;
13
15
  destroy(): void;
14
16
  }
17
+ /** The vertical extent the fold test reads; a `DOMRect` is one. */
18
+ export interface VerticalSpan {
19
+ top: number;
20
+ bottom: number;
21
+ height: number;
22
+ }
23
+ /**
24
+ * Whether `target` sits clear of `port`'s edges by the fold's clearance, which is the
25
+ * target's own height: a caret rect flush against an edge is visible and unusable, and
26
+ * the next line typed lands past it. Derived from the target rather than a margin dial,
27
+ * so it scales with zoom exactly as the caret does, and bounded at both ends so the
28
+ * answer is reachable across the whole range of targets a compile locates.
29
+ *
30
+ * The floor is what a zero-height rect gets, which bare intersection would score as
31
+ * clear at either edge. The cap is a third of the room the port has to spare, so a
32
+ * target too tall for its own height of clearance — a located line at zoom, a short
33
+ * split track — passes once centred and holds a band around it, rather than being
34
+ * re-centred by every keystroke. A target taller than the port is clear while it covers
35
+ * the port: no scroll shows more of it.
36
+ */
37
+ export declare function clearOfTheFold(target: VerticalSpan, port: VerticalSpan): boolean;
15
38
  export declare function createBridge(session: LiveSession, container: HTMLElement, slots: readonly PageSlot[], onPick: ((at: Landing) => void) | undefined): BridgeController;
@@ -1,4 +1,34 @@
1
1
  import { boxesForField, rectToPercent, clickToPdfPt, applyPercentRect } from './geometry.js';
2
+ /** A pane hidden by the shell's narrow switch (`preset/recipes.css`) measures 0×0, and so
3
+ * does every box on it: the clearance arithmetic reads that as a caret clear of both
4
+ * edges, and a centring computes its trip out of zeros. Neither is an answer about where
5
+ * the caret is. */
6
+ function hasBox(port) {
7
+ return port.width > 0 && port.height > 0;
8
+ }
9
+ /** What a rect reporting no height clears an edge by, in CSS px: a caret's own at the
10
+ * smallest zoom a page still reads at. */
11
+ const MIN_CLEARANCE = 8;
12
+ /**
13
+ * Whether `target` sits clear of `port`'s edges by the fold's clearance, which is the
14
+ * target's own height: a caret rect flush against an edge is visible and unusable, and
15
+ * the next line typed lands past it. Derived from the target rather than a margin dial,
16
+ * so it scales with zoom exactly as the caret does, and bounded at both ends so the
17
+ * answer is reachable across the whole range of targets a compile locates.
18
+ *
19
+ * The floor is what a zero-height rect gets, which bare intersection would score as
20
+ * clear at either edge. The cap is a third of the room the port has to spare, so a
21
+ * target too tall for its own height of clearance — a located line at zoom, a short
22
+ * split track — passes once centred and holds a band around it, rather than being
23
+ * re-centred by every keystroke. A target taller than the port is clear while it covers
24
+ * the port: no scroll shows more of it.
25
+ */
26
+ export function clearOfTheFold(target, port) {
27
+ if (target.height >= port.height)
28
+ return target.top <= port.top && target.bottom >= port.bottom;
29
+ const clearance = Math.min(Math.max(target.height, MIN_CLEARANCE), (port.height - target.height) / 3);
30
+ return target.top - clearance >= port.top && target.bottom + clearance <= port.bottom;
31
+ }
2
32
  export function createBridge(session, container, slots, onPick) {
3
33
  const unlisten = [];
4
34
  // Two rungs: `positionAt` over span-tracked content, `fieldAt` over every placement
@@ -53,43 +83,70 @@ export function createBridge(session, container, slots, onPick) {
53
83
  else if (target.right > port.right)
54
84
  container.scrollLeft += target.right - port.right;
55
85
  }
56
- // Clear of the fold is the target's own height of clearance at each edge, not bare
57
- // intersection: a caret rect flush against one is visible and unusable, and the
58
- // next line typed lands past it. Derived from the target rather than a margin
59
- // dial, so it scales with zoom exactly as the caret does.
60
- function clearOfTheFold(target) {
61
- const port = container.getBoundingClientRect();
62
- return target.top - target.height >= port.top && target.bottom + target.height <= port.bottom;
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
+ // The re-assert. Nothing else asks on the way back: `refresh` re-asks only on a
91
+ // recompile, and the reader who has just tapped Preview is not typing, so the pane
92
+ // would open where the caret was several edits ago. Guarded for jsdom, which ships no
93
+ // ResizeObserver and lays out nothing for one to report.
94
+ if (typeof ResizeObserver !== 'undefined') {
95
+ const observer = new ResizeObserver(() => {
96
+ if (!held || !hasBox(container.getBoundingClientRect()))
97
+ return;
98
+ const trip = held;
99
+ held = undefined;
100
+ trip();
101
+ });
102
+ observer.observe(container);
103
+ unlisten.push(() => observer.disconnect());
63
104
  }
64
- return {
65
- scrollToField(field) {
66
- const box = boxesForField(field, session.fieldBoxes(field), session.regions())[0];
67
- if (!box)
68
- return false;
69
- const slot = slots[box.page];
70
- if (!slot)
71
- return false;
72
- // A discrete act ("show me this field"), so it centres every time, unlike
73
- // the continuous hop below.
74
- centre(measure(slot, rectToPercent(box.rect, slot.size)));
105
+ function scrollToField(field) {
106
+ const box = boxesForField(field, session.fieldBoxes(field), session.regions())[0];
107
+ if (!box)
108
+ return false;
109
+ const slot = slots[box.page];
110
+ if (!slot)
111
+ return false;
112
+ // The address is placed, which is the whole of what the `boolean` answers; a pane
113
+ // with no box yet is not a second no.
114
+ if (!hasBox(container.getBoundingClientRect())) {
115
+ held = () => void scrollToField(field);
75
116
  return true;
76
- },
77
- focusPosition(field, pos) {
78
- const region = session.locate(field, pos);
79
- if (!region)
80
- return;
81
- const slot = slots[region.page];
82
- if (!slot)
83
- return;
84
- // The continuous hop: one call per keystroke and per arrow key, so it moves
85
- // the pane only when the caret has left the fold. Centring unconditionally
86
- // takes the scrollport back from the user on every one of them, including
87
- // the ones that changed nothing about where the caret already was.
88
- const target = measure(slot, rectToPercent(region.rect, slot.size));
89
- if (clearOfTheFold(target))
90
- return;
91
- centre(target);
92
- },
117
+ }
118
+ // A discrete act ("show me this field"), so it centres every time, unlike
119
+ // the continuous hop below.
120
+ centre(measure(slot, rectToPercent(box.rect, slot.size)));
121
+ return true;
122
+ }
123
+ function focusPosition(field, pos) {
124
+ const region = session.locate(field, pos);
125
+ if (!region)
126
+ return;
127
+ const slot = slots[region.page];
128
+ if (!slot)
129
+ return;
130
+ // Held before the guard below, which a pane measuring zero answers "already clear"
131
+ // for every keystroke of (`hasBox`): the follow would die silently for as long as
132
+ // a track is hidden. What is held is the request, not the trip, so it re-locates
133
+ // against whatever compile is current when the pane comes back.
134
+ if (!hasBox(container.getBoundingClientRect())) {
135
+ held = () => focusPosition(field, pos);
136
+ return;
137
+ }
138
+ // The continuous hop: one call per keystroke and per arrow key, so it moves
139
+ // the pane only when the caret has left the fold. Centring unconditionally
140
+ // takes the scrollport back from the user on every one of them, including
141
+ // the ones that changed nothing about where the caret already was.
142
+ const target = measure(slot, rectToPercent(region.rect, slot.size));
143
+ if (clearOfTheFold(target, container.getBoundingClientRect()))
144
+ return;
145
+ centre(target);
146
+ }
147
+ return {
148
+ scrollToField,
149
+ focusPosition,
93
150
  destroy() {
94
151
  for (const off of unlisten)
95
152
  off();
@@ -23,10 +23,11 @@ export interface PreviewController {
23
23
  * apply-driven hop. */
24
24
  refresh(change: ChangeSet): void;
25
25
  /**
26
- * Scroll `field`'s first box into view. `false` when this compile places nothing
27
- * at that address — an answer, not a failure: the plate places plenty it does not
28
- * track, and the preview carries no schema to tell that from a field the host
29
- * misnamed. The editor's `focusField` is what distinguishes them.
26
+ * Scroll `field`'s first box into view, and end any follow it outranks. `false`
27
+ * when this compile places nothing at that address — an answer, not a failure: the
28
+ * plate places plenty it does not track, and the preview carries no schema to tell
29
+ * that from a field the host misnamed. The editor's `focusField` is what
30
+ * distinguishes them.
30
31
  */
31
32
  scrollToField(field: DocPath): boolean;
32
33
  /**
@@ -40,7 +41,7 @@ export interface PreviewController {
40
41
  focusPosition(at: Place): void;
41
42
  /**
42
43
  * End the follow: the pane stays where the user left it until the next
43
- * `focusPosition`.
44
+ * `focusPosition`. A `scrollToField` that placed its target ends one too.
44
45
  *
45
46
  * The editor's `onActiveLeafChange` is the signal, firing for a form control as for
46
47
  * a prose leaf: `onActiveLeafChange={preview.endFollow}`. A control has no caret
@@ -84,12 +84,12 @@ export function createPreview(session, opts) {
84
84
  }
85
85
  render(session.pageCount, []);
86
86
  // The last place the editor put the caret, re-located after every recompile and
87
- // dropped on a focus change. `session.locate` answers against the last compiled
88
- // layout and a consumer debounces `update`, so a caret typed past that layout's
89
- // content is off-content for the whole burst: `focusPosition` no-ops and the pane
90
- // sits still, and a caret event is otherwise the only thing that asks again. The
91
- // re-locate is the preview's, not the consumer's: the staleness sits between two
92
- // session queries this module owns both ends of.
87
+ // dropped on a focus change or a discrete hop. `session.locate` answers against the
88
+ // last compiled layout and a consumer debounces `update`, so a caret typed past that
89
+ // layout's content is off-content for the whole burst: `focusPosition` no-ops and
90
+ // the pane sits still, and a caret event is otherwise the only thing that asks
91
+ // again. The re-locate is the preview's, not the consumer's: the staleness sits
92
+ // between two session queries this module owns both ends of.
93
93
  let followed;
94
94
  return {
95
95
  refresh(change) {
@@ -100,7 +100,13 @@ export function createPreview(session, opts) {
100
100
  bridge?.focusPosition(followed.field, followed.pos);
101
101
  },
102
102
  scrollToField(field) {
103
- return bridge?.scrollToField(field) ?? false;
103
+ const placed = bridge?.scrollToField(field) ?? false;
104
+ // The discrete hop outranks the follow: a caret re-asserted at the next
105
+ // recompile would pull the pane straight off the field a host just asked
106
+ // for. A hop that placed nothing moved nothing, so the follow stands.
107
+ if (placed)
108
+ followed = undefined;
109
+ return placed;
104
110
  },
105
111
  focusPosition(at) {
106
112
  followed = at;
@@ -4,13 +4,14 @@
4
4
  // land here. Reserved-package invariant: this subpath imports no editor-side code
5
5
  // (the codec, the VisualEditor) so a `@quillmark/preview` promotion stays a
6
6
  // re-export, not a refactor: it may reach `/core` (the shared WASM boundary) and
7
- // nothing else. Enforced by `check:deps`, which walks the subpath's import graph.
7
+ // nothing else. Enforced by `check:deps`, at the subpath's edge: a relative hop past a
8
+ // module directly in `core/` is what pulls the codec, and all of ProseMirror with it.
8
9
  // The theme derivation, which the page slots read through `var()`.
9
10
  // Imported at the barrel because a subpath is what a consumer gets: this surface
10
11
  // reaches modules inside `core/` and never its entry, so a sheet hanging off that
11
12
  // entry arrives only for a consumer importing `/core` for some other reason. The
12
13
  // sheet rather than the entry, because the barrel needs the derivation and not
13
- // `init`. `check:deps` (rule 4) walks this file's own graph and holds the reach.
14
+ // `init`. `check:deps` holds the reach.
14
15
  import '../core/theme.css';
15
16
  export { createPreview } from './controller.js';
16
17
  export { default as Preview } from './Preview.svelte';
@@ -192,9 +192,17 @@ onPaintError) {
192
192
  return;
193
193
  rafId = requestAnimationFrame(() => {
194
194
  rafId = 0;
195
+ const width = container.clientWidth;
196
+ // A pane the shell has hidden reports a 0×0 box, which is a width to paint
197
+ // nothing at rather than a width that moved: `paintSlot` would take its
198
+ // `clientWidth || widthPt` fallback and freeze every mounted canvas at the 1×
199
+ // raster. The last real width and any pending force are held instead — the
200
+ // observer fires again when the pane has a box, and a return at that same
201
+ // width needs no raster, the pixels being the ones it was hidden with.
202
+ if (width === 0)
203
+ return;
195
204
  const forced = forcePending;
196
205
  forcePending = false;
197
- const width = container.clientWidth;
198
206
  if (!forced && width === lastWidth)
199
207
  return;
200
208
  lastWidth = width;
@@ -11,8 +11,8 @@
11
11
 
12
12
  An `object` element collapses: the row is its own summary — a box, titled by the
13
13
  element's first `string` cell — and opens onto {@link ObjectField}, one at a time.
14
- Stacking the subforms instead would nest a field one level past the depth the band
15
- draws, once per row. Opening is therefore part of a landing rather than something the
14
+ Stacking the subforms instead would nest a field one level past the depth the
15
+ subform's own vertical draws, once per row. Opening is therefore part of a landing rather than something the
16
16
  user does first: `focusElement` opens the row it is aimed at before it focuses.
17
17
 
18
18
  The remove is inside the element: a slab over the end of the element's own box, taking
@@ -42,6 +42,7 @@
42
42
  import { emptyContent } from '../core/codec/index.js';
43
43
  import { createLifespan } from '../core/teardown.js';
44
44
  import { IdSeq, controlKind } from './structure.js';
45
+ import { holdInView } from './hold.js';
45
46
  import Icon from './icons/Icon.svelte';
46
47
  import TextField from './TextField.svelte';
47
48
  import ObjectField from './ObjectField.svelte';
@@ -166,8 +167,12 @@
166
167
  function untitled(k: number): string {
167
168
  return label != null ? t.strings.elementUntitled(label, k + 1) : String(k + 1);
168
169
  }
169
- function toggleRow(id: string): void {
170
- openId = openId === id ? undefined : id;
170
+ /** The summary is the anchor: a row closing above it is what would carry it off the fold,
171
+ * and a row's subform hangs under its own summary (`hold.ts`). */
172
+ function toggleRow(id: string, summary: HTMLElement): void {
173
+ holdInView(summary, () => {
174
+ openId = openId === id ? undefined : id;
175
+ });
171
176
  }
172
177
 
173
178
  // The awaited flush below is the only work that outlives a gesture here, so the
@@ -365,7 +370,7 @@
365
370
  type="button"
366
371
  class="qm-control-box qm-focus-ring qm-element-summary"
367
372
  aria-expanded={open}
368
- onclick={() => toggleRow(id)}
373
+ onclick={(e) => toggleRow(id, e.currentTarget)}
369
374
  >
370
375
  <!-- Leading, and it rotates: trailing is the figure for pushing a new
371
376
  screen, where this unfolds in place. Same glyph, same rotation and
@@ -391,7 +396,6 @@
391
396
  properties={items?.properties}
392
397
  label={label != null ? `${label} ${k + 1}` : undefined}
393
398
  idBase={idBase != null ? `${idBase}-e-${id}` : undefined}
394
- edges="close"
395
399
  onCommit={(obj) => commitElement(k, obj)}
396
400
  />
397
401
  {/if}
@@ -517,9 +521,9 @@
517
521
  /* ── An object element ──────────────────────────────────────────────────────
518
522
  The row is a summary and the subform hangs under it. `row-gap` rather than a
519
523
  margin on the subform, because the distance between a control and what it has
520
- unfolded belongs to the thing stacking them: the variant field spends its own
521
- `gap` on exactly this and `ObjectField` therefore opens flush, carrying no
522
- leading space of its own for a caller to cancel. */
524
+ unfolded belongs to the thing stacking them: the variant field spends its own `gap` on
525
+ exactly this, and what `ObjectField` adds is the cap on its own stroke, equal at both
526
+ of its ends. */
523
527
  .qm-element {
524
528
  row-gap: var(--_qm-space-2);
525
529
  }
@@ -16,6 +16,7 @@
16
16
  import type { LeafRegistry } from './leaves.js';
17
17
  import type { CardModel, FieldModel } from './structure.js';
18
18
  import { placeFields, humanize, initialExpandedGroup } from './structure.js';
19
+ import { holdInView } from './hold.js';
19
20
  import type { FieldDomIds } from './domid.js';
20
21
  import Field from './Field.svelte';
21
22
  import ProseField from './ProseField.svelte';
@@ -127,9 +128,9 @@
127
128
  const ungrouped = $derived(card.sections.filter((s) => s.group == null));
128
129
  const grouped = $derived(card.sections.filter((s) => s.group != null));
129
130
 
130
- // A schema that declares no fields still gets a body, and a bracket around nothing is
131
- // worse than none: with no content between them the two horizontals land on one y and
132
- // paint as a single stroke of twice the width.
131
+ // A schema that declares no fields still gets a body, and an empty metadata block
132
+ // draws nothing while the card's gap still counts it twice, standing a rung of dead
133
+ // space over the body.
133
134
  const hasMeta = $derived(ungrouped.length > 0 || grouped.length > 0);
134
135
 
135
136
  // Seeded once from the card's shape. Card is keyed by stable id, so this survives the
@@ -157,9 +158,11 @@
157
158
  headers[closing]?.focus();
158
159
  expanded = next;
159
160
  }
161
+ /** The header is the anchor: a section closing above it is what would carry it off the
162
+ * fold, and a section opens under its own header (`hold.ts`). */
160
163
  function toggleGroup(group: string): void {
161
164
  animate = true;
162
- setExpanded(expanded === group ? null : group);
165
+ holdInView(headers[group], () => setExpanded(expanded === group ? null : group));
163
166
  }
164
167
 
165
168
  /**
@@ -190,6 +193,9 @@
190
193
  * an insert, `nearest` for a reorder. The reduced-motion guard is JS rather than the
191
194
  * CSS the accordion uses: a scroll's behaviour is an argument, not a transition a
192
195
  * media query can cancel.
196
+ *
197
+ * The caller is what waits out a reorder's own trip before asking: the run holds the
198
+ * slot this card sits in, and the slot is the stack's (`VisualEditorInner`).
193
199
  */
194
200
  export function scrollIntoViewCard(block: ScrollLogicalPosition): void {
195
201
  const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
@@ -244,16 +250,8 @@
244
250
  {/if}
245
251
 
246
252
  <div class="qm-card-body">
247
- <!-- One element whose top and bottom edges are the bracket's two horizontals.
248
- Anchored here rather than on the header and on the accordion so an open section's
249
- vertical reaches them, and so a card with no groups still terminates above its
250
- body. -->
251
253
  {#if hasMeta}
252
- <div
253
- class="qm-card-meta"
254
- class:qm-meta-top={!card.isMain}
255
- class:qm-meta-bottom={card.hasBody}
256
- >
254
+ <div class="qm-card-meta" class:qm-meta-bottom={card.hasBody}>
257
255
  {#each ungrouped as section (section.group ?? '_ungrouped')}
258
256
  <div class="qm-section">
259
257
  {@render sectionFields(section.fields)}
@@ -261,8 +259,7 @@
261
259
  {/each}
262
260
 
263
261
  <!-- One open at a time. Rendered only when a card has groups, so an empty
264
- accordion is not the bracket's last child: the bottom rule's inset belongs
265
- to whichever block abuts it. -->
262
+ accordion does not stand a gap under the ungrouped fields. -->
266
263
  {#if grouped.length}
267
264
  <div class="qm-groups" class:qm-instant={!animate}>
268
265
  {#each grouped as section (section.group)}
@@ -409,11 +406,12 @@
409
406
  /* An island: the card is the base plane, the column behind it the sunken one, and the
410
407
  hairline closes it (ARCHITECTURE §Styling). The inset is the same rung as the gap, so
411
408
  what separates the header from the fields is what separates the fields from the
412
- card's edge. */
409
+ card's edge. The inline half reads `--_qm-nest` at that same number: this edge is the
410
+ nesting ladder's first stroke, and its inset the first step. */
413
411
  .qm-card {
414
412
  border: var(--_qm-border-width) solid var(--_qm-border);
415
413
  border-radius: var(--_qm-radius);
416
- padding: var(--_qm-space-3);
414
+ padding: var(--_qm-space-3) var(--_qm-nest);
417
415
  background: var(--_qm-surface);
418
416
  display: flex;
419
417
  flex-direction: column;
@@ -497,37 +495,44 @@
497
495
  .qm-card-title:focus {
498
496
  color: var(--_qm-ink);
499
497
  }
500
- .qm-card-body,
501
- .qm-card-meta {
498
+ .qm-card-body {
502
499
  display: flex;
503
500
  flex-direction: column;
504
501
  gap: var(--_qm-space-3);
505
502
  }
506
- /* The metadata bracket, in three strokes at one rung: these two horizontals and the
507
- open section's vertical below. A bracket whose sides disagree on width or tone reads
508
- as three unrelated lines rather than one figure, which is why the vertical takes
509
- `--_qm-border` and not `--_qm-accent`.
510
-
511
- Each condition is what its stroke means: a top rule is the line under a card title,
512
- so the headerless `main` has none; a bottom rule divides fields from body, so a card
513
- with no body has nothing to divide. Sometimes two strokes and sometimes one — an open
514
- figure, which is what distinguishes it from a second box inside the card. */
515
- .qm-meta-top {
516
- border-top: var(--_qm-border-width) solid var(--_qm-border);
503
+ /* Where two sections meet the distance is three of one rung — the cap closing the one
504
+ above, this gap, the cap opening the one below — the same whether either side is a
505
+ group or the ungrouped block. That total is what the fields inside a section keep
506
+ between their rows (`.qm-fields` row-gap), so a section boundary stands no further
507
+ apart than a field boundary: what ranks them is the stroke, not the air. */
508
+ .qm-card-meta,
509
+ .qm-groups {
510
+ display: flex;
511
+ flex-direction: column;
512
+ gap: var(--_qm-space);
517
513
  }
514
+ /* The one horizontal the card draws, and it is the one thing a horizontal is for: it
515
+ ranks the payload against the body rather than saying what is inside what, which is
516
+ the verticals' job (ARCHITECTURE §"A plane is a tone"). Conditional on a body, since
517
+ with none it would divide the payload from the card's own edge a rung below it.
518
+
519
+ The inset stands the rule off the last control at the rung the body sits under it by,
520
+ taking the section's own end cap into account: `--_qm-space-2` here, plus that cap,
521
+ is the `--_qm-space-3` the card spends between its blocks. */
518
522
  .qm-meta-bottom {
519
523
  border-bottom: var(--_qm-border-width) solid var(--_qm-border);
524
+ padding-bottom: var(--_qm-space-2);
520
525
  }
521
- /* The inset sits on the fields rather than on the block that holds them: an open
522
- section's vertical has to reach the rule to close a corner against it, and padding
523
- between the two is the distance by which it would miss. Unqualified by open — a shut
524
- panel clips it. */
525
- .qm-meta-top > .qm-section {
526
- padding-top: var(--_qm-space-3);
527
- }
528
- .qm-meta-bottom > .qm-section:last-child,
529
- .qm-meta-bottom .qm-group:last-child .qm-group-panel-inner > .qm-fields {
530
- padding-bottom: var(--_qm-space-3);
526
+ /* A section's share of the boundary between sections: one rung at each end, so two of
527
+ them stand three of it apart whichever kind they are (above). The accordion spends the
528
+ same rung from its header and its panel, where it also caps the vertical those two
529
+ edges hold.
530
+
531
+ No stroke and no inset: fields declaring no `ui.group` are the card's own, at the rank
532
+ its body is at, and the card's edge is the only container there is to state. A
533
+ vertical here would mark a section a reader has no name for. */
534
+ .qm-section {
535
+ padding-block: var(--_qm-space);
531
536
  }
532
537
  /* Two query containers, one per kind of section, so capacity follows the width the
533
538
  fields actually get: a panel's inset makes that narrower than the card. */
@@ -562,15 +567,21 @@
562
567
  --cols-half: 2;
563
568
  }
564
569
  }
565
- .qm-groups,
570
+ /* The nesting vertical, one `--_qm-nest` in from the card's edge with the panel's
571
+ fields the same rung off it (ARCHITECTURE §"A plane is a tone"). `--_qm-border` and
572
+ not `--_qm-accent`: a card's edge and the verticals inside it are one stroke at three
573
+ depths, and a tone that changed as it went in would read as unrelated lines rather
574
+ than one figure. A named section is what earns it, which is why the ungrouped block
575
+ above draws none.
576
+
577
+ On the group rather than on its panel: what the stroke marks is the section, and a
578
+ section is its header and what hangs under it. Drawn transparent when closed rather
579
+ than absent, so a toggle moves no text — and closed, there is nothing under the header
580
+ for it to gather. Its caps are the header's block padding above and the panel's below,
581
+ at the rung the ungrouped section spends on both of its own ends. */
566
582
  .qm-group {
567
583
  display: flex;
568
584
  flex-direction: column;
569
- }
570
- /* The bracket's third stroke, on the section rather than on its panel: spanning the
571
- header is what gives it a corner to close into at the block's edge. Drawn transparent
572
- when closed rather than absent, so a toggle moves no text. */
573
- .qm-group {
574
585
  border-left: var(--_qm-border-width) solid transparent;
575
586
  transition: border-color var(--_qm-duration-slow) var(--_qm-ease-reverse);
576
587
  }
@@ -583,6 +594,9 @@
583
594
  because at zero the chevron stands on the section's vertical with only the icon box's
584
595
  own bearing between them, and the glyph's rotation swaps which bearing faces the
585
596
  stroke; right, because the row is the target and an inset there is target given back.
597
+ The rung is the glyph's own and not the nesting step the fields under it take: what
598
+ the heading names is the section, so it rides the vertical rather than the column its
599
+ fields start on.
586
600
 
587
601
  `font: inherit` because a UA button inherits no face, then the body rung, one step
588
602
  over the field labels beneath it: at the label rung the two read as one register and
@@ -651,12 +665,13 @@
651
665
  .qm-group-panel-inner {
652
666
  min-height: 0;
653
667
  overflow: hidden;
654
- padding-inline-start: var(--_qm-space-4);
668
+ padding-inline-start: var(--_qm-nest);
655
669
  padding-inline-end: var(--_qm-ring-reach);
656
670
  margin-inline-end: calc(-1 * var(--_qm-ring-reach));
657
671
  }
672
+ /* The lower of the two caps an open section's vertical takes (`.qm-group`). */
658
673
  .qm-group-panel-inner > .qm-fields {
659
- padding-block: var(--_qm-space-2);
674
+ padding-block: var(--_qm-space);
660
675
  }
661
676
  .qm-body-leaf {
662
677
  display: flex;
@@ -108,10 +108,12 @@
108
108
  <Icon name="chevron-down" size={14} />
109
109
  </Select.Trigger>
110
110
  <Select.Portal to={portalTarget}>
111
- <Select.Content sideOffset={4}>
111
+ <Select.Content collisionBoundary={portalTarget ?? []} sideOffset={4}>
112
112
  <!-- The list portals out of the trigger's DOM but into the nearest
113
113
  `[data-qm-root]`, and carries the marker itself, like FormatPopover.
114
- The pill is this
114
+ That root is the boundary a flip is measured against too, being the box
115
+ that clips the surface where the consumer scrolls one; the viewport
116
+ default knows nothing of that. The pill is this
115
117
  element (not the primitive's) because scoped CSS keys off which
116
118
  component owns the markup: a `class` passed to a primitive is a
117
119
  plain string and never picks up the scoping hash. -->
@@ -4,22 +4,13 @@
4
4
  only: a nested prose/array/object property renders a placeholder instead of
5
5
  recursing.
6
6
 
7
- The nesting is a band: two full-width horizontals at `--_qm-border`, the figure the
8
- card draws around its own metadata (`.qm-meta-top` / `.qm-meta-bottom`) one octave
9
- down. Nothing insets, so a property's control keeps the card's left and right columns
10
- exactly as a field's does, and the field's own label caps the band from outside it —
11
- name, rule under the name, content, closing rule.
7
+ The nesting is a vertical at `--_qm-border`, the ladder's last stroke: the card's edge,
8
+ an open section's vertical one `--_qm-nest` in where the field is in one, this one a rung
9
+ further, the properties a rung inside it (ARCHITECTURE §"A plane is a tone").
12
10
 
13
- That absent inset is also what puts the properties on the section's own column tracks
14
- (below), so a subform's cells share edges with the fields above them rather than
15
- keeping a rhythm of their own.
16
-
17
- Which edges draw is {@link Props.edges}, and the rule is that a boundary is stated
18
- once: where a box already sits against the subform, that box IS the boundary and a
19
- stroke under it would say so twice. So a field-level subform takes the band, and a
20
- subform hanging off a control — a variant's cells under their discriminant, an
21
- array element's properties under its summary row — takes the closing rule alone.
22
- Sometimes two strokes and sometimes one: the open figure the card's bracket is.
11
+ One figure wherever it mounts: a variant's cells under their discriminant and an array
12
+ element's properties under its summary row sit at the depth a field-level subform sits
13
+ at, and the box above them states position rather than depth.
23
14
 
24
15
  A property's ghosted `default:` is the static schema `sub.default`, not the
25
16
  resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
@@ -56,20 +47,9 @@
56
47
  idBase?: string;
57
48
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
58
49
  label?: string;
59
- /** Which strokes the subform draws; see the note above. */
60
- edges?: 'band' | 'close';
61
50
  onCommit: (obj: Record<string, unknown>) => void;
62
51
  }
63
- let {
64
- value,
65
- properties,
66
- labelledBy,
67
- describedBy,
68
- idBase,
69
- label,
70
- edges = 'band',
71
- onCommit
72
- }: Props = $props();
52
+ let { value, properties, labelledBy, describedBy, idBase, label, onCommit }: Props = $props();
73
53
 
74
54
  const t = wording();
75
55
  const entries = $derived(Object.entries(properties ?? {}));
@@ -127,7 +107,6 @@
127
107
  <div
128
108
  bind:this={rootEl}
129
109
  class="qm-object"
130
- class:close={edges === 'close'}
131
110
  role="group"
132
111
  aria-labelledby={labelledBy}
133
112
  aria-describedby={describedBy}
@@ -211,46 +190,38 @@
211
190
  </div>
212
191
 
213
192
  <style>
214
- /* The band. `border-block` draws both strokes as one declaration, which is what
215
- makes the figure symmetric by construction rather than by two lines agreeing;
216
- `padding-block` at the same rung on both sides is the other half of that.
193
+ /* The vertical and the rung between it and the properties, `--_qm-nest` being the step
194
+ the card and the section hold too.
217
195
 
218
196
  `--_qm-border` and not `--_qm-border-faint`: this is the stroke that structures
219
- (ARCHITECTURE §"A plane is a tone"), the one the metadata bracket and the open
220
- section's vertical read. `faint` separates without structuring — a table's
221
- interior lines under a frame that is doing the structuring — which is the other
222
- job.
197
+ (ARCHITECTURE §"A plane is a tone"), the one the card's edge and the section's
198
+ vertical read. `faint` separates without structuring — a table's interior lines
199
+ under a frame that is doing the structuring — which is the other job.
200
+
201
+ `padding-block` is the stroke's end caps, the same rung and the same reason as a
202
+ section's (`Card.svelte`). A cap and not a leading gap: what stands the subform off
203
+ the label or the box above it is still that stacker's own.
223
204
 
224
- The columns are the section's, arrived at by arithmetic rather than by `subgrid`:
225
- the band insets nothing horizontally, so this box spans exactly what `.qm-fields`
226
- spans, and equal tracks at the same count over the same width with the same column
227
- gap fall on the same edges. `subgrid` would want the tracks chained through the
228
- three wrappers between the section grid and here (`Field.svelte`), none of which is
229
- a grid. `--cols` inherits instead, so the container query stepping the section's
230
- capacity steps the subform's with it — no second query container, no second set of
231
- rungs, nothing measured. */
205
+ The tracks are the section's count over the subform's own width — `--cols` inherits,
206
+ so the container query stepping the section's capacity steps this with it, and there
207
+ is no second query container and nothing measured. The edges are not the section's:
208
+ the inset is the nesting, and a cell lining up with the field above it would claim a
209
+ depth it is not at. */
232
210
  .qm-object {
233
211
  display: grid;
234
212
  grid-template-columns: repeat(var(--cols), 1fr);
235
213
  gap: var(--_qm-space-2);
236
- border-block: var(--_qm-border-width) solid var(--_qm-border);
237
- padding-block: var(--_qm-space-2);
238
- }
239
- /* Hanging off a control (a variant's discriminant, an array element's summary row):
240
- that box is the top boundary already, and a stroke under it would state the
241
- boundary twice. The closing rule is not optional in the same breath — without it
242
- the properties float between the box above and whatever follows. */
243
- .qm-object.close {
244
- border-block-start: none;
245
- padding-block-start: 0;
214
+ border-inline-start: var(--_qm-border-width) solid var(--_qm-border);
215
+ padding-inline-start: var(--_qm-nest);
216
+ padding-block: var(--_qm-space);
246
217
  }
247
218
  /* A property measures like a field: two tracks over the subform's own rows, so a
248
219
  label wrapping in one column leaves its control on the row's baseline rather than a
249
220
  line below it — `Field.svelte`'s rule for a row-sharing field, one level in. The
250
221
  tracks are this grid's own; the section's belong to the fields.
251
222
 
252
- `min-width: 0` is what holds the arithmetic above: a property overflowing its `1fr`
253
- grows the track, and the subform's edges leave the section's with it. */
223
+ `min-width: 0` because a property overflowing its `1fr` grows the track, and the
224
+ subform with it, past the field that holds it. */
254
225
  .qm-object-prop {
255
226
  display: grid;
256
227
  grid-row: span 2;
@@ -17,8 +17,6 @@ interface Props {
17
17
  idBase?: string;
18
18
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
19
19
  label?: string;
20
- /** Which strokes the subform draws; see the note above. */
21
- edges?: 'band' | 'close';
22
20
  onCommit: (obj: Record<string, unknown>) => void;
23
21
  }
24
22
  declare const ObjectField: import("svelte").Component<Props, {
@@ -72,8 +72,15 @@
72
72
  edge under the character that opened the menu, and stays there as the query is
73
73
  typed. Zero offset, because a gap reads as a surface floating near the caret
74
74
  rather than one growing out of it. -->
75
+ <!-- The boundary is the root the surface portals into, the box that clips it where
76
+ the consumer scrolls one ({@link FormatPopover} states it in full). It is also
77
+ what keeps the row scroll above inside the list: `nearest` scrolls any ancestor
78
+ the row is not visible in, and a surface raised into room the pane clips leaves
79
+ the pane one of them — so the scroll moves the pane, and with it the caret this
80
+ menu is anchored to. -->
75
81
  <Popover.Content
76
82
  customAnchor={menu?.anchor ?? null}
83
+ collisionBoundary={portalTarget ?? []}
77
84
  side="bottom"
78
85
  align="start"
79
86
  trapFocus={false}
@@ -73,8 +73,6 @@
73
73
  flip would carry it between them. -->
74
74
  {#if cells}
75
75
  {#key member}
76
- <!-- `close`: the discriminant's box directly above IS the cells' top boundary,
77
- so the band's opening stroke would state it twice. -->
78
76
  <ObjectField
79
77
  value={value ?? {}}
80
78
  properties={cells}
@@ -82,7 +80,6 @@
82
80
  idBase={id}
83
81
  {labelledBy}
84
82
  {describedBy}
85
- edges="close"
86
83
  onCommit={(obj) => onCommit(obj)}
87
84
  />
88
85
  {/key}
@@ -66,7 +66,7 @@
66
66
  } from './diagnostics.js';
67
67
  import { fieldDomIds, groupPanelId } from './domid.js';
68
68
  import { createLeafRegistry, type FieldControl } from './leaves.js';
69
- import { reorder } from './motion.js';
69
+ import { reorder, reorderTrips } from './motion.js';
70
70
  import { tipsChannel } from './tips.js';
71
71
  import { patchEditorExt } from './ext.js';
72
72
  import Card from './Card.svelte';
@@ -185,6 +185,9 @@
185
185
  };
186
186
  let mainCard = $state<CardHandle | undefined>(undefined);
187
187
  let cardRefs = $state<(CardHandle | undefined)[]>([]);
188
+ /** The slot each card sits in: what a reorder animates, and so what the post-mutation
189
+ * scroll waits on before it measures anything. */
190
+ let slotEls = $state<(HTMLElement | undefined)[]>([]);
188
191
  /** The stack's own element: it carries `data-qm-root`, so it is what the kind
189
192
  * menu portals into, the way each leaf's surface resolves its nearest root. */
190
193
  let rootEl = $state<HTMLElement | undefined>(undefined);
@@ -327,16 +330,29 @@
327
330
  * on a document of any length an insert lands off-screen, and a
328
331
  * viewport that does not follow reads as nothing happening.
329
332
  *
330
- * Coalesced on a single pending id: two quick adds resolve their `tick()` in
331
- * order, the earlier one sees a newer pending id and drops out, so one smooth
332
- * scroll runs to the last card rather than two fighting over the same scroller.
333
+ * Coalesced on the last call: each takes a ticket and a continuation holding a stale
334
+ * one drops out, so two mutations inside a tick run one smooth scroll to the last of
335
+ * them rather than two fighting over the same scroller. A ticket rather than the id
336
+ * being scrolled to, because an insert and a move of the *same* card are two calls
337
+ * naming one id, on different terms — `center` against `nearest`.
333
338
  */
334
- let pendingScrollId: string | null = null;
339
+ let scrollTicket = 0;
335
340
  async function scrollCardIntoView(id: string, block: ScrollLogicalPosition): Promise<void> {
336
- pendingScrollId = id;
341
+ const ticket = ++scrollTicket;
337
342
  if (!(await span.resumes(tick()))) return;
338
- if (pendingScrollId !== id) return;
339
- pendingScrollId = null;
343
+ if (ticket !== scrollTicket) return;
344
+ // A reorder is running by this flush and holds the slot at its pre-move position
345
+ // for the length of the trip (`motion.ts`), so asking now measures the box the
346
+ // card is leaving — where a `nearest` would take the stack back to for the card
347
+ // that moved in from off screen. The trip says the card moved; the scroll follows
348
+ // it to where it landed. `allSettled`, because a second reorder cancels the first
349
+ // and a cancelled run rejects.
350
+ const slot = slotEls[cardIndexOf(id)];
351
+ const trips = slot ? reorderTrips(slot) : [];
352
+ if (trips.length) {
353
+ if (!(await span.resumes(Promise.allSettled(trips.map((trip) => trip.finished))))) return;
354
+ if (ticket !== scrollTicket) return;
355
+ }
340
356
  const i = cardIndexOf(id);
341
357
  if (i >= 0) cardRefs[i]?.scrollIntoViewCard(block);
342
358
  }
@@ -813,7 +829,7 @@
813
829
  rather than being slid across. It is also the shape `animate:` asks for, being the
814
830
  keyed block's only child. -->
815
831
  {#each model.cards as c, i (c.id)}
816
- <div class="qm-card-slot" animate:reorder={isReordering}>
832
+ <div class="qm-card-slot" bind:this={slotEls[i]} animate:reorder={isReordering}>
817
833
  <Card
818
834
  bind:this={cardRefs[i]}
819
835
  card={c}
@@ -874,10 +890,12 @@
874
890
  >{marked ? merged.addCard : ''}</DropdownMenu.Trigger
875
891
  >
876
892
  <DropdownMenu.Portal to={rootEl}>
877
- <DropdownMenu.Content sideOffset={4}>
893
+ <DropdownMenu.Content collisionBoundary={rootEl ?? []} sideOffset={4}>
878
894
  <!-- Portalled out of the row but into the stack's root, and carrying the
879
895
  marker itself: floating is still a detached subtree to the
880
- derivation, like FormatPopover and the enum listbox. -->
896
+ derivation, like FormatPopover and the enum listbox. The root is the
897
+ flip's boundary for the reason it is theirs: it is the box that clips
898
+ the surface where the consumer scrolls one. -->
881
899
  <div class="qm-menu-surface" data-qm-root>
882
900
  {#each kinds as k (k)}
883
901
  <DropdownMenu.Item class="qm-menu-item" onSelect={() => addCard(atIndex, k)}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Run `change` and keep `anchor` inside the fold for the length of the move.
3
+ *
4
+ * A section closing above the pressed control carries that control off the fold with it,
5
+ * and what answers that is the trip every landing here already takes: the minimum, to the
6
+ * nearest edge, never a centring (VISUAL_EDITOR §"Focus and the preview bridge"). So a
7
+ * control the collapse never carries out of view costs no scroll at all, and one it would
8
+ * rides up and parks at the edge, which is where a section just opened wants its header:
9
+ * the fold below it is the room its fields unfold into, where a control held on the line
10
+ * it was pressed on opens its section into the screen it sits on the edge of.
11
+ *
12
+ * Per frame, because both panels move over the motion rung: a trip taken once is taken
13
+ * against a track that has not moved yet, the same flush a reveal's landing measures in
14
+ * (`Card.revealLeaf`). `instant`, because a host's `scroll-behavior: smooth` would
15
+ * otherwise animate each frame's correction toward the one before it.
16
+ */
17
+ export declare function holdInView(anchor: HTMLElement | undefined, change: () => void): void;
@@ -0,0 +1,39 @@
1
+ // The gesture's anchor, which scroll anchoring is not: the platform picks its anchor off
2
+ // the layout, and what fills the viewport as a section collapses is that section's own
3
+ // content, whose top never moves.
4
+ import { flushSync } from 'svelte';
5
+ import { rungMs } from './motion.js';
6
+ /**
7
+ * Run `change` and keep `anchor` inside the fold for the length of the move.
8
+ *
9
+ * A section closing above the pressed control carries that control off the fold with it,
10
+ * and what answers that is the trip every landing here already takes: the minimum, to the
11
+ * nearest edge, never a centring (VISUAL_EDITOR §"Focus and the preview bridge"). So a
12
+ * control the collapse never carries out of view costs no scroll at all, and one it would
13
+ * rides up and parks at the edge, which is where a section just opened wants its header:
14
+ * the fold below it is the room its fields unfold into, where a control held on the line
15
+ * it was pressed on opens its section into the screen it sits on the edge of.
16
+ *
17
+ * Per frame, because both panels move over the motion rung: a trip taken once is taken
18
+ * against a track that has not moved yet, the same flush a reveal's landing measures in
19
+ * (`Card.revealLeaf`). `instant`, because a host's `scroll-behavior: smooth` would
20
+ * otherwise animate each frame's correction toward the one before it.
21
+ */
22
+ export function holdInView(anchor, change) {
23
+ if (!anchor)
24
+ return void change();
25
+ change();
26
+ flushSync();
27
+ const reveal = () => anchor.scrollIntoView({ block: 'nearest', behavior: 'instant' });
28
+ reveal();
29
+ const rung = rungMs(getComputedStyle(anchor), '--_qm-duration-slow');
30
+ if (rung === undefined)
31
+ return;
32
+ const until = performance.now() + rung;
33
+ const step = (now) => {
34
+ reveal();
35
+ if (now < until)
36
+ requestAnimationFrame(step);
37
+ };
38
+ requestAnimationFrame(step);
39
+ }
@@ -13,7 +13,7 @@
13
13
  // reaches modules inside `core/` and never its entry, so a sheet hanging off that
14
14
  // entry arrives only for a consumer importing `/core` for some other reason. The
15
15
  // sheet rather than the entry, because the barrel needs the derivation and not
16
- // `init`. `check:deps` (rule 4) walks this file's own graph and holds the reach.
16
+ // `init`. `check:deps` holds the reach.
17
17
  import '../core/theme.css';
18
18
  export { default as VisualEditor } from './VisualEditor.svelte';
19
19
  // The prose leaf: the vanilla mount API a non-Svelte consumer (or a custom
@@ -1,4 +1,13 @@
1
1
  import type { AnimationConfig } from 'svelte/animate';
2
+ /** The trips this module is running on `node`. A run holds the slot at its pre-move
3
+ * position for its whole length, so anything that measures the node's box waits on
4
+ * these rather than reading the one a transform is holding. */
5
+ export declare function reorderTrips(node: HTMLElement): Animation[];
6
+ /** A duration rung off `style`, in ms; `undefined` where the derivation is out of reach
7
+ * (an unstyled root, or jsdom, where `getComputedStyle` reports custom properties as
8
+ * empty). No rung, no motion: a fallback here would be the scale restated in the one
9
+ * place `check:style` cannot read. */
10
+ export declare function rungMs(style: CSSStyleDeclaration, rung: string): number | undefined;
2
11
  /**
3
12
  * Slide a keyed slot from where it was to where it now is.
4
13
  *
@@ -7,11 +7,19 @@
7
7
  /** Marks this module's runs, so a second reorder cancels the first rather than layering
8
8
  * a second transform over it. */
9
9
  const RUN_ID = 'qm-reorder';
10
+ /** The trips this module is running on `node`. A run holds the slot at its pre-move
11
+ * position for its whole length, so anything that measures the node's box waits on
12
+ * these rather than reading the one a transform is holding. */
13
+ export function reorderTrips(node) {
14
+ if (typeof node.getAnimations !== 'function')
15
+ return [];
16
+ return node.getAnimations().filter((run) => run.id === RUN_ID);
17
+ }
10
18
  /** A duration rung off `style`, in ms; `undefined` where the derivation is out of reach
11
19
  * (an unstyled root, or jsdom, where `getComputedStyle` reports custom properties as
12
20
  * empty). No rung, no motion: a fallback here would be the scale restated in the one
13
21
  * place `check:style` cannot read. */
14
- function rungMs(style, rung) {
22
+ export function rungMs(style, rung) {
15
23
  const raw = style.getPropertyValue(rung).trim();
16
24
  const n = Number.parseFloat(raw);
17
25
  if (!Number.isFinite(n))
@@ -34,7 +42,7 @@ function rungMs(style, rung) {
34
42
  export function reorder(node, { from, to }, armed) {
35
43
  // The returned config is empty by design, in both exits and the run below: Svelte
36
44
  // samples a config's keyframes through a JS easing function, and this curve is a
37
- // rung the derivation mints and `check:style` holds.
45
+ // rung the derivation mints.
38
46
  if (!armed() || typeof node.animate !== 'function')
39
47
  return {};
40
48
  const style = getComputedStyle(node);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",