@quillmark/svelte 0.11.0 → 0.13.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 (62) hide show
  1. package/dist/core/address.d.ts +17 -10
  2. package/dist/core/address.js +26 -12
  3. package/dist/core/bloom.js +4 -3
  4. package/dist/core/codec/decode.d.ts +17 -0
  5. package/dist/core/codec/decode.js +78 -19
  6. package/dist/core/codec/encode.js +1 -1
  7. package/dist/core/codec/field.d.ts +64 -15
  8. package/dist/core/codec/field.js +201 -65
  9. package/dist/core/codec/index.d.ts +3 -3
  10. package/dist/core/codec/index.js +3 -3
  11. package/dist/core/codec/islands.d.ts +5 -4
  12. package/dist/core/codec/islands.js +23 -38
  13. package/dist/core/codec/keymap.d.ts +5 -4
  14. package/dist/core/codec/keymap.js +5 -4
  15. package/dist/core/codec/prose.css +42 -0
  16. package/dist/core/codec/schema.d.ts +24 -5
  17. package/dist/core/codec/schema.js +40 -9
  18. package/dist/core/codec/slash.js +0 -0
  19. package/dist/core/errors.d.ts +3 -2
  20. package/dist/core/rungs.d.ts +8 -0
  21. package/dist/core/rungs.js +24 -0
  22. package/dist/core/theme.css +19 -6
  23. package/dist/preview/paint.js +21 -17
  24. package/dist/visual/ArrayField.svelte +649 -176
  25. package/dist/visual/ArrayField.svelte.d.ts +16 -1
  26. package/dist/visual/BooleanField.svelte +36 -11
  27. package/dist/visual/BooleanField.svelte.d.ts +2 -0
  28. package/dist/visual/Card.svelte +6 -25
  29. package/dist/visual/DateField.svelte +66 -31
  30. package/dist/visual/EnumField.svelte +27 -9
  31. package/dist/visual/EnumField.svelte.d.ts +5 -0
  32. package/dist/visual/Field.svelte +113 -38
  33. package/dist/visual/FormatPopover.svelte +4 -3
  34. package/dist/visual/MatrixField.svelte +353 -0
  35. package/dist/visual/MatrixField.svelte.d.ts +36 -0
  36. package/dist/visual/NumberField.svelte +31 -14
  37. package/dist/visual/NumberField.svelte.d.ts +5 -1
  38. package/dist/visual/ObjectField.svelte +404 -139
  39. package/dist/visual/ObjectField.svelte.d.ts +17 -2
  40. package/dist/visual/ProseField.svelte +53 -23
  41. package/dist/visual/ProseField.svelte.d.ts +15 -3
  42. package/dist/visual/ProseValue.svelte +102 -46
  43. package/dist/visual/ProseValue.svelte.d.ts +17 -5
  44. package/dist/visual/TextField.svelte +35 -18
  45. package/dist/visual/TextField.svelte.d.ts +7 -0
  46. package/dist/visual/VariantField.svelte +56 -2
  47. package/dist/visual/VariantField.svelte.d.ts +10 -1
  48. package/dist/visual/VisualEditorInner.svelte +77 -73
  49. package/dist/visual/controls.css +47 -5
  50. package/dist/visual/diagnostics.d.ts +26 -2
  51. package/dist/visual/diagnostics.js +52 -5
  52. package/dist/visual/hold.js +2 -2
  53. package/dist/visual/icons/nodes.d.ts +3 -0
  54. package/dist/visual/icons/nodes.js +2 -1
  55. package/dist/visual/leaves.d.ts +25 -12
  56. package/dist/visual/motion.d.ts +14 -5
  57. package/dist/visual/motion.js +13 -11
  58. package/dist/visual/strings.d.ts +27 -10
  59. package/dist/visual/strings.js +8 -2
  60. package/dist/visual/structure.d.ts +159 -40
  61. package/dist/visual/structure.js +337 -45
  62. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
- // Islands: the `U+FFFC` slot + `ContentIsland` entry ↔ a PM leaf node. A slot
2
- // inside a `para` line is an inline island (an image); an `island`-kind line is a
3
- // block island (a table). The node carries `id`, `islandType` and `props` verbatim.
1
+ // Islands: the `U+FFFC` slot + `ContentIsland` entry ↔ a PM leaf node. Every slot sits
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.
4
4
  //
5
5
  // A props-aware consumer reads the shape straight off the boundary:
6
6
  // `ContentIsland.props` is the typed union (`TableProps` for `table`,
@@ -13,22 +13,17 @@ import { Plugin } from 'prosemirror-state';
13
13
  import { storableUrl } from './urls.js';
14
14
  /** The `U+FFFC` object-replacement char that occupies one island slot in `text`. */
15
15
  export const ISLAND_SLOT = '';
16
- // The four island node attributes carry the content entry verbatim: `id` is the
17
- // stable identity (rebases like an anchor), `islandType` the discriminator, `props`
18
- // the payload the codec preserves byte-for-byte across a round-trip, and `loss` how
19
- // faithfully markdown can carry it. `islandType` and `props` declare no default: a
20
- // node standing for an island holds one, so the pair is supplied at every `create`.
21
- // `loss` is authored, not derived: `applyChange` stores the class an island op gives
22
- // it and re-derives nothing, so an entry that did not carry its own would promote a
23
- // degraded table to lossless on the first cell edit. Its default is the weakest
24
- // claim; decode always supplies the stored value.
16
+ // The three island node attributes carry the content entry verbatim: `id` is the
17
+ // stable identity (rebases like an anchor), `islandType` the discriminator, and `props`
18
+ // the payload the codec preserves byte-for-byte across a round-trip. `islandType` and
19
+ // `props` declare no default: a node standing for an island holds one, so the pair is
20
+ // supplied at every `create`.
25
21
  const islandAttrs = {
26
22
  id: { default: '' },
27
23
  islandType: {},
28
- props: {},
29
- loss: { default: 'unrepresentable' }
24
+ props: {}
30
25
  };
31
- // All four attributes cross the DOM: a copy and a paste inside one body run the document
26
+ // All three attributes cross the DOM: a copy and a paste inside one body run the document
32
27
  // through `toDOM`/`parseDOM` (CODEC §"Markdown at the edges"), and an island is a leaf
33
28
  // whose tags alone say only `[table]`. `props` crosses as JSON, the form the payload has
34
29
  // at the boundary. The names are `data-qm-*` like every other carrier's, and the id is
@@ -39,21 +34,18 @@ function islandDOM(node) {
39
34
  return {
40
35
  'data-qm-island': node.attrs.islandType,
41
36
  'data-qm-island-id': node.attrs.id,
42
- 'data-qm-island-loss': node.attrs.loss,
43
37
  'data-qm-island-props': JSON.stringify(node.attrs.props ?? null)
44
38
  };
45
39
  }
46
- // The two closed sets an element claims a value from. A `Record` over the boundary's own
47
- // union rather than a literal check, so a member added upstream is a missing key here
48
- // rather than a silent refusal at the one door with no decode behind it.
49
- /** The island types the content model names. */
50
- const ISLAND_TYPES = { table: true, image: true };
51
- /** The loss classes it names, weakest last: an unrecognized claim takes that one. */
52
- const LOSS_CLASSES = {
53
- lossless: true,
54
- degraded: true,
55
- unrepresentable: true
56
- };
40
+ /** 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 };
45
+ /** Whether an island of `type` is block markup: a `table` stands alone on its line. */
46
+ export function isBlockIsland(type) {
47
+ return BLOCK_ISLAND[type];
48
+ }
57
49
  /** An island node's attributes off that DOM, or `false` where the id is absent or the
58
50
  * type is one the content vocabulary does not name — a name no read produces and no
59
51
  * write takes, so the element's text arrives as text. A `props` that is not the JSON
@@ -64,7 +56,7 @@ function islandAttrsFromDOM(el) {
64
56
  const id = el.getAttribute('data-qm-island-id');
65
57
  // Asserted off an untrusted attribute and checked against the set on the next line.
66
58
  const type = el.getAttribute('data-qm-island');
67
- if (id == null || type == null || !ISLAND_TYPES[type])
59
+ if (id == null || type == null || !Object.hasOwn(BLOCK_ISLAND, type))
68
60
  return false;
69
61
  const raw = el.getAttribute('data-qm-island-props');
70
62
  let props = null;
@@ -74,13 +66,7 @@ function islandAttrsFromDOM(el) {
74
66
  catch {
75
67
  props = null;
76
68
  }
77
- return { id, islandType: type, props: storableProps(type, props), loss: lossFromDOM(el) };
78
- }
79
- /** The `loss` an element claims, or the weakest class for a claim outside the set:
80
- * an under-claim keeps a degraded island from re-crossing as a lossless one. */
81
- function lossFromDOM(el) {
82
- const raw = el.getAttribute('data-qm-island-loss');
83
- return raw != null && LOSS_CLASSES[raw] ? raw : 'unrepresentable';
69
+ return { id, islandType: type, props: storableProps(type, props) };
84
70
  }
85
71
  /** `props` with the one key the authored lane refuses made storable: an `image`'s `url`
86
72
  * carrying a line ending, which `IslandOp` and `overwrite` throw on. The rest crosses
@@ -91,7 +77,7 @@ function storableProps(type, props) {
91
77
  const url = props.url;
92
78
  return typeof url === 'string' ? { ...props, url: storableUrl(url) } : props;
93
79
  }
94
- /** Block island node (a table): one `island`-kind content line. Atom, unselectable content. */
80
+ /** Block island node (a table): a `U+FFFC` slot alone on its `para` line. Atom, unselectable content. */
95
81
  export const islandBlockSpec = {
96
82
  group: 'block',
97
83
  atom: true,
@@ -119,8 +105,7 @@ export function islandEntryFromNode(attrs) {
119
105
  return {
120
106
  id: attrs.id,
121
107
  type: attrs.islandType,
122
- props: attrs.props,
123
- loss: attrs.loss
108
+ props: attrs.props
124
109
  };
125
110
  }
126
111
  /** The `TableProps` shape, over a value that reached the node off the DOM rather than
@@ -1,9 +1,10 @@
1
1
  import type { Schema } from 'prosemirror-model';
2
2
  import type { Command } from 'prosemirror-state';
3
- /** The block-schema body's structural keys: `{}` for the inline/plaintext schemas,
4
- * whose leaves declare neither lists nor code blocks. The argument list is the
5
- * precedence order VISUAL_EDITOR §Chrome states, so a new link is one more argument
6
- * at its place in that order rather than a nesting to read inside out.
3
+ /** A multi-block leaf's structural keys, each link binding only what its schema
4
+ * declares: over `plainSchema`, which holds neither lists nor code blocks nor islands,
5
+ * the block link and the break link alone. The argument list is the precedence order
6
+ * VISUAL_EDITOR §Chrome states, so a new link is one more argument at its place in that
7
+ * order rather than a nesting to read inside out.
7
8
  *
8
9
  * The slash menu is innermost of the three, and it is conditional rather than
9
10
  * schema-gated: the keys exist only where a menu can be drawn (`slash`), because Enter
@@ -17,10 +17,11 @@ function chainKeymaps(...maps) {
17
17
  }
18
18
  return out;
19
19
  }
20
- /** The block-schema body's structural keys: `{}` for the inline/plaintext schemas,
21
- * whose leaves declare neither lists nor code blocks. The argument list is the
22
- * precedence order VISUAL_EDITOR §Chrome states, so a new link is one more argument
23
- * at its place in that order rather than a nesting to read inside out.
20
+ /** A multi-block leaf's structural keys, each link binding only what its schema
21
+ * declares: over `plainSchema`, which holds neither lists nor code blocks nor islands,
22
+ * the block link and the break link alone. The argument list is the precedence order
23
+ * VISUAL_EDITOR §Chrome states, so a new link is one more argument at its place in that
24
+ * order rather than a nesting to read inside out.
24
25
  *
25
26
  * The slash menu is innermost of the three, and it is conditional rather than
26
27
  * schema-gated: the keys exist only where a menu can be drawn (`slash`), because Enter
@@ -71,6 +71,14 @@ li.ProseMirror-selectednode::after {
71
71
  margin-top: var(--_qm-space-3);
72
72
  }
73
73
 
74
+ /* A plain leaf's paragraph is a line of literal text (`schema.ts` §`plainSchema`), and
75
+ a boundary between two stores the one `\n` a hard break does. So no rhythm: a gap
76
+ would draw a blank line the value does not hold, and the two spellings of one string
77
+ would read apart. */
78
+ .ProseMirror > [data-qm-line] + [data-qm-line] {
79
+ margin-top: 0;
80
+ }
81
+
74
82
  /* A blockquote's UA indent was its only mark, and the reset above takes it. The rule
75
83
  and the inset carry it instead: the same hairline every surface reads, so a quote
76
84
  reads as set apart without a fill or a second box. */
@@ -187,6 +195,40 @@ li.ProseMirror-selectednode::after {
187
195
  font-weight: var(--_qm-weight-strong);
188
196
  }
189
197
 
198
+ /* The empty leaf's ghost (`field.ts`, `ghostPlugin`): a node decoration's data
199
+ attributes, so the text stays out of the document. At the rung every control's
200
+ placeholder takes; the italic is what a leaf with no written neighbour has instead
201
+ of the step to `ink`.
202
+
203
+ After the empty line rather than before it, and pulled up one line box onto it: the
204
+ caret keeps the start of that line, and a ghost's lines past the first grow the
205
+ leaf rather than hang over what follows it. A block leaf's ghost wraps and keeps the
206
+ breaks it was written with, a `body.example` being a document's worth; an inline
207
+ leaf's keeps to the one line the leaf is. The example is drawn at rest where there
208
+ is no placeholder, and stands in for the placeholder while the leaf holds the focus.
209
+
210
+ Drawn only where there is text to draw: an empty box pulled up a line would take
211
+ the empty line with it. */
212
+ .ProseMirror .qm-prose-placeholder[data-placeholder]::after,
213
+ .ProseMirror .qm-prose-placeholder[data-example]::after {
214
+ content: attr(data-placeholder);
215
+ display: block;
216
+ margin-top: -1lh;
217
+ white-space: pre-line;
218
+ color: var(--_qm-ink-label);
219
+ font-style: italic;
220
+ pointer-events: none;
221
+ }
222
+ .ProseMirror .qm-prose-placeholder[data-example]:not([data-placeholder])::after,
223
+ .ProseMirror-focused .qm-prose-placeholder[data-example]::after {
224
+ content: attr(data-example);
225
+ }
226
+ .ProseMirror .qm-prose-placeholder.qm-prose-placeholder-line::after {
227
+ white-space: nowrap;
228
+ overflow: hidden;
229
+ text-overflow: ellipsis;
230
+ }
231
+
190
232
  /* ── The table island ────────────────────────────────────────────────────────
191
233
 
192
234
  The one construct in this file with chrome in it. The rest of the stylesheet
@@ -7,8 +7,8 @@ export declare const blockSchema: Schema<string, string>;
7
7
  * a table cell, which is the same content unit). */
8
8
  export declare const inlineSchema: Schema<string, string>;
9
9
  /**
10
- * The inline schema with no mark types at all: a `plaintext` field, whose value is
11
- * literal text the boundary refuses to coerce back once it carries a mark.
10
+ * The inline schema with no mark types at all: a `plaintext(inline)` field, whose
11
+ * value is literal text the boundary refuses to coerce back once it carries a mark.
12
12
  *
13
13
  * Declaring none is what makes that structural rather than a rule each path
14
14
  * restates: `toggleMark` has no type to apply, the mark input rules build nothing,
@@ -16,11 +16,30 @@ export declare const inlineSchema: Schema<string, string>;
16
16
  * carrying (CODEC §Inline mode).
17
17
  */
18
18
  export declare const plaintextSchema: Schema<string, never>;
19
- /** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
20
- * alone. What a mark command asks before it offers itself. */
19
+ /**
20
+ * A `plaintext` field without `inline`: paragraphs and hard breaks, no marks, no
21
+ * islands, no containers, which is the shape `Content::is_plain` admits. Mark-free
22
+ * for the reason {@link plaintextSchema} is.
23
+ *
24
+ * A paragraph and a break both store one `\n`, so a paragraph here is a line and
25
+ * says so (`data-qm-line`): `prose.css` draws a run of them at the leading, and a
26
+ * boundary reads as the one line down that a break does.
27
+ */
28
+ export declare const plainSchema: Schema<"text" | "doc" | "paragraph" | "hard_break", never>;
29
+ /** The schema a leaf of the declared type mounts: `inline` narrows either type to one
30
+ * textblock, and `plaintext` takes its marks away. */
31
+ export declare function leafSchema(opts: {
32
+ plaintext?: boolean;
33
+ inline?: boolean;
34
+ }): Schema;
35
+ /** Whether `schema` can carry formatting at all: false for the two plaintext schemas.
36
+ * What a mark command asks before it offers itself. */
21
37
  export declare function hasMarks(schema: Schema): boolean;
22
- /** True for the constrained inline schema (no block containers); decode branches on it. */
38
+ /** True for the one-line schemas, which declare no `hard_break`; decode branches on it. */
23
39
  export declare function isInlineSchema(schema: Schema): boolean;
40
+ /** True for {@link blockSchema}, the one holding blocks past the paragraph: what a
41
+ * leaf's island, gap-cursor and slash machinery mounts on. */
42
+ export declare function isBlockSchema(schema: Schema): boolean;
24
43
  /** Whether `type` can hold a within-block line break, which a heading cannot (§`heading`).
25
44
  * What a `\n` landing in a textblock is rewritten to turns on it (`breaks.ts`), and so
26
45
  * does what decode puts between a block's continued segments. */
@@ -4,9 +4,10 @@
4
4
  // Each set is closed upstream, so the schema names its whole vocabulary and carries
5
5
  // nothing inert. `blockSchema` is the full field; `inlineSchema` is the constrained
6
6
  // single-textblock form for `richtext(inline)` (one paragraph, no block split, no
7
- // containers, no islands) and `plaintextSchema` is that one without marks: same
8
- // decode/lower/position machinery, narrower shape. Anchors are not marks here
9
- // (decorations).
7
+ // containers, no islands) and `plaintextSchema` is that one without marks, for
8
+ // `plaintext(inline)`; `plainSchema` is a `plaintext` field without `inline`,
9
+ // paragraphs and hard breaks and nothing else. Same decode/lower/position machinery,
10
+ // narrower shapes. Anchors are not marks here (decorations).
10
11
  //
11
12
  // `toDOM` and `parseDOM` are one tier, not two halves of a rendering: a copy and a
12
13
  // paste inside one body run the whole document through them (CODEC §"Markdown at the
@@ -188,8 +189,8 @@ const inlineNodes = {
188
189
  * a table cell, which is the same content unit). */
189
190
  export const inlineSchema = new Schema({ nodes: inlineNodes, marks });
190
191
  /**
191
- * The inline schema with no mark types at all: a `plaintext` field, whose value is
192
- * literal text the boundary refuses to coerce back once it carries a mark.
192
+ * The inline schema with no mark types at all: a `plaintext(inline)` field, whose
193
+ * value is literal text the boundary refuses to coerce back once it carries a mark.
193
194
  *
194
195
  * Declaring none is what makes that structural rather than a rule each path
195
196
  * restates: `toggleMark` has no type to apply, the mark input rules build nothing,
@@ -197,14 +198,44 @@ export const inlineSchema = new Schema({ nodes: inlineNodes, marks });
197
198
  * carrying (CODEC §Inline mode).
198
199
  */
199
200
  export const plaintextSchema = new Schema({ nodes: inlineNodes, marks: {} });
200
- /** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
201
- * alone. What a mark command asks before it offers itself. */
201
+ /**
202
+ * A `plaintext` field without `inline`: paragraphs and hard breaks, no marks, no
203
+ * islands, no containers, which is the shape `Content::is_plain` admits. Mark-free
204
+ * for the reason {@link plaintextSchema} is.
205
+ *
206
+ * A paragraph and a break both store one `\n`, so a paragraph here is a line and
207
+ * says so (`data-qm-line`): `prose.css` draws a run of them at the leading, and a
208
+ * boundary reads as the one line down that a break does.
209
+ */
210
+ export const plainSchema = new Schema({
211
+ nodes: {
212
+ doc: { content: 'paragraph+' },
213
+ paragraph: { ...blockNodes.paragraph, toDOM: () => ['p', { 'data-qm-line': '' }, 0] },
214
+ text: inlineLeafNodes.text,
215
+ hard_break: inlineLeafNodes.hard_break
216
+ },
217
+ marks: {}
218
+ });
219
+ /** The schema a leaf of the declared type mounts: `inline` narrows either type to one
220
+ * textblock, and `plaintext` takes its marks away. */
221
+ export function leafSchema(opts) {
222
+ if (opts.plaintext)
223
+ return opts.inline ? plaintextSchema : plainSchema;
224
+ return opts.inline ? inlineSchema : blockSchema;
225
+ }
226
+ /** Whether `schema` can carry formatting at all: false for the two plaintext schemas.
227
+ * What a mark command asks before it offers itself. */
202
228
  export function hasMarks(schema) {
203
229
  return Object.keys(schema.marks).length > 0;
204
230
  }
205
- /** True for the constrained inline schema (no block containers); decode branches on it. */
231
+ /** True for the one-line schemas, which declare no `hard_break`; decode branches on it. */
206
232
  export function isInlineSchema(schema) {
207
- return !schema.nodes.blockquote;
233
+ return !schema.nodes.hard_break;
234
+ }
235
+ /** True for {@link blockSchema}, the one holding blocks past the paragraph: what a
236
+ * leaf's island, gap-cursor and slash machinery mounts on. */
237
+ export function isBlockSchema(schema) {
238
+ return !!schema.nodes.blockquote;
208
239
  }
209
240
  /** Whether `type` can hold a within-block line break, which a heading cannot (§`heading`).
210
241
  * What a `\n` landing in a textblock is rewritten to turns on it (`breaks.ts`), and so
Binary file
@@ -11,8 +11,9 @@ export type EditorErrorCode =
11
11
  * the whole projection instead, so the store is correct and this field's
12
12
  * identity anchors were paid. */
13
13
  | 'commit-fallback'
14
- /** The overwrite fallback also failed: the optimistic PM state stands and the
15
- * store is stale for this field. The one code here that leaves damage. */
14
+ /** The overwrite fallback also failed, or the typed writer refused a `plaintext`
15
+ * leaf's text: the optimistic PM state stands and the store is stale for this
16
+ * field. The one code here that leaves damage. */
16
17
  | 'commit-lost'
17
18
  /** A card operation (add, move, remove, retype) that threw. The document is
18
19
  * unchanged; the boundary's mutators are transactional. */
@@ -0,0 +1,8 @@
1
+ /** The element the scale is declared on, for `node`: its root, or `node` where it stands
2
+ * outside one (a detached surface, a consumer's own mount). */
3
+ export declare function rungStyle(node: Element): CSSStyleDeclaration;
4
+ /** A duration rung off `style`, in ms; `undefined` where the derivation is out of reach
5
+ * (an unstyled root, or jsdom, where `getComputedStyle` reports custom properties as
6
+ * empty). No rung, no motion: a fallback here would be the scale restated in the one
7
+ * place `check:style` cannot read. */
8
+ export declare function rungMs(style: CSSStyleDeclaration, rung: string): number | undefined;
@@ -0,0 +1,24 @@
1
+ // Where a script reads the scale. The derivation declares every `--_qm-*` rung on
2
+ // `[data-qm-root]` and nowhere else (`core/theme.css`), so a descendant's value is that
3
+ // one inherited and the root is the element the question is about: asking a leaf three
4
+ // containers deep is asking an arbitrary element what the surface is set to.
5
+ //
6
+ // The read is also the cost. `getComputedStyle` compounds with nesting depth under jsdom,
7
+ // so a suite that resolves rungs off landed elements pays the depth of whatever it landed
8
+ // on; resolving them off the root pays the root's.
9
+ /** The element the scale is declared on, for `node`: its root, or `node` where it stands
10
+ * outside one (a detached surface, a consumer's own mount). */
11
+ export function rungStyle(node) {
12
+ return getComputedStyle(node.closest('[data-qm-root]') ?? node);
13
+ }
14
+ /** A duration rung off `style`, in ms; `undefined` where the derivation is out of reach
15
+ * (an unstyled root, or jsdom, where `getComputedStyle` reports custom properties as
16
+ * empty). No rung, no motion: a fallback here would be the scale restated in the one
17
+ * place `check:style` cannot read. */
18
+ export function rungMs(style, rung) {
19
+ const raw = style.getPropertyValue(rung).trim();
20
+ const n = Number.parseFloat(raw);
21
+ if (!Number.isFinite(n))
22
+ return undefined;
23
+ return raw.endsWith('ms') ? n : n * 1000;
24
+ }
@@ -163,13 +163,16 @@
163
163
  relationship at any mix: a stroke against the surface behind it, not a fill
164
164
  stacked on one. */
165
165
  --_qm-border-faint: color-mix(in oklab, var(--_qm-bg), var(--_qm-border) 50%);
166
- /* Two rungs, and there is no third: a step below `label` lands under 3:1 on the
167
- well a field draws it against, and what would take it — a placeholder, an
168
- unset value, the resolved `default:` — is content a reader has to read
169
- (WCAG 1.4.3), not decoration. So tone carries hierarchy alone, and a leaf
170
- saying shown-never-written says it against the `ink` a written value takes,
171
- in italic where it has no written neighbour to be read against. */
166
+ /* Three rungs, and nothing under the last: a step below `label` lands under 3:1 on
167
+ the well a field draws it against, and what would take it — an example, an unset
168
+ value's word — is content a reader has to read (WCAG 1.4.3), not decoration.
169
+ `ink` is written. `default` is the resolved `default:` drawn as the value it
170
+ prints: near enough to `ink` to read as an answer, a step off it so it never
171
+ reads as a written one. `label` is words about a value rather than the value — an
172
+ `example:`, `None`, a format hint — and the chrome that names fields; in italic
173
+ where it is a leaf's ghost, which has no written neighbour to be read against. */
172
174
  --_qm-ink: var(--_qm-fg);
175
+ --_qm-ink-default: color-mix(in oklab, var(--_qm-fg), var(--_qm-bg) 20%);
173
176
  --_qm-ink-label: color-mix(in oklab, var(--_qm-fg), var(--_qm-bg) 35%);
174
177
  /* The arrival wash (`core/bloom.ts`). Its alpha is a peak, not a tint: the
175
178
  wash animates from and back to zero, never held, so it reads over the leaf it
@@ -336,6 +339,16 @@
336
339
  through the icon's `size`, so a stylesheet beats the SVG presentation attribute
337
340
  and one rung is the whole of what a control draws. */
338
341
  --_qm-glyph-control: 14px;
342
+ /* The narrowest track a control that fills its width stays usable in: a table's
343
+ column floors at it, rises no further than its content, and a row of them scrolls
344
+ sideways rather than crushing a cell. A column drawing a mark takes no floor — a
345
+ switch is one width at every width, so there is no width for it to be usable in
346
+ (`ArrayField`). What the field has spare stands after the last column, an inner
347
+ grid carrying its own rhythm rather than the card's. Under the field minimum the
348
+ capacity ladder steps at (`.qm-tracks`), since a cell in a row has the header to
349
+ name it and no label of its own to fit. A threshold rather than a rhythm choice,
350
+ so it does not move with `--qm-space`. */
351
+ --_qm-track-min: 7rem;
339
352
 
340
353
  /* ── Type ────────────────────────────────────────────────────────────────
341
354
  `--qm-font-size` anchors the chrome rung; 1.125 is the ratio between adjacent
@@ -1,6 +1,11 @@
1
1
  // Vertical breathing room between stacked pages; cosmetic only, and a rung like
2
2
  // any other gap: the stack breathes on the same rhythm the cards do.
3
3
  const PAGE_GAP = 'var(--_qm-space-4)';
4
+ // The least raster density, in device pixels per CSS pixel. Glyphs rasterize
5
+ // unhinted, so a raster at a 1× or fractional ratio (1.25, 1.5; browser zoom)
6
+ // is resampled nearly 1:1 and small text smears; a raster at least twice the
7
+ // box is downsampled instead, which keeps body text legible.
8
+ const MIN_DENSITY = 2;
4
9
  export function createPaintLoop(session, container, margin,
5
10
  // Notified when a slot's `session.paint` throws: the loop swallows the throw
6
11
  // (see `paintSlot`) so one bad page never aborts the band sweep; the caller
@@ -65,16 +70,19 @@ onPaintError) {
65
70
  return { page, size, el };
66
71
  }
67
72
  // Rasterize `slot` into its canvas (creating the canvas if this is its first
68
- // paint since mounting). `layoutScale` is derived from the slot's own current
69
- // CSS width, so layout width tracks the container per the zoom settled
70
- // decision; `canvas.style.*` is then set from the authoritative PaintResult,
71
- // never guessed.
73
+ // paint since mounting), at the slot's own current CSS width.
72
74
  function paintSlot(slot) {
73
75
  let canvas = canvases.get(slot.page);
74
76
  if (!canvas) {
75
77
  canvas = document.createElement('canvas');
76
78
  canvas.className = 'qm-page-canvas';
77
- Object.assign(canvas.style, { position: 'absolute', inset: '0', display: 'block' });
79
+ Object.assign(canvas.style, {
80
+ position: 'absolute',
81
+ inset: '0',
82
+ display: 'block',
83
+ width: '100%',
84
+ height: '100%'
85
+ });
78
86
  }
79
87
  // Contextless canvas: bail before registering, so `updateBand` still sees
80
88
  // the page as unmounted and retries instead of keeping a blank page.
@@ -86,11 +94,9 @@ onPaintError) {
86
94
  canvases.set(slot.page, canvas);
87
95
  }
88
96
  const layoutScale = (slot.el.clientWidth || slot.size.widthPt) / slot.size.widthPt;
89
- const densityScale = (window.devicePixelRatio || 1) * zoom;
97
+ const densityScale = Math.max(window.devicePixelRatio || 1, MIN_DENSITY) * zoom;
90
98
  try {
91
- const result = session.paint(ctx, slot.page, { layoutScale, densityScale });
92
- canvas.style.width = `${result.layoutWidth}px`;
93
- canvas.style.height = `${result.layoutHeight}px`;
99
+ session.paint(ctx, slot.page, layoutScale * densityScale);
94
100
  }
95
101
  catch (err) {
96
102
  // A paint that throws must not abort the band loop; `updateBand` runs in
@@ -162,14 +168,12 @@ onPaintError) {
162
168
  // Slots are built on the first `refresh` (the controller calls it immediately),
163
169
  // not here: reconcile → `session.pageSize` must run after the controller's
164
170
  // `pageCount` gate, since `pageSize` throws for a page the count excludes.
165
- // ── Keep mounted rasters in step with the display ───────────────────────────
166
- // Every paint freezes `canvas.style.width/height` to the box width at that
167
- // paint (paintSlot). A page that stays mounted while the container's CSS width
168
- // or `devicePixelRatio` shifts would otherwise keep a stale raster the %-space
169
- // click/scroll math silently drifts off of: the box tracks the container, the
170
- // ink does not. Two observers close the gap by repainting mounted pages from
171
- // their current box; canvases are `position:absolute`, so a repaint can't feed
172
- // back into layout (and thus can't re-trigger the resize observer).
171
+ // ── Keep mounted rasters sharp ──────────────────────────────────────────────
172
+ // A canvas stretches with its slot, so a page that stays mounted while the
173
+ // container's CSS width or `devicePixelRatio` shifts keeps its place and goes
174
+ // soft. Two observers repaint mounted pages from their current box; canvases
175
+ // are `position:absolute`, so a repaint can't feed back into layout (and thus
176
+ // can't re-trigger the resize observer).
173
177
  // Repaint mounted pages (all, or a given subset) from their live geometry: the
174
178
  // one path resize, DPR, and zoom share; each `paintSlot` re-reads the slot's
175
179
  // current `clientWidth`. A no-op for an unmounted page.