@quillmark/svelte 0.0.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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +58 -0
  3. package/README.md +241 -0
  4. package/THEMING.md +153 -0
  5. package/dist/core/address.d.ts +56 -0
  6. package/dist/core/address.js +95 -0
  7. package/dist/core/bloom.d.ts +37 -0
  8. package/dist/core/bloom.js +116 -0
  9. package/dist/core/codec/anchor.d.ts +19 -0
  10. package/dist/core/codec/anchor.js +36 -0
  11. package/dist/core/codec/code.d.ts +12 -0
  12. package/dist/core/codec/code.js +118 -0
  13. package/dist/core/codec/decode.d.ts +24 -0
  14. package/dist/core/codec/decode.js +270 -0
  15. package/dist/core/codec/encode.d.ts +71 -0
  16. package/dist/core/codec/encode.js +480 -0
  17. package/dist/core/codec/field.d.ts +171 -0
  18. package/dist/core/codec/field.js +493 -0
  19. package/dist/core/codec/index.d.ts +22 -0
  20. package/dist/core/codec/index.js +43 -0
  21. package/dist/core/codec/inputrules.d.ts +7 -0
  22. package/dist/core/codec/inputrules.js +168 -0
  23. package/dist/core/codec/islands.d.ts +36 -0
  24. package/dist/core/codec/islands.js +82 -0
  25. package/dist/core/codec/keymap.d.ts +13 -0
  26. package/dist/core/codec/keymap.js +29 -0
  27. package/dist/core/codec/lists.d.ts +22 -0
  28. package/dist/core/codec/lists.js +104 -0
  29. package/dist/core/codec/marks.d.ts +37 -0
  30. package/dist/core/codec/marks.js +74 -0
  31. package/dist/core/codec/positions.d.ts +17 -0
  32. package/dist/core/codec/positions.js +108 -0
  33. package/dist/core/codec/prose.css +458 -0
  34. package/dist/core/codec/reconcile.d.ts +26 -0
  35. package/dist/core/codec/reconcile.js +64 -0
  36. package/dist/core/codec/schema.d.ts +13 -0
  37. package/dist/core/codec/schema.js +155 -0
  38. package/dist/core/codec/slash.d.ts +79 -0
  39. package/dist/core/codec/slash.js +0 -0
  40. package/dist/core/codec/table-view.d.ts +50 -0
  41. package/dist/core/codec/table-view.js +928 -0
  42. package/dist/core/codec/table.d.ts +85 -0
  43. package/dist/core/codec/table.js +224 -0
  44. package/dist/core/errors.d.ts +73 -0
  45. package/dist/core/errors.js +24 -0
  46. package/dist/core/index.d.ts +5 -0
  47. package/dist/core/index.js +19 -0
  48. package/dist/core/lifecycle.d.ts +7 -0
  49. package/dist/core/lifecycle.js +21 -0
  50. package/dist/core/rebind.svelte.d.ts +18 -0
  51. package/dist/core/rebind.svelte.js +44 -0
  52. package/dist/core/teardown.d.ts +20 -0
  53. package/dist/core/teardown.js +37 -0
  54. package/dist/core/theme.css +276 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +9 -0
  57. package/dist/preset/index.css +10 -0
  58. package/dist/preset/recipes.css +188 -0
  59. package/dist/preset/scale.css +97 -0
  60. package/dist/preview/Preview.svelte +118 -0
  61. package/dist/preview/Preview.svelte.d.ts +39 -0
  62. package/dist/preview/bridge.d.ts +13 -0
  63. package/dist/preview/bridge.js +88 -0
  64. package/dist/preview/controller.d.ts +40 -0
  65. package/dist/preview/controller.js +141 -0
  66. package/dist/preview/geometry.d.ts +37 -0
  67. package/dist/preview/geometry.js +45 -0
  68. package/dist/preview/index.d.ts +5 -0
  69. package/dist/preview/index.js +14 -0
  70. package/dist/preview/overlay.d.ts +10 -0
  71. package/dist/preview/overlay.js +82 -0
  72. package/dist/preview/paint.d.ts +17 -0
  73. package/dist/preview/paint.js +262 -0
  74. package/dist/preview/strings.d.ts +14 -0
  75. package/dist/preview/strings.js +22 -0
  76. package/dist/visual/ArrayField.svelte +362 -0
  77. package/dist/visual/ArrayField.svelte.d.ts +24 -0
  78. package/dist/visual/BooleanField.svelte +88 -0
  79. package/dist/visual/BooleanField.svelte.d.ts +20 -0
  80. package/dist/visual/Card.svelte +849 -0
  81. package/dist/visual/Card.svelte.d.ts +43 -0
  82. package/dist/visual/CardControls.svelte +92 -0
  83. package/dist/visual/CardControls.svelte.d.ts +11 -0
  84. package/dist/visual/DateField.svelte +203 -0
  85. package/dist/visual/DateField.svelte.d.ts +28 -0
  86. package/dist/visual/DiagnosticList.svelte +50 -0
  87. package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
  88. package/dist/visual/EnumField.svelte +169 -0
  89. package/dist/visual/EnumField.svelte.d.ts +25 -0
  90. package/dist/visual/Field.svelte +270 -0
  91. package/dist/visual/Field.svelte.d.ts +37 -0
  92. package/dist/visual/FieldHint.svelte +185 -0
  93. package/dist/visual/FieldHint.svelte.d.ts +13 -0
  94. package/dist/visual/FieldLabel.svelte +141 -0
  95. package/dist/visual/FieldLabel.svelte.d.ts +19 -0
  96. package/dist/visual/FormatPopover.svelte +396 -0
  97. package/dist/visual/FormatPopover.svelte.d.ts +9 -0
  98. package/dist/visual/NumberField.svelte +72 -0
  99. package/dist/visual/NumberField.svelte.d.ts +19 -0
  100. package/dist/visual/ObjectField.svelte +131 -0
  101. package/dist/visual/ObjectField.svelte.d.ts +17 -0
  102. package/dist/visual/ProseArrayElement.svelte +98 -0
  103. package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
  104. package/dist/visual/ProseField.svelte +213 -0
  105. package/dist/visual/ProseField.svelte.d.ts +50 -0
  106. package/dist/visual/SlashMenu.svelte +126 -0
  107. package/dist/visual/SlashMenu.svelte.d.ts +14 -0
  108. package/dist/visual/TextField.svelte +63 -0
  109. package/dist/visual/TextField.svelte.d.ts +23 -0
  110. package/dist/visual/TipsCard.svelte +191 -0
  111. package/dist/visual/TipsCard.svelte.d.ts +13 -0
  112. package/dist/visual/VisualEditor.svelte +96 -0
  113. package/dist/visual/VisualEditor.svelte.d.ts +14 -0
  114. package/dist/visual/VisualEditorInner.svelte +1032 -0
  115. package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
  116. package/dist/visual/controls.css +272 -0
  117. package/dist/visual/diagnostics.d.ts +52 -0
  118. package/dist/visual/diagnostics.js +83 -0
  119. package/dist/visual/domid.d.ts +21 -0
  120. package/dist/visual/domid.js +36 -0
  121. package/dist/visual/ext.d.ts +19 -0
  122. package/dist/visual/ext.js +29 -0
  123. package/dist/visual/icons/Icon.svelte +39 -0
  124. package/dist/visual/icons/Icon.svelte.d.ts +12 -0
  125. package/dist/visual/icons/nodes.d.ts +103 -0
  126. package/dist/visual/icons/nodes.js +55 -0
  127. package/dist/visual/index.d.ts +9 -0
  128. package/dist/visual/index.js +24 -0
  129. package/dist/visual/motion.d.ts +18 -0
  130. package/dist/visual/motion.js +53 -0
  131. package/dist/visual/props.d.ts +77 -0
  132. package/dist/visual/props.js +1 -0
  133. package/dist/visual/signals.d.ts +59 -0
  134. package/dist/visual/signals.js +1 -0
  135. package/dist/visual/strings.d.ts +123 -0
  136. package/dist/visual/strings.js +80 -0
  137. package/dist/visual/structure.d.ts +224 -0
  138. package/dist/visual/structure.js +270 -0
  139. package/dist/visual/synced.svelte.d.ts +15 -0
  140. package/dist/visual/synced.svelte.js +34 -0
  141. package/dist/visual/tips.d.ts +24 -0
  142. package/dist/visual/tips.js +50 -0
  143. package/package.json +102 -0
@@ -0,0 +1,849 @@
1
+ <!--
2
+ One card block: the header (composable cards only: an inline-editable title over
3
+ a full-width rename region, and the reorder/
4
+ delete controls), the grouped field list packed by `ui.group`/`ui.compact`, and
5
+ the body prose leaf. `main` is headerless with no controls. Every prose leaf
6
+ takes a parent-built LIVE address so a card reorder re-targets its commits
7
+ without a remount.
8
+ -->
9
+ <script lang="ts">
10
+ import { wording } from './strings.js';
11
+
12
+ // The surface's words, ambient from the editor root; the package's English
13
+ // off-tree, so this component renders standalone too.
14
+ const t = wording();
15
+ import { untrack } from 'svelte';
16
+ import Icon from './icons/Icon.svelte';
17
+ import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
18
+ import type { EditorErrorHandler } from '../core/errors.js';
19
+ import type { FieldController } from '../core/codec/index.js';
20
+ import type { CardModel, FieldModel } from './structure.js';
21
+ import { placeFields, humanize, initialExpandedGroup } from './structure.js';
22
+ import type { FieldDomIds } from './domid.js';
23
+ import Field from './Field.svelte';
24
+ import ProseField from './ProseField.svelte';
25
+ import CardControls from './CardControls.svelte';
26
+ import DiagnosticList from './DiagnosticList.svelte';
27
+
28
+ /** The per-card operation bundle the VisualEditor builds (all resolve id→index lazily). */
29
+ interface CardOps {
30
+ makeAddr: (field?: string) => Addr;
31
+ leafKey: (field?: string) => string;
32
+ domIds: (field?: string) => FieldDomIds;
33
+ panelId: (group: string) => string;
34
+ commit: (name: string, value: unknown) => void;
35
+ move: (dir: -1 | 1) => void;
36
+ remove: () => void;
37
+ retype: (kind: string) => void;
38
+ rename: (title: string) => void;
39
+ diagFor: (field?: string) => Diagnostic[] | undefined;
40
+ /** Consumer enum-option policy for a field; true when no hook set. */
41
+ enumAllowed: (field: string, value: string) => boolean;
42
+ }
43
+
44
+ interface Props {
45
+ card: CardModel;
46
+ doc: Document;
47
+ /** The schema a prose leaf reads its content through (`ProseField`). */
48
+ quill: Quill;
49
+ index: number;
50
+ isFirst: boolean;
51
+ isLast: boolean;
52
+ kinds: string[];
53
+ ops: CardOps;
54
+ onFocus?: (addr: Addr) => void;
55
+ onCaretMove?: (addr: Addr, pos: number) => void;
56
+ onChange?: (addr: Addr) => void;
57
+ onError?: EditorErrorHandler;
58
+ register?: (key: string, controller: FieldController) => void;
59
+ unregister?: (key: string) => void;
60
+ }
61
+ let {
62
+ card,
63
+ doc,
64
+ quill,
65
+ index,
66
+ isFirst,
67
+ isLast,
68
+ kinds,
69
+ ops,
70
+ onFocus,
71
+ onCaretMove,
72
+ onChange,
73
+ onError,
74
+ register,
75
+ unregister
76
+ }: Props = $props();
77
+
78
+ // Local title reconcile (external change only), like the scalar controls.
79
+ // svelte-ignore state_referenced_locally
80
+ let localTitle = $state(card.titleOverride);
81
+ $effect(() => {
82
+ const incoming = card.titleOverride;
83
+ untrack(() => {
84
+ if (incoming !== localTitle) localTitle = incoming;
85
+ });
86
+ });
87
+
88
+ // Inline-editable title: select-all on entry so a title reads as
89
+ // text you replace, and Enter/Escape as commit/revert. Rename stays LIVE on
90
+ // input: `titleAtFocus` is the pre-edit value Escape rolls back to.
91
+ let titleAtFocus = '';
92
+ function onTitleFocus(e: FocusEvent): void {
93
+ titleAtFocus = localTitle;
94
+ (e.currentTarget as HTMLInputElement).select();
95
+ }
96
+ // A mouse press on an UNFOCUSED title would place a caret on mouseup, collapsing
97
+ // the focus-time select-all. Take focus manually and suppress that caret so a
98
+ // click-to-enter selects all too; a press while already focused stays normal, so
99
+ // clicking mid-title to place the caret still works.
100
+ function onTitleMousedown(e: MouseEvent): void {
101
+ const el = e.currentTarget as HTMLInputElement;
102
+ if (document.activeElement !== el) {
103
+ e.preventDefault();
104
+ el.focus();
105
+ }
106
+ }
107
+ // The rename region extends the title's hit area to the header's free width.
108
+ // A press on that empty space focuses the input (which selects all, the
109
+ // same click-to-enter the title itself gets) and preventDefault keeps the press
110
+ // from starting a text selection on the wrapper. A press ON the input is left to
111
+ // `onTitleMousedown`, which owns the already-focused caret-placement case.
112
+ function onRenameMousedown(e: MouseEvent): void {
113
+ const input = (e.currentTarget as HTMLElement).querySelector('input');
114
+ if (!input || e.target === input) return;
115
+ e.preventDefault();
116
+ input.focus();
117
+ }
118
+ function onTitleKeydown(e: KeyboardEvent): void {
119
+ const el = e.currentTarget as HTMLInputElement;
120
+ if (e.key === 'Enter') {
121
+ e.preventDefault();
122
+ el.blur(); // commit: the live value already persisted on input
123
+ } else if (e.key === 'Escape') {
124
+ e.preventDefault();
125
+ if (localTitle !== titleAtFocus) {
126
+ localTitle = titleAtFocus;
127
+ ops.rename(titleAtFocus); // roll back the live edits
128
+ }
129
+ el.blur();
130
+ }
131
+ }
132
+
133
+ // Group accordion. Ungrouped fields (`group == null`) render above,
134
+ // always visible; grouped sections collapse into a one-open-at-a-time accordion.
135
+ const ungrouped = $derived(card.sections.filter((s) => s.group == null));
136
+ const grouped = $derived(card.sections.filter((s) => s.group != null));
137
+
138
+ // Whether there is a metadata block to bracket at all. A schema that
139
+ // declares no fields still gets a body (`bodyEnabled` defaults true), and a bracket
140
+ // around nothing is worse than none: with no content between them the two
141
+ // horizontals land on one y and paint as a stroke of twice the width, which is the
142
+ // one thing a figure claiming one width cannot do.
143
+ const hasMeta = $derived(ungrouped.length > 0 || grouped.length > 0);
144
+
145
+ // Ephemeral session state: the open group's id (`null` = all collapsed). Seeded
146
+ // ONCE from the card's shape (structure.initialExpandedGroup); Card is keyed by
147
+ // stable id, so this survives the VisualEditor re-derive that reassigns `card`
148
+ // and resets only on a remount (reload). Not reconciled to later section changes:
149
+ // a retype is the one reshape, and the open group stays where it still exists.
150
+ // svelte-ignore state_referenced_locally
151
+ let expanded = $state<string | null>(initialExpandedGroup(card.sections));
152
+ // The accordion's two elements per group, by group id. A closing panel goes
153
+ // `inert` with whatever it held still focused, and `inert` does not say where
154
+ // focus should go: the browser drops it on the body, losing the user's place in
155
+ // the card. So the close hands focus to the header, which is what reopens the
156
+ // section.
157
+ let headers = $state<Record<string, HTMLButtonElement | undefined>>({});
158
+ let panels = $state<Record<string, HTMLElement | undefined>>({});
159
+ /**
160
+ * Move to `next` (`null` = all closed), evacuating focus from the group this
161
+ * closes. Every write to `expanded` goes through here: a group also closes when
162
+ * ANOTHER opens, which is not a gesture the user aimed at the closing section.
163
+ */
164
+ function setExpanded(next: string | null): void {
165
+ const closing = expanded;
166
+ if (closing !== null && closing !== next && panels[closing]?.contains(document.activeElement))
167
+ headers[closing]?.focus();
168
+ expanded = next;
169
+ }
170
+ function toggleGroup(group: string): void {
171
+ setExpanded(expanded === group ? null : group);
172
+ }
173
+
174
+ /**
175
+ * Open the group holding leaf `key`, if this card has one. A collapsed panel is
176
+ * clipped to zero height and `inert` rather than unmounted, so a caret placed
177
+ * inside it does not land at all and the arrival wash goes unseen.
178
+ * `VisualEditor.setCaret` calls this on every card and then waits a flush before
179
+ * landing: `inert` clears when the panel renders open, not when `expanded` is
180
+ * assigned. The card keeps owning `expanded`, and a card that does not hold the
181
+ * key does nothing.
182
+ *
183
+ * A call, not a prop: a reveal is an event, and modelling it as state needs a
184
+ * fresh-identity wrapper to re-fire and an `untrack` to keep the per-keystroke
185
+ * re-derive from reopening the accordion under the user.
186
+ */
187
+ export function revealLeaf(key: string): void {
188
+ const section = grouped.find((s) => s.fields.some((f) => ops.leafKey(f.name) === key));
189
+ if (section?.group) setExpanded(section.group);
190
+ }
191
+
192
+ let el = $state<HTMLElement | undefined>(undefined);
193
+ /**
194
+ * Bring this card into view after the structure mutation that placed it:
195
+ * `center` for an insert, `nearest` for a reorder that only needs to stay
196
+ * on screen. `scrollIntoView` walks to its own scroll ancestor, so this is
197
+ * indifferent to whether the package or the consumer owns the scroll container.
198
+ *
199
+ * The reduced-motion guard is JS rather than the CSS the accordion uses: a
200
+ * scroll's behaviour is an argument, not a transition a media query can cancel.
201
+ */
202
+ export function scrollIntoViewCard(block: ScrollLogicalPosition): void {
203
+ const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
204
+ el?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block });
205
+ }
206
+ </script>
207
+
208
+ <!-- `qm-main` is a structural marker and carries no fill: a tone here would bet on
209
+ what the consumer put behind the column, and structure already says which card
210
+ this is. -->
211
+ <section
212
+ bind:this={el}
213
+ class="qm-card"
214
+ class:qm-main={card.isMain}
215
+ class:qm-unschemable={card.unschemable}
216
+ >
217
+ {#if card.unschemable}
218
+ {@render recoveryShell()}
219
+ {:else}
220
+ {#if !card.isMain}
221
+ <header class="qm-card-header">
222
+ <!-- The rename target is the header's whole free width, not the title's text
223
+ box. The autosize keeps that box exactly as wide as its
224
+ text; this wrapper makes the rest of the row enter the edit too. -->
225
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
226
+ <div class="qm-card-rename" onmousedown={onRenameMousedown}>
227
+ <!-- Autosize: the sizer span's ::after mirrors the text and dictates the grid
228
+ cell width, so the overlaid input grows with content and reads as text,
229
+ not a persistent box. `data-value` falls back to the
230
+ placeholder so an empty title still reserves its resolved-title width. -->
231
+ <span class="qm-card-title-sizer" data-value={localTitle || card.titlePlaceholder}>
232
+ <!-- `qm-focus-ring` is what the box used to do: it was this control's ONLY
233
+ focus indicator, and the ink step that replaced it cannot be one, since
234
+ hover already takes that step. `:focus-visible`, so a press enters the
235
+ edit unringed while a tab into it draws one. -->
236
+ <input
237
+ class="qm-card-title qm-focus-ring"
238
+ value={localTitle}
239
+ placeholder={card.titlePlaceholder}
240
+ aria-label={t.strings.cardTitle}
241
+ size="1"
242
+ onmousedown={onTitleMousedown}
243
+ onfocus={onTitleFocus}
244
+ onkeydown={onTitleKeydown}
245
+ oninput={(e) => {
246
+ localTitle = (e.currentTarget as HTMLInputElement).value;
247
+ ops.rename(localTitle);
248
+ }}
249
+ />
250
+ </span>
251
+ </div>
252
+ <div class="qm-card-header-right">
253
+ <CardControls
254
+ {isFirst}
255
+ {isLast}
256
+ onMoveUp={() => ops.move(-1)}
257
+ onMoveDown={() => ops.move(1)}
258
+ onDelete={() => ops.remove()}
259
+ />
260
+ </div>
261
+ </header>
262
+ {/if}
263
+
264
+ <div class="qm-card-body">
265
+ <!-- The metadata block, and the thing the bracket brackets: every
266
+ field the card has, ungrouped and grouped alike, inside one element whose
267
+ top and bottom EDGES are the bracket's two horizontals. Anchoring them here
268
+ rather than on the header and on the accordion is what lets an open
269
+ section's vertical actually reach them: a rule drawn in the card body's
270
+ gap instead would stand clear of where that vertical stops, and the corner
271
+ it exists to close would miss. It also survives the shapes the accordion
272
+ does not have: a card with no groups still terminates above its body,
273
+ because the rule belongs to the fields, not to the sections.
274
+
275
+ Rendered only when there ARE fields: the bracket is a statement about a
276
+ block, so with no block there is nothing to say, and an empty wrapper
277
+ between two rules is what collapses them onto each other. -->
278
+ {#if hasMeta}
279
+ <div
280
+ class="qm-card-meta"
281
+ class:qm-meta-top={!card.isMain}
282
+ class:qm-meta-bottom={card.hasBody}
283
+ >
284
+ <!-- Ungrouped fields render above the accordion, always visible:
285
+ a label-less section has no header to toggle, and these read as the card's
286
+ primary fields. -->
287
+ {#each ungrouped as section (section.group ?? '_ungrouped')}
288
+ <div class="qm-section">
289
+ {@render sectionFields(section.fields)}
290
+ </div>
291
+ {/each}
292
+
293
+ <!-- Group accordion: each `ui.group` is a collapsible section,
294
+ one open at a time. The header toggles; the panel slides via a
295
+ 0fr↔1fr grid row (the `slow` duration rung). The sections share ONE wrapper
296
+ with no gap of its own: each header's symmetric padding is the whole
297
+ inter-group rhythm. The card body's gap still separates this
298
+ block from the ungrouped fields and the body leaf.
299
+
300
+ Rendered only when a card HAS groups, so an empty accordion is not the
301
+ bracket's last child: the bottom rule's inset belongs to whichever block
302
+ actually abuts it, and a zero-height wrapper standing there would take it
303
+ from the ungrouped fields that do. -->
304
+ {#if grouped.length}
305
+ <div class="qm-groups">
306
+ {#each grouped as section (section.group)}
307
+ {@const group = section.group as string}
308
+ {@const isOpen = expanded === group}
309
+ {@const panelId = ops.panelId(group)}
310
+ <div class="qm-group" class:qm-open={isOpen}>
311
+ <button
312
+ type="button"
313
+ class="qm-group-header"
314
+ bind:this={headers[group]}
315
+ aria-expanded={isOpen}
316
+ aria-controls={panelId}
317
+ onclick={() => toggleGroup(group)}
318
+ >
319
+ <Icon name="chevron-right" class="qm-group-chevron" size={14} />{section.label}
320
+ </button>
321
+ <!-- `inert` is what makes closed mean CLOSED: the panel is clipped, not
322
+ unmounted, so without it every field
323
+ in a hidden section keeps its place in the tab order and in the a11y
324
+ tree, under a header announcing `aria-expanded="false"`. Paint is
325
+ untouched, so the 0fr↔1fr slide is the same slide. -->
326
+ <div
327
+ class="qm-group-panel"
328
+ id={panelId}
329
+ inert={!isOpen}
330
+ bind:this={panels[group]}
331
+ >
332
+ <div class="qm-group-panel-inner">
333
+ {@render sectionFields(section.fields)}
334
+ </div>
335
+ </div>
336
+ </div>
337
+ {/each}
338
+ </div>
339
+ {/if}
340
+ </div>
341
+ {/if}
342
+
343
+ <!-- The body: no label and no box. "Body" names the surface the
344
+ card is printed on, so a label restates it; and the accessible name
345
+ survives on the leaf itself, so the region
346
+ is still announced. Unframed is the point rather than a saving: the body is
347
+ the ONLY surface in the card without an edge, which is what makes it read
348
+ as paper instead of as one more field, and the bracket's bottom rule
349
+ already does the separating a box was doing. -->
350
+ {#if card.hasBody}
351
+ <div class="qm-body-leaf">
352
+ <ProseField
353
+ {doc}
354
+ {quill}
355
+ addr={ops.makeAddr(undefined)}
356
+ unframed
357
+ label="Body"
358
+ placeholder={card.bodyGhost}
359
+ leafKey={ops.leafKey(undefined)}
360
+ {onFocus}
361
+ {onCaretMove}
362
+ {onChange}
363
+ {onError}
364
+ {register}
365
+ {unregister}
366
+ />
367
+ <DiagnosticList diagnostics={ops.diagFor(undefined)} />
368
+ </div>
369
+ {/if}
370
+ </div>
371
+ {/if}
372
+ </section>
373
+
374
+ <!-- A section's fields as ONE grid: shared by the ungrouped block and the accordion
375
+ panels so both render a group's fields identically. Rows are the
376
+ grid's business: fields carry a span and auto-place, so nothing here re-derives
377
+ structure on resize and a prose leaf's key never moves. -->
378
+ {#snippet sectionFields(fields: FieldModel[])}
379
+ <div class="qm-fields">
380
+ {#each placeFields(fields) as { field: f, span } (f.name)}
381
+ <Field
382
+ field={f}
383
+ {span}
384
+ value={card.values[f.name]}
385
+ provenance={card.provenance[f.name]}
386
+ {doc}
387
+ {quill}
388
+ proseAddr={ops.makeAddr(f.name)}
389
+ leafKey={ops.leafKey(f.name)}
390
+ domIds={ops.domIds(f.name)}
391
+ onCommitScalar={(v) => ops.commit(f.name, v)}
392
+ optionAllowed={(v) => ops.enumAllowed(f.name, v)}
393
+ {onFocus}
394
+ {onCaretMove}
395
+ {onChange}
396
+ {onError}
397
+ {register}
398
+ {unregister}
399
+ diagnostics={ops.diagFor(f.name)}
400
+ />
401
+ {/each}
402
+ </div>
403
+ {/snippet}
404
+
405
+ <!-- Recovery shell for an un-schemable card: a card whose `kind` has no
406
+ schema (foreign kind, or a schema declaring no `card_kinds`). It stays VISIBLE and
407
+ REMOVABLE (its fields/body/`$ext` remain in the Document) so it is never a data
408
+ trap. Retyping to a declared kind projects it against that kind on the next
409
+ derive: `setCardKind` swaps the kind and leaves payload and body untouched, so
410
+ no field is dropped; with no kinds to offer, delete is the only exit. -->
411
+ {#snippet recoveryShell()}
412
+ <header class="qm-card-header">
413
+ <span class="qm-card-title-static">{humanize(card.kind)}</span>
414
+ <div class="qm-card-header-right">
415
+ <CardControls
416
+ {isFirst}
417
+ {isLast}
418
+ onMoveUp={() => ops.move(-1)}
419
+ onMoveDown={() => ops.move(1)}
420
+ onDelete={() => ops.remove()}
421
+ />
422
+ </div>
423
+ </header>
424
+ <div class="qm-card-recovery">
425
+ <p class="qm-recovery-note">
426
+ {t.strings.unknownKind(card.kind)}
427
+ </p>
428
+ {#if kinds.length}
429
+ <label class="qm-recovery-retype">
430
+ {t.strings.retypeLabel}
431
+ <select
432
+ onchange={(e) => {
433
+ const el = e.currentTarget as HTMLSelectElement;
434
+ if (el.value) ops.retype(el.value);
435
+ }}
436
+ >
437
+ <option value="" disabled selected>{t.strings.retypePlaceholder}</option>
438
+ {@render kindOptions()}
439
+ </select>
440
+ </label>
441
+ {:else}
442
+ <p class="qm-recovery-note qm-recovery-muted">
443
+ {t.strings.noCardKinds}
444
+ </p>
445
+ {/if}
446
+ </div>
447
+ {/snippet}
448
+
449
+ <!-- The declared card kinds as `<option>`s for the recovery shell's retype,
450
+ the one place a card's kind changes after insert. -->
451
+ {#snippet kindOptions()}
452
+ {#each kinds as k (k)}
453
+ <option value={k}>{humanize(k)}</option>
454
+ {/each}
455
+ {/snippet}
456
+
457
+ <style>
458
+ .qm-card {
459
+ border: var(--_qm-border-width) solid var(--_qm-border);
460
+ border-radius: var(--_qm-radius);
461
+ /* Uniform inset on every side: a body-shown and a
462
+ body-hidden card stay symmetric, every left edge on one gutter. The SAME rung
463
+ as the gap between the card's stacked regions below: the card's inset and its
464
+ internal rhythm are one number, so what separates the header from the fields is
465
+ what separates the fields from the card's edge. */
466
+ padding: var(--_qm-space-3);
467
+ background: var(--_qm-surface-raised);
468
+ display: flex;
469
+ flex-direction: column;
470
+ gap: var(--_qm-space-3);
471
+ }
472
+ .qm-card-header {
473
+ display: flex;
474
+ align-items: center;
475
+ justify-content: space-between;
476
+ gap: var(--_qm-space-2);
477
+ }
478
+ /* The header's other end, hung so the row is balanced: the title's ink sits on the
479
+ card's gutter, and this puts the control glyphs on the opposite one. What stands
480
+ between a glyph and the region's edge is the tap target's own centring — half the
481
+ difference between `--_qm-tap-min` and the glyph — which is a target, not a
482
+ rhythm, and so is exactly what a gutter should not be measured from. Both ends
483
+ therefore hang by the chrome they carry and by nothing else; the title's is now
484
+ zero, and this one is the arithmetic of two rungs rather than a rung of its own,
485
+ because the amount is off the space scale (5px at the shipped dials) and a scale
486
+ that could express it would be a scale with a step nothing else uses.
487
+
488
+ It is the fill, not the box, that overhangs the gutter here: an icon button rests
489
+ unboxed and fills only under the pointer, so the
490
+ thing crossing the line is a transient target wash, not an edge the card claims. */
491
+ .qm-card-header-right {
492
+ display: flex;
493
+ align-items: center;
494
+ gap: var(--_qm-space-2);
495
+ margin-right: calc(-1 * (var(--_qm-tap-min) - var(--_qm-glyph-control)) / 2);
496
+ }
497
+ /* Reveal the reorder chevrons while the pointer or the caret is in the card
498
+ (CardControls owns the default hidden state). Focus is read off the CARD, so a
499
+ caret in any leaf, the title, or a chevron itself holds the reveal: hover
500
+ alone would strand the pair on keyboard and touch. This is the card's whole
501
+ active treatment; nothing marks the section itself. */
502
+ .qm-card:hover :global(.qm-card-reorder),
503
+ .qm-card:focus-within :global(.qm-card-reorder) {
504
+ opacity: 1;
505
+ }
506
+ /* The rename hit region: the header's free width, full height, so a
507
+ press anywhere left of the controls enters the title edit. `min-width: 0` lets
508
+ it shrink past the title's intrinsic width; `align-self: stretch` beats the
509
+ header's `align-items: center` so the region is the row's whole height rather
510
+ than the input's.
511
+
512
+ It hangs by NOTHING, which is the whole of what the title carrying no box buys:
513
+ the first character lands on the card's gutter with the field list, the
514
+ bracket's rules and the body's first character, because nothing stands between
515
+ the region's edge and the glyph. The stretch is also what
516
+ keeps the target legal with the padding gone: WCAG 2.5.8's floor is the ROW's,
517
+ which the controls hold at `--_qm-tap-min`, so the input never carried it. */
518
+ .qm-card-rename {
519
+ flex: 1;
520
+ min-width: 0;
521
+ align-self: stretch;
522
+ display: flex;
523
+ align-items: center;
524
+ cursor: text;
525
+ }
526
+ /* Autosize sizer: an inline-grid whose ::after mirrors the text
527
+ into the single cell, so the overlaid input tracks its content width. Bounded
528
+ to the rename region's width (`min-width: 0` + `max-width: 100%`), which the
529
+ header's space-between keeps clear of the controls. The input inherits the type
530
+ tokens (`font: inherit`, leading included) so the mirror and the input measure
531
+ alike; which is why the leading rung is declared here and not on the input. */
532
+ .qm-card-title-sizer {
533
+ display: inline-grid;
534
+ align-items: center;
535
+ min-width: 0;
536
+ max-width: 100%;
537
+ font-size: var(--_qm-text-title);
538
+ font-weight: var(--_qm-weight-label);
539
+ line-height: var(--_qm-leading-tight);
540
+ font-family: inherit;
541
+ }
542
+ /* The mirror has no box model to match, because the input has none: with the
543
+ padding and the hairline gone from both, the two measure alike by carrying the
544
+ same nothing. */
545
+ .qm-card-title-sizer::after {
546
+ content: attr(data-value) ' ';
547
+ grid-area: 1 / 1;
548
+ visibility: hidden;
549
+ white-space: pre;
550
+ min-width: 2ch;
551
+ }
552
+ /* The title draws NO box, in any state. A box is
553
+ what says "type here", and a card title is already the one line of a card that
554
+ reads as its name; the region's `cursor: text` and the caret that lands on a
555
+ press say the rest. A hover-summoned box was also the inverse of the recipe every
556
+ other control follows, where the box rests drawn and hover changes nothing, and
557
+ it filled to `--_qm-surface` — the page rung,
558
+ BELOW the card it sits on, so the one hover on this surface that receded instead
559
+ of lifting.
560
+
561
+ What replaces it is an ink step, the same cue the group header takes for the same
562
+ reason: a wide borderless target drawing no box has no other way to answer the
563
+ pointer. Rest is `--_qm-ink-label`, which is where the recovery shell's static
564
+ title already sits, so the editable title and its unschemable twin finally agree
565
+ on tone as well as on the three type rungs they share. */
566
+ .qm-card-title {
567
+ grid-area: 1 / 1;
568
+ width: 100%;
569
+ min-width: 0;
570
+ font: inherit;
571
+ /* `font: inherit` does not carry colour: an actual form control, unlike this
572
+ rule's neighbours, defaults to the UA's own text colour rather than the
573
+ header's ink without this line. */
574
+ color: var(--_qm-ink-label);
575
+ border: none;
576
+ padding: 0;
577
+ background: transparent;
578
+ transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
579
+ }
580
+ .qm-card-title:hover,
581
+ .qm-card-title:focus {
582
+ color: var(--_qm-ink);
583
+ }
584
+ /* One rhythm for the card's stacked regions and for the metadata block inside it:
585
+ the gap between a field list and the body is the gap between two field lists. */
586
+ .qm-card-body,
587
+ .qm-card-meta {
588
+ display: flex;
589
+ flex-direction: column;
590
+ gap: var(--_qm-space-3);
591
+ }
592
+ /* The metadata bracket: the card's chrome brackets its metadata, and
593
+ the body is what falls outside it. Three strokes at ONE rung: the two
594
+ horizontals here and the open section's vertical below; because a bracket whose
595
+ sides disagree on width or tone reads as three unrelated lines rather than as
596
+ one figure. That sameness is the whole effect; it is why the vertical takes
597
+ `--_qm-border` and not `--_qm-accent`, which would be ornament with the chevron
598
+ and the header's ink step already saying open.
599
+
600
+ Both horizontals are conditional, and each condition is what the stroke means: a
601
+ top rule is the line under a card title, so the headerless `main` has none and
602
+ its bracket starts at the card's own top inset; a bottom rule divides fields from
603
+ body, so a card with no body has nothing to divide. The bracket is therefore
604
+ sometimes two strokes and sometimes one: an open figure by construction, which
605
+ is what distinguishes it from a second box inside the card. */
606
+ .qm-meta-top {
607
+ border-top: var(--_qm-border-width) solid var(--_qm-border);
608
+ }
609
+ .qm-meta-bottom {
610
+ border-bottom: var(--_qm-border-width) solid var(--_qm-border);
611
+ }
612
+ /* No inset between a horizontal and the accordion, deliberately: an open section's
613
+ vertical has to REACH the rule to close a corner against it, and any padding here
614
+ is the distance by which it would miss. The accordion needs none anyway: a group
615
+ header's symmetric padding is the whole inter-group rhythm.
616
+
617
+ A block of FIELDS has no padding of its own, so it takes the inset wherever it
618
+ abuts a rule; three places, since three blocks can: an ungrouped block under the
619
+ top rule, an ungrouped block over the bottom rule (only reachable because the
620
+ accordion's `{#if}` keeps an empty wrapper out of last position), and the last
621
+ section's panel when it is open, whose inset is otherwise zero at the bottom and
622
+ would stand its final row of controls directly on the stroke. The panel's padding
623
+ is inside `.qm-group`, so the vertical still runs the full height to the corner.
624
+ Ungrouped fields always render first, so the top selector needs no qualifier. */
625
+ .qm-meta-top > .qm-section {
626
+ padding-top: var(--_qm-space-3);
627
+ }
628
+ .qm-meta-bottom > .qm-section:last-child,
629
+ .qm-meta-bottom .qm-group:last-child.qm-open .qm-group-panel-inner {
630
+ padding-bottom: var(--_qm-space-3);
631
+ }
632
+ /* The two query containers: an ungrouped section, and a group panel's inner. Each
633
+ is its own, so capacity follows the width the fields actually get: a panel's
634
+ inset makes that narrower than the card.
635
+
636
+ The ACTION COLUMN is minted here and spent one level down, on the field
637
+ (`Field.svelte` holds why it is the field's inset and not this container's). It
638
+ is declared on the query container because both of its consumers hang off one:
639
+ the fields inside, and the capacity ramp below, which has to carry the column in
640
+ its own arithmetic now that the query no longer reads a content box the
641
+ reservation already narrowed. */
642
+ .qm-section,
643
+ .qm-group-panel-inner {
644
+ --action-col: calc(var(--_qm-tap-min) + var(--_qm-space-2));
645
+ container-type: inline-size;
646
+ }
647
+ /* One grid per section. Capacity is the container's, not JavaScript's:
648
+ nothing measures, so there is no observer to loop, no pre-measure pass at the
649
+ wrong capacity, and no re-packing to restructure the DOM under a prose leaf.
650
+ Fields auto-place, which is what keeps a trailing orphan at its column width
651
+ instead of growing to fill the line.
652
+
653
+ Capacity steps 1 → 2 → 4, skipping 3: each rung is the width at which a track
654
+ still clears the comfortable field minimum (2 needs 32rem, 4 needs 65rem), and
655
+ an even capacity is what lets `lone`'s half land on a track boundary. Each rung
656
+ pays for a track's own action column as well as the gutters between tracks, since
657
+ the reservation is per field now: a rung is `n * (220px + action) + gaps`.
658
+
659
+ A row-sharing field spans three implicit row tracks (Field.svelte), so `row-gap`
660
+ here is the gutter BETWEEN field rows; the tighter one inside a field is the
661
+ subgrid's own. The grid takes the container's full width; each field insets its
662
+ own action column inside the track it lands in. */
663
+ .qm-fields {
664
+ --cols: 1;
665
+ --cols-half: 1;
666
+ display: grid;
667
+ grid-template-columns: repeat(var(--cols), 1fr);
668
+ column-gap: var(--_qm-space-2);
669
+ row-gap: var(--_qm-space-2);
670
+ }
671
+ @container (min-width: 32rem) {
672
+ .qm-fields {
673
+ --cols: 2;
674
+ --cols-half: 1;
675
+ }
676
+ }
677
+ @container (min-width: 65rem) {
678
+ .qm-fields {
679
+ --cols: 4;
680
+ --cols-half: 2;
681
+ }
682
+ }
683
+ /* Group accordion. The header is a toggle
684
+ at the field-label rung; the panel slides via a 0fr↔1fr grid row so the height
685
+ animates without a magic max-height. */
686
+ /* The wrapper carries NO gap: the headers' own padding is the rhythm (see the
687
+ markup note). */
688
+ .qm-groups,
689
+ .qm-group {
690
+ display: flex;
691
+ flex-direction: column;
692
+ }
693
+ /* The bracket's third stroke, on the SECTION rather than on its panel:
694
+ spanning the header and the content is what gives it a corner to close into at
695
+ the block's edge: a rule that starts below the header has nothing to meet there.
696
+ One rung with the two horizontals, held there by `check:style`'s border-width axis.
697
+
698
+ Drawn transparent when closed rather than absent, so the 1px it occupies is the
699
+ same open or shut and a toggle moves no text. */
700
+ .qm-group {
701
+ border-left: var(--_qm-border-width) solid transparent;
702
+ transition: border-color var(--_qm-duration-slow) var(--_qm-ease-reverse);
703
+ }
704
+ .qm-group.qm-open {
705
+ border-left-color: var(--_qm-border);
706
+ }
707
+ /* Vertical padding is symmetric and is the TIGHTEST rung that still clears WCAG
708
+ 2.5.8's 24×24 floor over the label rung's line box: the header is the whole row,
709
+ so adjacent labels share one rhythm with no dead strip outside the button, and a
710
+ rung above the floor is a rung spent per section on a card whose scarce axis is
711
+ vertical. Horizontal is one rung left and zero right. Left, because the section's
712
+ vertical runs down this row: at zero the chevron stands on the stroke with only
713
+ the icon box's own bearing between them, which is neither a rung nor a constant:
714
+ the glyph's rotation swaps which bearing faces the stroke, so the clearance moves
715
+ as the section opens. Right stays at zero because the row is the target and an
716
+ inset there is target given back; the left one is inside the button and costs
717
+ none.
718
+
719
+ A button by tag and NEITHER button family by recipe: it reads no
720
+ `--_qm-tap-min` (the padding above already clears the floor) and takes no hover
721
+ fill (an ink step, below); so the type a family would have carried is declared
722
+ here. `font: inherit` because a UA button inherits no face and `--qm-font` would
723
+ stop at this row; then the field-label rung, at the tight leading a single line
724
+ takes. Sentence case at that rung, reused rather than a fifth size minted: a
725
+ section name is structurally a heading, and uppercase costs it twice: the word
726
+ shape a column of them is scanned by, and apparent width, so a long label crowds
727
+ sooner. Size, weight and leading all sit after `font`, which is a shorthand that
728
+ carries `line-height`.
729
+
730
+ On the BUTTON rather than on a span inside it: the row's type is one decision,
731
+ and a wrapper that carries nothing else is indirection between the header and
732
+ its own label. The label is a text run beside the chevron, and flex gives it an
733
+ anonymous item either way. */
734
+ .qm-group-header {
735
+ display: flex;
736
+ align-items: center;
737
+ gap: var(--_qm-space);
738
+ width: 100%;
739
+ border: none;
740
+ background: transparent;
741
+ padding: var(--_qm-space) 0 var(--_qm-space) var(--_qm-space);
742
+ font: inherit;
743
+ font-size: var(--_qm-text-label);
744
+ font-weight: var(--_qm-weight-soft);
745
+ line-height: var(--_qm-leading-tight);
746
+ cursor: pointer;
747
+ color: var(--_qm-ink-meta);
748
+ text-align: left;
749
+ transition: color var(--_qm-duration-fast) var(--_qm-ease-reverse);
750
+ }
751
+ .qm-group-header :global(.qm-group-chevron) {
752
+ flex-shrink: 0;
753
+ transition: transform var(--_qm-duration-slow) var(--_qm-ease-reverse);
754
+ }
755
+ /* An ink step, not a hue: the three status hues are the only exits from the
756
+ greyscale, and a section standing open is not a status anyway.
757
+
758
+ HOVER'S ALONE, though the step suits open as well. `color` is one transition
759
+ declaration, and open is the accordion's gesture where hover is its own, so the two
760
+ share a rung and one of them is wrong: an ink step at the slow rung lags the
761
+ pointer, and at the fast one it settles 80ms before the panel it belongs to.
762
+ Hover keeps the property because open has another cue in the
763
+ chevron's rotation and hover has none, a big borderless target drawing no box. */
764
+ .qm-group-header:hover {
765
+ color: var(--_qm-ink);
766
+ }
767
+ .qm-group.qm-open .qm-group-header :global(.qm-group-chevron) {
768
+ transform: rotate(90deg);
769
+ }
770
+ /* Sliding panel: the grid track goes 0fr→1fr; the inner clips at min-height 0.
771
+ Clipping is the whole of what CSS does here: what a closed panel is OUT of is
772
+ `inert`'s to say, on the element. The panel
773
+ draws no rule of its own: the vertical is the section's (`.qm-group`), which is
774
+ what spans the header too. */
775
+ .qm-group-panel {
776
+ display: grid;
777
+ grid-template-rows: 0fr;
778
+ transition: grid-template-rows var(--_qm-duration-slow) var(--_qm-ease-reverse);
779
+ }
780
+ .qm-group.qm-open .qm-group-panel {
781
+ grid-template-rows: 1fr;
782
+ }
783
+ /* The grouped sections' query container (declared above): its left inset is
784
+ exactly what makes a panel's usable width differ from the card's. */
785
+ .qm-group-panel-inner {
786
+ display: flex;
787
+ flex-direction: column;
788
+ gap: var(--_qm-space-2);
789
+ min-height: 0;
790
+ overflow: hidden;
791
+ /* Zero when closed, so the insets arrive with the panel: a `0fr` track collapses
792
+ the CONTENT, not the padding, and a top inset declared here stands under a
793
+ closed header as dead space the header's symmetric padding cannot absorb. The
794
+ panel animates its width along with them, and the capacity ramp reads that
795
+ width: a closed panel is `inert` and unmeasured either way, so what a rung
796
+ crosses mid-slide is a hidden grid. */
797
+ padding: 0;
798
+ transition: padding var(--_qm-duration-slow) var(--_qm-ease-reverse);
799
+ }
800
+ .qm-group.qm-open .qm-group-panel-inner {
801
+ padding: var(--_qm-space-2) 0 0 var(--_qm-space-3);
802
+ }
803
+ .qm-body-leaf {
804
+ display: flex;
805
+ flex-direction: column;
806
+ gap: var(--_qm-space);
807
+ }
808
+ /* Recovery shell: dashed edge marks an un-schemable card. The edge
809
+ alone carries that: fill stays the card rung, content behind it intact. */
810
+ .qm-card.qm-unschemable {
811
+ border-style: dashed;
812
+ }
813
+ /* One typographic role with the editable title above, so it reads the same three
814
+ rungs: including the tight leading a card title takes over the root's reading
815
+ rhythm. */
816
+ .qm-card-title-static {
817
+ font-size: var(--_qm-text-title);
818
+ font-weight: var(--_qm-weight-label);
819
+ line-height: var(--_qm-leading-tight);
820
+ color: var(--_qm-ink-label);
821
+ }
822
+ .qm-card-recovery {
823
+ display: flex;
824
+ flex-direction: column;
825
+ gap: var(--_qm-space-2);
826
+ }
827
+ .qm-recovery-note {
828
+ margin: 0;
829
+ font-size: var(--_qm-text-label);
830
+ color: var(--_qm-ink-label);
831
+ }
832
+ .qm-recovery-muted {
833
+ color: var(--_qm-ink-ghost);
834
+ }
835
+ .qm-recovery-retype {
836
+ display: inline-flex;
837
+ align-items: center;
838
+ gap: var(--_qm-space-2);
839
+ font-size: var(--_qm-text-label);
840
+ color: var(--_qm-ink-label);
841
+ }
842
+ .qm-recovery-retype select {
843
+ font: inherit;
844
+ border: var(--_qm-border-width) solid var(--_qm-border);
845
+ border-radius: var(--_qm-radius-inner);
846
+ background: var(--_qm-surface);
847
+ padding: var(--_qm-space-half) var(--_qm-space-2);
848
+ }
849
+ </style>