@quillmark/svelte 0.5.1 → 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.
Files changed (66) hide show
  1. package/README.md +6 -0
  2. package/THEMING.md +13 -17
  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 +5 -6
  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.d.ts +4 -0
  17. package/dist/core/codec/table-view.js +137 -158
  18. package/dist/core/codec/table.d.ts +0 -10
  19. package/dist/core/codec/table.js +0 -15
  20. package/dist/core/theme.css +357 -346
  21. package/dist/preset/recipes.css +25 -21
  22. package/dist/preview/Preview.svelte +4 -2
  23. package/dist/preview/bridge.d.ts +23 -0
  24. package/dist/preview/bridge.js +92 -35
  25. package/dist/preview/controller.d.ts +6 -5
  26. package/dist/preview/controller.js +13 -7
  27. package/dist/preview/geometry.d.ts +10 -9
  28. package/dist/preview/geometry.js +11 -13
  29. package/dist/preview/index.js +3 -2
  30. package/dist/preview/paint.js +9 -1
  31. package/dist/visual/ArrayField.svelte +246 -63
  32. package/dist/visual/ArrayField.svelte.d.ts +5 -1
  33. package/dist/visual/Card.svelte +84 -50
  34. package/dist/visual/EnumField.svelte +37 -3
  35. package/dist/visual/Field.svelte +28 -4
  36. package/dist/visual/FieldLabel.svelte +2 -2
  37. package/dist/visual/FormatPopover.svelte +13 -1
  38. package/dist/visual/ObjectField.svelte +128 -35
  39. package/dist/visual/ObjectField.svelte.d.ts +11 -4
  40. package/dist/visual/ProseArrayElement.svelte +22 -2
  41. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -0
  42. package/dist/visual/SlashMenu.svelte +7 -0
  43. package/dist/visual/TipsCard.svelte +33 -13
  44. package/dist/visual/VariantField.svelte +95 -0
  45. package/dist/visual/VariantField.svelte.d.ts +21 -0
  46. package/dist/visual/VisualEditorInner.svelte +44 -21
  47. package/dist/visual/controls.css +282 -283
  48. package/dist/visual/diagnostics.d.ts +13 -11
  49. package/dist/visual/diagnostics.js +11 -9
  50. package/dist/visual/domid.d.ts +13 -0
  51. package/dist/visual/domid.js +16 -0
  52. package/dist/visual/hold.d.ts +17 -0
  53. package/dist/visual/hold.js +39 -0
  54. package/dist/visual/index.js +1 -1
  55. package/dist/visual/leaves.d.ts +9 -7
  56. package/dist/visual/links.d.ts +5 -2
  57. package/dist/visual/links.js +9 -3
  58. package/dist/visual/motion.d.ts +9 -0
  59. package/dist/visual/motion.js +10 -2
  60. package/dist/visual/strings.d.ts +15 -0
  61. package/dist/visual/strings.js +3 -0
  62. package/dist/visual/structure.d.ts +32 -6
  63. package/dist/visual/structure.js +49 -3
  64. package/package.json +2 -2
  65. package/dist/core/codec/clipboard.d.ts +0 -59
  66. package/dist/core/codec/clipboard.js +0 -202
@@ -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
@@ -140,6 +141,10 @@
140
141
  let expanded = $state<string | null>(initialExpandedGroup(card.sections));
141
142
  let headers = $state<Record<string, HTMLButtonElement | undefined>>({});
142
143
  let panels = $state<Record<string, HTMLElement | undefined>>({});
144
+ /** Whether the panels animate the move `expanded` is about to make. False for a reveal
145
+ * ({@link revealLeaf}), restored by the header gesture the motion is there for, so its
146
+ * return needs no timer. */
147
+ let animate = $state(true);
143
148
  /**
144
149
  * Move to `next` (`null` = all closed). A closing panel goes `inert` with whatever it
145
150
  * held still focused, and `inert` does not say where focus should go — the browser
@@ -153,8 +158,11 @@
153
158
  headers[closing]?.focus();
154
159
  expanded = next;
155
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`). */
156
163
  function toggleGroup(group: string): void {
157
- setExpanded(expanded === group ? null : group);
164
+ animate = true;
165
+ holdInView(headers[group], () => setExpanded(expanded === group ? null : group));
158
166
  }
159
167
 
160
168
  /**
@@ -163,13 +171,20 @@
163
171
  * flush before landing, since `inert` clears when the panel renders open, not when
164
172
  * `expanded` is assigned.
165
173
  *
174
+ * The move is instant, because that same flush is when the landing measures its target:
175
+ * an animating track still reads its start value there, and the trip would be computed
176
+ * against a panel that has not moved yet. The motion explains a header click, and a
177
+ * click in the preview is not one.
178
+ *
166
179
  * A call, not a prop: a reveal is an event, and modelling it as state needs a
167
180
  * fresh-identity wrapper to re-fire and an `untrack` to keep the per-keystroke
168
181
  * re-derive from reopening the accordion under the user.
169
182
  */
170
183
  export function revealLeaf(key: string): void {
171
184
  const section = grouped.find((s) => s.fields.some((f) => ops.leafKey(f.name) === key));
172
- if (section?.group) setExpanded(section.group);
185
+ if (!section?.group) return;
186
+ animate = false;
187
+ setExpanded(section.group);
173
188
  }
174
189
 
175
190
  let el = $state<HTMLElement | undefined>(undefined);
@@ -178,6 +193,9 @@
178
193
  * an insert, `nearest` for a reorder. The reduced-motion guard is JS rather than the
179
194
  * CSS the accordion uses: a scroll's behaviour is an argument, not a transition a
180
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`).
181
199
  */
182
200
  export function scrollIntoViewCard(block: ScrollLogicalPosition): void {
183
201
  const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
@@ -232,16 +250,8 @@
232
250
  {/if}
233
251
 
234
252
  <div class="qm-card-body">
235
- <!-- One element whose top and bottom edges are the bracket's two horizontals.
236
- Anchored here rather than on the header and on the accordion so an open section's
237
- vertical reaches them, and so a card with no groups still terminates above its
238
- body. -->
239
253
  {#if hasMeta}
240
- <div
241
- class="qm-card-meta"
242
- class:qm-meta-top={!card.isMain}
243
- class:qm-meta-bottom={card.hasBody}
244
- >
254
+ <div class="qm-card-meta" class:qm-meta-bottom={card.hasBody}>
245
255
  {#each ungrouped as section (section.group ?? '_ungrouped')}
246
256
  <div class="qm-section">
247
257
  {@render sectionFields(section.fields)}
@@ -249,10 +259,9 @@
249
259
  {/each}
250
260
 
251
261
  <!-- One open at a time. Rendered only when a card has groups, so an empty
252
- accordion is not the bracket's last child: the bottom rule's inset belongs
253
- to whichever block abuts it. -->
262
+ accordion does not stand a gap under the ungrouped fields. -->
254
263
  {#if grouped.length}
255
- <div class="qm-groups">
264
+ <div class="qm-groups" class:qm-instant={!animate}>
256
265
  {#each grouped as section (section.group)}
257
266
  {@const group = section.group as string}
258
267
  {@const isOpen = expanded === group}
@@ -397,11 +406,12 @@
397
406
  /* An island: the card is the base plane, the column behind it the sunken one, and the
398
407
  hairline closes it (ARCHITECTURE §Styling). The inset is the same rung as the gap, so
399
408
  what separates the header from the fields is what separates the fields from the
400
- 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. */
401
411
  .qm-card {
402
412
  border: var(--_qm-border-width) solid var(--_qm-border);
403
413
  border-radius: var(--_qm-radius);
404
- padding: var(--_qm-space-3);
414
+ padding: var(--_qm-space-3) var(--_qm-nest);
405
415
  background: var(--_qm-surface);
406
416
  display: flex;
407
417
  flex-direction: column;
@@ -485,37 +495,44 @@
485
495
  .qm-card-title:focus {
486
496
  color: var(--_qm-ink);
487
497
  }
488
- .qm-card-body,
489
- .qm-card-meta {
498
+ .qm-card-body {
490
499
  display: flex;
491
500
  flex-direction: column;
492
501
  gap: var(--_qm-space-3);
493
502
  }
494
- /* The metadata bracket, in three strokes at one rung: these two horizontals and the
495
- open section's vertical below. A bracket whose sides disagree on width or tone reads
496
- as three unrelated lines rather than one figure, which is why the vertical takes
497
- `--_qm-border` and not `--_qm-accent`.
498
-
499
- Each condition is what its stroke means: a top rule is the line under a card title,
500
- so the headerless `main` has none; a bottom rule divides fields from body, so a card
501
- with no body has nothing to divide. Sometimes two strokes and sometimes one — an open
502
- figure, which is what distinguishes it from a second box inside the card. */
503
- .qm-meta-top {
504
- 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);
505
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. */
506
522
  .qm-meta-bottom {
507
523
  border-bottom: var(--_qm-border-width) solid var(--_qm-border);
524
+ padding-bottom: var(--_qm-space-2);
508
525
  }
509
- /* The inset sits on the fields rather than on the block that holds them: an open
510
- section's vertical has to reach the rule to close a corner against it, and padding
511
- between the two is the distance by which it would miss. Unqualified by open — a shut
512
- panel clips it. */
513
- .qm-meta-top > .qm-section {
514
- padding-top: var(--_qm-space-3);
515
- }
516
- .qm-meta-bottom > .qm-section:last-child,
517
- .qm-meta-bottom .qm-group:last-child .qm-group-panel-inner > .qm-fields {
518
- 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);
519
536
  }
520
537
  /* Two query containers, one per kind of section, so capacity follows the width the
521
538
  fields actually get: a panel's inset makes that narrower than the card. */
@@ -550,15 +567,21 @@
550
567
  --cols-half: 2;
551
568
  }
552
569
  }
553
- .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. */
554
582
  .qm-group {
555
583
  display: flex;
556
584
  flex-direction: column;
557
- }
558
- /* The bracket's third stroke, on the section rather than on its panel: spanning the
559
- header is what gives it a corner to close into at the block's edge. Drawn transparent
560
- when closed rather than absent, so a toggle moves no text. */
561
- .qm-group {
562
585
  border-left: var(--_qm-border-width) solid transparent;
563
586
  transition: border-color var(--_qm-duration-slow) var(--_qm-ease-reverse);
564
587
  }
@@ -571,6 +594,9 @@
571
594
  because at zero the chevron stands on the section's vertical with only the icon box's
572
595
  own bearing between them, and the glyph's rotation swaps which bearing faces the
573
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.
574
600
 
575
601
  `font: inherit` because a UA button inherits no face, then the body rung, one step
576
602
  over the field labels beneath it: at the label rung the two read as one register and
@@ -618,6 +644,13 @@
618
644
  .qm-group.qm-open .qm-group-panel {
619
645
  grid-template-rows: 1fr;
620
646
  }
647
+ /* The track is the accordion's only stroke that moves layout, and a reveal's landing
648
+ measures its target one flush after asking — where an animating track still reads its
649
+ start value. Both panels of the move are inside the group box, so the opening one and
650
+ the closing one above it stand still together (`revealLeaf`). */
651
+ .qm-groups.qm-instant .qm-group-panel {
652
+ transition: none;
653
+ }
621
654
  /* Two boxes because the axes clip differently, and the track is the only thing that
622
655
  animates: a field sits at its open position from the first frame of the reveal.
623
656
 
@@ -632,12 +665,13 @@
632
665
  .qm-group-panel-inner {
633
666
  min-height: 0;
634
667
  overflow: hidden;
635
- padding-inline-start: var(--_qm-space-4);
668
+ padding-inline-start: var(--_qm-nest);
636
669
  padding-inline-end: var(--_qm-ring-reach);
637
670
  margin-inline-end: calc(-1 * var(--_qm-ring-reach));
638
671
  }
672
+ /* The lower of the two caps an open section's vertical takes (`.qm-group`). */
639
673
  .qm-group-panel-inner > .qm-fields {
640
- padding-block: var(--_qm-space-2);
674
+ padding-block: var(--_qm-space);
641
675
  }
642
676
  .qm-body-leaf {
643
677
  display: flex;
@@ -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[];
@@ -105,17 +108,30 @@
105
108
  <Icon name="chevron-down" size={14} />
106
109
  </Select.Trigger>
107
110
  <Select.Portal to={portalTarget}>
108
- <Select.Content sideOffset={4}>
111
+ <Select.Content collisionBoundary={portalTarget ?? []} sideOffset={4}>
109
112
  <!-- The list portals out of the trigger's DOM but into the nearest
110
113
  `[data-qm-root]`, and carries the marker itself, like FormatPopover.
111
- 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
112
117
  element (not the primitive's) because scoped CSS keys off which
113
118
  component owns the markup: a `class` passed to a primitive is a
114
119
  plain string and never picks up the scoping hash. -->
115
120
  <div class="qm-menu-surface qm-select-content" data-qm-root>
116
121
  <Select.Viewport>
117
- <Select.Item class="qm-menu-item qm-select-item" value={UNSET} label={ghostText}>
122
+ <!-- The sentinel renders the `default:`'s own text, so a defaulted enum
123
+ lists that word twice: this row, and the member below it. The tag has
124
+ to be a word rather than a tone — the ghost is already `ink-label`,
125
+ and while the field is unset this is the selected row, so the weight
126
+ step pulls the other way. It rides the accessible name for the same
127
+ reason. -->
128
+ <Select.Item
129
+ class="qm-menu-item qm-select-item qm-select-unset"
130
+ value={UNSET}
131
+ label={`${ghostText} ${t.strings.enumUnsetTag}`}
132
+ >
118
133
  <span class="qm-select-ghost">{ghostText}</span>
134
+ <span class="qm-select-tag">{t.strings.enumUnsetTag}</span>
119
135
  </Select.Item>
120
136
  <!-- A refused option still draws under `'disable'`, and under either
121
137
  policy when it is the one selected: a listbox whose selected value
@@ -186,4 +202,22 @@
186
202
  .qm-select-ghost {
187
203
  color: var(--_qm-ink-label);
188
204
  }
205
+ /* The sentinel's row: the ghost at one end, the tag at the other, so the pair reads
206
+ as a value and a note about it rather than as two words. Only this row takes the
207
+ split; a member's row is one string and has nothing to distribute. */
208
+ .qm-select-content :global(.qm-select-unset) {
209
+ display: flex;
210
+ align-items: baseline;
211
+ justify-content: space-between;
212
+ gap: var(--_qm-space-2);
213
+ }
214
+ /* A step down the size ramp, which is what says this is chrome and the word beside
215
+ it is the value. The ink is the ghost's own: a second tone here would rank the
216
+ tag against the thing it annotates, and there is no rung under `label` to take
217
+ (theme.css, "Two rungs, and there is no third"). */
218
+ .qm-select-tag {
219
+ flex-shrink: 0;
220
+ font-size: var(--_qm-text-label);
221
+ color: var(--_qm-ink-label);
222
+ }
189
223
  </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
  }