@quillmark/svelte 0.5.1 → 0.6.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 (53) hide show
  1. package/README.md +6 -0
  2. package/THEMING.md +12 -16
  3. package/dist/core/address.d.ts +17 -3
  4. package/dist/core/address.js +20 -3
  5. package/dist/core/codec/blocks.d.ts +37 -0
  6. package/dist/core/codec/blocks.js +163 -0
  7. package/dist/core/codec/field.js +3 -5
  8. package/dist/core/codec/index.d.ts +1 -1
  9. package/dist/core/codec/index.js +3 -2
  10. package/dist/core/codec/inputrules.js +24 -144
  11. package/dist/core/codec/prose.css +14 -25
  12. package/dist/core/codec/schema.d.ts +8 -0
  13. package/dist/core/codec/schema.js +33 -1
  14. package/dist/core/codec/slash.d.ts +18 -16
  15. package/dist/core/codec/slash.js +0 -0
  16. package/dist/core/codec/table-view.js +127 -157
  17. package/dist/core/codec/table.d.ts +0 -10
  18. package/dist/core/codec/table.js +0 -15
  19. package/dist/core/theme.css +350 -344
  20. package/dist/preset/recipes.css +10 -17
  21. package/dist/preview/Preview.svelte +4 -2
  22. package/dist/preview/geometry.d.ts +10 -9
  23. package/dist/preview/geometry.js +11 -13
  24. package/dist/visual/ArrayField.svelte +242 -63
  25. package/dist/visual/ArrayField.svelte.d.ts +5 -1
  26. package/dist/visual/Card.svelte +21 -2
  27. package/dist/visual/EnumField.svelte +33 -1
  28. package/dist/visual/Field.svelte +28 -4
  29. package/dist/visual/FieldLabel.svelte +2 -2
  30. package/dist/visual/FormatPopover.svelte +13 -1
  31. package/dist/visual/ObjectField.svelte +157 -35
  32. package/dist/visual/ObjectField.svelte.d.ts +13 -4
  33. package/dist/visual/ProseArrayElement.svelte +22 -2
  34. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -0
  35. package/dist/visual/TipsCard.svelte +33 -13
  36. package/dist/visual/VariantField.svelte +98 -0
  37. package/dist/visual/VariantField.svelte.d.ts +21 -0
  38. package/dist/visual/VisualEditorInner.svelte +15 -10
  39. package/dist/visual/controls.css +282 -283
  40. package/dist/visual/diagnostics.d.ts +13 -11
  41. package/dist/visual/diagnostics.js +11 -9
  42. package/dist/visual/domid.d.ts +13 -0
  43. package/dist/visual/domid.js +16 -0
  44. package/dist/visual/leaves.d.ts +9 -7
  45. package/dist/visual/links.d.ts +5 -2
  46. package/dist/visual/links.js +9 -3
  47. package/dist/visual/strings.d.ts +15 -0
  48. package/dist/visual/strings.js +3 -0
  49. package/dist/visual/structure.d.ts +32 -6
  50. package/dist/visual/structure.js +49 -3
  51. package/package.json +2 -2
  52. package/dist/core/codec/clipboard.d.ts +0 -59
  53. package/dist/core/codec/clipboard.js +0 -202
@@ -16,8 +16,11 @@
16
16
  import { Select } from 'bits-ui';
17
17
  import Icon from './icons/Icon.svelte';
18
18
  import { syncedLocal } from './synced.svelte.js';
19
+ import { wording } from './strings.js';
19
20
  import './controls.css';
20
21
 
22
+ const t = wording();
23
+
21
24
  interface Props {
22
25
  value: string | undefined;
23
26
  values: string[];
@@ -114,8 +117,19 @@
114
117
  plain string and never picks up the scoping hash. -->
115
118
  <div class="qm-menu-surface qm-select-content" data-qm-root>
116
119
  <Select.Viewport>
117
- <Select.Item class="qm-menu-item qm-select-item" value={UNSET} label={ghostText}>
120
+ <!-- The sentinel renders the `default:`'s own text, so a defaulted enum
121
+ lists that word twice: this row, and the member below it. The tag has
122
+ to be a word rather than a tone — the ghost is already `ink-label`,
123
+ and while the field is unset this is the selected row, so the weight
124
+ step pulls the other way. It rides the accessible name for the same
125
+ reason. -->
126
+ <Select.Item
127
+ class="qm-menu-item qm-select-item qm-select-unset"
128
+ value={UNSET}
129
+ label={`${ghostText} ${t.strings.enumUnsetTag}`}
130
+ >
118
131
  <span class="qm-select-ghost">{ghostText}</span>
132
+ <span class="qm-select-tag">{t.strings.enumUnsetTag}</span>
119
133
  </Select.Item>
120
134
  <!-- A refused option still draws under `'disable'`, and under either
121
135
  policy when it is the one selected: a listbox whose selected value
@@ -186,4 +200,22 @@
186
200
  .qm-select-ghost {
187
201
  color: var(--_qm-ink-label);
188
202
  }
203
+ /* The sentinel's row: the ghost at one end, the tag at the other, so the pair reads
204
+ as a value and a note about it rather than as two words. Only this row takes the
205
+ split; a member's row is one string and has nothing to distribute. */
206
+ .qm-select-content :global(.qm-select-unset) {
207
+ display: flex;
208
+ align-items: baseline;
209
+ justify-content: space-between;
210
+ gap: var(--_qm-space-2);
211
+ }
212
+ /* A step down the size ramp, which is what says this is chrome and the word beside
213
+ it is the value. The ink is the ghost's own: a second tone here would rank the
214
+ tag against the thing it annotates, and there is no rung under `label` to take
215
+ (theme.css, "Two rungs, and there is no third"). */
216
+ .qm-select-tag {
217
+ flex-shrink: 0;
218
+ font-size: var(--_qm-text-label);
219
+ color: var(--_qm-ink-label);
220
+ }
189
221
  </style>
@@ -18,7 +18,7 @@
18
18
  import type { EditorErrorHandler } from '../core/errors.js';
19
19
  import type { LeafRegistry } from './leaves.js';
20
20
  import type { FieldModel, FieldSpan } from './structure.js';
21
- import { ghostDefault, stringifyGhost } from './structure.js';
21
+ import { VARIANT_DISCRIMINANT, ghostDefault, stringifyGhost } from './structure.js';
22
22
  import type { FieldDomIds } from './domid.js';
23
23
  import ProseField from './ProseField.svelte';
24
24
  import TextField from './TextField.svelte';
@@ -28,6 +28,7 @@
28
28
  import DateField from './DateField.svelte';
29
29
  import ArrayField from './ArrayField.svelte';
30
30
  import ObjectField from './ObjectField.svelte';
31
+ import VariantField from './VariantField.svelte';
31
32
  import DiagnosticList from './DiagnosticList.svelte';
32
33
  import FieldLabel from './FieldLabel.svelte';
33
34
 
@@ -91,14 +92,22 @@
91
92
  // control's `YYYY-MM-DD`. An object-valued default does not ghost.
92
93
  const ghost = $derived(ghostDefault(provenance));
93
94
  const defaultStr = $derived(stringifyGhost(ghost));
95
+ // A variant resolves as one rung whose value is the whole container, so the
96
+ // discriminant's ghost is that container's own discriminant cell.
97
+ const ghostMember = $derived(
98
+ (ghost as Record<string, unknown> | undefined)?.[VARIANT_DISCRIMINANT] as string | undefined
99
+ );
94
100
 
95
101
  // `for` reaches a labelable control and the browser does the rest. The other four
96
102
  // are not labelable (the prose leaf's `contenteditable`, the date field's segment
97
103
  // container, the object subform, an array's N inputs) so `for` there would be
98
104
  // both inert and invalid markup; they take `aria-labelledby` and a click handoff.
105
+ // A variant is labelable through its discriminant: the select is the one control
106
+ // the field's label names, and its cells carry composed names of their own.
99
107
  const labelable = $derived(
100
108
  field.control === 'text' ||
101
109
  field.control === 'enum' ||
110
+ field.control === 'variant' ||
102
111
  field.control === 'number' ||
103
112
  field.control === 'boolean'
104
113
  );
@@ -120,7 +129,7 @@
120
129
  let arrayEl = $state<
121
130
  | {
122
131
  focus: () => void;
123
- focusElement: (k: number) => void;
132
+ focusElement: (k: number, pos?: number) => void;
124
133
  washBox: () => HTMLElement | undefined;
125
134
  }
126
135
  | undefined
@@ -133,8 +142,8 @@
133
142
  }
134
143
  /** The array's per-ELEMENT landing, for the addresses the preview mints under an
135
144
  * array field (`leaves.ts`); read at the call, so it tracks the mounted repeater. */
136
- function focusElement(k: number): void {
137
- arrayEl?.focusElement(k);
145
+ function focusElement(k: number, pos?: number): void {
146
+ arrayEl?.focusElement(k, pos);
138
147
  }
139
148
  // Only where `for` cannot reach; the labelable four are the browser's own, and a
140
149
  // second handler over them would be a focus the label already placed.
@@ -229,6 +238,19 @@
229
238
  {optionAllowed}
230
239
  {enumDisallowed}
231
240
  />
241
+ {:else if field.control === 'variant'}
242
+ <VariantField
243
+ value={value as Record<string, unknown> | undefined}
244
+ schema={field.schema}
245
+ {ghostMember}
246
+ label={field.label}
247
+ id={domIds.control}
248
+ labelledBy={domIds.label}
249
+ {describedBy}
250
+ onCommit={onCommitScalar}
251
+ {optionAllowed}
252
+ {enumDisallowed}
253
+ />
232
254
  {:else if field.control === 'number'}
233
255
  <NumberField
234
256
  value={value as number | undefined}
@@ -265,6 +287,7 @@
265
287
  description={field.description}
266
288
  labelId={domIds.label}
267
289
  descriptionId={domIds.description}
290
+ idBase={domIds.control}
268
291
  elementContent={(i) => quill.reader(doc).getContentAt(addr, [i])}
269
292
  onCommit={onCommitScalar}
270
293
  />
@@ -274,6 +297,7 @@
274
297
  value={value as Record<string, unknown> | undefined}
275
298
  properties={field.schema.properties}
276
299
  label={field.label}
300
+ idBase={domIds.control}
277
301
  labelledBy={domIds.label}
278
302
  {describedBy}
279
303
  onCommit={onCommitScalar}
@@ -11,8 +11,8 @@
11
11
  element is inert and invalid.
12
12
 
13
13
  Two non-gating label decorations:
14
- • a persistent required `*` for a field the schema obliges (`must_fill`, which
15
- derives from a missing `default:`; DOCUMENT_MODEL). Its
14
+ • a persistent required `*` for a field the schema obliges — one that declares no
15
+ `default:` (DOCUMENT_MODEL). Its
16
16
  accessible name is "required", so a screen reader announces the word, not the
17
17
  glyph; inside the `<label>`, so it names the control along with the text
18
18
  ("Subject required") rather than announcing as a node adjacent to it;
@@ -277,6 +277,18 @@
277
277
  sync();
278
278
  }
279
279
 
280
+ /**
281
+ * A fresh anchor id: 128 random bits as hex, the id being opaque to the
282
+ * boundary and needing only to be unique. `crypto.randomUUID` is
283
+ * secure-context-only, and absent on a page served over plain `http` to
284
+ * something other than localhost; `getRandomValues` is there either way.
285
+ */
286
+ function mintAnchorId(): string {
287
+ return [...crypto.getRandomValues(new Uint8Array(16))]
288
+ .map((b) => b.toString(16).padStart(2, '0'))
289
+ .join('');
290
+ }
291
+
280
292
  /**
281
293
  * Toggle an identity anchor over the selection: the `anchor`
282
294
  * button's answer to a formatting toggle. If the selection already covers
@@ -292,7 +304,7 @@
292
304
  const { from, to } = leaf.selectionRange();
293
305
  const covered = leaf.anchorsInRange(from, to);
294
306
  if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
295
- else leaf.insertAnchor(crypto.randomUUID(), from);
307
+ else leaf.insertAnchor(mintAnchorId(), from);
296
308
  view.focus();
297
309
  sync();
298
310
  }
@@ -4,6 +4,23 @@
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.
12
+
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.
23
+
7
24
  A property's ghosted `default:` is the static schema `sub.default`, not the
8
25
  resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
9
26
  carries no per-property row (an object field resolves as one row whose value is
@@ -11,7 +28,10 @@
11
28
  -->
12
29
  <script lang="ts">
13
30
  import type { QuillFieldSchema } from '@quillmark/wasm';
14
- import { controlKind, humanize } from './structure.js';
31
+ import { controlKind, humanize, obliged } from './structure.js';
32
+ import { wording } from './strings.js';
33
+ import { propertyDomIds } from './domid.js';
34
+ import FieldLabel from './FieldLabel.svelte';
15
35
  import TextField from './TextField.svelte';
16
36
  import EnumField from './EnumField.svelte';
17
37
  import NumberField from './NumberField.svelte';
@@ -21,21 +41,53 @@
21
41
  interface Props {
22
42
  value: Record<string, unknown> | undefined;
23
43
  properties: Record<string, QuillFieldSchema> | undefined;
24
- /** Accessible-name prefix for the property controls. */
25
- label?: string;
26
44
  /** The field label's own id. A subform is a group of controls, not one control
27
- * `for` could reach, so the field's label names the set and each property
28
- * control keeps its own composed `aria-label`. */
45
+ * `for` could reach, so the field's label names the set; each property carries
46
+ * its own `<label for>` inside it. */
29
47
  labelledBy?: string;
30
48
  /** The parked `description` (FieldLabel): announced on entering the group. */
31
49
  describedBy?: string;
50
+ /**
51
+ * The parent control's DOM id: the base each property's own three names derive
52
+ * from ({@link propertyDomIds}). Absent — a subform mounted with no field around
53
+ * it — the properties fall back to `aria-label`, which names them without a
54
+ * `for` target to click.
55
+ */
56
+ idBase?: string;
57
+ /** Accessible-name prefix used only on the `aria-label` fallback above. */
58
+ label?: string;
59
+ /** Which strokes the subform draws; see the note above. */
60
+ edges?: 'band' | 'close';
32
61
  onCommit: (obj: Record<string, unknown>) => void;
33
62
  }
34
- let { value, properties, label, labelledBy, describedBy, onCommit }: Props = $props();
63
+ let {
64
+ value,
65
+ properties,
66
+ labelledBy,
67
+ describedBy,
68
+ idBase,
69
+ label,
70
+ edges = 'band',
71
+ onCommit
72
+ }: Props = $props();
35
73
 
74
+ const t = wording();
36
75
  const entries = $derived(Object.entries(properties ?? {}));
37
76
  const obj = $derived((value ?? {}) as Record<string, unknown>);
38
77
 
78
+ /** Obligation, read off the cell exactly as `fieldModels` reads it off a field
79
+ * ({@link obliged}). `validate` anchors it per leaf, so a property with no
80
+ * `default:` is obliged in its own right and the container around it holds none
81
+ * (VISUAL_EDITOR §"Enum variants"). */
82
+ const required = obliged;
83
+
84
+ const title = (key: string, sub: QuillFieldSchema): string => sub.ui?.title ?? humanize(key);
85
+ /** The `aria-label` fallback, for a subform mounted without a field's id space:
86
+ * the field's name and the property's, since nothing else names the control. */
87
+ const fallbackName = (key: string, sub: QuillFieldSchema): string =>
88
+ `${label != null ? `${label} ` : ''}${title(key, sub)}` +
89
+ (required(sub) ? ` ${t.strings.fieldRequired}` : '');
90
+
39
91
  /** Take the caret: the first property's control, a subform having no single control
40
92
  * of its own to land on. Resolved off the DOM rather than a ref per property: every
41
93
  * property is a scalar control the DOM already knows how to focus (the date field's
@@ -43,10 +95,19 @@
43
95
  * handle each would be five refs restating document order. An empty or wholly
44
96
  * unsupported subform lands nothing. */
45
97
  let rootEl = $state<HTMLElement | undefined>();
98
+ const FOCUSABLE = 'input, select, button, [tabindex]:not([tabindex="-1"])';
46
99
  export function focus(): void {
47
- rootEl
48
- ?.querySelector<HTMLElement>('input, select, button, [tabindex]:not([tabindex="-1"])')
49
- ?.focus();
100
+ rootEl?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
101
+ }
102
+ /** A label click for the one property `for` cannot reach: the date control, whose
103
+ * focus lives on a segment. Scoped to that property's own cell, so the click lands
104
+ * where the label is rather than on the subform's first control. */
105
+ function focusProp(key: string): void {
106
+ // Matched on the dataset: a key is the schema's own string, and spelling one into
107
+ // a selector needs `CSS.escape`, which the test DOM does not carry.
108
+ for (const cell of rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? []) {
109
+ if (cell.dataset.qmProp === key) return cell.querySelector<HTMLElement>(FOCUSABLE)?.focus();
110
+ }
50
111
  }
51
112
 
52
113
  function commitProp(key: string, v: unknown): void {
@@ -66,18 +127,38 @@
66
127
  <div
67
128
  bind:this={rootEl}
68
129
  class="qm-object"
130
+ class:close={edges === 'close'}
69
131
  role="group"
70
132
  aria-labelledby={labelledBy}
71
133
  aria-describedby={describedBy}
72
134
  >
73
135
  {#each entries as [key, sub] (key)}
74
136
  {@const kind = controlKind(sub)}
75
- {@const propLabel = `${label != null ? `${label} ` : ''}${sub.ui?.title ?? humanize(key)}`}
76
- <div class="qm-object-prop">
77
- <span class="qm-object-label">{sub.ui?.title ?? humanize(key)}</span>
137
+ {@const ids = idBase ? propertyDomIds(idBase, key) : undefined}
138
+ {@const named = ids ? undefined : fallbackName(key, sub)}
139
+ {@const describes = sub.description && ids ? ids.description : undefined}
140
+ <!-- `for` reaches the four labelable controls; the date field's focus lives on a
141
+ segment, so it takes the click handoff instead, exactly as `Field` does one
142
+ level up. -->
143
+ {@const labelable =
144
+ kind === 'text' || kind === 'enum' || kind === 'number' || kind === 'boolean'}
145
+ <div class="qm-object-prop" data-qm-prop={key}>
146
+ {#if ids}
147
+ <FieldLabel
148
+ label={title(key, sub)}
149
+ controlId={labelable ? ids.control : undefined}
150
+ id={ids.label}
151
+ descriptionId={describes}
152
+ onActivate={labelable ? undefined : () => focusProp(key)}
153
+ required={required(sub)}
154
+ description={sub.description}
155
+ />
156
+ {/if}
78
157
  {#if kind === 'enum'}
79
158
  <EnumField
80
- label={propLabel}
159
+ label={named}
160
+ id={ids?.control}
161
+ describedBy={describes}
81
162
  value={obj[key] as string | undefined}
82
163
  values={sub.values ?? []}
83
164
  fallback={sub.default as string | undefined}
@@ -85,7 +166,9 @@
85
166
  />
86
167
  {:else if kind === 'number'}
87
168
  <NumberField
88
- label={propLabel}
169
+ label={named}
170
+ id={ids?.control}
171
+ describedBy={describes}
89
172
  value={obj[key] as number | undefined}
90
173
  integer={sub.type === 'integer'}
91
174
  fallback={sub.default as number | undefined}
@@ -93,54 +176,93 @@
93
176
  />
94
177
  {:else if kind === 'boolean'}
95
178
  <BooleanField
96
- label={propLabel}
179
+ label={named}
180
+ id={ids?.control}
181
+ describedBy={describes}
97
182
  value={obj[key] as boolean | undefined}
98
183
  fallback={sub.default as boolean | undefined}
99
184
  onCommit={(v) => commitProp(key, v)}
100
185
  />
101
186
  {:else if kind === 'date'}
102
187
  <DateField
103
- label={propLabel}
188
+ label={named}
189
+ labelledBy={ids?.label}
190
+ describedBy={describes}
104
191
  value={obj[key] as string | undefined}
105
192
  onCommit={(v) => commitProp(key, v)}
106
193
  />
107
194
  {:else if kind === 'text'}
108
195
  <TextField
109
- label={propLabel}
196
+ label={named}
197
+ id={ids?.control}
198
+ describedBy={describes}
110
199
  value={obj[key] as string | undefined}
111
200
  placeholder={sub.default != null ? String(sub.default) : undefined}
112
201
  onCommit={(v) => commitProp(key, v)}
113
202
  />
114
203
  {:else}
115
- <span class="qm-unsupported">({kind} property — not editable in V1)</span>
204
+ <!-- A property the subform does not recurse into. What it says is what the
205
+ user can do about it, which is edit the field from the source surface;
206
+ a version number is the roadmap talking to somebody filling in a form. -->
207
+ <span class="qm-unsupported">{t.strings.nestedUnsupported(kind)}</span>
116
208
  {/if}
117
209
  </div>
118
210
  {/each}
119
211
  </div>
120
212
 
121
213
  <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.
217
+
218
+ `--_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.
223
+
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. */
122
232
  .qm-object {
123
- display: flex;
124
- flex-direction: column;
233
+ display: grid;
234
+ grid-template-columns: repeat(var(--cols), 1fr);
125
235
  gap: var(--_qm-space-2);
126
- border-left: var(--_qm-border-width) solid var(--_qm-border);
127
- padding-left: 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;
128
246
  }
247
+ /* A property measures like a field: two tracks over the subform's own rows, so a
248
+ label wrapping in one column leaves its control on the row's baseline rather than a
249
+ line below it — `Field.svelte`'s rule for a row-sharing field, one level in. The
250
+ tracks are this grid's own; the section's belong to the fields.
251
+
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. */
129
254
  .qm-object-prop {
130
- display: flex;
131
- flex-direction: column;
132
- gap: var(--_qm-space-half);
255
+ display: grid;
256
+ grid-row: span 2;
257
+ grid-template-rows: subgrid;
258
+ row-gap: var(--_qm-space-half);
259
+ align-items: start;
260
+ min-width: 0;
133
261
  }
134
- .qm-object-label {
135
- font-size: var(--_qm-text-label);
136
- /* The chrome weight, the same one the field label above it takes: what says
137
- this name is nested is the subform's rule and its inset, so a weight step
138
- here would state it twice and rank two names the ramp does not separate. */
139
- font-weight: var(--_qm-weight-mid);
140
- /* A label is a line, not a passage, so it overrides the root's reading
141
- rhythm the way every other label rung does. */
142
- line-height: var(--_qm-leading-tight);
143
- color: var(--_qm-ink-label);
262
+ /* A subform of one takes half the capacity, the way a packable run of one does
263
+ (`.qm-field.lone`): at full capacity a single track reads as truncated. */
264
+ .qm-object-prop:only-child {
265
+ grid-column: span var(--cols-half);
144
266
  }
145
267
  .qm-unsupported {
146
268
  font-size: var(--_qm-text-body);
@@ -2,14 +2,23 @@ import type { QuillFieldSchema } from '@quillmark/wasm';
2
2
  interface Props {
3
3
  value: Record<string, unknown> | undefined;
4
4
  properties: Record<string, QuillFieldSchema> | undefined;
5
- /** Accessible-name prefix for the property controls. */
6
- label?: string;
7
5
  /** The field label's own id. A subform is a group of controls, not one control
8
- * `for` could reach, so the field's label names the set and each property
9
- * control keeps its own composed `aria-label`. */
6
+ * `for` could reach, so the field's label names the set; each property carries
7
+ * its own `<label for>` inside it. */
10
8
  labelledBy?: string;
11
9
  /** The parked `description` (FieldLabel): announced on entering the group. */
12
10
  describedBy?: string;
11
+ /**
12
+ * The parent control's DOM id: the base each property's own three names derive
13
+ * from ({@link propertyDomIds}). Absent — a subform mounted with no field around
14
+ * it — the properties fall back to `aria-label`, which names them without a
15
+ * `for` target to click.
16
+ */
17
+ idBase?: string;
18
+ /** Accessible-name prefix used only on the `aria-label` fallback above. */
19
+ label?: string;
20
+ /** Which strokes the subform draws; see the note above. */
21
+ edges?: 'band' | 'close';
13
22
  onCommit: (obj: Record<string, unknown>) => void;
14
23
  }
15
24
  declare const ObjectField: import("svelte").Component<Props, {
@@ -17,14 +17,16 @@
17
17
  -->
18
18
  <script lang="ts">
19
19
  import { onMount } from 'svelte';
20
- import { EditorState } from 'prosemirror-state';
20
+ import { EditorState, Selection } from 'prosemirror-state';
21
21
  import { EditorView } from 'prosemirror-view';
22
22
  import {
23
23
  decode,
24
24
  pmToContent,
25
25
  inlineSchema,
26
26
  plaintextSchema,
27
- proseLeafPlugins
27
+ proseLeafPlugins,
28
+ buildLineIndex,
29
+ usvToPM
28
30
  } from '../core/codec/index.js';
29
31
  import './controls.css';
30
32
  import type { Content } from '@quillmark/wasm';
@@ -61,6 +63,24 @@
61
63
  view.focus();
62
64
  view.dom.scrollIntoView({ block: 'nearest' });
63
65
  }
66
+ /** Take the caret to USV `pos` in this element's own content: what a landing on an
67
+ * element address resolves to when the compile answered a cluster-exact offset
68
+ * (`leaves.ts`). `createField`'s body over the view this component owns.
69
+ *
70
+ * The index is built here rather than held: a landing is a discrete act, and one
71
+ * doc walk per click is cheaper than a cache to invalidate per keystroke. `usvToPM`
72
+ * clamps, so an offset past a value the compile has moved on from lands the end.
73
+ *
74
+ * `Selection.near`, not `TextSelection.create`: the mapped position can be
75
+ * non-inline. Focus first, then the caret, flagged for scroll — the order and the
76
+ * reasons are `FieldController.setCaret`'s (`codec/field.ts`). */
77
+ export function setCaret(pos: number): void {
78
+ if (!view) return;
79
+ const pm = usvToPM(buildLineIndex(view.state.doc), pos);
80
+ const sel = Selection.near(view.state.doc.resolve(pm));
81
+ view.focus();
82
+ view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
83
+ }
64
84
 
65
85
  onMount(() => {
66
86
  if (!containerEl) return;
@@ -19,6 +19,7 @@ interface Props {
19
19
  }
20
20
  declare const ProseArrayElement: import("svelte").Component<Props, {
21
21
  focus: () => void;
22
+ setCaret: (pos: number) => void;
22
23
  }, "">;
23
24
  type ProseArrayElement = ReturnType<typeof ProseArrayElement>;
24
25
  export default ProseArrayElement;
@@ -97,27 +97,34 @@
97
97
  paints over the rest. The two read as one block, which is what document-level
98
98
  guidance is: the main card's, not a card of its own. Still in-flow, no lift.
99
99
 
100
- It takes the card's hairline, and the tuck makes the two outlines one: the slip's top
101
- stroke and the tops of its sides run under `main`, so what shows starts at the card's
102
- bottom corners.
100
+ No stroke of its own. A tint below the card reads as a slip; the same tint inside an
101
+ outline reads as a second card, which is the one thing the tuck exists to deny. What
102
+ shows below the seam is fill, and `main`'s hairline is the pair's only edge.
103
103
 
104
104
  The bottom corners take the inner rung where the card above takes the outer: a slip
105
105
  is cut crisper than the thing it is tucked under, at every setting of the dial.
106
106
 
107
- The insets are asymmetric so the ink is not: the top takes the tuck back, and the
108
- bottom gives up a rung to the foot's control, whose box stands on the tap floor
109
- rather than on its own one-line label. The horizontal is the card's inset plus the
110
- body's margin (ProseField), so the tip starts under the prose rather than under the
111
- fields. */
107
+ Down, the insets are the document's own rhythm and they are measured to the ink, not
108
+ to the boxes. The top is a block gap below the seam, what two of the body's paragraphs
109
+ take, so the tip reads as the block after the prose; the bottom is the card's inset
110
+ rung; and the foot sits at half of either, being the message's own foot rather than
111
+ the next thing after it. The control gives its tap floor back as margin for that to
112
+ hold: a box standing on the floor rather than on its one-line label carries a rung of
113
+ slack at each edge, and spent inside the gap that is enough to make the tightest space
114
+ in the card measure as the widest.
115
+
116
+ Across, the inset is `main`'s stroke and the two nested insets under it, the card's
117
+ own and the prose control's, so the tip starts on the prose's edge rather than short
118
+ of it. */
112
119
  .qm-tips {
113
120
  margin-top: calc(var(--_qm-radius) * -1);
114
- border: var(--_qm-border-width) solid var(--_qm-border);
115
121
  border-radius: 0 0 var(--_qm-radius-inner) var(--_qm-radius-inner);
116
- padding: calc(var(--_qm-space-3) + var(--_qm-radius)) var(--_qm-space-5) var(--_qm-space-2);
122
+ padding: calc(var(--_qm-space-3) + var(--_qm-radius))
123
+ calc(var(--_qm-border-width) + var(--_qm-space-3) * 2) var(--_qm-space-3);
117
124
  background: var(--_qm-tips-surface);
118
125
  display: flex;
119
126
  flex-direction: column;
120
- gap: var(--_qm-space-2);
127
+ gap: var(--_qm-space);
121
128
  font-family: var(--_qm-tips-font);
122
129
  font-size: var(--_qm-text-body);
123
130
  line-height: var(--_qm-leading-body);
@@ -168,7 +175,11 @@
168
175
  the spacing one — the foot's button clears the floor and walks the whole set from
169
176
  the tip the card opens on, so no tip is reachable only by a dot. State is three
170
177
  steps of one ink on the recede ladder, never a second hue and never a size: a dot
171
- that grew would move the row it sits in. */
178
+ that grew would move the row it sits in.
179
+
180
+ `color` is restated because a button takes the UA's and not its parent's, so
181
+ `currentColor` is the card's ink only once it is inherited; left to the UA it is
182
+ `buttontext`, which is the one neutral on a card that carries no neutrals. */
172
183
  .qm-tips-dot {
173
184
  box-sizing: content-box;
174
185
  width: 5px;
@@ -176,6 +187,7 @@
176
187
  padding: var(--_qm-space-half);
177
188
  border: none;
178
189
  border-radius: var(--_qm-radius-pill);
190
+ color: inherit;
179
191
  background: currentColor;
180
192
  background-clip: content-box;
181
193
  opacity: var(--_qm-opacity-idle);
@@ -192,8 +204,16 @@
192
204
  overrides is everything the family sets for a glyph among callers of its own
193
205
  colour, a labelled control on a warm card wanting none of it. The hover included:
194
206
  the family's neutral step would read as lent from the document above. Unlayered,
195
- so it beats the base rule without out-specifying it. */
207
+ so it beats the base rule without out-specifying it.
208
+
209
+ The box is the tap target and the label is what the rhythm measures, so both axes
210
+ give the difference back as margin: the inline rung lands the label on the body's
211
+ edge above, and the block rung takes the floor's slack out of the foot's height.
212
+ The box, and the hover it paints, still reaches past the label the way a control
213
+ does. */
196
214
  .qm-tips-action {
215
+ margin-block: calc(var(--_qm-space) * -1);
216
+ margin-right: calc(var(--_qm-space-2) * -1);
197
217
  padding: var(--_qm-space-half) var(--_qm-space-2);
198
218
  font-family: inherit;
199
219
  font-size: inherit;