@quillmark/svelte 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,12 +14,12 @@ npm install @quillmark/svelte
14
14
 
15
15
  Each subpath is its own module root; a bundler pulls only what the entry you import reaches.
16
16
 
17
- | Import | Surface |
18
- | --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
19
- | `@quillmark/svelte/core` | What the surfaces share: the `DocPath`/`Place` address vocabulary, the `EditorError` channel, `init`. Framework-free. |
20
- | `@quillmark/svelte/preview` | The live preview: `createPreview` + `<Preview>`. Reaches nothing editor-side. |
21
- | `@quillmark/svelte/visual` | The federated WYSIWYG: `<VisualEditor>`, the codec's `createField` prose leaf. |
22
- | `@quillmark/svelte` | Re-exports `/core`. |
17
+ | Import | Surface |
18
+ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
19
+ | `@quillmark/svelte/core` | What the surfaces share: the `DocPath`/`Place` address vocabulary, the `EditorError` channel, `init`. No UI framework. |
20
+ | `@quillmark/svelte/preview` | The live preview: `createPreview` + `<Preview>`. Reaches nothing editor-side. |
21
+ | `@quillmark/svelte/visual` | The federated WYSIWYG: `<VisualEditor>`, the codec's `createField` prose leaf. |
22
+ | `@quillmark/svelte` | Re-exports `/core`. |
23
23
 
24
24
  ## Open a session
25
25
 
@@ -29,8 +29,7 @@ The **consumer** owns the session and the handles, and drives every edit; the su
29
29
  import { Engine } from '@quillmark/wasm';
30
30
  import { init } from '@quillmark/svelte/core';
31
31
 
32
- // One-time, and the only door to Quill and Document: the artifact exports
33
- // neither statically.
32
+ // Quill and Document are exported only through init(), which loads the wasm once.
34
33
  const { Quill, Document } = await init();
35
34
 
36
35
  const quill = Quill.fromTree(tree); // tree: Map<string, Uint8Array> of the quill dir
@@ -29,7 +29,8 @@ export declare function fitsInline(rt: Content): boolean;
29
29
  export declare function fitsPlain(rt: Content): boolean;
30
30
  /** Whether a leaf so declared edits `rt` losing nothing: {@link fitsInline} narrowed
31
31
  * and {@link fitsPlain} plain, past which the decode drops what the first commit would
32
- * store; on the block schema anything but an element ({@link editable}),
32
+ * store; on the block schema anything but a construct read and not edited — an element,
33
+ * a definition list ({@link editable}), a footnote or an equation (`isEditedIsland`) —
33
34
  * and an island where the leaf mounts no island view (`islands`), which draws as its
34
35
  * placeholder tag, an atom a keystroke deletes unseen. What a leaf held over the rest
35
36
  * reads. */
@@ -8,7 +8,7 @@
8
8
  // applied here; they are decorations (field.ts). Positions throughout are USV; `Array.from` iterates by
9
9
  // code point so an astral char is one unit, never a surrogate half.
10
10
  import { DOMSerializer } from 'prosemirror-model';
11
- import { ISLAND_SLOT, isBlockIsland } from './islands.js';
11
+ import { ISLAND_SLOT, isBlockIsland, isEditedIsland } from './islands.js';
12
12
  import { descriptorOf, markKey, pmMarkFromContent } from './marks.js';
13
13
  import { core } from '../lifecycle.js';
14
14
  import { hasMarks, isBlockSchema, isInlineSchema, takesLineBreak } from './schema.js';
@@ -96,7 +96,8 @@ export function fitsPlain(rt) {
96
96
  }
97
97
  /** Whether a leaf so declared edits `rt` losing nothing: {@link fitsInline} narrowed
98
98
  * and {@link fitsPlain} plain, past which the decode drops what the first commit would
99
- * store; on the block schema anything but an element ({@link editable}),
99
+ * store; on the block schema anything but a construct read and not edited — an element,
100
+ * a definition list ({@link editable}), a footnote or an equation (`isEditedIsland`) —
100
101
  * and an island where the leaf mounts no island view (`islands`), which draws as its
101
102
  * placeholder tag, an atom a keystroke deletes unseen. What a leaf held over the rest
102
103
  * reads. */
@@ -105,11 +106,16 @@ export function fitsLeaf(rt, leaf) {
105
106
  return fitsInline(rt);
106
107
  if (leaf.plaintext)
107
108
  return fitsPlain(rt);
108
- return (leaf.islands || rt.islands.length === 0) && rt.lines.every(editable);
109
+ if (!leaf.islands && rt.islands.length)
110
+ return false;
111
+ return rt.islands.every((isl) => isEditedIsland(isl.type)) && rt.lines.every(editable);
109
112
  }
110
- /** Whether every container on `line` is one the block schema edits: an element decodes
111
- * to no node, so the first commit would drop it. */
113
+ /** Whether the block schema edits `line`'s kind and every container on it: a term draws
114
+ * as a paragraph and an element and a description as the blocks they wrap, so the first
115
+ * commit would drop each. */
112
116
  function editable(line) {
117
+ if (line.kind === 'term')
118
+ return false;
113
119
  return line.containers.every((c) => c.container === 'quote' || c.container === 'list_item');
114
120
  }
115
121
  /** Inline decode: one paragraph, containers and islands stripped. */
@@ -192,9 +198,9 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
192
198
  i = j;
193
199
  continue;
194
200
  }
195
- if (here.container === 'element') {
196
- // No node holds an element, and a leaf holding one is held (`fitsLeaf`), so
197
- // what it wraps draws in place, read-only.
201
+ if (here.container === 'element' || here.container === 'description') {
202
+ // No node holds an element or a description, and a leaf holding one is held
203
+ // (`fitsLeaf`), so what it wraps draws in place, read-only.
198
204
  const key = containerKey(here);
199
205
  let j = i + 1;
200
206
  while (j < leaves.length) {
@@ -249,7 +255,7 @@ function instanceOf(c) {
249
255
  }
250
256
  /** Identity of a container for run gathering: its name, its `instance` and an element's
251
257
  * attributes, NUL-joined as `markKey` joins a mark's. `list_item` carries its own run
252
- * rule, so the quote and the element are what key here. */
258
+ * rule, so the rest are what key here. */
253
259
  function containerKey(c) {
254
260
  const attrs = c.container === 'element' ? JSON.stringify(c.attrs) : '';
255
261
  return `${c.container}\u0000${instanceOf(c)}\u0000${attrs}`;
@@ -269,13 +275,15 @@ function makeLeaf(schema, leaf, marks, cursor) {
269
275
  return schema.nodes.code_block.create({ lang: line.attrs?.lang ?? null }, content);
270
276
  }
271
277
  case 'para':
278
+ case 'term':
272
279
  case 'heading': {
273
- if (line.kind === 'para' && blockIslandLine(leaf, cursor))
280
+ if (line.kind !== 'heading' && blockIslandLine(leaf, cursor))
274
281
  return schema.nodes.island_block.create(islandAttrs(cursor));
275
282
  // Inline content, the segment boundary a `hard_break` in a paragraph and a space
276
283
  // in a heading, which takes none (`schema.ts` §`takesLineBreak`). A stored heading
277
284
  // carries no continuation — the store clears one after a block of a single line —
278
- // so the space is what keeps a hand-built content decodable.
285
+ // so the space is what keeps a hand-built content decodable. A term draws as a
286
+ // paragraph, read-only (`fitsLeaf`).
279
287
  const type = line.kind === 'heading' ? schema.nodes.heading : schema.nodes.paragraph;
280
288
  const inline = [];
281
289
  leaf.segments.forEach((seg, idx) => {
@@ -28,7 +28,8 @@ export interface CreateFieldOpts {
28
28
  * leaf, or a `text` one, is held over a value its schema cannot
29
29
  * hold (`fitsLeaf`, upstream's `isInline` and `isPlain`): the decode would join lines
30
30
  * and drop containers and islands for the first commit to store. Any leaf is held
31
- * over an element, which no node holds. A held leaf draws
31
+ * over what is read and not edited: an element, a definition list, a footnote and an
32
+ * equation (CODEC §Decode). A held leaf draws
32
33
  * its content on the block schema, read-only, and commits nothing. Judged of the value
33
34
  * each mount and re-hydrate reads, so {@link FieldController.applyExternal} is what
34
35
  * re-evaluates it.
@@ -5,9 +5,12 @@ import type { ContentIsland, TableProps } from '@quillmark/wasm';
5
5
  export declare const ISLAND_SLOT = "\uFFFC";
6
6
  /** Whether an island of `type` is block markup: a `table` stands alone on its line. */
7
7
  export declare function isBlockIsland(type: ContentIsland['type']): boolean;
8
+ /** Whether a leaf edits an island of `type`; one holding any other is held (`fitsLeaf`). */
9
+ export declare function isEditedIsland(type: ContentIsland['type']): boolean;
8
10
  /** Block island node (a table): a `U+FFFC` slot alone on its `para` line. Atom, unselectable content. */
9
11
  export declare const islandBlockSpec: NodeSpec;
10
- /** Inline island node (an image): a `U+FFFC` slot inside a `para` line. */
12
+ /** Inline island node (an image, a footnote, an equation): a `U+FFFC` slot inside a
13
+ * `para` line. */
11
14
  export declare const islandInlineSpec: NodeSpec;
12
15
  /** A PM island node's attributes: the content entry, spelled the way a node spec
13
16
  * spells it (`islandType`, because `type` on a PM node is the node type). `islandType`
@@ -1,10 +1,11 @@
1
1
  // Islands: the `U+FFFC` slot + `ContentIsland` entry ↔ a PM leaf node. Every slot sits
2
2
  // in a `para` line; the island's type says which node it is, a `table` a block alone on
3
- // its line and an `image` inline. The node carries `id`, `islandType` and `props` verbatim.
3
+ // its line and every other type inline. The node carries `id`, `islandType` and `props`
4
+ // verbatim.
4
5
  //
5
6
  // A props-aware consumer reads the shape straight off the boundary:
6
- // `ContentIsland.props` is the typed union (`TableProps` for `table`,
7
- // `ImageProps` for `image`; DOCUMENT_MODEL §Stability seams), so the codec
7
+ // `ContentIsland.props` is the typed union (`TableProps` for `table`, `ImageProps` for
8
+ // `image`, …; DOCUMENT_MODEL §Stability seams), so the codec
8
9
  // reads it rather than hand-rolling a shape. `type` is a closed set, so a
9
10
  // discriminant check narrows it; `props` still crosses the DOM as opaque JSON,
10
11
  // which is what §`tablePropsOfNode` guards.
@@ -38,25 +39,35 @@ function islandDOM(node) {
38
39
  };
39
40
  }
40
41
  /** The island types the content model names, each with whether its markup is a block,
41
- * which decides the node its slot decodes to. A `Record` over the boundary's own union,
42
- * so a member added upstream is a missing key here rather than a silent refusal at the
43
- * one door with no decode behind it. */
44
- const BLOCK_ISLAND = { table: true, image: false };
42
+ * which decides the node its slot decodes to, and whether a leaf edits it. A `Record`
43
+ * over the boundary's own union, so a member added upstream is a missing key here
44
+ * rather than a silent refusal at the one door with no decode behind it. A footnote and
45
+ * an equation are read, not edited (CODEC §Decode): a leaf holding one is held. */
46
+ const ISLANDS = {
47
+ table: { block: true, edited: true },
48
+ image: { block: false, edited: true },
49
+ footnote: { block: false, edited: false },
50
+ math: { block: false, edited: false }
51
+ };
45
52
  /** Whether an island of `type` is block markup: a `table` stands alone on its line. */
46
53
  export function isBlockIsland(type) {
47
- return BLOCK_ISLAND[type];
54
+ return ISLANDS[type].block;
55
+ }
56
+ /** Whether a leaf edits an island of `type`; one holding any other is held (`fitsLeaf`). */
57
+ export function isEditedIsland(type) {
58
+ return ISLANDS[type].edited;
48
59
  }
49
60
  /** An island node's attributes off that DOM, or `false` where the id is absent or the
50
- * type is one the content vocabulary does not name — a name no read produces and no
51
- * write takes, so the element's text arrives as text. A `props` that is not the JSON
52
- * written above reads as `null`, and one of the wrong shape is `undefined` at its
53
- * reader (§`tablePropsOfNode`): the entry survives as an island of its type rather
54
- * than taking the paste down with it. */
61
+ * type is one no leaf edits — a name the vocabulary does not hold, or a footnote or an
62
+ * equation, which no gesture mints — so the element's text arrives as text. A `props`
63
+ * that is not the JSON written above reads as `null`, and one of the wrong shape is
64
+ * `undefined` at its reader (§`tablePropsOfNode`): the entry survives as an island of
65
+ * its type rather than taking the paste down with it. */
55
66
  function islandAttrsFromDOM(el) {
56
67
  const id = el.getAttribute('data-qm-island-id');
57
68
  // Asserted off an untrusted attribute and checked against the set on the next line.
58
69
  const type = el.getAttribute('data-qm-island');
59
- if (id == null || type == null || !Object.hasOwn(BLOCK_ISLAND, type))
70
+ if (id == null || type == null || !Object.hasOwn(ISLANDS, type) || !isEditedIsland(type))
60
71
  return false;
61
72
  const raw = el.getAttribute('data-qm-island-props');
62
73
  let props = null;
@@ -86,7 +97,8 @@ export const islandBlockSpec = {
86
97
  parseDOM: [{ tag: 'div[data-qm-island]', getAttrs: (el) => islandAttrsFromDOM(el) }],
87
98
  toDOM: (node) => ['div', islandDOM(node), `[${node.attrs.islandType || 'island'}]`]
88
99
  };
89
- /** Inline island node (an image): a `U+FFFC` slot inside a `para` line. */
100
+ /** Inline island node (an image, a footnote, an equation): a `U+FFFC` slot inside a
101
+ * `para` line. */
90
102
  export const islandInlineSpec = {
91
103
  group: 'inline',
92
104
  inline: true,
@@ -94,8 +106,23 @@ export const islandInlineSpec = {
94
106
  selectable: true,
95
107
  attrs: islandAttrs,
96
108
  parseDOM: [{ tag: 'span[data-qm-island]', getAttrs: (el) => islandAttrsFromDOM(el) }],
97
- toDOM: (node) => ['span', islandDOM(node), `[${node.attrs.islandType || 'island'}]`]
109
+ toDOM: inlineIslandDOM
98
110
  };
111
+ /** An inline island's DOM: a footnote its reference mark, whose number `prose.css` draws
112
+ * in reading order and whose note is its title, an equation its TeX between the dollars
113
+ * that write it, and an image its tag. */
114
+ function inlineIslandDOM(node) {
115
+ const attrs = node.attrs;
116
+ const dom = islandDOM(node);
117
+ const props = attrs.props;
118
+ if (attrs.islandType === 'footnote')
119
+ return ['sup', typeof props?.text === 'string' ? { ...dom, title: props.text } : dom];
120
+ if (attrs.islandType === 'math' && typeof props?.tex === 'string') {
121
+ const fence = props.display ? '$$' : '$';
122
+ return ['span', dom, `${fence}${props.tex}${fence}`];
123
+ }
124
+ return ['span', dom, `[${attrs.islandType || 'island'}]`];
125
+ }
99
126
  /** Build a content island entry from a PM island node's attrs (block or inline). The
100
127
  * cast pairs a closed `type` with a `props` that crossed the DOM as opaque JSON. The
101
128
  * store is lenient about a table's shape — it fills what it can and keeps the rest —
@@ -2,8 +2,9 @@ import { Plugin } from 'prosemirror-state';
2
2
  /**
3
3
  * A block leaf's markdown paste, consulted after the table paste: ProseMirror hands text
4
4
  * over only where the clipboard holds no HTML, whose DOM parse reads the source's own
5
- * structure, so HTML wins. A paste as plain text keeps the text. An island the parse
6
- * builds arrives under the id `importMarkdown` minted, which the paste's island pass
7
- * re-mints where the field holds it (`islands.ts`).
5
+ * structure, so HTML wins. A paste as plain text keeps the text, and so does a parse
6
+ * building a construct no leaf edits (`fitsLeaf`). An island the parse builds arrives
7
+ * under the id `importMarkdown` minted, which the paste's island pass re-mints where the
8
+ * field holds it (`islands.ts`).
8
9
  */
9
10
  export declare function markdownPastePlugin(): Plugin;
@@ -7,7 +7,7 @@
7
7
  import { Slice } from 'prosemirror-model';
8
8
  import { Plugin } from 'prosemirror-state';
9
9
  import { core } from '../lifecycle.js';
10
- import { decode } from './decode.js';
10
+ import { decode, fitsLeaf } from './decode.js';
11
11
  import { blockSchema } from './schema.js';
12
12
  /** A line opening a block markdown spells and prose does not: a bullet or an ordered item at
13
13
  * any indent, and within CommonMark's three spaces an ATX heading, a quote, a fence, or a
@@ -21,9 +21,10 @@ const BLOCK_HEAD = new RegExp([
21
21
  /**
22
22
  * A block leaf's markdown paste, consulted after the table paste: ProseMirror hands text
23
23
  * over only where the clipboard holds no HTML, whose DOM parse reads the source's own
24
- * structure, so HTML wins. A paste as plain text keeps the text. An island the parse
25
- * builds arrives under the id `importMarkdown` minted, which the paste's island pass
26
- * re-mints where the field holds it (`islands.ts`).
24
+ * structure, so HTML wins. A paste as plain text keeps the text, and so does a parse
25
+ * building a construct no leaf edits (`fitsLeaf`). An island the parse builds arrives
26
+ * under the id `importMarkdown` minted, which the paste's island pass re-mints where the
27
+ * field holds it (`islands.ts`).
27
28
  */
28
29
  export function markdownPastePlugin() {
29
30
  return new Plugin({
@@ -42,6 +43,8 @@ export function markdownPastePlugin() {
42
43
  // Nesting past the store's depth is refused; the literal text holds it.
43
44
  return null;
44
45
  }
46
+ if (!fitsLeaf(rt, { inline: false, plaintext: false, islands: true }))
47
+ return null;
45
48
  return new Slice(decode(rt, blockSchema).content, 0, 0);
46
49
  })
47
50
  }
@@ -162,6 +162,21 @@ li.ProseMirror-selectednode::after {
162
162
  pointer-events: none;
163
163
  }
164
164
 
165
+ /* A footnote and an equation are read, not edited (CODEC §Decode), and draw as what
166
+ their markdown writes: a footnote its reference mark, numbered in reading order off a
167
+ counter each leaf resets, its note the marker's title (`islands.ts`); an equation its
168
+ TeX between its dollars, in the code face. */
169
+ .ProseMirror {
170
+ counter-reset: qm-footnote;
171
+ }
172
+ .ProseMirror sup[data-qm-island='footnote']::after {
173
+ counter-increment: qm-footnote;
174
+ content: counter(qm-footnote);
175
+ }
176
+ .ProseMirror [data-qm-island='math'] {
177
+ font-family: var(--_qm-font-mono);
178
+ }
179
+
165
180
  /* A link takes the surface's ink (the UA's blue is the `#8cf` defect again) and a
166
181
  dotted underline. Dotted rather than solid because `underline` is a mark in the
167
182
  same schema, and a solid rule renders an authored `<u>` and a link identically in a
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // `@quillmark/svelte` root: the framework-free substrate.
1
+ // `@quillmark/svelte` root: the substrate, free of any UI framework.
2
2
  //
3
3
  // The headline surfaces mount from their own subpaths (`@quillmark/svelte/preview`,
4
4
  // `/visual`) so a bundler pulls only what an entry reaches; the root
@@ -1,5 +1,6 @@
1
1
  <!--
2
- One card block, its fields grouped by `ui.group`/`ui.compact`. `main` is headerless
2
+ One card block, its top-level objects (or legacy `ui.group`s) drawn as panels and its
3
+ fields packed by `ui.compact`. `main` is headerless
3
4
  and takes no controls. Every prose leaf takes a parent-built live address, so a card
4
5
  reorder re-targets its commits without a remount.
5
6
  -->
@@ -273,9 +274,9 @@
273
274
  {@const headIds = section.head ? ops.domIds(section.head.name) : undefined}
274
275
  {@const headHint = section.head?.description}
275
276
  <div class={['qm-group', { 'qm-open': isOpen }]}>
276
- <!-- A flat field alone in its section is named by the header, which
277
- takes its label id and parks its description: the field draws no
278
- label of its own to say the section's name twice. -->
277
+ <!-- A panel's object, or an object alone in its section, is named by the
278
+ header, which takes its label id and parks its description: the field
279
+ draws no label of its own to say the section's name twice. -->
279
280
  <div class="qm-group-head">
280
281
  <button
281
282
  type="button"
@@ -33,7 +33,6 @@
33
33
  VARIANT_DISCRIMINANT,
34
34
  arrayLayout,
35
35
  baseType,
36
- flatObject,
37
36
  ghostDefault,
38
37
  isContainer,
39
38
  optionalCell,
@@ -174,7 +173,8 @@
174
173
  );
175
174
  // Two controls own their label row, the add chip and the held count sharing it.
176
175
  const ownsLabel = $derived(field.control === 'array' || field.control === 'matrix');
177
- const flat = $derived(flatObject(field.schema));
176
+ // A headed object is a panel's, and the panel is its frame.
177
+ const flat = $derived(headed);
178
178
 
179
179
  // A container walks its diagnostics down to the cell each names and draws the rest
180
180
  // at its own foot (`diagnostics.ts`); every other control draws them here, under
@@ -30,9 +30,8 @@
30
30
  member's id. So a cell reads at whatever depth it sits, which nothing here knows.
31
31
 
32
32
  `bare` is the table's row: the same cells with no labels and no figure of their own,
33
- laid onto the tracks the table's header names ({@link ArrayField}). `flat` is
34
- `ui.layout: flat`: the cells keep their labels and lose the figure, standing at the
35
- depth of whatever holds the subform.
33
+ laid onto the tracks the table's header names ({@link ArrayField}). `flat` is a
34
+ panel's object: the cells keep their labels and lose the figure, the panel being it.
36
35
  -->
37
36
  <script lang="ts">
38
37
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
@@ -42,7 +41,6 @@
42
41
  controlKind,
43
42
  declaredContent,
44
43
  fieldModels,
45
- flatObject,
46
44
  humanize,
47
45
  isContainer,
48
46
  literalText,
@@ -103,8 +101,8 @@
103
101
  /** A table's row: the cells alone, on the tracks the table's header names, with
104
102
  * no labels and no vertical of their own. */
105
103
  bare?: boolean;
106
- /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
107
- * list the subform stands in. */
104
+ /** A panel's object: no vertical and no rung, the properties at the depth of the
105
+ * panel's field list. */
108
106
  flat?: boolean;
109
107
  /** A card-level object, or one nested in it: its properties are placed as a
110
108
  * card's fields are (`placeFields`), so moving a field into a group leaves its row
@@ -426,7 +424,6 @@
426
424
  bind:this={nestedEls[key]}
427
425
  value={obj[key] as Record<string, unknown> | undefined}
428
426
  properties={sub.properties}
429
- flat={flatObject(sub)}
430
427
  {placed}
431
428
  label={ids ? undefined : fallbackName(key, sub)}
432
429
  idBase={ids?.control}
@@ -32,8 +32,8 @@ interface Props {
32
32
  /** A table's row: the cells alone, on the tracks the table's header names, with
33
33
  * no labels and no vertical of their own. */
34
34
  bare?: boolean;
35
- /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
36
- * list the subform stands in. */
35
+ /** A panel's object: no vertical and no rung, the properties at the depth of the
36
+ * panel's field list. */
37
37
  flat?: boolean;
38
38
  /** A card-level object, or one nested in it: its properties are placed as a
39
39
  * card's fields are (`placeFields`), so moving a field into a group leaves its row
@@ -54,10 +54,7 @@
54
54
  isContainer,
55
55
  shortCell,
56
56
  schemaAt,
57
- fieldModels,
58
- groupOrder,
59
- groupSections,
60
- groupLabel,
57
+ cardSections,
61
58
  cardTitle,
62
59
  kindTitle,
63
60
  fieldValues,
@@ -325,9 +322,9 @@
325
322
  try {
326
323
  if (value === undefined) {
327
324
  // The unset rung (VISUAL_EDITOR §"The commitment ladder"): removing the field
328
- // leaves the engine's authored › `default:` › blank-fill resolve to render the
325
+ // leaves Quillmark's authored › `default:` › blank-fill resolve to render the
329
326
  // default, where writing one bakes a snapshot the schema cannot track (canon
330
- // SCHEMAS.md: the engine never writes a default; the editor writes one only
327
+ // SCHEMAS.md: Quillmark never writes a default; the editor writes one only
331
328
  // as the edit that takes it). Removal writes no value, so there is
332
329
  // nothing for a schema to conform and the lane is the quill-free one.
333
330
  if (isMain) {
@@ -632,14 +629,11 @@
632
629
  payloadItems: readonly PayloadItem[];
633
630
  ext?: Record<string, unknown>;
634
631
  },
635
- cardSchema: Parameters<typeof fieldModels>[0] | undefined,
632
+ cardSchema: Parameters<typeof cardSections>[0] | undefined,
636
633
  rows: ResolvedCardRows
637
634
  ): CardModel {
638
635
  const values = fieldValues(card.payloadItems);
639
- const fields = cardSchema ? fieldModels(cardSchema) : [];
640
- const sections = cardSchema
641
- ? groupSections(fields, groupOrder(cardSchema), (g) => groupLabel(cardSchema, g))
642
- : [];
636
+ const sections = cardSchema ? cardSections(cardSchema) : [];
643
637
  const extEditor = card.ext?.editor as { title?: string } | undefined;
644
638
  const hasBody = bodyEnabled(cardSchema);
645
639
  return {
@@ -122,7 +122,7 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
122
122
  *
123
123
  * - **`validation::enum_violation`, `type_mismatch`, `format_violation`**: the
124
124
  * constraint re-words from the quill's schema at `path`, the offending value from
125
- * `args` (`value` / `sourceToken` / `actual`, the engine testifying to what it
125
+ * `args` (`value` / `sourceToken` / `actual`, Quillmark testifying to what it
126
126
  * saw) and never from the document at `path`: validation runs post-coercion, so
127
127
  * the validator read a value the document does not hold and a sentence built from
128
128
  * `path` names a spelling the user never typed.
@@ -130,7 +130,7 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
130
130
  * refused value being in neither document (unchanged on throw) nor schema.
131
131
  * - **`parse::yaml_error_with_location`, `invalid_structure`**: does not re-word. No
132
132
  * `path`; `args` names the block (`blockIndex`) and `location` places the failure in
133
- * the document's own coordinates, engine prose riding under no key: the parser's own
133
+ * the document's own coordinates, Quillmark prose riding under no key: the parser's own
134
134
  * text, its column and its caret snippet exist only inside `message`.
135
135
  * - **`LiveSession.warnings`**: does not re-word. Backend text, an external feed.
136
136
  *
@@ -26,18 +26,17 @@ export interface FieldModel {
26
26
  inline: boolean;
27
27
  plaintext: boolean;
28
28
  }
29
- /** A `ui.group` section: its label and the fields (declaration order) inside it. */
29
+ /** A panel section, a top-level `object`'s or a legacy `ui.group`'s: its label and the
30
+ * fields (declaration order) inside it. */
30
31
  export interface GroupSection {
31
32
  group: string | undefined;
32
33
  label: string;
33
34
  fields: FieldModel[];
34
- /** The field the section's header names in place of a label of its own: the one
35
- * field of a group section, where that field is {@link flatObject}. */
35
+ /** The object the section's header names in place of a label of its own: the
36
+ * object a panel is, or a group section's one field where that field is an
37
+ * `object`. Its properties draw flat, the panel being their frame. */
36
38
  head?: FieldModel;
37
39
  }
38
- /** `ui.layout: flat` on a typed dictionary: no frame of its own, its properties
39
- * standing at the depth of the fields around it rather than a rung in. */
40
- export declare function flatObject(schema: QuillFieldSchema): boolean;
41
40
  /**
42
41
  * One card instance projected for rendering: the schema × payload join result
43
42
  * the VisualEditor's `$derived` produces and hands to `<Card>`. Positional
@@ -112,7 +111,7 @@ export declare function resolvedByCardIndex(resolved: Resolved | undefined): Map
112
111
  export declare function ghostDefault(row: ResolvedField | undefined): unknown;
113
112
  /** A stored `boolean` cell as the render reads it: a boolean, `true` or `false` in any
114
113
  * case, or a number by whether it is non-zero; undefined where the render refuses the
115
- * value. The engine's coercion, restated, so the suite holds it to `reader.resolve()`. */
114
+ * value. Quillmark's coercion, restated, so the suite holds it to `reader.resolve()`. */
116
115
  export declare function renderedBoolean(value: unknown): boolean | undefined;
117
116
  /** A ghost value's string form, or undefined for null/object (only text ghosts
118
117
  * render a placeholder). A scalar's resolved default reads through it; a content
@@ -207,9 +206,9 @@ export declare function matrixMembers(roster: Record<string, string> | undefined
207
206
  export declare function memberValue(map: Record<string, unknown> | undefined, id: string): unknown;
208
207
  /**
209
208
  * Whether a stored member is held: by being present. A mapping is held whatever it
210
- * carries, `{}` included, and a bare scalar is the tick itself, read as the engine's
209
+ * carries, `{}` included, and a bare scalar is the tick itself, read as Quillmark's
211
210
  * coercion reads it: `false`, `0`, `"false"` and `null` unheld, every other present
212
- * scalar held, so a spelling the engine would refuse still draws the tick it claims.
211
+ * scalar held, so a spelling Quillmark would refuse still draws the tick it claims.
213
212
  */
214
213
  export declare function matrixHeld(stored: unknown): boolean;
215
214
  /** A member's columns: its mapping without the reserved keys, `{}` for a bare tick or an
@@ -245,7 +244,7 @@ export declare function matrixDeclares(matrix: QuillFieldSchema, id: string): bo
245
244
  /**
246
245
  * The added items a stored map holds, sorted by id as the plate orders them: on an
247
246
  * open matrix, each key off the roster spelled as an id whose mapping carries a
248
- * `title`. Any other key off the roster is the engine's refusal, drawn nowhere and
247
+ * `title`. Any other key off the roster is Quillmark's refusal, drawn nowhere and
249
248
  * ridden through.
250
249
  */
251
250
  export declare function addedItems(matrix: QuillFieldSchema, map: Record<string, unknown> | undefined): MatrixMember[];
@@ -329,6 +328,19 @@ export declare function groupLabel(cardSchema: QuillCardSchema, group: string):
329
328
  * the card schema, which falls back to {@link humanize} itself.
330
329
  */
331
330
  export declare function groupSections(fields: FieldModel[], order: string[], labelFor: (group: string) => string): GroupSection[];
331
+ /**
332
+ * Object-panel sections (VISUAL_EDITOR §"Structure mirrors the schema"): every
333
+ * top-level `object` is a panel of its own, headed by the object, in declaration
334
+ * order; everything else stands in one unframed section above them. The section id
335
+ * is the field name, which a card's keys hold unique.
336
+ */
337
+ export declare function objectSections(fields: FieldModel[]): GroupSection[];
338
+ /**
339
+ * A card's sections: its ungrouped fields' {@link objectSections}, then each legacy
340
+ * `ui.group` a panel in registry order, the order the loader settles the fields in.
341
+ * A panel named as a group is keyed off the group's id space, which `.` never joins.
342
+ */
343
+ export declare function cardSections(cardSchema: QuillCardSchema): GroupSection[];
332
344
  /**
333
345
  * The group section a card's accordion opens on first mount: the first in
334
346
  * order, or `null` when the card declares none. Ungrouped fields render outside
@@ -11,11 +11,6 @@ import { core } from '../core/lifecycle.js';
11
11
  export function isContainer(kind) {
12
12
  return kind === 'array' || kind === 'object' || kind === 'variant' || kind === 'matrix';
13
13
  }
14
- /** `ui.layout: flat` on a typed dictionary: no frame of its own, its properties
15
- * standing at the depth of the fields around it rather than a rung in. */
16
- export function flatObject(schema) {
17
- return controlKind(schema) === 'object' && schema.ui?.layout === 'flat';
18
- }
19
14
  /**
20
15
  * A card's resolved rows keyed by field name: the provenance channel parallel to
21
16
  * {@link CardModel.values}. `resolve` returns rows in declaration order; the
@@ -47,7 +42,7 @@ export function ghostDefault(row) {
47
42
  }
48
43
  /** A stored `boolean` cell as the render reads it: a boolean, `true` or `false` in any
49
44
  * case, or a number by whether it is non-zero; undefined where the render refuses the
50
- * value. The engine's coercion, restated, so the suite holds it to `reader.resolve()`. */
45
+ * value. Quillmark's coercion, restated, so the suite holds it to `reader.resolve()`. */
51
46
  export function renderedBoolean(value) {
52
47
  if (typeof value === 'boolean')
53
48
  return value;
@@ -197,16 +192,16 @@ export function memberValue(map, id) {
197
192
  }
198
193
  /**
199
194
  * Whether a stored member is held: by being present. A mapping is held whatever it
200
- * carries, `{}` included, and a bare scalar is the tick itself, read as the engine's
195
+ * carries, `{}` included, and a bare scalar is the tick itself, read as Quillmark's
201
196
  * coercion reads it: `false`, `0`, `"false"` and `null` unheld, every other present
202
- * scalar held, so a spelling the engine would refuse still draws the tick it claims.
197
+ * scalar held, so a spelling Quillmark would refuse still draws the tick it claims.
203
198
  */
204
199
  export function matrixHeld(stored) {
205
200
  if (typeof stored === 'object' && stored !== null)
206
201
  return true;
207
202
  return tickOf(stored);
208
203
  }
209
- /** A tick's scalar reading: absent and the engine's false spellings unheld, the rest held. */
204
+ /** A tick's scalar reading: absent and Quillmark's false spellings unheld, the rest held. */
210
205
  function tickOf(v) {
211
206
  if (v === undefined || v === null)
212
207
  return false;
@@ -280,7 +275,7 @@ export function matrixDeclares(matrix, id) {
280
275
  /**
281
276
  * The added items a stored map holds, sorted by id as the plate orders them: on an
282
277
  * open matrix, each key off the roster spelled as an id whose mapping carries a
283
- * `title`. Any other key off the roster is the engine's refusal, drawn nowhere and
278
+ * `title`. Any other key off the roster is Quillmark's refusal, drawn nowhere and
284
279
  * ridden through.
285
280
  */
286
281
  export function addedItems(matrix, map) {
@@ -526,7 +521,7 @@ export function groupSections(fields, order, labelFor) {
526
521
  for (const g of order) {
527
522
  const fs = fields.filter((f) => f.group === g);
528
523
  if (fs.length) {
529
- const head = fs.length === 1 && flatObject(fs[0].schema) ? fs[0] : undefined;
524
+ const head = fs.length === 1 && fs[0].control === 'object' ? fs[0] : undefined;
530
525
  sections.push({ group: g, label: labelFor(g), fields: fs, head });
531
526
  emitted.add(g);
532
527
  }
@@ -543,6 +538,35 @@ export function groupSections(fields, order, labelFor) {
543
538
  }
544
539
  return sections;
545
540
  }
541
+ /**
542
+ * Object-panel sections (VISUAL_EDITOR §"Structure mirrors the schema"): every
543
+ * top-level `object` is a panel of its own, headed by the object, in declaration
544
+ * order; everything else stands in one unframed section above them. The section id
545
+ * is the field name, which a card's keys hold unique.
546
+ */
547
+ export function objectSections(fields) {
548
+ const panel = (f) => f.control === 'object';
549
+ const loose = fields.filter((f) => !panel(f));
550
+ const sections = loose.length
551
+ ? [{ group: undefined, label: '', fields: loose }]
552
+ : [];
553
+ for (const f of fields)
554
+ if (panel(f))
555
+ sections.push({ group: f.name, label: f.label, fields: [f], head: f });
556
+ return sections;
557
+ }
558
+ /**
559
+ * A card's sections: its ungrouped fields' {@link objectSections}, then each legacy
560
+ * `ui.group` a panel in registry order, the order the loader settles the fields in.
561
+ * A panel named as a group is keyed off the group's id space, which `.` never joins.
562
+ */
563
+ export function cardSections(cardSchema) {
564
+ const fields = fieldModels(cardSchema);
565
+ const order = groupOrder(cardSchema);
566
+ const panels = objectSections(fields.filter((f) => f.group === undefined)).map((s) => s.head && order.includes(s.head.name) ? { ...s, group: `${s.head.name}.` } : s);
567
+ const grouped = fields.filter((f) => f.group !== undefined);
568
+ return [...panels, ...groupSections(grouped, order, (g) => groupLabel(cardSchema, g))];
569
+ }
546
570
  /**
547
571
  * The group section a card's accordion opens on first mount: the first in
548
572
  * order, or `null` when the card declares none. Ungrouped fields render outside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -69,7 +69,7 @@
69
69
  "check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
70
70
  },
71
71
  "peerDependencies": {
72
- "@quillmark/wasm": ">=0.126.0-0",
72
+ "@quillmark/wasm": ">=0.129.0-0",
73
73
  "svelte": "^5.33.0"
74
74
  },
75
75
  "dependencies": {