@quillmark/svelte 0.11.0 → 0.12.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 (37) 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.js +21 -15
  5. package/dist/core/codec/encode.js +1 -1
  6. package/dist/core/codec/islands.d.ts +5 -4
  7. package/dist/core/codec/islands.js +23 -38
  8. package/dist/core/codec/slash.js +0 -0
  9. package/dist/core/rungs.d.ts +8 -0
  10. package/dist/core/rungs.js +24 -0
  11. package/dist/core/theme.css +10 -0
  12. package/dist/visual/ArrayField.svelte +596 -167
  13. package/dist/visual/ArrayField.svelte.d.ts +14 -1
  14. package/dist/visual/BooleanField.svelte +10 -7
  15. package/dist/visual/Card.svelte +3 -22
  16. package/dist/visual/Field.svelte +69 -29
  17. package/dist/visual/MatrixField.svelte +352 -0
  18. package/dist/visual/MatrixField.svelte.d.ts +36 -0
  19. package/dist/visual/ObjectField.svelte +351 -133
  20. package/dist/visual/ObjectField.svelte.d.ts +17 -2
  21. package/dist/visual/VariantField.svelte +55 -2
  22. package/dist/visual/VariantField.svelte.d.ts +10 -1
  23. package/dist/visual/VisualEditorInner.svelte +53 -49
  24. package/dist/visual/controls.css +31 -0
  25. package/dist/visual/diagnostics.d.ts +26 -2
  26. package/dist/visual/diagnostics.js +52 -5
  27. package/dist/visual/hold.js +2 -2
  28. package/dist/visual/icons/nodes.d.ts +3 -0
  29. package/dist/visual/icons/nodes.js +2 -1
  30. package/dist/visual/leaves.d.ts +25 -12
  31. package/dist/visual/motion.d.ts +14 -5
  32. package/dist/visual/motion.js +13 -11
  33. package/dist/visual/strings.d.ts +19 -9
  34. package/dist/visual/strings.js +6 -2
  35. package/dist/visual/structure.d.ts +94 -9
  36. package/dist/visual/structure.js +229 -12
  37. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import type { Addr, HitGranularity } from '@quillmark/wasm';
1
+ import type { Addr, HitGranularity, PathStep } from '@quillmark/wasm';
2
2
  /**
3
3
  * A canonical field address: `main.<field>` / `main.body` /
4
4
  * `cards.<kind>[<i>].<field>`, cards keyed by absolute document index.
@@ -63,7 +63,7 @@ export declare function cardPath(index: number, kinds: readonly string[]): DocPa
63
63
  /**
64
64
  * The inverse: a canonical `DocPath` back to the `Addr` the document verbs take, or
65
65
  * `undefined` for a path that names no single commit address — a nested or
66
- * array-element path (`main.keywords[0]`, which {@link elementAddrForFieldPath} and
66
+ * array-element path (`main.keywords[0]`, which {@link nestedAddrForFieldPath} and
67
67
  * {@link nearestAddrForFieldPath} take instead), a field-rooted one, or a malformed
68
68
  * one. A bare card and a `.body` terminal both land on the field-less `{card: i}`
69
69
  * the body leaf answers to.
@@ -86,14 +86,21 @@ export declare function addrForFieldPath(path: DocPath): Addr | undefined;
86
86
  */
87
87
  export declare function nearestAddrForFieldPath(path: DocPath): Addr | undefined;
88
88
  /**
89
- * An array element's address, split: `main.keywords[0]` lands here, and anything
90
- * {@link addrForFieldPath} can name — or whose trailing segment is not an index —
91
- * does not. `regions()`, `positionAt` and `formatDocPath` all spell the index
92
- * segment bracketed, so there is one spelling to read and none to bridge.
89
+ * A nested address, split: the field's `Addr`, and the steps from it to the leaf.
90
+ * `main.keywords[0]` is `{field: keywords, steps: [0]}`,
91
+ * `main.vectors[0].tours[2].title` is `[0, 'tours', 2, 'title']`, and
92
+ * `main.contact.email` is `['email']`: an index segment reads as a number and a key as
93
+ * a string, the `PathStep` grammar `reader.getContentAt` walks. Anything
94
+ * {@link addrForFieldPath} can name — a field, a body, a card — has no steps and lands
95
+ * here as `undefined`; so does a body with something after it, a body holding no
96
+ * cells. Whether the schema declares each step is the caller's to check: this module
97
+ * holds the grammar and no schema. `regions()`, `positionAt` and `formatDocPath` all
98
+ * spell the index segment bracketed, so there is one spelling to read and none to
99
+ * bridge.
93
100
  */
94
- export declare function elementAddrForFieldPath(path: DocPath): ElementAddr | undefined;
95
- /** An array element's address: the array's `Addr`, and which element of it. */
96
- export interface ElementAddr {
101
+ export declare function nestedAddrForFieldPath(path: DocPath): NestedAddr | undefined;
102
+ /** A nested address: the field's `Addr`, and the steps from it to the leaf. */
103
+ export interface NestedAddr {
97
104
  field: Addr;
98
- index: number;
105
+ steps: PathStep[];
99
106
  }
@@ -37,7 +37,7 @@ export function cardPath(index, kinds) {
37
37
  /**
38
38
  * The inverse: a canonical `DocPath` back to the `Addr` the document verbs take, or
39
39
  * `undefined` for a path that names no single commit address — a nested or
40
- * array-element path (`main.keywords[0]`, which {@link elementAddrForFieldPath} and
40
+ * array-element path (`main.keywords[0]`, which {@link nestedAddrForFieldPath} and
41
41
  * {@link nearestAddrForFieldPath} take instead), a field-rooted one, or a malformed
42
42
  * one. A bare card and a `.body` terminal both land on the field-less `{card: i}`
43
43
  * the body leaf answers to.
@@ -66,21 +66,35 @@ export function nearestAddrForFieldPath(path) {
66
66
  return segs && addrForSegs(segs.slice(0, 2));
67
67
  }
68
68
  /**
69
- * An array element's address, split: `main.keywords[0]` lands here, and anything
70
- * {@link addrForFieldPath} can name — or whose trailing segment is not an index —
71
- * does not. `regions()`, `positionAt` and `formatDocPath` all spell the index
72
- * segment bracketed, so there is one spelling to read and none to bridge.
69
+ * A nested address, split: the field's `Addr`, and the steps from it to the leaf.
70
+ * `main.keywords[0]` is `{field: keywords, steps: [0]}`,
71
+ * `main.vectors[0].tours[2].title` is `[0, 'tours', 2, 'title']`, and
72
+ * `main.contact.email` is `['email']`: an index segment reads as a number and a key as
73
+ * a string, the `PathStep` grammar `reader.getContentAt` walks. Anything
74
+ * {@link addrForFieldPath} can name — a field, a body, a card — has no steps and lands
75
+ * here as `undefined`; so does a body with something after it, a body holding no
76
+ * cells. Whether the schema declares each step is the caller's to check: this module
77
+ * holds the grammar and no schema. `regions()`, `positionAt` and `formatDocPath` all
78
+ * spell the index segment bracketed, so there is one spelling to read and none to
79
+ * bridge.
73
80
  */
74
- export function elementAddrForFieldPath(path) {
81
+ export function nestedAddrForFieldPath(path) {
75
82
  const segs = segsOf(path);
76
- if (!segs)
83
+ if (!segs || segs.length < 3)
77
84
  return undefined;
78
- const last = segs[segs.length - 1];
79
- if (last?.seg !== 'index')
85
+ const field = addrForSegs(segs.slice(0, 2));
86
+ if (field?.field == null)
80
87
  return undefined;
81
- const field = addrForSegs(segs.slice(0, -1));
82
- // A body has no elements: the parent must name a field.
83
- return field?.field != null ? { field, index: last.index } : undefined;
88
+ const steps = [];
89
+ for (const seg of segs.slice(2)) {
90
+ if (seg.seg === 'index')
91
+ steps.push(seg.index);
92
+ else if (seg.seg === 'field')
93
+ steps.push(seg.name);
94
+ else
95
+ return undefined;
96
+ }
97
+ return { field, steps };
84
98
  }
85
99
  /** `parseDocPath`, with a malformed path as `undefined` rather than a throw. The gate
86
100
  * is read outside the try: an uninitialized core is not a malformed path, and would
@@ -6,6 +6,7 @@
6
6
  // fixed by `--_qm-accent-wash`, so interpolating 0→1→0 keeps the scale in CSS where
7
7
  // `color-mix` resolves, instead of in keyframe values a script would have to
8
8
  // pre-resolve and re-resolve on a theme change.
9
+ import { rungStyle } from './rungs.js';
9
10
  const BLOOM_CLASS = 'qm-bloom';
10
11
  const WASH = 'var(--_qm-accent-wash)';
11
12
  /** The dwell used when the derivation is out of reach (an unstyled root, or jsdom,
@@ -24,8 +25,8 @@ const FRAMES = [
24
25
  ];
25
26
  const FRAMES_REDUCED = [{ opacity: 1 }, { opacity: 1 }];
26
27
  /**
27
- * Run one wash on `el`, resolving the dwell off it so the number is single-sourced
28
- * in CSS.
28
+ * Run one wash on `el`, resolving the dwell off its root so the number is
29
+ * single-sourced in CSS (`core/rungs.ts`).
29
30
  *
30
31
  * One property, both motion preferences: `--_qm-bloom-dwell` already shortens under
31
32
  * `prefers-reduced-motion` in the derivation, so what is left to `matchMedia` here is
@@ -36,7 +37,7 @@ function bloom(el) {
36
37
  if (typeof el.animate !== 'function')
37
38
  return undefined;
38
39
  const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
39
- const raw = getComputedStyle(el).getPropertyValue('--_qm-bloom-dwell').trim();
40
+ const raw = rungStyle(el).getPropertyValue('--_qm-bloom-dwell').trim();
40
41
  const n = Number.parseFloat(raw);
41
42
  const duration = !Number.isFinite(n) ? FALLBACK_MS : raw.endsWith('ms') ? n : n * 1000;
42
43
  return el.animate(reduced ? FRAMES_REDUCED : FRAMES, { duration });
@@ -3,12 +3,12 @@
3
3
  // nodes; a code fence's lines → one `code_block`), nest by shared `containers`
4
4
  // prefix (`list_item`/`quote` → lists/blockquote), select the block node by
5
5
  // `kind`, apply marks over their `[start,end)` USV ranges (PM splits inline nodes
6
- // at mark boundaries), and lower island slots to leaf nodes (inline in a `para`
7
- // line, block on an `island` line). Anchors are not applied here; they are
8
- // decorations (field.ts). Positions throughout are USV; `Array.from` iterates by
6
+ // at mark boundaries), and lower island slots to leaf nodes (block where a block
7
+ // island's slot stands alone on its line, inline otherwise). Anchors are not
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 } from './islands.js';
11
+ import { ISLAND_SLOT, isBlockIsland } from './islands.js';
12
12
  import { descriptorOf, markKey, pmMarkFromContent } from './marks.js';
13
13
  import { hasMarks, isInlineSchema, takesLineBreak } from './schema.js';
14
14
  /** Code points of `s` (USV units): the iteration granularity the content speaks. */
@@ -177,10 +177,6 @@ function makeLeaf(schema, leaf, marks, cursor) {
177
177
  switch (line.kind) {
178
178
  case 'rule':
179
179
  return schema.nodes.horizontal_rule.create();
180
- case 'island': {
181
- const attrs = islandAttrs(cursor);
182
- return attrs ? schema.nodes.island_block.create(attrs) : schema.nodes.paragraph.create();
183
- }
184
180
  case 'code': {
185
181
  // One code_block: the segments' texts joined by literal `\n`, no marks.
186
182
  const text = leaf.segments.map((s) => s.text).join('\n');
@@ -189,6 +185,8 @@ function makeLeaf(schema, leaf, marks, cursor) {
189
185
  }
190
186
  case 'para':
191
187
  case 'heading': {
188
+ if (line.kind === 'para' && blockIslandLine(leaf, cursor))
189
+ return schema.nodes.island_block.create(islandAttrs(cursor));
192
190
  // Inline content, the segment boundary a `hard_break` in a paragraph and a space
193
191
  // in a heading, which takes none (`schema.ts` §`takesLineBreak`). A stored heading
194
192
  // carries no continuation — the store clears one after a block of a single line —
@@ -210,15 +208,23 @@ function makeLeaf(schema, leaf, marks, cursor) {
210
208
  }
211
209
  }
212
210
  }
213
- /** Consume the next island entry as PM node attrs (text-order matched to slots):
214
- * the whole entry, `loss` included, which an island edit has to write back
215
- * (`islands.ts`). `null` where a slot has no entry behind it, which is a malformed
216
- * content: the slot draws an empty paragraph or nothing at all, so the decode stays
217
- * total without minting a node whose `type` the vocabulary would refuse on the way
218
- * back. */
211
+ /** Whether a `para` leaf is one block island: a single line holding its slot alone,
212
+ * backed by an island whose markup is a block. The island is the one fact; the line
213
+ * carries no kind of its own for it. */
214
+ function blockIslandLine(leaf, cursor) {
215
+ if (leaf.segments.length !== 1 || leaf.segments[0].text !== ISLAND_SLOT)
216
+ return false;
217
+ const isl = cursor.rt.islands[cursor.i];
218
+ return isl != null && isBlockIsland(isl.type);
219
+ }
220
+ /** Consume the next island entry as PM node attrs (text-order matched to slots): the
221
+ * whole entry, which an island edit has to write back (`islands.ts`). `null` where a
222
+ * slot has no entry behind it, which is a malformed content: the slot draws nothing
223
+ * and its line an empty paragraph, so the decode stays total without minting a node
224
+ * whose `type` the vocabulary would refuse on the way back. */
219
225
  function islandAttrs(cursor) {
220
226
  const isl = cursor.rt.islands[cursor.i++];
221
- return isl ? { id: isl.id, islandType: isl.type, props: isl.props, loss: isl.loss } : null;
227
+ return isl ? { id: isl.id, islandType: isl.type, props: isl.props } : null;
222
228
  }
223
229
  /**
224
230
  * Inline nodes for one segment: split text into maximal runs of a constant mark
@@ -104,7 +104,7 @@ function scanBlock(acc, node, nodePos, containers, instance) {
104
104
  acc.lastContentEndPm = nodePos + 1;
105
105
  break;
106
106
  case 'island_block':
107
- beginLine(acc, nodePos, containers, { kind: 'island' });
107
+ beginLine(acc, nodePos, containers, { kind: 'para' });
108
108
  acc.runs.push({ kind: 'atom', pmStart: nodePos, usvStart: acc.usvEnd });
109
109
  appendText(acc, ISLAND_SLOT);
110
110
  acc.islands.push(islandEntryFromNode(node.attrs));
@@ -3,19 +3,20 @@ import { Plugin } from 'prosemirror-state';
3
3
  import type { ContentIsland, TableProps } from '@quillmark/wasm';
4
4
  /** The `U+FFFC` object-replacement char that occupies one island slot in `text`. */
5
5
  export declare const ISLAND_SLOT = "\uFFFC";
6
- /** Block island node (a table): one `island`-kind content line. Atom, unselectable content. */
6
+ /** Whether an island of `type` is block markup: a `table` stands alone on its line. */
7
+ export declare function isBlockIsland(type: ContentIsland['type']): boolean;
8
+ /** Block island node (a table): a `U+FFFC` slot alone on its `para` line. Atom, unselectable content. */
7
9
  export declare const islandBlockSpec: NodeSpec;
8
10
  /** Inline island node (an image): a `U+FFFC` slot inside a `para` line. */
9
11
  export declare const islandInlineSpec: NodeSpec;
10
12
  /** A PM island node's attributes: the content entry, spelled the way a node spec
11
13
  * spells it (`islandType`, because `type` on a PM node is the node type). `islandType`
12
- * and `loss` read their sets off the entry rather than restating them, the public entry
13
- * point naming neither (DOCUMENT_MODEL §Stability seams). */
14
+ * reads its set off the entry rather than restating it (DOCUMENT_MODEL §Stability
15
+ * seams). */
14
16
  export interface IslandNodeAttrs {
15
17
  id: string;
16
18
  islandType: ContentIsland['type'];
17
19
  props: unknown;
18
- loss: ContentIsland['loss'];
19
20
  }
20
21
  /** Build a content island entry from a PM island node's attrs (block or inline). The
21
22
  * cast pairs a closed `type` with a `props` that crossed the DOM as opaque JSON. The
@@ -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
Binary file
@@ -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
+ }
@@ -336,6 +336,16 @@
336
336
  through the icon's `size`, so a stylesheet beats the SVG presentation attribute
337
337
  and one rung is the whole of what a control draws. */
338
338
  --_qm-glyph-control: 14px;
339
+ /* The narrowest track a control that fills its width stays usable in: a table's
340
+ column floors at it, rises no further than its content, and a row of them scrolls
341
+ sideways rather than crushing a cell. A column drawing a mark takes no floor — a
342
+ switch is one width at every width, so there is no width for it to be usable in
343
+ (`ArrayField`). What the field has spare stands after the last column, an inner
344
+ grid carrying its own rhythm rather than the card's. Under the field minimum the
345
+ capacity ladder steps at (`.qm-tracks`), since a cell in a row has the header to
346
+ name it and no label of its own to fit. A threshold rather than a rhythm choice,
347
+ so it does not move with `--qm-space`. */
348
+ --_qm-track-min: 7rem;
339
349
 
340
350
  /* ── Type ────────────────────────────────────────────────────────────────
341
351
  `--qm-font-size` anchors the chrome rung; 1.125 is the ratio between adjacent