@quillmark/svelte 0.27.0 → 0.28.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 (95) hide show
  1. package/NOTICE +4 -4
  2. package/README.md +26 -8
  3. package/THEMING.md +10 -8
  4. package/dist/core/address.d.ts +35 -64
  5. package/dist/core/address.js +35 -55
  6. package/dist/core/bloom.d.ts +4 -10
  7. package/dist/core/bloom.js +14 -27
  8. package/dist/core/codec/blocks.d.ts +41 -43
  9. package/dist/core/codec/blocks.js +97 -130
  10. package/dist/core/codec/code.d.ts +7 -10
  11. package/dist/core/codec/code.js +37 -46
  12. package/dist/core/codec/decode.d.ts +19 -23
  13. package/dist/core/codec/decode.js +76 -111
  14. package/dist/core/codec/encode.d.ts +11 -15
  15. package/dist/core/codec/encode.js +60 -67
  16. package/dist/core/codec/field.d.ts +62 -95
  17. package/dist/core/codec/field.js +107 -179
  18. package/dist/core/codec/islands.d.ts +33 -41
  19. package/dist/core/codec/islands.js +76 -86
  20. package/dist/core/codec/lists.d.ts +17 -26
  21. package/dist/core/codec/lists.js +117 -157
  22. package/dist/core/codec/marks.d.ts +4 -3
  23. package/dist/core/codec/marks.js +8 -3
  24. package/dist/core/codec/prose.css +84 -46
  25. package/dist/core/codec/schema.d.ts +18 -23
  26. package/dist/core/codec/schema.js +84 -76
  27. package/dist/core/codec/slash.d.ts +42 -48
  28. package/dist/core/codec/slash.js +0 -0
  29. package/dist/core/codec/table-copy.d.ts +4 -3
  30. package/dist/core/codec/table-copy.js +25 -7
  31. package/dist/core/codec/table-view.d.ts +12 -12
  32. package/dist/core/codec/table-view.js +460 -383
  33. package/dist/core/codec/table.d.ts +94 -61
  34. package/dist/core/codec/table.js +331 -83
  35. package/dist/core/codec/tasks.d.ts +6 -0
  36. package/dist/core/codec/tasks.js +30 -3
  37. package/dist/core/errors.d.ts +15 -30
  38. package/dist/core/errors.js +2 -6
  39. package/dist/core/index.js +7 -12
  40. package/dist/core/lifecycle.d.ts +7 -16
  41. package/dist/core/lifecycle.js +10 -21
  42. package/dist/core/rebind.svelte.d.ts +10 -20
  43. package/dist/core/rebind.svelte.js +15 -27
  44. package/dist/core/rungs.d.ts +14 -11
  45. package/dist/core/rungs.js +17 -19
  46. package/dist/core/teardown.d.ts +4 -6
  47. package/dist/core/teardown.js +7 -10
  48. package/dist/core/theme.css +74 -54
  49. package/dist/index.js +3 -8
  50. package/dist/preset/app.css +3 -1
  51. package/dist/preset/index.css +6 -8
  52. package/dist/preset/recipes.css +12 -15
  53. package/dist/preset/scale.css +18 -17
  54. package/dist/preset/shell.css +10 -0
  55. package/dist/preview/bridge.d.ts +18 -22
  56. package/dist/preview/bridge.js +29 -58
  57. package/dist/preview/controller.d.ts +21 -29
  58. package/dist/preview/controller.js +14 -39
  59. package/dist/preview/geometry.d.ts +13 -34
  60. package/dist/preview/geometry.js +13 -34
  61. package/dist/preview/index.js +4 -16
  62. package/dist/preview/paint.d.ts +3 -3
  63. package/dist/preview/paint.js +28 -68
  64. package/dist/preview/strings.d.ts +1 -1
  65. package/dist/preview/strings.js +2 -14
  66. package/dist/preview/zoom.d.ts +18 -23
  67. package/dist/preview/zoom.js +28 -27
  68. package/dist/visual/ArrayField.svelte +77 -16
  69. package/dist/visual/BooleanField.svelte +27 -14
  70. package/dist/visual/Card.svelte +61 -48
  71. package/dist/visual/CardControls.svelte +5 -11
  72. package/dist/visual/DateField.svelte +20 -4
  73. package/dist/visual/EnumField.svelte +3 -0
  74. package/dist/visual/FieldHint.svelte +2 -1
  75. package/dist/visual/FormatPopover.svelte +82 -15
  76. package/dist/visual/FormatPopover.svelte.d.ts +4 -0
  77. package/dist/visual/MatrixAdd.svelte +30 -12
  78. package/dist/visual/MatrixField.svelte +24 -16
  79. package/dist/visual/NumberField.svelte +7 -0
  80. package/dist/visual/ObjectField.svelte +4 -4
  81. package/dist/visual/ProseField.svelte +0 -5
  82. package/dist/visual/TipsCard.svelte +11 -10
  83. package/dist/visual/VariantField.svelte +3 -1
  84. package/dist/visual/VisualEditorInner.svelte +19 -3
  85. package/dist/visual/controls.css +7 -2
  86. package/dist/visual/fonts.d.ts +18 -0
  87. package/dist/visual/fonts.js +59 -0
  88. package/dist/visual/icons/nodes.d.ts +0 -5
  89. package/dist/visual/icons/nodes.js +0 -4
  90. package/dist/visual/props.d.ts +14 -1
  91. package/dist/visual/strings.d.ts +7 -0
  92. package/dist/visual/strings.js +5 -2
  93. package/dist/visual/structure.d.ts +43 -87
  94. package/dist/visual/structure.js +42 -83
  95. package/package.json +2 -2
@@ -307,12 +307,19 @@
307
307
  }
308
308
  /* The ring rides `.qm-focus-ring-within` (controls.css) rather than the plain
309
309
  marker: focus lives on the segment, so it rings the field, not the segment
310
- the caret happens to be in. */
310
+ the caret happens to be in. Tabular figures, so a segment keeps its width as its
311
+ digits change. */
311
312
  .qm-date-wrap :global(.qm-date-segment) {
312
313
  padding: 0 var(--_qm-space-half);
314
+ font-variant-numeric: tabular-nums;
313
315
  border-radius: var(--_qm-radius-inner);
314
316
  outline: none;
315
317
  }
318
+ /* The first segment's pad sits inside the box's inset rather than after it, so its
319
+ digits start on the column every other box's text starts on. */
320
+ .qm-date-wrap :global(.qm-date-segment:first-child) {
321
+ margin-inline-start: calc(-1 * var(--_qm-space-half));
322
+ }
316
323
  .qm-date-wrap :global(.qm-date-segment:focus) {
317
324
  background: var(--_qm-surface-hover);
318
325
  }
@@ -329,12 +336,21 @@
329
336
  .qm-date-wrap :global(.qm-date[data-default] .qm-date-segment) {
330
337
  color: var(--_qm-ink-default);
331
338
  }
339
+ /* A separator is punctuation between the parts rather than part of the value, so it
340
+ takes the label rung whatever the parts beside it hold: at `ink` it stood darker than
341
+ the hint or the default digits either side of it. */
342
+ .qm-date-wrap :global(.qm-date .qm-date-segment[data-segment='literal']) {
343
+ color: var(--_qm-ink-label);
344
+ }
332
345
  /* The toggle's state is its fill and its glyph, never the pointer: a hover fill
333
346
  would read as pressed. Off, it rests hidden where a pointer can reveal it, still
334
347
  holding its width so the digits never shift. It sits inside the box's line, so
335
- the box stands as tall as the inputs beside it. */
348
+ the box stands as tall as the inputs beside it. Its pad sits inside the box's
349
+ inset, so its label ends on the column the digits start on from the other side,
350
+ and its fill stands about as far off the box's end as off its top and bottom. */
336
351
  .qm-date-today {
337
352
  margin-inline-start: auto;
353
+ margin-inline-end: calc(-1 * var(--_qm-space-2));
338
354
  padding-block: var(--_qm-space-half);
339
355
  line-height: 1;
340
356
  border-radius: var(--_qm-radius-inner);
@@ -347,9 +363,9 @@
347
363
  background: var(--_qm-accent-tint);
348
364
  color: var(--_qm-ink);
349
365
  }
366
+ /* A default-supplied today keeps the pressed fill: the default ink alone says it is
367
+ shown and not written, as it does for the digits beside it. */
350
368
  .qm-date-today[data-default] {
351
- background: transparent;
352
- box-shadow: inset 0 0 0 var(--_qm-border-width) var(--_qm-border);
353
369
  color: var(--_qm-ink-default);
354
370
  }
355
371
  @media (hover: hover) {
@@ -203,8 +203,11 @@
203
203
  text-overflow: ellipsis;
204
204
  white-space: nowrap;
205
205
  }
206
+ /* The chevron is chrome, so it takes the label rung the other chrome glyphs take: at
207
+ `ink` beside a written value it read as part of the value. */
206
208
  .qm-select-wrap :global(.qm-select-chevron) {
207
209
  flex-shrink: 0;
210
+ color: var(--_qm-ink-label);
208
211
  width: var(--_qm-glyph-control);
209
212
  height: var(--_qm-glyph-control);
210
213
  }
@@ -31,6 +31,7 @@
31
31
  <script lang="ts">
32
32
  import { Popover } from 'bits-ui';
33
33
  import Icon from './icons/Icon.svelte';
34
+ import { spacePx } from '../core/rungs.js';
34
35
  import './controls.css';
35
36
 
36
37
  interface Props {
@@ -205,7 +206,7 @@
205
206
  collisionBoundary={bounds}
206
207
  side={beside ? 'right' : 'top'}
207
208
  align={beside ? 'center' : 'start'}
208
- sideOffset={6 + reach}
209
+ sideOffset={(spacePx(triggerEl) ?? 0) + reach}
209
210
  trapFocus={false}
210
211
  onOpenAutoFocus={(e: Event) => e.preventDefault()}
211
212
  onCloseAutoFocus={(e: Event) => e.preventDefault()}
@@ -10,12 +10,14 @@
10
10
  the result as it commits a keystroke: a field's lowers it to `markOps` through
11
11
  `applyChange` (field.ts), a by-value leaf's hands the re-encoded value to its parent
12
12
  (`ProseValue`). This component never touches the content. The keymap mirror
13
- (Mod-b/i/u) lives in the codec's keymap (field.ts); this is the pointer
14
- affordance for all six marks. Mod-K is this surface's own key, the prompt it raises
15
- being this surface's: a document `keydown` the leaf's keymap left unclaimed. Over a
16
- leaf that holds lists, two more buttons number or bullet every selected line, or take
17
- the marker off where every one carries it: the codec's `toggleList`, whose keys
18
- (Mod-Shift-7/8) the leaf's keymap binds.
13
+ (Mod-b/i/u, Mod-Shift-x, Mod-e) lives in the codec's keymap (field.ts); this is
14
+ the pointer affordance for all six marks. Mod-K is this surface's own key, the
15
+ prompt it raises being this surface's: a document `keydown` the leaf's keymap left
16
+ unclaimed. Over a leaf that holds lists, two more buttons number or bullet every
17
+ selected line, or take the marker off where every one carries it: the codec's
18
+ `toggleList`, whose keys (Mod-Shift-7/8) the leaf's keymap binds. Where the host
19
+ handed the editor a font library, or the selection already sets a family, a picker
20
+ sets the selection's family or takes it back to the surrounding face (`fonts.ts`).
19
21
 
20
22
  Selection observation. The view's `dispatchTransaction` is the leaf's
21
23
  (read-only), so there is no hook to observe transactions from outside.
@@ -69,7 +71,7 @@
69
71
  const t = wording();
70
72
  import { toggleMark } from 'prosemirror-commands';
71
73
  import { keydownHandler } from 'prosemirror-keymap';
72
- import { TextSelection, type Command } from 'prosemirror-state';
74
+ import { NodeSelection, type Command } from 'prosemirror-state';
73
75
  import type { EditorView } from 'prosemirror-view';
74
76
  import type { MarkType } from 'prosemirror-model';
75
77
  import { Popover } from 'bits-ui';
@@ -78,14 +80,19 @@
78
80
  import { hasMarks, rangeAnchor, toggleList, type RangeAnchor } from '../core/codec/index.js';
79
81
  import type { ActiveProse } from './leaves.js';
80
82
  import { clearLink, hrefInSelection, normalizeHref, setLink } from './links.js';
83
+ import { familyInSelection, setFamily } from './fonts.js';
81
84
  import { spacePx } from '../core/rungs.js';
82
85
  import './controls.css';
83
86
 
84
87
  interface Props {
85
88
  /** The observation seam VisualEditor exposes over its `leaves` registry. */
86
89
  getActiveLeaf: () => ActiveProse | undefined;
90
+ /** The families the host's font library offers, in its order. */
91
+ families?: string[];
92
+ /** The name the picker shows for a family, where it is not the family itself. */
93
+ fontLabels?: Readonly<Record<string, string>>;
87
94
  }
88
- let { getActiveLeaf }: Props = $props();
95
+ let { getActiveLeaf, families = [], fontLabels = {} }: Props = $props();
89
96
 
90
97
  const SETTLE_MS = 450;
91
98
  /** The six content formatting marks. `anchor` is withheld: it draws nothing until
@@ -108,6 +115,23 @@
108
115
  /** Whether the leaf under the selection holds lists: a body does, a one-line field
109
116
  * and a table cell do not. */
110
117
  let listable = $state(false);
118
+ /** Whether the leaf under the selection declares `font_family`: a `text` leaf does not. */
119
+ let markable = $state(false);
120
+ /** The family the selection is set in, `''` for none and `null` for several. */
121
+ let family = $state<string | null>('');
122
+ /** Whether the picker draws: the leaf declares the mark, and there is a family to
123
+ * offer or one under the selection to clear, mixed with others or with none. */
124
+ const fontable = $derived(markable && (families.length > 0 || family !== ''));
125
+ /** The picker's options: the library's families, and the selection's own where the
126
+ * library lacks it, so the picker always shows what the document says. */
127
+ const familyOptions = $derived(
128
+ family && !families.includes(family) ? [...families, family] : families
129
+ );
130
+ /** The picker's value over a selection setting several families: no family's. */
131
+ const MIXED = '\u0000';
132
+ /** The view the surface rose over. The picker takes the focus, and a cell view
133
+ * without it is no longer the leaf's `focusedView`. */
134
+ let surfaceView: EditorView | undefined;
111
135
  /** A pointer is down outside this surface: the selection is still being made. */
112
136
  let pressed = false;
113
137
  let linkPromptOpen = $state(false);
@@ -147,11 +171,12 @@
147
171
  if (insidePopover) return;
148
172
  if (pressed) return;
149
173
  const view = activeLeafView();
150
- // A non-empty text selection. A node selection is non-empty too and has nothing
151
- // to format: it covers a leaf block (an island, a rule), which is exactly where
152
- // Escape out of a table cell lands.
174
+ // A non-empty selection that is not a node's. Select-all is one: an `AllSelection`,
175
+ // which spans text like any range. A node selection covers a leaf block (an island,
176
+ // a rule), which has nothing to format and is exactly where Escape out of a table
177
+ // cell lands.
153
178
  const selection = view?.state.selection;
154
- const formattable = selection instanceof TextSelection && !selection.empty;
179
+ const formattable = !!selection && !selection.empty && !(selection instanceof NodeSelection);
155
180
  // …in a leaf that has formatting at all. A `text` leaf declares no mark types
156
181
  // (`plaintextSchema`, `plainSchema`), so this surface withholds itself over one
157
182
  // entirely, the way it never rises over a form control: the value is literal
@@ -173,6 +198,9 @@
173
198
  }
174
199
  portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
175
200
  listable = !!view.state.schema.nodes.list_item;
201
+ markable = !!view.state.schema.marks.font_family;
202
+ family = familyInSelection(view.state);
203
+ surfaceView = view;
176
204
  const { from, to } = view.state.selection;
177
205
  anchor = rangeAnchor(view, from, to);
178
206
  open = true;
@@ -280,6 +308,14 @@
280
308
  sync();
281
309
  }
282
310
 
311
+ function pickFamily(name: string): void {
312
+ const view = surfaceView && !surfaceView.isDestroyed ? surfaceView : activeLeafView();
313
+ if (!view) return;
314
+ setFamily(name)(view.state, view.dispatch);
315
+ view.focus();
316
+ sync();
317
+ }
318
+
283
319
  function toggleLines(ordered: boolean): void {
284
320
  const view = activeLeafView();
285
321
  if (!view) return;
@@ -441,6 +477,24 @@
441
477
  onclick={() => toggle(m.name)}><Icon name={m.icon} /></button
442
478
  >
443
479
  {/each}
480
+ {#if fontable}
481
+ <span class="qm-format-divider" aria-hidden="true"></span>
482
+ <select
483
+ class="qm-font-select qm-focus-ring"
484
+ title={t.strings.formatFont}
485
+ aria-label={t.strings.formatFont}
486
+ value={family ?? MIXED}
487
+ onchange={(e) => pickFamily(e.currentTarget.value)}
488
+ >
489
+ {#if family === null}
490
+ <option value={MIXED} disabled hidden>{t.strings.fontMixed}</option>
491
+ {/if}
492
+ <option value="">{t.strings.fontDefault}</option>
493
+ {#each familyOptions as name (name)}
494
+ <option value={name}>{fontLabels[name] ?? name}</option>
495
+ {/each}
496
+ </select>
497
+ {/if}
444
498
  {#if listable}
445
499
  <span class="qm-format-divider" aria-hidden="true"></span>
446
500
  {#each LISTS as l (l.icon)}
@@ -487,18 +541,31 @@
487
541
  itself, which a two-state button has no third face for. */
488
542
  .qm-format-divider {
489
543
  align-self: stretch;
490
- width: 1px;
544
+ width: var(--_qm-border-width);
491
545
  margin: var(--_qm-space-half) 0;
492
- background: var(--_qm-rule);
546
+ background: var(--_qm-border);
493
547
  }
494
548
  .qm-mark-btn {
495
549
  padding: var(--_qm-space) var(--_qm-space-2);
496
550
  color: var(--_qm-ink);
497
551
  }
552
+ /* The link input's box, a pick rather than a typed value. */
553
+ .qm-font-select {
554
+ font: inherit;
555
+ font-size: var(--_qm-text-body);
556
+ padding: var(--_qm-inset-control);
557
+ border: none;
558
+ border-radius: var(--_qm-radius-inner);
559
+ background: var(--_qm-surface-well);
560
+ color: var(--_qm-ink);
561
+ max-width: 12rem;
562
+ }
563
+ /* Stretched, so the prompt's buttons stand the input's box line and the face is one
564
+ row height rather than a box between two shorter buttons. */
498
565
  .qm-link-prompt {
499
566
  display: flex;
500
567
  gap: var(--_qm-space);
501
- align-items: center;
568
+ align-items: stretch;
502
569
  }
503
570
  /* The box's recipe without `.qm-input`, which adds the `width: 100%` that would
504
571
  stretch this to the popover. The fill is the box's own rung and there is no
@@ -2,6 +2,10 @@ import type { ActiveProse } from './leaves.js';
2
2
  interface Props {
3
3
  /** The observation seam VisualEditor exposes over its `leaves` registry. */
4
4
  getActiveLeaf: () => ActiveProse | undefined;
5
+ /** The families the host's font library offers, in its order. */
6
+ families?: string[];
7
+ /** The name the picker shows for a family, where it is not the family itself. */
8
+ fontLabels?: Readonly<Record<string, string>>;
5
9
  }
6
10
  declare const FormatPopover: import("svelte").Component<Props, {}, "">;
7
11
  type FormatPopover = ReturnType<typeof FormatPopover>;
@@ -1,6 +1,7 @@
1
1
  <!--
2
2
  An open matrix's add box (VISUAL_EDITOR §"The matrix"): a plain text input. Enter adds
3
- what was typed, trimmed, and clears the box; Escape clears it.
3
+ what was typed, trimmed, and clears the box; Escape clears it. It is drawn as the item
4
+ it adds, unwritten: the plus in the tick's column and the words in the title's.
4
5
  -->
5
6
  <script lang="ts">
6
7
  import Icon from './icons/Icon.svelte';
@@ -38,7 +39,7 @@
38
39
  </script>
39
40
 
40
41
  <div class="qm-matrix-add">
41
- <span class="qm-matrix-add-field">
42
+ <label class="qm-matrix-add-field">
42
43
  <Icon name="plus" class="qm-matrix-add-glyph" />
43
44
  <input
44
45
  bind:this={inputEl}
@@ -51,41 +52,58 @@
51
52
  {placeholder}
52
53
  {onkeydown}
53
54
  />
54
- </span>
55
+ </label>
55
56
  </div>
56
57
 
57
58
  <style>
58
59
  .qm-matrix-add {
59
- --_slot: calc(var(--_qm-space-2) * 2 + var(--_qm-glyph-control));
60
60
  min-width: 0;
61
61
  }
62
+ /* An added item's head (`MatrixField.svelte`): the glyph a tick's width at the
63
+ tick's place, the gap after it the head's, and the input pulled back over that gap
64
+ by its own inset, so its words start where the titles above them do. A `<label>`,
65
+ so a press on the glyph lands in the box. */
62
66
  .qm-matrix-add-field {
63
- position: relative;
64
67
  display: flex;
65
68
  align-items: center;
69
+ gap: var(--_qm-space-2);
66
70
  min-width: 0;
67
71
  }
68
72
  .qm-matrix-add :global(.qm-matrix-add-glyph) {
69
- position: absolute;
70
- inset-inline-start: var(--_qm-space-2);
73
+ flex-shrink: 0;
71
74
  width: var(--_qm-glyph-control);
72
75
  height: var(--_qm-glyph-control);
73
76
  color: var(--_qm-ink-label);
74
- pointer-events: none;
75
77
  }
76
78
  /* At rest the box is the add chip it stands in for: no well, its words at the label
77
- ink. The well comes up under the pointer, and stays while it holds the focus or
78
- typed words. */
79
+ ink, and the line at the chip's idle rung. The line comes up under the pointer with
80
+ the well, and both stay while the box holds the focus or typed words. */
81
+ .qm-matrix-add-field {
82
+ opacity: var(--_qm-opacity-idle);
83
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
84
+ }
85
+ .qm-matrix-add-field:hover,
86
+ .qm-matrix-add-field:focus-within,
87
+ .qm-matrix-add-field:has(.qm-input:not(:placeholder-shown)) {
88
+ opacity: 1;
89
+ }
79
90
  .qm-matrix-add-field .qm-input {
80
91
  flex: 1;
81
92
  min-width: 0;
82
- padding-inline-start: var(--_slot);
93
+ margin-inline-start: calc(-1 * var(--_qm-space-2));
94
+ padding-inline-start: var(--_qm-space-2);
83
95
  transition: background-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
84
96
  }
85
97
  .qm-matrix-add-field .qm-input:placeholder-shown:not(:focus) {
86
98
  background: transparent;
87
99
  }
88
- .qm-matrix-add-field .qm-input:placeholder-shown:not(:focus):hover {
100
+ .qm-matrix-add-field:hover .qm-input:placeholder-shown:not(:focus) {
89
101
  background: var(--_qm-surface-hover);
90
102
  }
103
+ /* No hover to come up on: the line stands at the muted rung, as the chip does. */
104
+ @media (hover: none) {
105
+ .qm-matrix-add-field {
106
+ opacity: var(--_qm-opacity-muted);
107
+ }
108
+ }
91
109
  </style>
@@ -283,7 +283,7 @@
283
283
  {#each members as m (m.id)}
284
284
  {@const on = held(m.id)}
285
285
  <div
286
- class={['qm-member qm-wash', { held: on }]}
286
+ class={['qm-member qm-wash', { held: on, open: on && hasColumns }]}
287
287
  data-qm-prop={m.id}
288
288
  bind:this={memberEls[m.id]}
289
289
  >
@@ -311,7 +311,7 @@
311
311
  {#each added as item, k (item.id)}
312
312
  {@const name = item.title || t.strings.matrixItemTitle(label ?? '', k + 1)}
313
313
  <div
314
- class="qm-member qm-wash held added"
314
+ class={['qm-member qm-wash held added', { open: hasColumns }]}
315
315
  data-qm-prop={item.id}
316
316
  bind:this={memberEls[item.id]}
317
317
  >
@@ -337,7 +337,7 @@
337
337
  class="qm-icon-btn qm-focus-ring qm-member-remove"
338
338
  title={t.strings.matrixRemove(name)}
339
339
  aria-label={t.strings.matrixRemove(name)}
340
- onclick={() => removeItem(item.id)}><Icon name="x" /></button
340
+ onclick={() => removeItem(item.id)}><Icon name="minus" /></button
341
341
  >
342
342
  </div>
343
343
  {@render columns(item.id, name, true)}
@@ -426,6 +426,13 @@
426
426
  gap: var(--_qm-space);
427
427
  border-radius: var(--_qm-radius-inner);
428
428
  }
429
+ /* An open array row's guard (`ArrayField`): a member whose columns are unfolded takes
430
+ a rung of air from the members either side of it, so its columns stand nearer its own
431
+ head than the next member's. Abreast, a margin would set the member below the heads
432
+ its row of tracks shares, and each track holds one member's columns alone. */
433
+ .qm-matrix-roster:not(.compact) > .qm-member.open {
434
+ margin-block: var(--_qm-space);
435
+ }
429
436
  /* The tick and its title on one line, the line holding the tap floor a tick alone
430
437
  would not reach: the title is the target, the `for` carrying its press to the input. */
431
438
  .qm-member-head {
@@ -441,28 +448,25 @@
441
448
  width: var(--_qm-glyph-control);
442
449
  height: var(--_qm-glyph-control);
443
450
  }
444
- /* The box: a well, edged, since a mark this small has no plane under it to step off
445
- by tone alone; held, it takes the accent the switch's track takes when checked. */
451
+ /* The box is the switch's track (`BooleanField.svelte`): a fill and nothing else,
452
+ which held swaps for the accent, so the surface's two marks for one boolean decode
453
+ alike. */
446
454
  .qm-tick-input {
447
455
  appearance: none;
448
456
  margin: 0;
449
457
  width: 100%;
450
458
  height: 100%;
451
- border: var(--_qm-border-width) solid var(--_qm-border);
452
- border-radius: calc(var(--_qm-radius-inner) / 2);
453
- background: var(--_qm-surface-well);
459
+ border: none;
460
+ border-radius: var(--_qm-radius-inner);
461
+ background: var(--_qm-surface-band);
454
462
  cursor: pointer;
455
- transition:
456
- background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
457
- border-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
463
+ transition: background-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
458
464
  }
459
465
  .qm-tick-input:checked,
460
466
  .qm-tick-fixed {
461
467
  background: var(--_qm-accent);
462
- border-color: var(--_qm-accent);
463
468
  }
464
469
  .qm-tick-fixed {
465
- box-sizing: border-box;
466
470
  cursor: default;
467
471
  }
468
472
  /* The mark rides over the input and takes no press of its own; it is drawn by the
@@ -512,8 +516,8 @@
512
516
  .qm-member-title-input:focus {
513
517
  background: var(--_qm-surface-well);
514
518
  }
515
- /* The remove is offered by the row the pointer or the focus is in, as an array row's
516
- is, and says destructive under the pointer. */
519
+ /* The remove is an array row's, the minus to the add box's plus: offered by the row
520
+ the pointer or the focus is in, and destructive under the pointer. */
517
521
  .qm-member-remove {
518
522
  flex-shrink: 0;
519
523
  color: var(--_qm-ink-label);
@@ -531,6 +535,11 @@
531
535
  background: var(--_qm-danger-tint);
532
536
  color: var(--_qm-danger);
533
537
  }
538
+ @media (hover: none) {
539
+ .qm-member-remove {
540
+ opacity: var(--_qm-opacity-muted);
541
+ }
542
+ }
534
543
  /* The default's set is what prints and nothing written: its titles, its ticks and
535
544
  every value its columns draw take the default rung (theme.css), and the first
536
545
  gesture, writing it, takes it off. */
@@ -541,7 +550,6 @@
541
550
  }
542
551
  .qm-matrix-roster[data-default] :is(.qm-tick-input:checked, .qm-tick-fixed) {
543
552
  background: var(--_qm-accent-default);
544
- border-color: var(--_qm-accent-default);
545
553
  }
546
554
  .qm-matrix-roster[data-default] :global(.qm-toggle[data-state='checked']) {
547
555
  background: var(--_qm-accent-default);
@@ -139,3 +139,10 @@
139
139
  onkeydown={revert}
140
140
  onchange={(e) => commit(e.currentTarget as HTMLInputElement)}
141
141
  />
142
+
143
+ <style>
144
+ /* Tabular figures, so numbers stacked in a column line up digit for digit. */
145
+ .qm-input {
146
+ font-variant-numeric: tabular-nums;
147
+ }
148
+ </style>
@@ -8,9 +8,9 @@
8
8
  a subform is collapsed rows again, so an open row under an open row is one figure two
9
9
  rungs in.
10
10
 
11
- The nesting is a vertical at `--_qm-border`, the ladder's last stroke: the card's edge,
12
- an open section's vertical one `--_qm-nest` in where the field is in one, this one a rung
13
- further, the properties a rung inside it (ARCHITECTURE §"A plane is a tone").
11
+ The nesting is a vertical at `--_qm-border`, the ladder's last stroke: an open section's
12
+ vertical one `--_qm-nest` in from the card's edge where the field is in one, this one a
13
+ rung further, the properties a rung inside it (ARCHITECTURE §"A plane is a tone").
14
14
 
15
15
  One figure wherever it mounts, and where its stroke lands is the stacker's. A
16
16
  variant's cells and a matrix member's columns sit at the depth a field-level subform
@@ -482,7 +482,7 @@
482
482
  the card and the section hold too.
483
483
 
484
484
  `--_qm-border`: this is the stroke that structures (ARCHITECTURE §"A plane is a
485
- tone"), the one the card's edge and the section's vertical read.
485
+ tone"), the one the section's vertical reads.
486
486
 
487
487
  `padding-block` is the stroke's end caps, the same rung and the same reason as a
488
488
  section's (`Card.svelte`). A cap and not a leading gap: what stands the subform off
@@ -208,11 +208,6 @@
208
208
  .qm-prose-block:not(.qm-control-box) {
209
209
  font-size: var(--_qm-text-paper);
210
210
  }
211
- /* Paper's margin, which a field has none of. On the editable, so a press in the strip
212
- lands a caret; carried by the card's inset it would be dead width. */
213
- .qm-prose-block:not(.qm-control-box) :global(.ProseMirror) {
214
- padding-inline: var(--_qm-space-2);
215
- }
216
211
  /* The caret is the body's focus indicator: a ring around the one surface in a card
217
212
  that is paper reads as form chrome. So the contenteditable's own outline is dropped
218
213
  everywhere, and a boxed leaf takes the shared ring on its wrapper instead
@@ -93,12 +93,12 @@
93
93
  paints over the rest. The two read as one block, which is what document-level
94
94
  guidance is: the main card's, not a card of its own. Still in-flow, no lift.
95
95
 
96
- No stroke of its own. A tint below the card reads as a slip; the same tint inside an
97
- outline reads as a second card, which is the one thing the tuck exists to deny. What
98
- shows below the seam is fill, and `main`'s hairline is the pair's only edge.
96
+ No stroke, as `main` draws none: the pair's edge is where the two fills meet the
97
+ ground. What shows below the seam is the slip's tint, a step off `main`'s plane, so it
98
+ reads as a slip of the card rather than as a second card.
99
99
 
100
- The bottom corners take the inner rung where the card above takes the outer: a slip
101
- is cut crisper than the thing it is tucked under, at every setting of the dial.
100
+ The bottom corners are the card's own, so the pair stands in the stack with the same
101
+ silhouette as every island beside it: the tint is what says slip, once.
102
102
 
103
103
  Down, the insets are the document's own rhythm and they are measured to the ink, not
104
104
  to the boxes. The top is a block gap below the seam, what two of the body's paragraphs
@@ -109,14 +109,15 @@
109
109
  slack at each edge, and spent inside the gap that is enough to make the tightest space
110
110
  in the card measure as the widest.
111
111
 
112
- Across, the inset is `main`'s stroke and the two nested insets under it, the card's
113
- own and the prose control's, so the tip starts on the prose's edge rather than short
114
- of it. */
112
+ Across, the inset is the card's inline one and then its text column, so the tip starts
113
+ on the edge the body's lines do and the foot's label ends where a card's delete glyph
114
+ does: the face and the tint already set the tip apart, and an indent would say it a
115
+ third time. */
115
116
  .qm-tips {
116
117
  margin-top: calc(var(--_qm-radius) * -1);
117
- border-radius: 0 0 var(--_qm-radius-inner) var(--_qm-radius-inner);
118
+ border-radius: 0 0 var(--_qm-radius) var(--_qm-radius);
118
119
  padding: calc(var(--_qm-space-3) + var(--_qm-radius))
119
- calc(var(--_qm-border-width) + var(--_qm-space-3) * 2) var(--_qm-space-3);
120
+ calc(var(--_qm-space-2) + var(--_qm-column)) var(--_qm-space-3);
120
121
  background: var(--_qm-tips-surface);
121
122
  display: flex;
122
123
  flex-direction: column;
@@ -149,9 +149,11 @@
149
149
  </div>
150
150
 
151
151
  <style>
152
+ /* The distance from the discriminant to the cells it unfolds is the one an array row
153
+ and a matrix member keep from what they unfold. */
152
154
  .qm-variant {
153
155
  display: flex;
154
156
  flex-direction: column;
155
- gap: var(--_qm-space-2);
157
+ gap: var(--_qm-space);
156
158
  }
157
159
  </style>
@@ -82,6 +82,7 @@
82
82
  import { tipsChannel } from './tips.js';
83
83
  import { patchEditorExt } from './ext.js';
84
84
  import { raiseGuidance } from './guidance.js';
85
+ import { familiesOf, registerFaces } from './fonts.js';
85
86
  import Card from './Card.svelte';
86
87
  import Icon from './icons/Icon.svelte';
87
88
  import TipsCard from './TipsCard.svelte';
@@ -100,6 +101,8 @@
100
101
  diagnostics,
101
102
  enumOptionAllowed,
102
103
  enumDisallowed = 'disable',
104
+ fonts,
105
+ fontLabels,
103
106
  strings,
104
107
  formatDiagnostic,
105
108
  class: className,
@@ -119,6 +122,11 @@
119
122
  }
120
123
  });
121
124
 
125
+ // The host's font library: registered with the page while mounted, so a run draws in
126
+ // the face the preview sets it in, and offered family by family to the popover.
127
+ const fontFamilies = $derived(familiesOf(fonts ?? []));
128
+ $effect(() => registerFaces(fonts ?? []));
129
+
122
130
  // ── Reactivity + session identity ───────────────────────────────────────────
123
131
  let revision = $state(0);
124
132
  // This editor's own id, prefixed onto every field's DOM ids (`domid.ts`). The
@@ -966,7 +974,7 @@
966
974
  {/each}
967
975
  </div>
968
976
 
969
- <FormatPopover {getActiveLeaf} />
977
+ <FormatPopover {getActiveLeaf} families={fontFamilies} {fontLabels} />
970
978
 
971
979
  <!-- The add affordance is gated on the schema declaring `card_kinds`, there being
972
980
  nothing to seed otherwise. Cards render regardless: a kind with no schema degrades to
@@ -1057,7 +1065,8 @@
1057
1065
  than as more card (ARCHITECTURE §Styling). The inset is the widest rung on the
1058
1066
  ramp, which is what gives the ground enough of itself to read as ground: at a
1059
1067
  narrower one the stack meets whatever frames the surface and the islands are
1060
- back to being one block clipped at its edges. */
1068
+ back to being one block clipped at its edges. That is the page form; a pane's
1069
+ frame is the host's, and it spends less (`.qm-pane` below). */
1061
1070
  :global(:where(.qm-editor)) {
1062
1071
  box-sizing: border-box;
1063
1072
  display: flex;
@@ -1090,11 +1099,18 @@
1090
1099
  `.qm-pane` claims (THEMING §"Drop it in"), and it resolves to `auto` on a mounting
1091
1100
  site with no height of its own, which is the page form kept. The cards do not
1092
1101
  shrink to fit: a flex item's automatic minimum floors each at its own height, so
1093
- the stack overflows and scrolls rather than squashing. */
1102
+ the stack overflows and scrolls rather than squashing.
1103
+
1104
+ A pane is where the host has already framed the surface, so the ground needs less
1105
+ of itself to read as ground: the frame is what the stack would otherwise meet. So
1106
+ the gutter steps in to the seam the add strip holds between cards, the ground
1107
+ being one width all round the stack, and the width it gives back goes to what is
1108
+ written. */
1094
1109
  :global(:where(.qm-editor.qm-pane)) {
1095
1110
  height: 100%;
1096
1111
  overflow: auto;
1097
1112
  overscroll-behavior: contain;
1113
+ padding: var(--_qm-space-4);
1098
1114
  padding-block-end: var(--_qm-tail);
1099
1115
  }
1100
1116
  /* The stack's one gapless seam, which `TipsCard` draws and this holds the two
@@ -339,9 +339,14 @@
339
339
  over content the surface did not choose, so there is no plane under it for a
340
340
  tone to step off (ARCHITECTURE §Styling). The edge and `--_qm-lift` carry it
341
341
  there, and the raised rung joins them in dark, where a tone still has somewhere
342
- to go. */
342
+ to go.
343
+
344
+ The inset is the difference between the two radii, `--_qm-space` between the
345
+ surface's `--_qm-radius` and the item's `--_qm-radius-inner`, so a highlighted
346
+ item's corners run concentric with the surface's. It is the format popover's inset
347
+ too: every floating surface holds its contents one rung in. */
343
348
  :where(.qm-menu-surface) {
344
- padding: var(--_qm-space-half);
349
+ padding: var(--_qm-space);
345
350
  border: var(--_qm-border-width) solid var(--_qm-border);
346
351
  border-radius: var(--_qm-radius);
347
352
  background: var(--_qm-surface-raised);