@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
@@ -0,0 +1,224 @@
1
+ /**
2
+ * `@warn` / `@debug` / `@error` statements inside rules and blocks: they
3
+ * evaluate on the matched element and report to the logger and the
4
+ * DevTools hook; `@warn` / `@error` once per element and rule, `@debug` on
5
+ * every application. Nothing is written to the document.
6
+ */
7
+ import { Quark } from "../../index";
8
+ import { QuarkLogger } from "../../src/utils";
9
+ import type { QuarkRenderer } from "../../src/devtools-hook";
10
+ import {
11
+ type DevtoolsHook,
12
+ NUCLEUS_DEVTOOLS_HOOK_KEY,
13
+ pathMatches,
14
+ type PublicizeMeta,
15
+ type PublicizePath,
16
+ } from "@excom/kit-devtools";
17
+ import {
18
+ afterEach,
19
+ beforeEach,
20
+ describe,
21
+ expect,
22
+ it,
23
+ vi,
24
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
25
+ import { createSheet, flush, mount, unregisterAll } from "./helpers";
26
+
27
+ type Spy = ReturnType<typeof vi.spyOn>;
28
+ type LogArg = { message?: string; values?: unknown[] };
29
+
30
+ const messages = (spy: Spy) =>
31
+ spy.mock.calls
32
+ .map(([arg]) => String((arg as LogArg)?.message ?? arg))
33
+ .filter((m) => m.startsWith("Quark @"));
34
+ const values = (spy: Spy) =>
35
+ spy.mock.calls
36
+ .map(([arg]) => arg as LogArg)
37
+ .filter((arg) => String(arg?.message).startsWith("Quark @"))
38
+ .map((arg) => arg.values);
39
+
40
+ describe("@warn / @debug / @error", () => {
41
+ let warn: Spy;
42
+ let debug: Spy;
43
+ let error: Spy;
44
+ beforeEach(() => {
45
+ warn = vi.spyOn(QuarkLogger, "warn").mockImplementation(() => {});
46
+ debug = vi.spyOn(QuarkLogger, "debug").mockImplementation(() => {});
47
+ error = vi.spyOn(QuarkLogger, "error").mockImplementation(() => {});
48
+ });
49
+ afterEach(() => {
50
+ unregisterAll();
51
+ document.body.innerHTML = "";
52
+ vi.restoreAllMocks();
53
+ delete (globalThis as Record<string, unknown>)[
54
+ NUCLEUS_DEVTOOLS_HOOK_KEY
55
+ ];
56
+ });
57
+
58
+ it("warns once per element and rule, naming the selector; the selector is the condition", async () => {
59
+ const { root } = mount(
60
+ `<img id="a" src="a.png"><img id="b" src="b.png" alt="fine">`,
61
+ `img:not([alt]) { @warn "img needs alt"; }`
62
+ );
63
+ await flush();
64
+ expect(messages(warn)).toEqual([
65
+ "Quark @warn (img:not([alt])): img needs alt",
66
+ ]);
67
+ expect(values(warn)).toEqual([["img needs alt"]]);
68
+ const a = root.querySelector("#a")!;
69
+ const b = root.querySelector("#b")!;
70
+ // `a` stops and starts matching again: no second warning for it
71
+ a.setAttribute("alt", "");
72
+ await flush();
73
+ a.removeAttribute("alt");
74
+ await flush();
75
+ expect(messages(warn)).toHaveLength(1);
76
+ // a new offender is its own warning
77
+ b.removeAttribute("alt");
78
+ await flush();
79
+ expect(messages(warn)).toHaveLength(2);
80
+ // nothing was written to the document
81
+ expect([...a.attributes].map((attr) => attr.name)).toEqual(["id", "src"]);
82
+ });
83
+
84
+ it("@debug reports every application and again when a binding it reads changes", async () => {
85
+ const { root } = mount(
86
+ `<section id="host" data-count="1"><span bind-count></span></section>`,
87
+ `#host {
88
+ $count: +attr("data-count");
89
+ @debug "count", $count;
90
+ [bind-count] { content: $count; }
91
+ }`
92
+ );
93
+ await flush();
94
+ expect(values(debug)).toEqual([["count", 1]]);
95
+ root.querySelector("#host")!.setAttribute("data-count", "2");
96
+ await flush();
97
+ expect(values(debug).at(-1)).toEqual(["count", 2]);
98
+ expect(values(debug)).toHaveLength(2);
99
+ expect(root.querySelector("[bind-count]")!.textContent).toBe("2");
100
+ });
101
+
102
+ it("@error reports at error level, inside an @on block with event data", async () => {
103
+ const { root } = mount(
104
+ `<form id="f"><button type="button">go</button></form>`,
105
+ `#f { @on request-failed { @error "request failed", event.detail.code; } }`
106
+ );
107
+ await flush();
108
+ expect(messages(error)).toEqual([]);
109
+ root
110
+ .querySelector("#f")!
111
+ .dispatchEvent(
112
+ new CustomEvent("request-failed", {
113
+ bubbles: true,
114
+ detail: { code: 502 },
115
+ })
116
+ );
117
+ await flush();
118
+ expect(messages(error)).toEqual(["Quark @error (#f): request failed 502"]);
119
+ expect(values(error)).toEqual([["request failed", 502]]);
120
+ });
121
+
122
+ it("reports one value per comma-list item and a single expression as one value", async () => {
123
+ mount(
124
+ `<p id="p"></p>`,
125
+ `#p { $pair: (1, 2); @debug $pair; @debug "a", "b"; @debug (x: 1); }`
126
+ );
127
+ await flush();
128
+ expect(values(debug)).toEqual([[[1, 2]], ["a", "b"], [{ x: 1 }]]);
129
+ expect(messages(debug)[2]).toBe('Quark @debug (#p): {"x":1}');
130
+ });
131
+
132
+ it("publishes quark/diagnostic with level, values, expression and the element", async () => {
133
+ const publications: { path: PublicizePath; meta: PublicizeMeta }[] = [];
134
+ const hook: DevtoolsHook = {
135
+ version: 1,
136
+ publicize: (path, meta) => {
137
+ publications.push({ path, meta });
138
+ },
139
+ };
140
+ Quark.attachDevtools(hook);
141
+ const { root } = mount(
142
+ `<img id="a" src="a.png">`,
143
+ `img:not([alt]) { @warn "img needs alt", attr("src"); }`
144
+ );
145
+ await flush();
146
+ const records = publications
147
+ .filter((p) => pathMatches(p.path, ["quark", "diagnostic"]))
148
+ .map((p) => p.meta);
149
+ expect(records).toHaveLength(1);
150
+ expect(records[0]).toMatchObject({
151
+ level: "warn",
152
+ key: "@warn",
153
+ tag: "img",
154
+ selector: "img:not([alt])",
155
+ expression: '"img needs alt", attr("src")',
156
+ values: ["img needs alt", "a.png"],
157
+ message: "img needs alt a.png",
158
+ });
159
+ expect((records[0].weakElement as WeakRef<Element>).deref()).toBe(
160
+ root.querySelector("#a")
161
+ );
162
+ });
163
+
164
+ it("lists diagnostics on the rule for DevTools", async () => {
165
+ const renderers: QuarkRenderer[] = [];
166
+ Quark.attachDevtools({
167
+ version: 1,
168
+ inject: (renderer) => {
169
+ renderers.push(renderer as QuarkRenderer);
170
+ },
171
+ });
172
+ const { quark, register } = createSheet(
173
+ `<p id="p"></p>`,
174
+ `#p { data-a: 1; @warn "w"; @debug "d", 1; }`
175
+ );
176
+ register();
177
+ await flush();
178
+ const sheet = renderers
179
+ .find((r) => r.kind === "quark")!
180
+ .sheets()
181
+ .find((s) => s.sheetId === quark.id)!;
182
+ expect(sheet.rules[0].diagnostics).toEqual([
183
+ { level: "warn", expression: '"w"' },
184
+ { level: "debug", expression: '"d", 1' },
185
+ ]);
186
+ expect(sheet.rules[0].declarations).toEqual([
187
+ { key: "data-a", value: "1" },
188
+ ]);
189
+ });
190
+
191
+ it("rejects statements outside a rule at build", () => {
192
+ new Quark({ src: '@warn "top"; @delay 10 { x: 1; }' });
193
+ const built = error.mock.calls.map(([arg]) =>
194
+ String((arg as LogArg)?.message)
195
+ );
196
+ expect(built).toContain("Quark: @warn must be written inside a rule");
197
+ expect(built).toContain("Quark: @delay must be written inside a rule");
198
+ });
199
+
200
+ it("reports a failing expression as an error, not a diagnostic", async () => {
201
+ mount(`<p id="p"></p>`, `#p { @warn nope(); }`);
202
+ await flush();
203
+ expect(messages(warn)).toEqual([]);
204
+ expect(
205
+ error.mock.calls.some(([arg]) =>
206
+ String((arg as LogArg)?.message).includes(
207
+ "Could not resolve expression"
208
+ )
209
+ )
210
+ ).toBe(true);
211
+ });
212
+
213
+ it("works inside @view-transition blocks and nested rules", async () => {
214
+ mount(
215
+ `<div id="host"><span></span><span></span></div>`,
216
+ `#host { @view-transition { span { @warn "in block"; } } }`
217
+ );
218
+ await flush();
219
+ expect(messages(warn)).toEqual([
220
+ "Quark @warn (#host span): in block",
221
+ "Quark @warn (#host span): in block",
222
+ ]);
223
+ });
224
+ });
@@ -0,0 +1,43 @@
1
+ import "@excom/quark-sheet";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
9
+ import {
10
+ click,
11
+ expectComplexity,
12
+ flush,
13
+ installDemoModules,
14
+ measureComplexity,
15
+ mountView,
16
+ readDemo,
17
+ restoreDemoModules,
18
+ } from "./view-helpers";
19
+
20
+ describe("dispatch view", () => {
21
+ beforeEach(() => installDemoModules());
22
+ afterEach(() => {
23
+ document.body.innerHTML = "";
24
+ restoreDemoModules();
25
+ });
26
+
27
+ it("relays a click to the panel as a custom event the panel's own block turns into State", async () => {
28
+ const { root, quark } = await mountView(readDemo(import.meta.url, "dispatch"));
29
+ const panel = root.querySelector("[aria-label='panel']")!;
30
+ const meter = measureComplexity(quark!);
31
+ click(root.querySelector("button"));
32
+ await flush();
33
+ const budget = meter.take();
34
+ meter.stop();
35
+ expect(panel.getAttribute("data-pings")).toBe("1");
36
+ expect(root.querySelector("output")?.textContent).toBe("pinged 1×");
37
+ click(root.querySelector("button"));
38
+ await flush();
39
+ expect(panel.getAttribute("data-pings")).toBe("2");
40
+ expect(root.querySelector("output")?.textContent).toBe("pinged 2×");
41
+ expectComplexity(budget);
42
+ });
43
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * `element.quark`: the JS side of bindings (`setProperty` /
3
+ * `setProperties` / `removeProperty` / `getPropertyValue`) on every
4
+ * element, and how sheets react to those writes.
5
+ */
6
+ import { Quark } from "../../index";
7
+ import { BINDING_CHANGE_EVENT } from "../../src/bindings";
8
+ import { installElementApi, QuarkElementApi } from "../../src/element-api";
9
+ import {
10
+ afterEach,
11
+ describe,
12
+ expect,
13
+ fixture,
14
+ it,
15
+ vi,
16
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
17
+ import { createSheet, flush, mount, unregisterAll } from "./helpers";
18
+
19
+ describe("element.quark", () => {
20
+ afterEach(() => {
21
+ unregisterAll();
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ it("is a lazy, stable accessor on every element, SVG included", () => {
26
+ const div = document.createElement("div");
27
+ expect(div.quark).toBeInstanceOf(QuarkElementApi);
28
+ expect(div.quark).toBe(div.quark);
29
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "g");
30
+ expect(svg.quark).toBeInstanceOf(QuarkElementApi);
31
+ expect(svg.quark).not.toBe(div.quark);
32
+ // not enumerable: never serialized alongside real properties
33
+ expect(Object.keys(div)).not.toContain("quark");
34
+ });
35
+
36
+ it("installs once and stays configurable", () => {
37
+ const before = Object.getOwnPropertyDescriptor(Element.prototype, "quark");
38
+ installElementApi();
39
+ expect(
40
+ Object.getOwnPropertyDescriptor(Element.prototype, "quark")?.get
41
+ ).toBe(before?.get);
42
+ expect(before?.configurable).toBe(true);
43
+ // a foreign prototype gets its own accessor
44
+ const proto = {};
45
+ installElementApi(proto);
46
+ expect(typeof Object.getOwnPropertyDescriptor(proto, "quark")?.get).toBe(
47
+ "function"
48
+ );
49
+ });
50
+
51
+ it("writes a binding on the owner and re-runs readers below it", async () => {
52
+ const { root, quark } = mount(
53
+ `<main><span bind-x></span></main>`,
54
+ `main { $x: "a"; }
55
+ [bind-x] { content: $x; }`
56
+ );
57
+ await flush();
58
+ const main = root.querySelector("main")!;
59
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("a");
60
+ expect(main.quark.setProperty("$x", "b")).toBe(true);
61
+ await flush();
62
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("b");
63
+ // same value: no change, no re-run
64
+ const runs = quark.rules[1].numberOfRuns;
65
+ expect(main.quark.setProperty("x", "b")).toBe(false);
66
+ await flush();
67
+ expect(quark.rules[1].numberOfRuns).toBe(runs);
68
+ });
69
+
70
+ it("works on an element no rule matched and shadows an ancestor's binding", async () => {
71
+ const { root } = mount(
72
+ `<main><section><span bind-x></span></section></main>`,
73
+ `main { $x: "outer"; }
74
+ [bind-x] { content: $x; }`
75
+ );
76
+ await flush();
77
+ const section = root.querySelector("section")!;
78
+ section.quark.setProperty("$x", "inner");
79
+ await flush();
80
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("inner");
81
+ expect(section.quark.getPropertyValue("$x")).toBe("inner");
82
+ // removeProperty = unset: the reader falls through to the ancestor
83
+ expect(section.quark.removeProperty("$x")).toBe(true);
84
+ await flush();
85
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("outer");
86
+ expect(section.quark.getPropertyValue("x")).toBe("outer");
87
+ expect(section.quark.removeProperty("$x")).toBe(false);
88
+ });
89
+
90
+ it("getPropertyValue resolves upward like a rule would", () => {
91
+ const root = fixture<HTMLElement>(
92
+ `<div><main><section><span></span></section></main></div>`
93
+ );
94
+ const main = root.querySelector("main")!;
95
+ const span = root.querySelector("span")!;
96
+ expect(span.quark.getPropertyValue("$missing")).toBeUndefined();
97
+ main.quark.setProperty("$list", [1, 2]);
98
+ expect(span.quark.getPropertyValue("$list")).toEqual([1, 2]);
99
+ // by value: a new object is a change, an equal one is not
100
+ expect(main.quark.setProperty("$list", [1, 2])).toBe(false);
101
+ expect(main.quark.setProperty("$list", [1, 2, 3])).toBe(true);
102
+ });
103
+
104
+ it("setProperties stores every value before announcing any of them", async () => {
105
+ const { root } = mount(
106
+ `<main><span bind-sum></span></main>`,
107
+ `main { $a: 1; $b: 1; }
108
+ [bind-sum] { content: "#{$a}+#{$b}"; }`
109
+ );
110
+ await flush();
111
+ const main = root.querySelector("main")!;
112
+ const seen: string[] = [];
113
+ root.addEventListener(BINDING_CHANGE_EVENT, () => {
114
+ seen.push(root.querySelector("[bind-sum]")?.textContent ?? "");
115
+ seen.push(`${main.quark.getPropertyValue("$a")}/${main.quark.getPropertyValue("$b")}`);
116
+ });
117
+ expect(main.quark.setProperties({ $a: 2, b: 3 })).toBe(true);
118
+ await flush();
119
+ expect(root.querySelector("[bind-sum]")?.textContent).toBe("2+3");
120
+ // both values were stored when the first change event fired
121
+ expect(seen[1]).toBe("2/3");
122
+ expect(main.quark.setProperties({ $a: 2, $b: 3 })).toBe(false);
123
+ });
124
+
125
+ it("tags the change event with sheetId null for DevTools", () => {
126
+ const el = fixture<HTMLElement>(`<div></div>`);
127
+ const heard = vi.fn();
128
+ el.addEventListener(BINDING_CHANGE_EVENT, (e) =>
129
+ heard((e as CustomEvent).detail)
130
+ );
131
+ el.quark.setProperty("$k", 1);
132
+ expect(heard).toHaveBeenCalledWith(
133
+ expect.objectContaining({ name: "$k", sheetId: null })
134
+ );
135
+ });
136
+
137
+ it("is State, not an event: a write before the sheet registers is read on the first run", async () => {
138
+ const { root, register } = createSheet(
139
+ `<main><span bind-x></span></main>`,
140
+ `[bind-x] { content: $x; }`
141
+ );
142
+ root.querySelector("main")!.quark.setProperty("$x", "early");
143
+ register();
144
+ await flush();
145
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("early");
146
+ });
147
+
148
+ it("reaches readers in another sheet below the owner", async () => {
149
+ const root = fixture<HTMLElement>(
150
+ `<div><main><div id="s1"></div><section><div id="s2"></div><span bind-x></span></section></main></div>`
151
+ );
152
+ const outer = new Quark({ src: `:scope { $x: "one"; }`, options: { isScoped: true } });
153
+ outer.register({ sheetElement: root.querySelector("#s1") as HTMLElement });
154
+ const inner = new Quark({ src: `[bind-x] { content: $x; }`, options: { isScoped: true } });
155
+ inner.register({ sheetElement: root.querySelector("#s2") as HTMLElement });
156
+ await flush();
157
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("one");
158
+ root.querySelector("main")!.quark.setProperty("$x", "two");
159
+ await flush();
160
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("two");
161
+ });
162
+
163
+ it("a rule's def re-running rewrites a JS value (last writer wins)", async () => {
164
+ const { root } = mount(
165
+ `<main><span bind-x></span></main>`,
166
+ `main { $x: attr("data-mode"); }
167
+ [bind-x] { content: $x; }`
168
+ );
169
+ await flush();
170
+ const main = root.querySelector("main")!;
171
+ main.quark.setProperty("$x", "js");
172
+ await flush();
173
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("js");
174
+ // the def's dependency changes: the rule writes again and wins
175
+ main.setAttribute("data-mode", "rule");
176
+ await flush();
177
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("rule");
178
+ });
179
+
180
+ it("lets a @use handler raise state without a signal box", async () => {
181
+ const { root } = mount(
182
+ `<div data-demo-counter><button></button><output></output></div>`,
183
+ `:scope { $count: 0; }
184
+ button { @on click (handle: bump(closest("[data-demo-counter]"))); }
185
+ output { content: "#{$count}"; }`,
186
+ {
187
+ bump: (owner: Element) => () => {
188
+ owner.quark.setProperty(
189
+ "$count",
190
+ ((owner.quark.getPropertyValue("$count") as number) ?? 0) + 1
191
+ );
192
+ },
193
+ }
194
+ );
195
+ await flush();
196
+ const button = root.querySelector("button")!;
197
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
198
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
199
+ await flush();
200
+ expect(root.querySelector("output")?.textContent).toBe("2");
201
+ });
202
+ });