@excom/quark 0.1.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 (130) hide show
  1. package/.rush/temp/chunked-rush-logs/quark.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/quark.build_package-metas.chunks.jsonl +1 -0
  3. package/.rush/temp/operation/apply-exports/all.log +1 -0
  4. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  5. package/.rush/temp/operation/apply-exports/state.json +3 -0
  6. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  7. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  8. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +3 -0
  10. package/config/rig.json +6 -0
  11. package/index.ts +4 -0
  12. package/language.ts +7 -0
  13. package/package.json +45 -0
  14. package/rush-logs/quark.apply-exports.cache.log +1 -0
  15. package/rush-logs/quark.apply-exports.log +1 -0
  16. package/rush-logs/quark.build_package-metas.cache.log +1 -0
  17. package/rush-logs/quark.build_package-metas.log +1 -0
  18. package/src/actions.ts +445 -0
  19. package/src/ast.ts +187 -0
  20. package/src/bindings.ts +115 -0
  21. package/src/builtin-modules.ts +401 -0
  22. package/src/constants.ts +47 -0
  23. package/src/cycle-check.ts +121 -0
  24. package/src/devtools-hook.ts +303 -0
  25. package/src/element-api.ts +109 -0
  26. package/src/evaluator.ts +317 -0
  27. package/src/form-controls.ts +69 -0
  28. package/src/language-tables.ts +137 -0
  29. package/src/language.ts +1225 -0
  30. package/src/observer.ts +110 -0
  31. package/src/paint.ts +305 -0
  32. package/src/properties.ts +818 -0
  33. package/src/props.ts +94 -0
  34. package/src/quark-element.ts +7 -0
  35. package/src/quark-internal.ts +365 -0
  36. package/src/quark.ts +817 -0
  37. package/src/resolvers.ts +867 -0
  38. package/src/rule.ts +800 -0
  39. package/src/scope-id.ts +47 -0
  40. package/src/selector-utils.ts +430 -0
  41. package/src/settle.ts +117 -0
  42. package/src/types.ts +155 -0
  43. package/src/utils.ts +79 -0
  44. package/src/variables.ts +359 -0
  45. package/support/demos/css-variables.html +18 -0
  46. package/support/demos/dispatch.html +20 -0
  47. package/support/demos/events.html +9 -0
  48. package/support/demos/iterate.html +13 -0
  49. package/support/demos/js-api.html +17 -0
  50. package/support/demos/provider-list.html +16 -0
  51. package/support/demos/toggle-content.html +14 -0
  52. package/support/demos/unset.html +12 -0
  53. package/support/demos/view-transition.html +36 -0
  54. package/support/docs/ATTRIBUTES.md +37 -0
  55. package/support/docs/AT_RULES.md +45 -0
  56. package/support/docs/BUILTINS.md +57 -0
  57. package/support/docs/CONTENT.md +40 -0
  58. package/support/docs/CSS_VARIABLES.md +16 -0
  59. package/support/docs/DECLARATIONS.md +26 -0
  60. package/support/docs/DELAY.md +37 -0
  61. package/support/docs/DIAGNOSTICS.md +32 -0
  62. package/support/docs/DISPATCH.md +81 -0
  63. package/support/docs/ELEMENT_PROPERTIES.md +34 -0
  64. package/support/docs/EXPRESSIONS.md +31 -0
  65. package/support/docs/INTERNAL.md +8 -0
  66. package/support/docs/JS_API.md +53 -0
  67. package/support/docs/JS_WRITES.md +36 -0
  68. package/support/docs/LIMITATIONS.md +26 -0
  69. package/support/docs/LOOP_GUARD.md +30 -0
  70. package/support/docs/METHODS.md +43 -0
  71. package/support/docs/MODULES.md +111 -0
  72. package/support/docs/NO_REVERSION.md +18 -0
  73. package/support/docs/ON.md +87 -0
  74. package/support/docs/REACTIVITY.md +24 -0
  75. package/support/docs/README.md +143 -0
  76. package/support/docs/SELECTORS.md +87 -0
  77. package/support/docs/SHEETS.md +67 -0
  78. package/support/docs/SYNTAX.md +36 -0
  79. package/support/docs/USE.md +24 -0
  80. package/support/docs/VALUES.md +27 -0
  81. package/support/docs/VARIABLES.md +47 -0
  82. package/support/docs/VIEW_TRANSITION.md +64 -0
  83. package/support/docs-sections.json +65 -0
  84. package/support/package-meta.json +208 -0
  85. package/support/scripts/build-language-docs.mjs +196 -0
  86. package/support/tests/__snapshots__/css-variables.view.test.ts.snap +23 -0
  87. package/support/tests/__snapshots__/dispatch.view.test.ts.snap +23 -0
  88. package/support/tests/__snapshots__/events.view.test.ts.snap +23 -0
  89. package/support/tests/__snapshots__/iterate.view.test.ts.snap +23 -0
  90. package/support/tests/__snapshots__/js-api.view.test.ts.snap +23 -0
  91. package/support/tests/__snapshots__/provider-list.view.test.ts.snap +23 -0
  92. package/support/tests/__snapshots__/quark-features.test.ts.snap +1079 -0
  93. package/support/tests/__snapshots__/quark-with-providers.test.ts.snap +89 -0
  94. package/support/tests/__snapshots__/quark.test.ts.snap +221 -0
  95. package/support/tests/__snapshots__/selectors.test.ts.snap +133 -0
  96. package/support/tests/__snapshots__/toggle-content.view.test.ts.snap +23 -0
  97. package/support/tests/__snapshots__/unset.view.test.ts.snap +23 -0
  98. package/support/tests/__snapshots__/view-transition.view.test.ts.snap +23 -0
  99. package/support/tests/actions.test.ts +437 -0
  100. package/support/tests/builtin-modules.test.ts +337 -0
  101. package/support/tests/builtins.test.ts +189 -0
  102. package/support/tests/css-variables.view.test.ts +35 -0
  103. package/support/tests/delay.test.ts +244 -0
  104. package/support/tests/devtools-hook.test.ts +496 -0
  105. package/support/tests/diagnostics.test.ts +224 -0
  106. package/support/tests/dispatch.view.test.ts +43 -0
  107. package/support/tests/element-api.test.ts +202 -0
  108. package/support/tests/evaluator.test.ts +237 -0
  109. package/support/tests/events.view.test.ts +40 -0
  110. package/support/tests/form-controls.test.ts +452 -0
  111. package/support/tests/helpers.ts +354 -0
  112. package/support/tests/internals.test.ts +568 -0
  113. package/support/tests/iterate.view.test.ts +40 -0
  114. package/support/tests/js-api.view.test.ts +45 -0
  115. package/support/tests/language-docs.test.ts +147 -0
  116. package/support/tests/lifecycle.test.ts +440 -0
  117. package/support/tests/loop-guard.test.ts +323 -0
  118. package/support/tests/provider-list.view.test.ts +47 -0
  119. package/support/tests/quark-features.test.ts +2887 -0
  120. package/support/tests/quark-with-providers.test.ts +169 -0
  121. package/support/tests/quark.test.ts +359 -0
  122. package/support/tests/resolvers.test.ts +346 -0
  123. package/support/tests/selector-analysis.test.ts +281 -0
  124. package/support/tests/selectors.test.ts +670 -0
  125. package/support/tests/toggle-content.view.test.ts +40 -0
  126. package/support/tests/unset.view.test.ts +42 -0
  127. package/support/tests/view-helpers.ts +81 -0
  128. package/support/tests/view-transition.test.ts +1069 -0
  129. package/support/tests/view-transition.view.test.ts +76 -0
  130. package/tsconfig.json +5 -0
package/src/rule.ts ADDED
@@ -0,0 +1,800 @@
1
+ export type * from "./types";
2
+ import { Action } from "./actions";
3
+ import {
4
+ declarationStrings,
5
+ eventNamesText,
6
+ listenerOptionSources,
7
+ type RuleNode,
8
+ ruleSelectorTexts,
9
+ sliceNode,
10
+ type Statement,
11
+ type TransitionRule,
12
+ transitionSourceText,
13
+ viewBlock,
14
+ } from "./ast";
15
+ import { CHILD_REMOVED } from "./constants";
16
+ import {
17
+ Attribute,
18
+ Delay,
19
+ Diagnostic,
20
+ Listener,
21
+ recordSeq,
22
+ StyleProperty,
23
+ Variable,
24
+ } from "./properties";
25
+ import type { Quark } from "./quark";
26
+ import type { TQuarkElement } from "./quark-internal";
27
+ import { SCOPE_ATTR } from "./scope-id";
28
+ import {
29
+ analyzeSelector,
30
+ buildSelectorLine,
31
+ type CompoundDependency,
32
+ EMPTY_ANALYSIS,
33
+ type FanOutRoot,
34
+ type SelectorAnalysis,
35
+ splitScopePrefix,
36
+ } from "./selector-utils";
37
+ import type { MutationMap, QuarkOptions, TransitionSpec } from "./types";
38
+ import { isInfoLogging, QuarkLogger } from "./utils";
39
+ import { selectAll, tc } from "@excom/kit-utils";
40
+
41
+ let quarkRuleIdCounter = 0;
42
+
43
+ /**
44
+ * Elements not contained by another in the list, in the list's own
45
+ * order (fan-out order is observable: it decides which match's
46
+ * properties run first). Containment is decided on a document-ordered
47
+ * copy: a nested element always follows its container there, and kept
48
+ * elements are disjoint, so each candidate needs one `contains` against
49
+ * the last kept, not against every other.
50
+ */
51
+ export const outermostElements = (elements: HTMLElement[]): HTMLElement[] => {
52
+ if (elements.length < 2) return elements;
53
+ const sorted = elements
54
+ .slice()
55
+ .sort((a, b) =>
56
+ a === b
57
+ ? 0
58
+ : a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING
59
+ ? -1
60
+ : 1
61
+ );
62
+ const kept = new Set<HTMLElement>();
63
+ let last: HTMLElement | undefined;
64
+ for (const el of sorted) {
65
+ if (!last || (last !== el && !last.contains(el))) {
66
+ kept.add(el);
67
+ last = el;
68
+ }
69
+ }
70
+ return elements.filter((el) => kept.has(el));
71
+ };
72
+
73
+ /**
74
+ * Mutation kinds run structural first: `RUN_ALL`, then `NEW_SELF`, then
75
+ * `content` / `CHILD_REMOVED`, then attr names and `$bindings`.
76
+ */
77
+ const KIND_ORDER: Record<string, number> = {
78
+ RUN_ALL: 0,
79
+ NEW_SELF: 1,
80
+ content: 2,
81
+ [CHILD_REMOVED]: 3,
82
+ };
83
+ const kindOrder = (a: string, b: string) =>
84
+ (KIND_ORDER[a] ?? 4) - (KIND_ORDER[b] ?? 4);
85
+
86
+ /**
87
+ * The spec a `@view-transition` node gives the writes inside it.
88
+ * `ownerRule` is the rule the block is written in (`null` at sheet level).
89
+ */
90
+ export const transitionSpec = (
91
+ node: TransitionRule,
92
+ source: string,
93
+ ownerRule: Rule | null
94
+ ): TransitionSpec => ({
95
+ optionSources: listenerOptionSources(node, source),
96
+ source: transitionSourceText(node, source),
97
+ firstRender: node.options.some((option) => option.name === "first-render"),
98
+ ownerRule,
99
+ warned: new Set(),
100
+ });
101
+
102
+ export class Rule {
103
+ // for debugging
104
+ id: number;
105
+ numberOfRuns: number = 0;
106
+ // for runtime
107
+ /** Authored selector (may contain `:scope`); parent for nested rules. */
108
+ selector: string;
109
+ /**
110
+ * Selector usable with DOM APIs (no `:scope`). Empty string: the rule
111
+ * targets the host itself (`:scope { ... }`).
112
+ */
113
+ matchSelector: string;
114
+ /**
115
+ * Set when the selector is `:scope`-anchored: remainder of the first
116
+ * compound, matched against the host (`:scope[is-on]` → `"[is-on]"`,
117
+ * bare `:scope` → `""`). `null` if the selector has no `:scope`.
118
+ */
119
+ hostCompound: string | null;
120
+ /**
121
+ * The rule's own selector text, before composition with the parent's
122
+ * (`+ provider-fetch`)
123
+ */
124
+ ownSelector: string;
125
+ /** Rules inside an `@scope` block only match within the host's subtree. */
126
+ isScoped: boolean;
127
+ path: string[];
128
+ /** Selector attribute gates plus literal `attr("x")` reads, observer input. */
129
+ observedAttrs: Set<string>;
130
+ /** What `matchSelector` depends on and where its subjects sit relative to each change. */
131
+ deps: SelectorAnalysis;
132
+ /** Same for `hostCompound` (`:scope:has([x])`), or `null`. */
133
+ hostDeps: SelectorAnalysis | null;
134
+ /**
135
+ * Match can change when elements are removed (`:has()`, sibling
136
+ * position, sibling combinators). The sheet observes child removals
137
+ * only when some rule says so.
138
+ */
139
+ reactsToRemovals: boolean;
140
+ attributes: Attribute[] = [];
141
+ variables: Variable[] = [];
142
+ listeners: Listener[] = [];
143
+ /** `@warn` / `@debug` / `@error` statements, run after the writes. */
144
+ diagnostics: Diagnostic[] = [];
145
+ /** `@delay` blocks, scheduled after the writes. */
146
+ delays: Delay[] = [];
147
+ /** `@dispatch` / `@command` statements, run after the block's writes and nested rules. */
148
+ actions: Action[] = [];
149
+ parent?: Rule;
150
+ quarkInstance: Quark;
151
+ /**
152
+ * True for the rule built from an `@on … { }` block (and its nested
153
+ * rules). Event-block rules never join `quarkInstance.rules`, so sheet
154
+ * passes, observer filter, and binding / prop indexes ignore them.
155
+ * They run only through `runEvent`.
156
+ */
157
+ isEventBlock: boolean;
158
+ /** Nested rules, kept only for event-block rules (see `runEvent`). */
159
+ childRules: Rule[] = [];
160
+ /**
161
+ * True inside an `@on … { }` block (its nested rules and `@delay`
162
+ * blocks included): the only place `@dispatch` / `@command` may appear,
163
+ * so an outgoing event is always caused by an event the sheet heard.
164
+ */
165
+ inListenerBlock: boolean;
166
+ /**
167
+ * The `@view-transition` block enclosing this rule, inherited by its
168
+ * writes and nested rules (a block written inside the rule stamps its
169
+ * own spec on the writes it holds: see `Property.transition`).
170
+ */
171
+ transition: TransitionSpec | null;
172
+ constructor({
173
+ quarkInstance,
174
+ statement,
175
+ source,
176
+ parent,
177
+ parentSelector,
178
+ selectorText,
179
+ scoped = false,
180
+ isEventBlock = false,
181
+ inListenerBlock = false,
182
+ transition = null,
183
+ }: {
184
+ quarkInstance: Quark;
185
+ statement: RuleNode;
186
+ source: string;
187
+ parent?: Rule;
188
+ parentSelector?: string;
189
+ /** Set when fanning out a comma-separated selector list. */
190
+ selectorText?: string;
191
+ scoped?: boolean;
192
+ isEventBlock?: boolean;
193
+ inListenerBlock?: boolean;
194
+ transition?: TransitionSpec | null;
195
+ }) {
196
+ this.isEventBlock = isEventBlock;
197
+ this.inListenerBlock = inListenerBlock;
198
+ this.transition = transition;
199
+ if (transition) quarkInstance.hasTransitions = true;
200
+ const selectorTexts = selectorText
201
+ ? [selectorText]
202
+ : ruleSelectorTexts(statement, source);
203
+ this.ownSelector = selectorTexts[0].trim();
204
+ this.selector = buildSelectorLine(selectorTexts[0], parentSelector);
205
+ selectorTexts.slice(1).forEach((sel) => {
206
+ new Rule({
207
+ quarkInstance,
208
+ statement,
209
+ source,
210
+ parent,
211
+ parentSelector,
212
+ selectorText: sel,
213
+ scoped,
214
+ isEventBlock,
215
+ inListenerBlock,
216
+ transition,
217
+ });
218
+ });
219
+ const { hostCompound, rest } = splitScopePrefix(this.selector);
220
+ this.hostCompound = hostCompound;
221
+ this.matchSelector = rest;
222
+ // `:scope` explicitly references the host, so such rules are
223
+ // host-anchored even at the top level of a global sheet
224
+ this.isScoped = scoped || hostCompound !== null;
225
+ this.deps = rest ? analyzeSelector(rest) : EMPTY_ANALYSIS;
226
+ this.path = this.deps.path;
227
+ this.observedAttrs = new Set(this.deps.attrs);
228
+ // `:scope[is-on]`, host attribute changes must trigger this rule
229
+ this.hostDeps = hostCompound ? analyzeSelector(hostCompound) : null;
230
+ this.hostDeps?.attrs.forEach((a) => this.observedAttrs.add(a));
231
+ this.reactsToRemovals =
232
+ this.deps.reactsToRemovals || !!this.hostDeps?.reactsToRemovals;
233
+ if (!isEventBlock) {
234
+ // warn where the pseudo is written, once, not again in every nested rule
235
+ const ownText = selectorTexts[0];
236
+ const unobserved = [
237
+ ...this.deps.unobserved,
238
+ ...(this.hostDeps?.unobserved ?? []),
239
+ ].filter((name) => ownText.includes(name));
240
+ if (unobserved.length) {
241
+ QuarkLogger.warn({
242
+ method: "rule",
243
+ message: `Quark: ${[...new Set(unobserved)].join(", ")} in "${this.selector}" is not observed — the rule matches on its first run only. Select on reflected attributes or listen for events instead.`,
244
+ });
245
+ }
246
+ }
247
+ this.parent = parent;
248
+ this.quarkInstance = quarkInstance;
249
+ /*
250
+ * A `@view-transition { … }` written in this rule is not a rule of
251
+ * its own: its declarations and `@on`s join this rule, stamped with
252
+ * the block's spec, and its nested rules become children carrying
253
+ * it. Blocks nest; the innermost wins.
254
+ */
255
+ const children: Array<[RuleNode, TransitionSpec | null]> = [];
256
+ const collect = (body: Statement[], spec: TransitionSpec | null) => {
257
+ const view = viewBlock(body);
258
+ view.declarations.forEach((declaration) => {
259
+ const { key, value } = declarationStrings(declaration, source);
260
+ const args = { key, value, parent: this, transition: spec };
261
+ if (key.startsWith("$")) {
262
+ this.variables.push(new Variable(args));
263
+ } else if (key.startsWith("--")) {
264
+ this.attributes.push(new StyleProperty(args));
265
+ } else {
266
+ this.attributes.push(new Attribute(args));
267
+ }
268
+ });
269
+ view.diagnostics.forEach((node) => {
270
+ this.diagnostics.push(
271
+ new Diagnostic({
272
+ level: node.name,
273
+ value: sliceNode(source, node.value).trim(),
274
+ parent: this,
275
+ transition: spec,
276
+ })
277
+ );
278
+ });
279
+ view.delays.forEach((node) => {
280
+ this.delays.push(
281
+ new Delay({
282
+ value: sliceNode(source, node.duration).trim(),
283
+ parent: this,
284
+ transition: spec,
285
+ // the block body is a rule at this rule's own selector, applied
286
+ // to the matched element once when the timer fires
287
+ block: new Rule({
288
+ quarkInstance,
289
+ statement: { block: node.block } as RuleNode,
290
+ source,
291
+ parent: this,
292
+ selectorText: this.selector,
293
+ scoped,
294
+ isEventBlock: true,
295
+ inListenerBlock,
296
+ transition: spec,
297
+ }),
298
+ })
299
+ );
300
+ });
301
+ view.actions.forEach((node) => {
302
+ if (!inListenerBlock) {
303
+ QuarkLogger.error({
304
+ method: "rule",
305
+ message: `Quark: @${node.name} must be written inside an @on block — a rule matching is not an occurrence (${this.selector})`,
306
+ });
307
+ return;
308
+ }
309
+ this.actions.push(
310
+ new Action({
311
+ kind: node.name,
312
+ names: node.names,
313
+ optionSources: listenerOptionSources(node, source),
314
+ parent: this,
315
+ })
316
+ );
317
+ });
318
+ view.listeners.forEach((node) => {
319
+ if (isEventBlock) {
320
+ QuarkLogger.error({
321
+ method: "rule",
322
+ message: `Quark: @${node.name} inside an @on / @delay block is not supported (${this.selector})`,
323
+ });
324
+ return;
325
+ }
326
+ this.listeners.push(
327
+ new Listener({
328
+ eventTypes: node.events.map((e) => e.name),
329
+ eventsText: eventNamesText(node.events),
330
+ optionSources: listenerOptionSources(node, source),
331
+ parent: this,
332
+ transition: spec,
333
+ // the block body is a rule at this rule's own selector, applied
334
+ // to the matched element once per event (never by the passes)
335
+ block: node.block
336
+ ? new Rule({
337
+ quarkInstance,
338
+ statement: { block: node.block } as RuleNode,
339
+ source,
340
+ parent: this,
341
+ selectorText: this.selector,
342
+ scoped,
343
+ isEventBlock: true,
344
+ inListenerBlock: true,
345
+ transition: spec,
346
+ })
347
+ : null,
348
+ })
349
+ );
350
+ });
351
+ view.children.forEach((child) => {
352
+ if (child.type === "rule") children.push([child, spec]);
353
+ else collect(child.block.body, transitionSpec(child, source, this));
354
+ });
355
+ };
356
+ collect(statement.block.body, transition);
357
+
358
+ // if there are no attributes or variables, do not pass reference to self anywhere
359
+ // so garbage collection can clean this instance up
360
+ const hasProperties =
361
+ this.attributes.length > 0 ||
362
+ this.variables.length > 0 ||
363
+ this.listeners.length > 0 ||
364
+ this.diagnostics.length > 0 ||
365
+ this.delays.length > 0 ||
366
+ this.actions.length > 0;
367
+ if (hasProperties || isEventBlock) {
368
+ if (!isEventBlock) quarkInstance.rules.push(this);
369
+ this.id = quarkRuleIdCounter;
370
+ quarkRuleIdCounter++;
371
+ }
372
+ children.forEach(([child, childTransition]) => {
373
+ const childRule = new Rule({
374
+ quarkInstance,
375
+ statement: child,
376
+ source,
377
+ parent: hasProperties || isEventBlock ? this : parent,
378
+ parentSelector: this.selector,
379
+ scoped,
380
+ isEventBlock,
381
+ inListenerBlock,
382
+ transition: childTransition,
383
+ });
384
+ if (isEventBlock) this.childRules.push(childRule);
385
+ });
386
+ }
387
+ /**
388
+ * Apply a block rule (`@on … { }`, `@delay … { }`) to `element` once:
389
+ * every declaration runs against the element (no re-run gating; a
390
+ * block is a one-shot), then nested rules run against matching
391
+ * descendants, then `@dispatch` / `@command` statements run (after
392
+ * every write of the block is queued). `event` is exposed to
393
+ * expressions through the options (`undefined` for a `@delay`
394
+ * scheduled outside an `@on` block).
395
+ */
396
+ runEvent(
397
+ element: HTMLElement,
398
+ event: Event | undefined,
399
+ baseOptions: QuarkOptions,
400
+ eventTarget: Element | null = null
401
+ ) {
402
+ const options: QuarkOptions = {
403
+ ...baseOptions,
404
+ event,
405
+ eventTarget: eventTarget ?? (event?.target as Element | null) ?? null,
406
+ rule: this,
407
+ property: undefined,
408
+ properties: undefined,
409
+ propertiesToRun: undefined,
410
+ isFirstRun: false,
411
+ isAsyncRun: false,
412
+ changedBinding: undefined,
413
+ };
414
+ this.numberOfRuns++;
415
+ const target = element as TQuarkElement;
416
+ this.variables.forEach((v) => v._run(target, options));
417
+ this.attributes.forEach((a) => a._run(target, options));
418
+ this.diagnostics.forEach((d) => d._run(target, options));
419
+ this.delays.forEach((d) => d._run(target, options));
420
+ this.childRules.forEach((child) => {
421
+ /*
422
+ * A nested selector starting with a sibling combinator means the
423
+ * element's siblings, not its descendants
424
+ */
425
+ if (/^[+~]/.test(child.ownSelector)) {
426
+ const siblings = tc(() =>
427
+ selectAll(`:scope ${child.ownSelector}`, { scope: element })
428
+ );
429
+ siblings?.forEach((el) => {
430
+ child.runEvent(el, event, baseOptions, eventTarget);
431
+ });
432
+ return;
433
+ }
434
+ const selector = child.isScoped
435
+ ? child.scopedSelector()
436
+ : child.matchSelector;
437
+ if (!selector) return;
438
+ element.querySelectorAll(selector).forEach((el) => {
439
+ child.runEvent(el as HTMLElement, event, baseOptions, eventTarget);
440
+ });
441
+ });
442
+ this.actions.forEach((action) => action.run(target, options));
443
+ }
444
+ run(
445
+ mutationMap: MutationMap,
446
+ {
447
+ host,
448
+ options,
449
+ }: {
450
+ host: HTMLElement;
451
+ options: QuarkOptions;
452
+ }
453
+ ) {
454
+ const propertiesToRun = this.filterPropertiesToRun(options);
455
+ if (propertiesToRun.length > 0) {
456
+ this._run(mutationMap, {
457
+ host,
458
+ options: { ...options, propertiesToRun },
459
+ });
460
+ }
461
+ }
462
+ _run(
463
+ mutationMap: MutationMap,
464
+ {
465
+ host,
466
+ options,
467
+ }: {
468
+ host: HTMLElement;
469
+ options: QuarkOptions;
470
+ }
471
+ ) {
472
+ const elementsToMutate = new Set<HTMLElement>();
473
+ // `:scope[x] …` rules are inert while the host doesn't match the compound
474
+ if (this.hostCompound && !host.matches(this.hostCompound)) return;
475
+ const targetsHost = this.hostCompound !== null && !this.matchSelector;
476
+ /*
477
+ Scoping lives in the host marker, not tree walks. Registered hosts
478
+ carry `q-scope="<id>"` and scoped selectors are prefixed with
479
+ `[q-scope="<id>"] ` (see scope-id.ts), so the native engine
480
+ enforces CSS `@scope`. Every compound matches strict descendants;
481
+ the host itself only via explicit `:scope` (hostCompound /
482
+ targetsHost). `host.contains()` and query-root clamping below bound
483
+ the work and keep stale cloned markers outside the host inert.
484
+ */
485
+ const runSelector = this.isScoped
486
+ ? this.scopedSelector()
487
+ : this.matchSelector;
488
+ const matchesRule = (el: HTMLElement) => this.matchesElement(el, host);
489
+ // a full-property fan-out visits every match below its query roots.
490
+ // recorded so deferred binding writes can tell which readers it reached
491
+ const trace = this.quarkInstance.runTrace;
492
+ const coverage =
493
+ trace && !options.properties
494
+ ? (trace.coverage.get(this) ??
495
+ trace.coverage.set(this, new Set()).get(this))
496
+ : undefined;
497
+ const { compounds, usesHas } = this.deps;
498
+ const hostDeps = this.hostDeps?.compounds[0];
499
+ const hostReactsToChildren = !!this.hostDeps?.usesHas;
500
+ /*
501
+ Fan-out roots: elements whose subtree is re-queried for subjects,
502
+ in queue order (observable: it decides which match's properties
503
+ run first). Duplicates and nested roots collapse in
504
+ `outermostElements`.
505
+ */
506
+ const roots = new Set<HTMLElement>();
507
+ const fanOut = (root: HTMLElement | null) => {
508
+ if (root) roots.add(root);
509
+ };
510
+ const rootFor = (el: HTMLElement, where: FanOutRoot) =>
511
+ where === "parent" ? (el.parentElement ?? el) : el;
512
+ /**
513
+ * Ancestors-or-self of `el` that are compound `c`'s element (the
514
+ * subject itself for the last compound). Scoped rules stop at the
515
+ * host; the marker keeps every compound a strict descendant anyway.
516
+ * Global rules walk to the root.
517
+ */
518
+ const matchingAncestors = (
519
+ el: HTMLElement,
520
+ c: CompoundDependency
521
+ ): HTMLElement[] => {
522
+ const found: HTMLElement[] = [];
523
+ for (let a: HTMLElement | null = el; a; a = a.parentElement) {
524
+ if (a === host && this.isScoped) break;
525
+ if (c.isSubject ? matchesRule(a) : this.matchesPrefix(a, c)) {
526
+ found.push(a);
527
+ }
528
+ }
529
+ return found;
530
+ };
531
+ mutationMap?.forEach((attrs, element) => {
532
+ let addedSelf = false;
533
+ const mutateSelf = (el: HTMLElement = element) => {
534
+ if (el === element) {
535
+ if (addedSelf) return;
536
+ addedSelf = true;
537
+ } else if (elementsToMutate.has(el)) {
538
+ return;
539
+ }
540
+ this.numberOfRuns++;
541
+ elementsToMutate.add(el);
542
+ };
543
+ /** Compound `c`'s element `el` was affected: it is a subject, or subjects sit below / beside it. */
544
+ const reach = (el: HTMLElement, c: CompoundDependency) => {
545
+ if (c.isSubject) mutateSelf(el);
546
+ else fanOut(rootFor(el, c.root));
547
+ };
548
+ /**
549
+ * Elements came or went below `el`: `:has()` / `:empty` candidates
550
+ * on the way up may have flipped.
551
+ */
552
+ const childrenChanged = (el: HTMLElement) => {
553
+ for (const c of compounds) {
554
+ if (!c.reactsToChildren) continue;
555
+ if (c.broad) fanOut(host);
556
+ else matchingAncestors(el, c).forEach((a) => reach(a, c));
557
+ }
558
+ if (hostReactsToChildren) fanOut(host);
559
+ };
560
+ [...attrs].sort(kindOrder).forEach((attr) => {
561
+ if (attr === "RUN_ALL") {
562
+ // on init, if host matches selector
563
+ if (element === host && matchesRule(element)) mutateSelf();
564
+ fanOut(element);
565
+ } else if (attr === "NEW_SELF") {
566
+ // if self added children
567
+ if (matchesRule(element)) mutateSelf();
568
+ if (host.contains(element)) fanOut(element);
569
+ } else if (attr === "content" || attr === CHILD_REMOVED) {
570
+ if (attr === CHILD_REMOVED && !this.reactsToRemovals) return;
571
+ if (!host.contains(element)) return;
572
+ // matching children were added / removed below `element`
573
+ fanOut(element);
574
+ if (usesHas || hostReactsToChildren) childrenChanged(element);
575
+ } else if (attr === "PROP") {
576
+ // a JS property changed on `element`; `prop()` reads are on
577
+ // the matched element only, so only it re-runs
578
+ if (matchesRule(element)) mutateSelf();
579
+ } else if (attr.startsWith("$")) {
580
+ /*
581
+ * A $binding changed on `element` (the owner). Bindings scope
582
+ * to the DOM tree, so consumers are the owner plus matching
583
+ * descendants, regardless of rule nesting. Owners above the
584
+ * host (another sheet) still fan out; the scoped selector
585
+ * keeps matches inside this sheet's subtree.
586
+ */
587
+ if (matchesRule(element)) mutateSelf();
588
+ else if (host.contains(element) || element.contains(host)) {
589
+ fanOut(element);
590
+ }
591
+ } else {
592
+ /*
593
+ * An observed attr changed on `element`. A literal `attr("x")`
594
+ * read re-runs a matching subject; every compound that depends
595
+ * on the name says where its subjects went (see
596
+ * `CompoundDependency`).
597
+ */
598
+ if (this.readsAttr(attr) && matchesRule(element)) mutateSelf();
599
+ for (const c of compounds) {
600
+ if (
601
+ c.selfAttrs.has(attr) &&
602
+ (c.isSubject
603
+ ? matchesRule(element)
604
+ : this.matchesPrefix(element, c))
605
+ ) {
606
+ reach(element, c);
607
+ }
608
+ if (c.looseAttrs.has(attr)) fanOut(rootFor(element, c.looseRoot));
609
+ if (c.hasAttrs.has(attr)) {
610
+ matchingAncestors(element, c).forEach((a) => reach(a, c));
611
+ }
612
+ if (c.broadAttrs.has(attr)) fanOut(host);
613
+ }
614
+ if (hostDeps) {
615
+ /*
616
+ * Host state changed on a `:scope[attr] …` rule, or a
617
+ * descendant named in `:scope:has(…)`. The host is the first
618
+ * path segment, so re-run the rule for its subtree.
619
+ */
620
+ const affected =
621
+ element === host
622
+ ? hostDeps.selfAttrs.has(attr) || hostDeps.looseAttrs.has(attr)
623
+ : hostDeps.hasAttrs.has(attr) || hostDeps.broadAttrs.has(attr);
624
+ if (affected) fanOut(host);
625
+ }
626
+ }
627
+ });
628
+ });
629
+ // find the most distant ancestors
630
+ outermostElements([...roots]).forEach((ancestor) => {
631
+ /*
632
+ * Scoped rules clamp to the host so fan-out from an ancestor
633
+ * above it (cross-sheet binding owners, providers above scope)
634
+ * never leaks into sibling scopes. Unscoped rules run in the
635
+ * root context, so a host-level fan-out (RUN_ALL) expands to the
636
+ * root.
637
+ */
638
+ const queryRoot = this.isScoped
639
+ ? host.contains(ancestor)
640
+ ? ancestor
641
+ : host
642
+ : ancestor === host
643
+ ? (host.getRootNode() as Document | HTMLElement)
644
+ : ancestor;
645
+ if (targetsHost) {
646
+ if (queryRoot.contains(host)) elementsToMutate.add(host);
647
+ } else {
648
+ if (queryRoot instanceof Element) coverage?.add(queryRoot);
649
+ queryRoot.querySelectorAll(runSelector).forEach((el) => {
650
+ elementsToMutate.add(el as HTMLElement);
651
+ });
652
+ }
653
+ this.numberOfRuns++;
654
+ });
655
+ // execute mutations
656
+ if (elementsToMutate.size > 0 && isInfoLogging()) {
657
+ QuarkLogger.info({
658
+ method: "foundElements",
659
+ runId: options.runId,
660
+ sheetId: this.quarkInstance.id,
661
+ ruleId: this.id,
662
+ elements: [[Array.from(elementsToMutate)]],
663
+ options,
664
+ });
665
+ }
666
+ elementsToMutate.forEach((element) => {
667
+ this.runProps(mutationMap, element, options);
668
+ });
669
+ }
670
+ /**
671
+ * Whether `el` is compound `c`'s element: it matches the selector up
672
+ * to and including that compound, host-scoped when the rule is.
673
+ */
674
+ matchesPrefix(el: Element, c: CompoundDependency): boolean {
675
+ // a leading combinator (`> span` after `:scope`) is only valid scoped;
676
+ // other prefixes stay bare, the fan-out root is clamped to the host
677
+ const selector =
678
+ c.prefixNeedsScope && this.isScoped ? this.scoped(c.prefix) : c.prefix;
679
+ return tc(() => el.matches(selector)) === true;
680
+ }
681
+ /** Whether `el` is a subject of this rule under `host` (scope-aware). */
682
+ matchesElement(el: Element, host: HTMLElement): boolean {
683
+ if (this.hostCompound !== null && !this.matchSelector) return el === host;
684
+ return (
685
+ !!this.matchSelector &&
686
+ (!this.isScoped || host.contains(el)) &&
687
+ el.matches(this.isScoped ? this.scopedSelector() : this.matchSelector)
688
+ );
689
+ }
690
+ /** Selector cache for the current scope id (rebuilt after re-register). */
691
+ private scopedCache: { id: string; selectors: Map<string, string> } | null =
692
+ null;
693
+ /**
694
+ * `selector` prefixed with the host's `q-scope` marker so the native
695
+ * engine clamps every compound to strict descendants of the host (CSS
696
+ * `@scope`, see scope-id.ts). Bare selector until the sheet is
697
+ * registered.
698
+ */
699
+ scoped(selector: string): string {
700
+ const id = this.quarkInstance.scopeId;
701
+ if (!id) return selector;
702
+ if (this.scopedCache?.id !== id) {
703
+ this.scopedCache = { id, selectors: new Map() };
704
+ }
705
+ let scoped = this.scopedCache.selectors.get(selector);
706
+ if (!scoped) {
707
+ scoped = `[${SCOPE_ATTR}="${id}"] ${selector}`;
708
+ this.scopedCache.selectors.set(selector, scoped);
709
+ }
710
+ return scoped;
711
+ }
712
+ /** The whole `matchSelector`, host-scoped (see `scoped`). */
713
+ scopedSelector(): string {
714
+ return this.scoped(this.matchSelector);
715
+ }
716
+
717
+ /** True when a property body reads this name via literal `attr("x")`. */
718
+ readsAttr(attr: string): boolean {
719
+ const inProps = (p: Variable | Attribute | Listener) =>
720
+ p.referencedAttrNames.includes(attr);
721
+ return (
722
+ this.variables.some(inProps) ||
723
+ this.attributes.some(inProps) ||
724
+ this.listeners.some(inProps) ||
725
+ this.diagnostics.some(inProps) ||
726
+ this.delays.some(inProps)
727
+ );
728
+ }
729
+
730
+ runProps(
731
+ mutationMap: MutationMap,
732
+ element: HTMLElement,
733
+ options: QuarkOptions
734
+ ) {
735
+ const { propertiesToRun } = options;
736
+ const mutatedProperties = Array.from(
737
+ mutationMap.get(element as HTMLElement) || []
738
+ );
739
+
740
+ const trace = this.quarkInstance.runTrace;
741
+ const shouldRunProperty = (property: Variable | Attribute | Listener) => {
742
+ if (propertiesToRun && !propertiesToRun.includes(property)) return false;
743
+ // this second check ensures that we avoid running a given property if
744
+ // that property is what triggered the change to begin with. `class`
745
+ // is exempt: its tokens are separate facts (`.a { class: (b: true) }`
746
+ // must apply when `a` arrives); class cycles are cut by the loop guard
747
+ if (
748
+ property.key !== "class" &&
749
+ mutatedProperties.includes(property.key)
750
+ ) {
751
+ // not refreshed by this run: a deferred write it reads must reach it
752
+ if (trace) recordSeq(trace.visited, element, property, 0);
753
+ return false;
754
+ }
755
+ return true;
756
+ };
757
+
758
+ this.variables.forEach((v) => {
759
+ if (shouldRunProperty(v)) {
760
+ v.run(element, options);
761
+ }
762
+ });
763
+ this.listeners.forEach((l) => {
764
+ if (shouldRunProperty(l)) {
765
+ l.run(element, options);
766
+ }
767
+ });
768
+ this.attributes.forEach((a) => {
769
+ if (shouldRunProperty(a)) {
770
+ a.run(element, options);
771
+ }
772
+ });
773
+ // after the writes: a diagnostic reads what this pass set; a delay
774
+ // restarts on every application of the rule
775
+ this.diagnostics.forEach((d) => {
776
+ if (shouldRunProperty(d)) {
777
+ d.run(element, options);
778
+ }
779
+ });
780
+ this.delays.forEach((d) => {
781
+ if (shouldRunProperty(d)) {
782
+ d.run(element, options);
783
+ }
784
+ });
785
+ }
786
+ filterPropertiesToRun(options: QuarkOptions) {
787
+ const allProps = [
788
+ ...this.variables,
789
+ ...this.attributes,
790
+ ...this.listeners,
791
+ ...this.diagnostics,
792
+ ...this.delays,
793
+ ];
794
+ return !options.properties
795
+ ? allProps
796
+ : allProps.filter((prop) => {
797
+ return options.properties?.includes(prop);
798
+ });
799
+ }
800
+ }