@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/quark.ts ADDED
@@ -0,0 +1,817 @@
1
+ export * from "./types";
2
+ import { collectUseRules, type Statement, type TransitionRule } from "./ast";
3
+ import {
4
+ BINDING_CHANGE_EVENT,
5
+ type BindingChangeDetail,
6
+ dispatchBindingChange,
7
+ } from "./bindings";
8
+ import {
9
+ BUILTIN_MODULE_SCHEME,
10
+ builtinModuleName,
11
+ QUARK_MODULES,
12
+ resolveBuiltinModule,
13
+ } from "./builtin-modules";
14
+ import { warnStaticCycles } from "./cycle-check";
15
+ import { attachDevtools, publicize } from "./devtools-hook";
16
+ import { installElementApi } from "./element-api";
17
+ import { listen, observe, unobserve } from "./observer";
18
+ import type { Attribute, Listener, Variable } from "./properties";
19
+ import {
20
+ PROP_CHANGE_EVENT,
21
+ type PropChangeDetail,
22
+ subscribeProp,
23
+ } from "./props";
24
+ import { getQuarkInternal } from "./quark-internal";
25
+ import { Rule, transitionSpec } from "./rule";
26
+ import { acquireScopeId, releaseScopeId } from "./scope-id";
27
+ import { addBusyCheck, whenSettled } from "./settle";
28
+ import type {
29
+ MutationMap,
30
+ QuarkListenerConfig,
31
+ QuarkOptions,
32
+ RunTrace,
33
+ TransitionSpec,
34
+ Vars,
35
+ } from "./types";
36
+ import {
37
+ getQuarkHost,
38
+ getQuarkMin,
39
+ isInfoLogging,
40
+ QuarkLogger,
41
+ stringToHash,
42
+ } from "./utils";
43
+ import { LoopGuard, resolveModuleReference } from "@excom/kit-utils";
44
+ import type { UseRule } from "@excom/quark-parser";
45
+ import { parse } from "@excom/quark-parser";
46
+
47
+ /*
48
+ * Loop-guard trips show in DevTools as orchestration errors. The
49
+ * Orchestrator is where a runaway attr / effect / event cycle is
50
+ * reasoned about, whichever engine dropped the write.
51
+ */
52
+ // `element.quark` — the JS side of bindings, on every element
53
+ installElementApi();
54
+
55
+ LoopGuard.onTrip((trip) => {
56
+ const element = trip.target instanceof Element ? trip.target : null;
57
+ publicize(["quark", "error"], {
58
+ ...(element ? { weakElement: new WeakRef(element) } : {}),
59
+ tag: element?.localName ?? null,
60
+ selector: null,
61
+ ruleId: null,
62
+ sheetId: null,
63
+ runId: null,
64
+ key: trip.name,
65
+ expression: null,
66
+ errorMessage: trip.message,
67
+ errorName: trip.kind === "batch" ? "LoopGuardBatch" : "LoopGuardDepth",
68
+ });
69
+ });
70
+
71
+ /** Deepest loop-guard stamp among the changes a run reacts to. */
72
+ const inheritedDepth = (mutationMap: MutationMap) => {
73
+ let depth = 0;
74
+ mutationMap.forEach((attrs, element) =>
75
+ attrs.forEach((attr) => {
76
+ depth = Math.max(depth, LoopGuard.depthOf(element, attr));
77
+ })
78
+ );
79
+ return depth;
80
+ };
81
+
82
+ interface TQuarkRegistry {
83
+ sheets: WeakRef<Quark>[];
84
+ add: (sheet: Quark) => void;
85
+ remove: (sheet: Quark) => void;
86
+ findRules: (selectorOrId: string | number) => Rule[];
87
+ }
88
+
89
+ export const QuarkRegistry: TQuarkRegistry = {
90
+ sheets: [],
91
+ add: (sheet: Quark) => {
92
+ QuarkRegistry.sheets.push(new WeakRef(sheet));
93
+ },
94
+ remove: (sheet: Quark) => {
95
+ QuarkRegistry.sheets = QuarkRegistry.sheets.filter(
96
+ (q: WeakRef<Quark>) => q.deref() !== sheet
97
+ );
98
+ },
99
+ findRules: (selectorOrId: string | number) => {
100
+ return QuarkRegistry.sheets
101
+ .map((q: WeakRef<Quark>) => q.deref())
102
+ .filter((q: Quark | undefined) => q)
103
+ .flatMap((q: Quark) =>
104
+ q.rules.filter((r: Rule) => {
105
+ const isNumber = typeof selectorOrId === "number";
106
+ return isNumber
107
+ ? r.id === selectorOrId
108
+ : String(r.selector)
109
+ .split(" ")
110
+ .slice(-1)[0]
111
+ .includes(String(selectorOrId));
112
+ })
113
+ );
114
+ },
115
+ };
116
+
117
+ if ((import.meta as any).env?.DEV) {
118
+ (window as any).QuarkRegistry = QuarkRegistry;
119
+ }
120
+
121
+ // settle (see settle.ts): a registered sheet is busy while a rule pass is
122
+ // queued or running, or while its `@use` modules load before the first run
123
+ addBusyCheck(() =>
124
+ QuarkRegistry.sheets.some((ref) => {
125
+ const sheet = ref.deref();
126
+ return (
127
+ !!sheet &&
128
+ (sheet.isRunningRules ||
129
+ sheet.isLoadingModules ||
130
+ sheet.ELEMENTS_TO_MATCH.size > 0)
131
+ );
132
+ })
133
+ );
134
+
135
+ export const DEFAULT_OPTIONS = {
136
+ observe: true,
137
+ autoRegister: true,
138
+ };
139
+
140
+ let quarkIdTotal = 0;
141
+ export class Quark {
142
+ /**
143
+ * Loads a `@use "url"` JS module. Resolves against the document origin.
144
+ * Overridable (stubbed in tests, or a bundler-aware loader).
145
+ */
146
+ static moduleLoader: (url: string) => Promise<Vars> = resolveModuleReference;
147
+ /**
148
+ * Install the Nucleus DevTools hook (shared with
149
+ * `Neutron.attachDevtools`). Chrome extensions install the global at
150
+ * `document_start`; this is for tests and late attach.
151
+ */
152
+ static attachDevtools = attachDevtools;
153
+ /**
154
+ * Resolve once Quark is idle — no queued or running rule pass, no paint
155
+ * waiting to commit, no async `content` or `@use` load pending — or
156
+ * after `timeout` ms (default 1000): `"settled"`, `"until"` or
157
+ * `"timeout"`. For tests and tools; sheets have no after-render hook.
158
+ */
159
+ static whenSettled = whenSettled;
160
+ id: number;
161
+ rules: Rule[] = [];
162
+ host: WeakRef<HTMLElement>;
163
+ listenerConfig: QuarkListenerConfig;
164
+ src: string;
165
+ hash: string;
166
+ options: QuarkOptions;
167
+ isRegistered: boolean = false;
168
+ /** The host's `q-scope` marker id; set while registered (see scope-id.ts). */
169
+ scopeId: string | null = null;
170
+ observer: MutationObserver | null = null;
171
+ /** `prop()` subscriptions this sheet holds, released on unregister. */
172
+ private propSubscriptions: Map<Element, Map<string, () => void>> = new Map();
173
+ ELEMENTS_TO_MATCH: MutationMap = new Map();
174
+ allAttrs: string[] = [];
175
+ /**
176
+ * `$name` → properties in this sheet that reference it. Bindings
177
+ * cascade across sheets (CSS custom-property semantics), so writers
178
+ * are not known statically, but a sheet always knows its readers.
179
+ * Cheap dismiss of `quark-binding-change` for unreferenced names.
180
+ */
181
+ bindingIndex: Map<string, Array<Variable | Attribute | Listener>> = new Map();
182
+ /** property name → properties in this sheet that read it via `prop()`. */
183
+ propIndex: Map<string, Array<Variable | Attribute | Listener>> = new Map();
184
+ /** Namespaced module buckets from `@use` rules, once all imports settle. */
185
+ pendingModules: Promise<{ [key: string]: Vars }> | null = null;
186
+ /** Timers of pending `@delay` blocks (cleared on unregister). */
187
+ delayTimers = new Set<ReturnType<typeof setTimeout>>();
188
+ /**
189
+ * Set while this sheet is inside the outermost `run()`: what ran, in
190
+ * order. `$binding` writes meanwhile are queued (`queueBindingChange`)
191
+ * and announced when the run completes, with the trace so consumers
192
+ * that already ran after a write are skipped. A def under `tbody td`
193
+ * no longer nests a run per cell for readers this pass already hits.
194
+ */
195
+ runTrace: RunTrace | null = null;
196
+ private runDepth = 0;
197
+ private pendingBindingChanges: Map<
198
+ Element,
199
+ Map<string, Omit<BindingChangeDetail, "name">>
200
+ > = new Map();
201
+ /** `writeBinding` notifier used mid-run: remember, dispatch after. */
202
+ queueBindingChange = (owner: Element, detail: BindingChangeDetail) => {
203
+ const { name, ...rest } = detail;
204
+ let names = this.pendingBindingChanges.get(owner);
205
+ if (!names) this.pendingBindingChanges.set(owner, (names = new Map()));
206
+ // the latest write wins: a consumer is stale unless it ran after it
207
+ names.set(name, { ...rest, sinceSeq: this.runTrace?.seq ?? 0 });
208
+ this.runTrace?.written.add(name);
209
+ };
210
+ /**
211
+ * Announce the run's deferred writes. A write is `selfCovered` (this
212
+ * sheet ignores its own event) when every rule of this sheet reading
213
+ * the name fanned out over the owner's subtree with its full property
214
+ * set (each reader below the owner was visited) and no reader below
215
+ * the owner was visited before the write. Readers visited after it
216
+ * already evaluated with the new value (`written` gating in
217
+ * `Property.run`).
218
+ */
219
+ private flushBindingChanges(trace: RunTrace) {
220
+ const pending = this.pendingBindingChanges;
221
+ if (!pending.size) return;
222
+ this.pendingBindingChanges = new Map();
223
+ const stale = new Set<Omit<BindingChangeDetail, "name">>();
224
+ // readers visited before the write they depend on
225
+ trace.visited.forEach((byProperty, element) =>
226
+ byProperty.forEach((seq, property) =>
227
+ property.referencedVarNames.forEach((name) => {
228
+ if (!trace.written.has(name)) return;
229
+ const entry = nearestPending(pending, element, name);
230
+ if (entry && seq < (entry.sinceSeq ?? 0)) stale.add(entry);
231
+ })
232
+ )
233
+ );
234
+ const host = this.host.deref();
235
+ pending.forEach((names, owner) =>
236
+ names.forEach((detail, name) => {
237
+ const readers = this.bindingIndex.get(name);
238
+ const selfCovered =
239
+ !!host &&
240
+ !!readers?.length &&
241
+ !stale.has(detail) &&
242
+ readers.every((reader) => isCovered(trace, reader, owner, host));
243
+ dispatchBindingChange(owner, { name, ...detail, trace, selfCovered });
244
+ })
245
+ );
246
+ }
247
+ // `isRunningRules` is for debouncing
248
+ isRunningRules = false;
249
+ /** Registered, first run waiting on `@use` modules (settle busy source). */
250
+ isLoadingModules = false;
251
+ /** Some rule sits inside a `@view-transition` block. */
252
+ hasTransitions = false;
253
+ /**
254
+ * From `register()` until that registration's first run settles. Paints
255
+ * resolved meanwhile are the first render and never start a view
256
+ * transition, unless their block says `first-render`.
257
+ */
258
+ isFirstRender = true;
259
+ private registrations = 0;
260
+ runRules() {
261
+ const mapToRun = new Map(this.ELEMENTS_TO_MATCH);
262
+ this.ELEMENTS_TO_MATCH.clear();
263
+ mapToRun.forEach((_, element) => {
264
+ if (!element.isConnected) {
265
+ mapToRun.delete(element);
266
+ }
267
+ });
268
+ // the causal depth the queued changes inherited (see queueRunRules)
269
+ const depth = this.pendingDepth;
270
+ this.pendingDepth = 0;
271
+ if (mapToRun.size) {
272
+ LoopGuard.run(depth, () => this.run(mapToRun));
273
+ }
274
+ }
275
+ /**
276
+ * Loop-guard depth for the next `runRules()`: deepest chain among the
277
+ * queued changes. Read when a change is queued (MutationObserver is
278
+ * still in the write's task, so the `(element, attribute)` stamp is
279
+ * live) and carried across the debounce.
280
+ */
281
+ private pendingDepth = 0;
282
+ queueRunRules = ({
283
+ element,
284
+ attribute,
285
+ }: {
286
+ element: HTMLElement;
287
+ attribute: string;
288
+ }) => {
289
+ this.pendingDepth = Math.max(
290
+ this.pendingDepth,
291
+ LoopGuard.current(),
292
+ LoopGuard.depthOf(element, attribute)
293
+ );
294
+ if (this.ELEMENTS_TO_MATCH.has(element)) {
295
+ this.ELEMENTS_TO_MATCH.get(element)?.add(attribute);
296
+ } else {
297
+ this.ELEMENTS_TO_MATCH.set(element, new Set([attribute]));
298
+ }
299
+ if (!this.isRunningRules) {
300
+ this.isRunningRules = true;
301
+ // todo: clean up - double setTimeout(0) ensures that a new quark run is triggered after the current paint is complete.
302
+ setTimeout(() => {
303
+ setTimeout(() => {
304
+ this.runRules();
305
+ this.isRunningRules = false;
306
+ }, 0);
307
+ }, 0);
308
+ }
309
+ };
310
+ constructor({
311
+ src,
312
+ options = {},
313
+ }: {
314
+ src: string;
315
+ modules?: { [key: string]: Vars };
316
+ options?: QuarkOptions;
317
+ }) {
318
+ this.id = quarkIdTotal;
319
+ quarkIdTotal++;
320
+ const min = getQuarkMin(src);
321
+ const hash = stringToHash(min);
322
+ this.options = {
323
+ ...DEFAULT_OPTIONS,
324
+ ...options,
325
+ };
326
+ /*
327
+ * `isScoped` wraps the sheet in `@scope`, anchoring every rule to
328
+ * the host (CSS inline-<style> scoping; quark-sheet passes it unless
329
+ * `is-global` is set). Otherwise top-level rules run in the root
330
+ * context and authors write `@scope { }` themselves.
331
+ */
332
+ this.src = this.options.isScoped ? wrapInScope(min) : min;
333
+ this.hash = hash;
334
+ const ast = parse(this.src);
335
+ // Kick off @use imports immediately so the fetch overlaps rule
336
+ // construction, registration, and the initial DOM matching pass.
337
+ const useRules = collectUseRules(ast.body);
338
+ if (useRules.length) {
339
+ this.pendingModules = loadUseModules(useRules);
340
+ }
341
+ const buildRules = (
342
+ statements: Statement[],
343
+ scoped: boolean,
344
+ transition: TransitionSpec | null = null
345
+ ) =>
346
+ statements.forEach((statement) => {
347
+ if (statement.type === "rule") {
348
+ new Rule({
349
+ quarkInstance: this,
350
+ statement,
351
+ source: this.src,
352
+ scoped,
353
+ transition,
354
+ });
355
+ } else if (
356
+ statement.type === "atrule" &&
357
+ statement.name === "scope" &&
358
+ statement.block
359
+ ) {
360
+ buildRules(statement.block.body, true, transition);
361
+ } else if (
362
+ statement.type === "atrule" &&
363
+ statement.name === "view-transition"
364
+ ) {
365
+ // sheet-level block: its rules transition, owned by the host
366
+ buildRules(
367
+ (statement as TransitionRule).block.body,
368
+ scoped,
369
+ transitionSpec(statement as TransitionRule, this.src, null)
370
+ );
371
+ } else if (
372
+ statement.type === "atrule" &&
373
+ ["warn", "debug", "error", "delay"].includes(statement.name)
374
+ ) {
375
+ // no element to evaluate against outside a rule
376
+ QuarkLogger.error({
377
+ method: "rule",
378
+ message: `Quark: @${statement.name} must be written inside a rule`,
379
+ });
380
+ } else if (
381
+ statement.type === "atrule" &&
382
+ (statement.name === "dispatch" || statement.name === "command")
383
+ ) {
384
+ QuarkLogger.error({
385
+ method: "rule",
386
+ message: `Quark: @${statement.name} must be written inside an @on block`,
387
+ });
388
+ }
389
+ });
390
+ buildRules(ast.body, false);
391
+ this.rules.forEach((rule) =>
392
+ [
393
+ ...rule.variables,
394
+ ...rule.attributes,
395
+ ...rule.listeners,
396
+ ...rule.diagnostics,
397
+ ...rule.delays,
398
+ ].forEach((prop) => {
399
+ prop.referencedVarNames.forEach((name) =>
400
+ this.bindingIndex.set(name, [
401
+ ...(this.bindingIndex.get(name) ?? []),
402
+ prop,
403
+ ])
404
+ );
405
+ prop.referencedPropNames.forEach((name) =>
406
+ this.propIndex.set(name, [...(this.propIndex.get(name) ?? []), prop])
407
+ );
408
+ })
409
+ );
410
+ // rules that write each other's selector attributes: warn at build
411
+ warnStaticCycles(this);
412
+ }
413
+
414
+ /**
415
+ * A binding changed somewhere in the DOM (any sheet, or JS via
416
+ * `element.quark.setProperty()`). If this sheet references the name and the owner is in
417
+ * scope, re-run those properties for the owner's subtree.
418
+ */
419
+ handleBindingChange(e: CustomEvent<BindingChangeDetail>) {
420
+ this.applyBindingChange(e.target as HTMLElement, e.detail);
421
+ }
422
+ applyBindingChange(owner: HTMLElement, detail?: BindingChangeDetail) {
423
+ const { name, sheetId, runId, sinceSeq, trace, selfCovered } =
424
+ detail ?? ({} as BindingChangeDetail);
425
+ // this sheet's own run already refreshed every reader of that write
426
+ if (selfCovered && sheetId === this.id) return;
427
+ const properties = this.bindingIndex.get(name);
428
+ if (!properties?.length) return;
429
+ const host = this.host.deref();
430
+ if (!host) return;
431
+ // owner must be an ancestor-or-self of this sheet's scope, or within it
432
+ if (!(host.contains(owner) || owner.contains(host))) return;
433
+ this.runElement(owner, [name], {
434
+ runId,
435
+ properties,
436
+ isAsyncRun: true,
437
+ changedBinding: { name, origin: new WeakRef(owner), sinceSeq, trace },
438
+ });
439
+ }
440
+
441
+ run(mutationMap: MutationMap, options: QuarkOptions = {}) {
442
+ const host = this.host.deref();
443
+ if (!host) return QuarkLogger.error("Quark: Host not found");
444
+ const runId = options?.runId || generateID();
445
+ if (isInfoLogging()) {
446
+ QuarkLogger.info({
447
+ method: "run",
448
+ sheetId: this.id,
449
+ runId: runId,
450
+ changes: new Map(
451
+ [...mutationMap].map(([key, set]) => [key, [...set].join(",")])
452
+ ),
453
+ });
454
+ }
455
+ const trace = (this.runTrace ??= {
456
+ seq: 0,
457
+ ran: new Map(),
458
+ visited: new Map(),
459
+ written: new Set(),
460
+ coverage: new Map(),
461
+ });
462
+ this.runDepth++;
463
+ try {
464
+ /*
465
+ * A sync trigger (binding / prop change) carries the chain in the
466
+ * stamps of the changed names. Observer-driven runs already opened
467
+ * their context in runRules().
468
+ */
469
+ LoopGuard.run(inheritedDepth(mutationMap), () =>
470
+ this.rules?.forEach((rule) => {
471
+ if (
472
+ !options.properties ||
473
+ options.properties.find((p) => p.parent === rule)
474
+ ) {
475
+ rule.run(mutationMap, {
476
+ host,
477
+ options: {
478
+ runId,
479
+ ...this.options,
480
+ ...options,
481
+ },
482
+ });
483
+ }
484
+ })
485
+ );
486
+ } finally {
487
+ this.runDepth--;
488
+ if (this.runDepth === 0) {
489
+ this.runTrace = null;
490
+ this.flushBindingChanges(trace);
491
+ }
492
+ }
493
+ }
494
+
495
+ runElement(scope: Element, attrs: string[], options: QuarkOptions = {}) {
496
+ const mutationMap = new Map();
497
+ mutationMap.set(scope, new Set(attrs));
498
+ this.run(mutationMap, options);
499
+ }
500
+
501
+ runAll(options: QuarkOptions = {}) {
502
+ const host = this.host.deref();
503
+ if (!host) return QuarkLogger.error("Quark: Host not found");
504
+ this.runElement(host, ["RUN_ALL"], {
505
+ ...options,
506
+ isFirstRun: true,
507
+ });
508
+ }
509
+
510
+ register({
511
+ sheetElement,
512
+ modules,
513
+ }: {
514
+ sheetElement: HTMLElement;
515
+ modules?: { [key: string]: Vars };
516
+ }) {
517
+ if (this.isRegistered) {
518
+ throw new Error("Quark: Quark sheet already registered");
519
+ }
520
+ {
521
+ // CAREFUL using this variable. Needs to be garbage collectable.
522
+ const __h = getQuarkHost(sheetElement.parentElement);
523
+ this.host = new WeakRef(__h);
524
+ // scoped selector matching rides on the host marker; only mark when a
525
+ // rule actually queries descendants
526
+ if (this.rules.some((r) => r.isScoped && r.matchSelector)) {
527
+ this.scopeId = acquireScopeId(__h);
528
+ }
529
+ // new children are seen by the MutationObserver (childList), whoever
530
+ // inserted them, no render event contract with elements
531
+ this.listenerConfig = [
532
+ [
533
+ // property changes bubble from the element that owns them
534
+ new WeakRef(__h.getRootNode()),
535
+ PROP_CHANGE_EVENT,
536
+ (e) => this.handlePropChange(e as CustomEvent<PropChangeDetail>),
537
+ ],
538
+ [
539
+ // bindings cascade across sheets, so listen at the root, an
540
+ // owner above this sheet's host is still a valid writer
541
+ new WeakRef(__h.getRootNode()),
542
+ BINDING_CHANGE_EVENT,
543
+ (e) =>
544
+ this.handleBindingChange(e as CustomEvent<BindingChangeDetail>),
545
+ ],
546
+ ];
547
+ const elementInternal = getQuarkInternal(this.host);
548
+ elementInternal.addModules(this.hash, modules);
549
+ QuarkRegistry.add(this);
550
+ this.isRegistered = true;
551
+ this.isFirstRender = true;
552
+ const registration = ++this.registrations;
553
+ publicize(["quark", "sheet", "registered"], {
554
+ weakElement: this.host,
555
+ tag: __h.localName,
556
+ sheetId: this.id,
557
+ hash: this.hash,
558
+ ruleCount: this.rules.length,
559
+ isScoped: !!this.options.isScoped,
560
+ });
561
+ const finish = () => {
562
+ this.isLoadingModules = false;
563
+ // the sheet may have been unregistered while @use modules loaded
564
+ if (!this.isRegistered) return;
565
+ if (this.options.observe) {
566
+ /*
567
+ * Listeners first: binding changes announced during the first
568
+ * run (this run's deferred defs) must reach
569
+ * readers that already ran, whatever the rule order.
570
+ */
571
+ listen(this.listenerConfig);
572
+ }
573
+ this.runAll();
574
+ if (this.hasTransitions) {
575
+ // the first render ends once this run's cascade settles
576
+ whenSettled().then(() => {
577
+ if (this.registrations === registration) {
578
+ this.isFirstRender = false;
579
+ }
580
+ });
581
+ } else {
582
+ this.isFirstRender = false;
583
+ }
584
+ if (this.options.observe) {
585
+ this.allAttrs = this.reduceAttrs();
586
+ const o = observe(this.host, this.allAttrs, this.queueRunRules, {
587
+ childRemovals: this.rules.some((rule) => rule.reactsToRemovals),
588
+ classNames: this.allAttrs.includes("class")
589
+ ? this.reduceClassNames()
590
+ : null,
591
+ });
592
+ if (!o) return QuarkLogger.error("Quark: Observer not created");
593
+ this.observer = o;
594
+ }
595
+ };
596
+ if (this.pendingModules) {
597
+ // First run (and observation) is gated on @use imports so every
598
+ // rule sees its modules; the fetches started at parse time.
599
+ this.isLoadingModules = true;
600
+ this.pendingModules.then((useModules) => {
601
+ const current = elementInternal.getModules(this.hash);
602
+ elementInternal.addModules(this.hash, {
603
+ ...useModules,
604
+ // `as *` exports merge into the bare (dfault) bucket rather
605
+ // than replacing modules passed to register()
606
+ dfault: { ...current?.dfault, ...useModules.dfault },
607
+ });
608
+ finish();
609
+ });
610
+ } else {
611
+ finish();
612
+ }
613
+ }
614
+ }
615
+
616
+ unregister() {
617
+ const host = this.host?.deref();
618
+ if (this.isRegistered && host) {
619
+ publicize(["quark", "sheet", "unregistered"], {
620
+ weakElement: this.host,
621
+ tag: host.localName,
622
+ sheetId: this.id,
623
+ hash: this.hash,
624
+ });
625
+ }
626
+ QuarkRegistry.remove(this);
627
+ this.isLoadingModules = false;
628
+ // a delayed block must not fire into a sheet that let go
629
+ this.delayTimers.forEach((timer) => clearTimeout(timer));
630
+ this.delayTimers.clear();
631
+ unobserve(this.observer, this.listenerConfig);
632
+ this.observer = null;
633
+ this.propSubscriptions.forEach((byName) =>
634
+ byName.forEach((release) => release())
635
+ );
636
+ this.propSubscriptions = new Map();
637
+ this.isRegistered = false;
638
+ if (this.scopeId) {
639
+ const host = this.host?.deref();
640
+ if (host) releaseScopeId(host);
641
+ this.scopeId = null;
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Observe `element[name]` for this sheet (once per element + name).
647
+ * Called from `prop()` on the first read by a property that statically
648
+ * references the name.
649
+ */
650
+ subscribeProp(element: Element, name: string) {
651
+ if (!this.isRegistered) return;
652
+ let byName = this.propSubscriptions.get(element);
653
+ if (!byName) this.propSubscriptions.set(element, (byName = new Map()));
654
+ if (!byName.has(name)) byName.set(name, subscribeProp(element, name));
655
+ }
656
+
657
+ /**
658
+ * A property changed on an element (coalesced per microtask, see
659
+ * props.ts). Re-run this sheet's readers of those names on that
660
+ * element.
661
+ */
662
+ handlePropChange(e: CustomEvent<PropChangeDetail>) {
663
+ const names = e.detail?.names ?? [];
664
+ const properties = names.flatMap((name) => this.propIndex.get(name) ?? []);
665
+ if (!properties.length) return;
666
+ const host = this.host.deref();
667
+ if (!host) return;
668
+ const element = e.target as HTMLElement;
669
+ if (this.options.isScoped && element !== host && !host.contains(element)) {
670
+ return;
671
+ }
672
+ // the assignment that fired this event stamped `(element, name)`
673
+ LoopGuard.run(
674
+ Math.max(0, ...names.map((name) => LoopGuard.depthOf(element, name))),
675
+ () => this.runElement(element, ["PROP"], { properties, isAsyncRun: true })
676
+ );
677
+ }
678
+
679
+ private reduceAttrs() {
680
+ const attrs = new Set<string>();
681
+ this.rules.forEach((rule) => {
682
+ rule?.observedAttrs?.forEach((attr) => attrs.add(attr));
683
+ });
684
+ return Array.from(attrs);
685
+ }
686
+
687
+ /**
688
+ * Class tokens the rules select on (`.x`), so the observer can drop
689
+ * `class` records that touch none of them. `null` when any class
690
+ * change matters: `[class~="x"]`, an escaped name, `attr("class")`.
691
+ */
692
+ private reduceClassNames(): Set<string> | null {
693
+ const names = new Set<string>();
694
+ for (const rule of this.rules) {
695
+ if (rule.readsAttr("class")) return null;
696
+ for (const deps of [rule.deps, rule.hostDeps]) {
697
+ if (!deps) continue;
698
+ if (!deps.classNames) return null;
699
+ deps.classNames.forEach((name) => names.add(name));
700
+ }
701
+ }
702
+ return names;
703
+ }
704
+ }
705
+
706
+ /** Nearest ancestor-or-self of `element` with a pending write of `name`. */
707
+ const nearestPending = (
708
+ pending: Map<Element, Map<string, Omit<BindingChangeDetail, "name">>>,
709
+ element: Element,
710
+ name: string
711
+ ) => {
712
+ for (let el: Element | null = element; el; el = el.parentElement) {
713
+ const entry = pending.get(el)?.get(name);
714
+ if (entry) return entry;
715
+ }
716
+ return undefined;
717
+ };
718
+
719
+ /**
720
+ * Did the run reach every element below `owner` that `reader`'s rule
721
+ * matches? True when the rule fanned out (full property set) from an
722
+ * ancestor of `owner`, or from `owner` itself if the reader was
723
+ * visited there too or the rule cannot match `owner` (a fan-out
724
+ * root's own properties are not part of a children fan-out).
725
+ */
726
+ const isCovered = (
727
+ trace: RunTrace,
728
+ reader: Variable | Attribute | Listener,
729
+ owner: Element,
730
+ host: HTMLElement
731
+ ): boolean => {
732
+ const roots = trace.coverage.get(reader.parent);
733
+ if (!roots?.size) return false;
734
+ for (let el = owner.parentElement; el; el = el.parentElement) {
735
+ if (roots.has(el)) return true;
736
+ }
737
+ if (!roots.has(owner)) return false;
738
+ return (
739
+ trace.visited.get(owner)?.has(reader) ||
740
+ !reader.parent.matchesElement(owner, host)
741
+ );
742
+ };
743
+
744
+ function generateID() {
745
+ return Math.random().toString(36).substring(2, 10);
746
+ }
747
+
748
+ /**
749
+ * Wrap sheet source in the implicit `@scope{}`, after any leading `@use`
750
+ * statements. Imports are sheet-level, not scope-level, so they must
751
+ * stay outside the block.
752
+ */
753
+ function wrapInScope(min: string): string {
754
+ const insertAt =
755
+ parse(min).body.find(
756
+ (statement) => !(statement.type === "atrule" && statement.name === "use")
757
+ )?.start ?? min.length;
758
+ return `${min.slice(0, insertAt)}@scope{${min.slice(insertAt)}}`;
759
+ }
760
+
761
+ /**
762
+ * `"/mods/string-utils.js"` -> `"string-utils"` (SCSS-style default ns);
763
+ * `"quark:math"` -> `"math"`.
764
+ */
765
+ function deriveUseNamespace(url: string): string {
766
+ const path = url.split(/[?#]/)[0].replace(BUILTIN_MODULE_SCHEME, "");
767
+ const segment = path.split("/").filter(Boolean).pop() ?? "";
768
+ return segment.replace(/\.[a-zA-Z]+$/, "");
769
+ }
770
+
771
+ /**
772
+ * Import all `@use` modules in parallel. `as *` exports land in the bare
773
+ * `dfault` bucket; others under their given or derived namespace. Failed
774
+ * imports are logged and skipped so one bad module does not block the
775
+ * sheet.
776
+ */
777
+ function loadUseModules(useRules: UseRule[]): Promise<{ [key: string]: Vars }> {
778
+ return Promise.all(
779
+ useRules.map(async (use) => {
780
+ try {
781
+ // `quark:` urls are the built-in modules (no fetch, no loader)
782
+ if (builtinModuleName(use.url) !== null) {
783
+ const mod = resolveBuiltinModule(use.url);
784
+ if (!mod) {
785
+ throw new Error(
786
+ `unknown built-in module; available: ${Object.keys(QUARK_MODULES)
787
+ .map((name) => `"${BUILTIN_MODULE_SCHEME}${name}"`)
788
+ .join(", ")}`
789
+ );
790
+ }
791
+ return { use, mod };
792
+ }
793
+ return { use, mod: await Quark.moduleLoader(use.url) };
794
+ } catch (error) {
795
+ QuarkLogger.error({
796
+ method: "use",
797
+ message: `Quark: Failed to load @use module "${use.url}"`,
798
+ error: [error],
799
+ });
800
+ return null;
801
+ }
802
+ })
803
+ ).then((loaded) => {
804
+ const modules: { [key: string]: Vars } = {};
805
+ for (const entry of loaded) {
806
+ if (!entry) continue;
807
+ const namespace =
808
+ entry.use.namespace ?? deriveUseNamespace(entry.use.url);
809
+ if (namespace === "*") {
810
+ modules.dfault = { ...modules.dfault, ...entry.mod };
811
+ } else {
812
+ modules[namespace] = { ...entry.mod };
813
+ }
814
+ }
815
+ return modules;
816
+ });
817
+ }