@studiometa/js-toolkit 4.0.0-alpha.0 → 4.0.0-alpha.2

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 (126) hide show
  1. package/README.md +10 -0
  2. package/dist/utils/index.d.ts +2 -2
  3. package/package.json +5 -2
  4. package/skills/studiometa-js-toolkit/SKILL.md +87 -0
  5. package/skills/studiometa-js-toolkit/references/api/configuration.md +169 -0
  6. package/skills/studiometa-js-toolkit/references/api/context/createContext.md +46 -0
  7. package/skills/studiometa-js-toolkit/references/api/context/createGroup.md +67 -0
  8. package/skills/studiometa-js-toolkit/references/api/context/index.md +69 -0
  9. package/skills/studiometa-js-toolkit/references/api/context/injectContext.md +58 -0
  10. package/skills/studiometa-js-toolkit/references/api/context/injectContextSync.md +44 -0
  11. package/skills/studiometa-js-toolkit/references/api/context/provideContext.md +61 -0
  12. package/skills/studiometa-js-toolkit/references/api/context/provideRootContext.md +42 -0
  13. package/skills/studiometa-js-toolkit/references/api/context/signal.md +85 -0
  14. package/skills/studiometa-js-toolkit/references/api/context/subscribeContext.md +71 -0
  15. package/skills/studiometa-js-toolkit/references/api/decorators/children.md +92 -0
  16. package/skills/studiometa-js-toolkit/references/api/decorators/component.md +75 -0
  17. package/skills/studiometa-js-toolkit/references/api/decorators/index.md +41 -0
  18. package/skills/studiometa-js-toolkit/references/api/decorators/inject.md +73 -0
  19. package/skills/studiometa-js-toolkit/references/api/decorators/on.md +149 -0
  20. package/skills/studiometa-js-toolkit/references/api/decorators/provide.md +77 -0
  21. package/skills/studiometa-js-toolkit/references/api/decorators/read-write.md +93 -0
  22. package/skills/studiometa-js-toolkit/references/api/diagnostics/DIAGNOSTICS.md +137 -0
  23. package/skills/studiometa-js-toolkit/references/api/diagnostics/EVENTS.md +45 -0
  24. package/skills/studiometa-js-toolkit/references/api/diagnostics/index.md +58 -0
  25. package/skills/studiometa-js-toolkit/references/api/diagnostics/reportDiagnostic.md +67 -0
  26. package/skills/studiometa-js-toolkit/references/api/diagnostics/warn.md +54 -0
  27. package/skills/studiometa-js-toolkit/references/api/dom/breakpoints.md +81 -0
  28. package/skills/studiometa-js-toolkit/references/api/dom/domUpdate.md +70 -0
  29. package/skills/studiometa-js-toolkit/references/api/dom/emitExtendable.md +68 -0
  30. package/skills/studiometa-js-toolkit/references/api/dom/index.md +26 -0
  31. package/skills/studiometa-js-toolkit/references/api/dom/swap.md +105 -0
  32. package/skills/studiometa-js-toolkit/references/api/dom/watchAttributeNamespace.md +96 -0
  33. package/skills/studiometa-js-toolkit/references/api/dom/watchAttributes.md +56 -0
  34. package/skills/studiometa-js-toolkit/references/api/dom/whenDOMSettled.md +41 -0
  35. package/skills/studiometa-js-toolkit/references/api/html/data-component.md +105 -0
  36. package/skills/studiometa-js-toolkit/references/api/html/data-mount.md +69 -0
  37. package/skills/studiometa-js-toolkit/references/api/html/data-option.md +106 -0
  38. package/skills/studiometa-js-toolkit/references/api/html/data-ref.md +63 -0
  39. package/skills/studiometa-js-toolkit/references/api/html/index.md +32 -0
  40. package/skills/studiometa-js-toolkit/references/api/index.md +73 -0
  41. package/skills/studiometa-js-toolkit/references/api/instance-events.md +75 -0
  42. package/skills/studiometa-js-toolkit/references/api/instance-methods.md +267 -0
  43. package/skills/studiometa-js-toolkit/references/api/instance-properties.md +90 -0
  44. package/skills/studiometa-js-toolkit/references/api/methods-hooks-events.md +149 -0
  45. package/skills/studiometa-js-toolkit/references/api/methods-hooks-lifecycle.md +90 -0
  46. package/skills/studiometa-js-toolkit/references/api/methods-hooks-options.md +96 -0
  47. package/skills/studiometa-js-toolkit/references/api/methods-hooks-services.md +164 -0
  48. package/skills/studiometa-js-toolkit/references/api/registry/defineManifest.md +57 -0
  49. package/skills/studiometa-js-toolkit/references/api/registry/fromMetaGlob.md +33 -0
  50. package/skills/studiometa-js-toolkit/references/api/registry/fromWebpackContext.md +37 -0
  51. package/skills/studiometa-js-toolkit/references/api/registry/getInstance.md +52 -0
  52. package/skills/studiometa-js-toolkit/references/api/registry/getInstances.md +48 -0
  53. package/skills/studiometa-js-toolkit/references/api/registry/getMountedInstances.md +39 -0
  54. package/skills/studiometa-js-toolkit/references/api/registry/getUnmountedInstances.md +46 -0
  55. package/skills/studiometa-js-toolkit/references/api/registry/index.md +49 -0
  56. package/skills/studiometa-js-toolkit/references/api/registry/registerComponent.md +93 -0
  57. package/skills/studiometa-js-toolkit/references/api/registry/registerComponents.md +37 -0
  58. package/skills/studiometa-js-toolkit/references/api/registry/registerManifest.md +72 -0
  59. package/skills/studiometa-js-toolkit/references/api/scheduler/defaultScheduler.md +104 -0
  60. package/skills/studiometa-js-toolkit/references/api/scheduler/index.md +27 -0
  61. package/skills/studiometa-js-toolkit/references/api/scheduler/nextFrame.md +31 -0
  62. package/skills/studiometa-js-toolkit/references/api/scheduler/viewTransition.md +53 -0
  63. package/skills/studiometa-js-toolkit/references/api/services/createService.md +95 -0
  64. package/skills/studiometa-js-toolkit/references/api/services/createServiceMixin.md +81 -0
  65. package/skills/studiometa-js-toolkit/references/api/services/index.md +94 -0
  66. package/skills/studiometa-js-toolkit/references/api/services/mixins.md +148 -0
  67. package/skills/studiometa-js-toolkit/references/api/services/perTarget.md +65 -0
  68. package/skills/studiometa-js-toolkit/references/api/services/toggle.md +80 -0
  69. package/skills/studiometa-js-toolkit/references/api/services/until.md +44 -0
  70. package/skills/studiometa-js-toolkit/references/api/services/useBreakpoint.md +42 -0
  71. package/skills/studiometa-js-toolkit/references/api/services/useDrag.md +119 -0
  72. package/skills/studiometa-js-toolkit/references/api/services/useInView.md +64 -0
  73. package/skills/studiometa-js-toolkit/references/api/services/useKey.md +105 -0
  74. package/skills/studiometa-js-toolkit/references/api/services/useMediaQuery.md +45 -0
  75. package/skills/studiometa-js-toolkit/references/api/services/useMutation.md +69 -0
  76. package/skills/studiometa-js-toolkit/references/api/services/usePointer.md +91 -0
  77. package/skills/studiometa-js-toolkit/references/api/services/usePrefersReducedMotion.md +42 -0
  78. package/skills/studiometa-js-toolkit/references/api/services/useRaf.md +91 -0
  79. package/skills/studiometa-js-toolkit/references/api/services/useResize.md +60 -0
  80. package/skills/studiometa-js-toolkit/references/api/services/useScroll.md +88 -0
  81. package/skills/studiometa-js-toolkit/references/api/services/useScrollProgress.md +88 -0
  82. package/skills/studiometa-js-toolkit/references/api/services/useWindowScroll.md +25 -0
  83. package/skills/studiometa-js-toolkit/references/api/services/useWindowSize.md +25 -0
  84. package/skills/studiometa-js-toolkit/references/api/storage/createStorage.md +126 -0
  85. package/skills/studiometa-js-toolkit/references/api/storage/index.md +33 -0
  86. package/skills/studiometa-js-toolkit/references/api/storage/presets.md +58 -0
  87. package/skills/studiometa-js-toolkit/references/api/storage/providers.md +109 -0
  88. package/skills/studiometa-js-toolkit/references/api/test/index.md +214 -0
  89. package/skills/studiometa-js-toolkit/references/guide/concepts/attribute-grammar.md +93 -0
  90. package/skills/studiometa-js-toolkit/references/guide/concepts/mutation-engine.md +71 -0
  91. package/skills/studiometa-js-toolkit/references/guide/concepts/philosophy.md +78 -0
  92. package/skills/studiometa-js-toolkit/references/guide/concepts/the-registry.md +110 -0
  93. package/skills/studiometa-js-toolkit/references/guide/going-further/autoloading.md +104 -0
  94. package/skills/studiometa-js-toolkit/references/guide/going-further/handling-diagnostics.md +104 -0
  95. package/skills/studiometa-js-toolkit/references/guide/going-further/mount-strategies.md +95 -0
  96. package/skills/studiometa-js-toolkit/references/guide/going-further/scheduling-work.md +124 -0
  97. package/skills/studiometa-js-toolkit/references/guide/going-further/sharing-state.md +218 -0
  98. package/skills/studiometa-js-toolkit/references/guide/going-further/swapping-content.md +92 -0
  99. package/skills/studiometa-js-toolkit/references/guide/going-further/testing-components.md +137 -0
  100. package/skills/studiometa-js-toolkit/references/guide/going-further/typing-components.md +190 -0
  101. package/skills/studiometa-js-toolkit/references/guide/going-further/using-decorators.md +168 -0
  102. package/skills/studiometa-js-toolkit/references/guide/going-further/using-storage.md +125 -0
  103. package/skills/studiometa-js-toolkit/references/guide/index.md +163 -0
  104. package/skills/studiometa-js-toolkit/references/guide/introduction/installation.md +144 -0
  105. package/skills/studiometa-js-toolkit/references/guide/introduction/lifecycle-hooks.md +174 -0
  106. package/skills/studiometa-js-toolkit/references/guide/introduction/managing-components.md +229 -0
  107. package/skills/studiometa-js-toolkit/references/guide/introduction/managing-options.md +235 -0
  108. package/skills/studiometa-js-toolkit/references/guide/introduction/managing-refs.md +174 -0
  109. package/skills/studiometa-js-toolkit/references/guide/introduction/using-services.md +218 -0
  110. package/skills/studiometa-js-toolkit/references/guide/introduction/working-with-events.md +251 -0
  111. package/skills/studiometa-js-toolkit/references/guide/migration/v3-to-v4.md +373 -0
  112. package/skills/studiometa-js-toolkit/references/utils/css.md +98 -0
  113. package/skills/studiometa-js-toolkit/references/utils/dom.md +54 -0
  114. package/skills/studiometa-js-toolkit/references/utils/easings.md +217 -0
  115. package/skills/studiometa-js-toolkit/references/utils/focus.md +84 -0
  116. package/skills/studiometa-js-toolkit/references/utils/history.md +69 -0
  117. package/skills/studiometa-js-toolkit/references/utils/index.md +53 -0
  118. package/skills/studiometa-js-toolkit/references/utils/is.md +84 -0
  119. package/skills/studiometa-js-toolkit/references/utils/load.md +59 -0
  120. package/skills/studiometa-js-toolkit/references/utils/math.md +129 -0
  121. package/skills/studiometa-js-toolkit/references/utils/motion.md +218 -0
  122. package/skills/studiometa-js-toolkit/references/utils/objects.md +68 -0
  123. package/skills/studiometa-js-toolkit/references/utils/scroll.md +128 -0
  124. package/skills/studiometa-js-toolkit/references/utils/strings.md +176 -0
  125. package/skills/studiometa-js-toolkit/references/utils/timing.md +111 -0
  126. package/skills/studiometa-js-toolkit/references/utils/transitions.md +122 -0
@@ -0,0 +1,214 @@
1
+ # Test helpers
2
+
3
+ ```js
4
+ import { mount, settle, waitFor } from '@studiometa/js-toolkit/test';
5
+ ```
6
+
7
+ Nine helpers a component test cannot write for itself.
8
+
9
+ ## Why the subpath exists
10
+
11
+ **The timing recipe is not derivable.** "Has this component mounted and finished its writes?" is answered by five rounds of a 10 ms timer followed by `defaultScheduler.whenIdle()`, and those two numbers encode the mount observer's delivery latency and the scheduler's lane order.
12
+
13
+ Neither half works alone: `whenIdle()` can resolve before the observer has reported the element, and a timer can return between two lanes. `defaultScheduler` and `nextFrame` were already public, so the pieces shipped and the recipe did not.
14
+
15
+ **It depends on no test framework.** Nothing in the module imports a runner, an assertion library or a spy; it reads the DOM and the scheduler only, so it runs under Vitest, under Playwright and on a plain browser page. That is also why it is not on the root barrel: a page has no use for it, and the root export count stays what it was.
16
+
17
+ ## `mount()`
18
+
19
+ ```ts
20
+ mount(html: string): Promise<HTMLElement>
21
+ ```
22
+
23
+ Inserts the markup and resolves once the eager components have mounted.
24
+
25
+ ```js
26
+ const root = await mount(`
27
+ <button data-component="Counter">
28
+ <span data-ref="output">0</span>
29
+ </button>
30
+ `);
31
+ ```
32
+
33
+ **It returns the wrapper `div` it created**, never the markup's own root, so a fragment of several siblings works and `querySelector()` has a stable handle. `.firstElementChild` is one property away.
34
+
35
+ ## `settle()`
36
+
37
+ ```ts
38
+ settle(): Promise<void>
39
+ ```
40
+
41
+ Resolves once the framework has caught up — the same recipe `mount()` uses.
42
+
43
+ **Not for a transition's end state**
44
+
45
+ `settle()` is generous rather than deterministic. See [Transitions](#transitions) below.
46
+
47
+ ## `frames()`
48
+
49
+ ```ts
50
+ frames(count?: number): Promise<void>
51
+ ```
52
+
53
+ Awaits animation frames. `nextFrame()` with a count.
54
+
55
+ ## `waitFor()`
56
+
57
+ ```ts
58
+ waitFor<T>(predicate: () => T | false | null | undefined, options?: { timeout?: number; message?: string }): Promise<T>
59
+ ```
60
+
61
+ Polls until the predicate is truthy and **returns that value**, which makes the same call either a guard or a query:
62
+
63
+ ```js
64
+ // a guard
65
+ await waitFor(() => el.classList.contains('is-open'));
66
+
67
+ // a query
68
+ const panel = await waitFor(() => root.querySelector('.panel'));
69
+ ```
70
+
71
+ It polls on a **10 ms cadence and drains the scheduler between attempts**. On timeout it throws — with the caller's `message`, or one naming the timeout.
72
+
73
+ ## `captureDiagnostics()`
74
+
75
+ ```ts
76
+ captureDiagnostics(target?: EventTarget): { codes: string[]; entries: ToolkitDiagnosticDetail[]; stop(): void }
77
+ ```
78
+
79
+ ```js
80
+ const diagnostics = captureDiagnostics();
81
+ // … exercise the component
82
+ expect(diagnostics.codes).toContain('ref.mismatch');
83
+ diagnostics.stop();
84
+ ```
85
+
86
+ **It reads the channel, and asserting on `console.warn` does not.** A recovered failure is reported on a cancelable event whose _default behaviour_ is the console line; a spy on that line cannot see the code, the severity or the reporting component, and it passes for the wrong diagnostic.
87
+
88
+ The helper **cancels each event as it arrives**, which is the same act that suppresses the sink — so collecting and silencing are one step, not two, and no spy is involved.
89
+
90
+ `target` defaults to `document`, which sees everything a connected element reported, because diagnostics bubble and compose.
91
+
92
+ ## `recordEvents()`
93
+
94
+ ```ts
95
+ recordEvents(target: EventTarget, ...types: string[]): { events: { type: string; detail: unknown }[]; stop(): void }
96
+ ```
97
+
98
+ ```js
99
+ const recording = recordEvents(el, 'open', 'opened');
100
+
101
+ el.querySelector('button').click();
102
+
103
+ await waitFor(() => recording.events.length === 2);
104
+ expect(recording.events.map(({ type }) => type)).toEqual(['open', 'opened']);
105
+ expect(recording.events[1].detail).toEqual({ height: 120 });
106
+
107
+ recording.stop();
108
+ ```
109
+
110
+ **It keeps the order and the payloads.** A component's contract is "`open` came before `opened`, and `opened` carried the height it measured", and a call-counting spy throws both away.
111
+
112
+ Recording several types into one array is the only place their **relative order** is visible. The richer `{ type, detail }` shape ships because a caller wanting names alone can map, and the reverse is impossible.
113
+
114
+ `$emit` dispatches synchronously but is almost never _called_ synchronously, so the count is awaited with `waitFor`, not read.
115
+
116
+ ## `countRequestedFrames()`
117
+
118
+ ```ts
119
+ countRequestedFrames(during: () => Promise<void> | void): Promise<number>
120
+ ```
121
+
122
+ ```js
123
+ const frames = await countRequestedFrames(async () => {
124
+ for (let i = 0; i < 10; i += 1) el.dispatchEvent(new Event('scroll'));
125
+ await settle();
126
+ });
127
+ expect(frames).toBeLessThan(3);
128
+ ```
129
+
130
+ **It patches a global, and says so.** It swaps `globalThis.requestAnimationFrame` for a counting wrapper that forwards to the original, and restores it in a `finally` — so it comes back whether `during` returns or throws.
131
+
132
+ It is here despite the patch because the assertion it serves, "this did not schedule a frame per event", has no other seam: the framework's own scheduler makes the calls, so there is nothing of the component's for a spy to sit on.
133
+
134
+ **Two concurrent calls would nest their wrappers. Do not.**
135
+
136
+ ## `resetDom()`
137
+
138
+ ```ts
139
+ resetDom(): Promise<void>
140
+ ```
141
+
142
+ Empties the document body and lets the teardown run.
143
+
144
+ ## `resetRegistry()`
145
+
146
+ ```ts
147
+ resetRegistry(): void
148
+ ```
149
+
150
+ **The inverse `registerComponent()` deliberately lacks.** A page registers once and keeps it; a test suite is the one caller for which a page-wide registry surviving `resetDom()` is wrong, and the workaround it forces is a counter minting `Widget-1`, `Widget-2`.
151
+
152
+ It is **coarse on purpose**: the element→controller map is a `WeakMap`, so it cannot be enumerated, nothing can walk it to dispose live triggers, and a `querySelectorAll()` sweep would still miss detached elements. Clearing everything works because the mutation-observer path already disposes a controller as its element leaves the DOM — **hence the call belongs after `resetDom()`**.
153
+
154
+ Two things it does not do: it cannot clear that `WeakMap`, so empty the DOM first; and it does not narrow the attribute filter the shared observer built from everything ever registered — an extra watched attribute costs a reconciliation pass that finds no owner, and nothing more.
155
+
156
+ **Never in an `afterEach`**
157
+
158
+ Spec files register at module top level, so this in an `afterEach` silently unregisters everything for every later test in the file. Put it in an `afterAll`, or call it and register again straight away:
159
+
160
+ ```js
161
+ afterEach(async () => {
162
+ await resetDom();
163
+ resetRegistry();
164
+ registerComponents(Subject, Emitter);
165
+ });
166
+ ```
167
+
168
+ ## Transitions {#transitions}
169
+
170
+ **A transition's end state is asserted by polling, never by `settle()`.** A method that starts a transition does not hand it back, and a kept end state lands only after `nextFrame()`, the `from` and `active` states, and either a `transitionend` or one more frame. `settle()` is generous rather than deterministic, which is a flake that passes alone and fails under load.
171
+
172
+ **Polling for an _absence_ is wrong** for the mirror-image reason: `leaveTransition()` clears the other direction's `to` state synchronously, so the poll passes before anything has happened.
173
+
174
+ ## What is deliberately not in it
175
+
176
+ An instance lookup — [`getInstance()`](../registry/getInstance.md) and [`getInstances()`](../registry/getInstances.md) on the root barrel answer it — fetch stubs, and pointer sequences. Each is either specific to one spec or already served by `vi.fn()` and `@vitest/browser`'s `userEvent`.
177
+
178
+ ## A full example
179
+
180
+ ```js
181
+ import { afterAll, expect, test } from 'vitest';
182
+ import { Base, registerComponent } from '@studiometa/js-toolkit';
183
+ import { mount, resetDom, resetRegistry, waitFor } from '@studiometa/js-toolkit/test';
184
+
185
+ class Counter extends Base {
186
+ static config = { name: 'Counter', refs: ['output'] };
187
+
188
+ count = 0;
189
+
190
+ onClick() {
191
+ this.count += 1;
192
+ this.$refs.output.textContent = String(this.count);
193
+ }
194
+ }
195
+
196
+ registerComponent(Counter);
197
+
198
+ test('it counts clicks', async () => {
199
+ const root = await mount(`
200
+ <button data-component="Counter">
201
+ <span data-ref="output">0</span>
202
+ </button>
203
+ `);
204
+
205
+ root.querySelector('button').click();
206
+
207
+ await waitFor(() => root.querySelector('[data-ref="output"]').textContent === '1');
208
+ });
209
+
210
+ afterAll(async () => {
211
+ await resetDom();
212
+ resetRegistry();
213
+ });
214
+ ```
@@ -0,0 +1,93 @@
1
+ # The attribute grammar
2
+
3
+ Every attribute the framework reads has one shape, and one module owns it:
4
+
5
+ ```
6
+ data-<namespace>[:<qualifier>[.<part>…]]
7
+ ```
8
+
9
+ ## A namespace is a whole name
10
+
11
+ There are two kinds.
12
+
13
+ **A fixed namespace is written in a module** — `data-component`, `data-mount`, `data-ref`, and in `@studiometa/ui` `data-on`, `data-track`, `data-bind`.
14
+
15
+ **A generated namespace comes from a declaration.** Every declared option owns the one built for it, so `columns` owns `data-option-columns`, and `data-option-` is a namespace _family_ rather than a namespace.
16
+
17
+ An option's name is therefore **inside** its namespace, never a qualifier of a shared one: `data-option-columns:s`, never `data-option:columns:s`.
18
+
19
+ ## The colon has one meaning
20
+
21
+ It selects one member of the vocabulary the namespace declares.
22
+
23
+ `data-option-columns:s` and `data-on:click` are not two meanings of the separator. They differ in what the _namespace_ is — one already-declared option against an open family of bindings — and in both cases the colon picks one member.
24
+
25
+ So **an attribute the framework reads holds at most one colon**, which is a rule you can check, and `attributes.spec.ts` does.
26
+
27
+ `data-on:click:s` is not an attribute. Responsiveness belongs to declared options, the only vocabulary that is finite, ordered, and has a value to resolve through a cascade.
28
+
29
+ ## A dot splits the qualifier, and core reads none of it
30
+
31
+ Whether the first part names what a binding writes to (`data-bind:prop.value`) or modifies how it fires (`data-on:click.prevent`) is the business of whoever declared the namespace.
32
+
33
+ Core reads the qualifier's **head** only, to check it against a vocabulary the caller handed over, and never past it.
34
+
35
+ > **Core owns when a declaration is re-parsed and how the attribute is observed. The namespace's owner owns what the string means.**
36
+
37
+ ## The kind of namespace decides the mechanism
38
+
39
+ What matters is whether the _whole set of names_ can be listed in advance — not whether the qualifier vocabulary is finite.
40
+
41
+ | Namespace | Names | Mechanism |
42
+ | ----------------------- | ------------------------------------------- | --------------------------------------- |
43
+ | `data-option-columns` | `attribute × breakpoint` — enumerable | registered in the one `attributeFilter` |
44
+ | `data-component` | fixed plus one per breakpoint — enumerable | registered in the one `attributeFilter` |
45
+ | `data-on`, `data-track` | any DOM event — open | `watchAttributeNamespace()` |
46
+ | `data-bind` | finite head, open name — **not** enumerable | `watchAttributeNamespace()` |
47
+
48
+ A generated namespace is enumerable _because_ it comes from a declaration, which is why the page-wide filter stays precise and no option costs a second observer.
49
+
50
+ `data-bind` shows why "finite or open" is the wrong axis on its own: its binding types are finite, but the class, property or attribute name after the dot is not — so the names cannot be listed and the namespace must be watched. Validating a finite head is an **independent** capability, available to a watched namespace and a registered one alike.
51
+
52
+ ## The two escape hatches
53
+
54
+ `attributeFilter` takes exact names and the DOM has no wildcard, so the engine cannot see an attribute the framework cannot name.
55
+
56
+ ### `watchAttributes(element, callback)`
57
+
58
+ Observes **every** attribute of that element, through a second, unfiltered observer. The page pays for the elements that ask.
59
+
60
+ - **The caller owns it.** It returns one idempotent cleanup and knows nothing about `Base`.
61
+ - **The records join the shared queue**, so they are drained wherever the engine drains its own, `whenDOMSettled()` included.
62
+ - **A callback runs after the framework work of the batch.**
63
+ - **Changes are coalesced**, with the rule of `option<Name>Changed()`.
64
+ - **The payload is one object**: `{ name, value, previousValue }`, with raw attribute strings and `null` for an absent attribute. It covers the element's whole attribute set, framework names included, so a caller narrows by prefix.
65
+
66
+ See [`watchAttributes()`](../../api/dom/watchAttributes.md).
67
+
68
+ ### `watchAttributeNamespace(element, namespace, bind, options?)`
69
+
70
+ The mechanism for a namespace whose names cannot be enumerated. Declare the prefix, hand over a binder, and get the per-element observation, the keyed bindings and the teardown.
71
+
72
+ - **One binding per attribute, keyed by the name that produced it.** That is what lets one code path cover all three shapes a change takes: **added** attaches with nothing to release, **changed** releases then attaches, **removed** releases with nothing to attach. A memoised parse cannot express the middle one — and rewriting an attribute in place is not hypothetical, since `swap({ mode: 'morph' })` does it.
73
+ - **The binder returns that binding's release**, or nothing when the declaration produced no binding, so a malformed value costs no bookkeeping.
74
+ - **Declaration order survives a rewrite.** The bindings are a `Map` keyed by attribute, and `set` on a key already there keeps its position.
75
+ - **An optional finite vocabulary validates the qualifier's head.** Given one, an unknown head warns once per element and per name with `attribute.unknown-qualifier` and binds nothing — so `data-bind:prpo.value` stops being an attribute that silently does nothing. Omitted, the vocabulary is open and anything binds, which is the only honest answer for `data-on`.
76
+ - **It is built on `watchAttributes()`**, so `whenDOMSettled()`, and therefore `swap()`, covers a namespaced declaration the way it covers a mount.
77
+ - `Base` has no wrapper and owns no cleanup. A component calls it from `mounted()` and returns its cleanup.
78
+
79
+ See [`watchAttributeNamespace()`](../../api/dom/watchAttributeNamespace.md).
80
+
81
+ To watch a subtree, character data, or a node the framework does not know, use [`useMutation()`](../../api/services/useMutation.md).
82
+
83
+ ## The one module that owns the vocabulary
84
+
85
+ `attributes.ts` owns every name in the filter and every test against one: `data-component`, `data-mount`, `data-ref`, the `data-option-` prefix with `optionAttributeFor()` and `isOptionAttribute()`, the responsive separator, and the coalescing rule. **It imports nothing from core.**
86
+
87
+ `component-declarations.ts` consumes that vocabulary and reads the DOM through the responsive cascade.
88
+
89
+ ## The coalescing rule, written once
90
+
91
+ Several writes to one attribute in a batch give **one** change, from the value before the first write to the value at the end of the batch. A write that ends where it started is not a change.
92
+
93
+ `rememberPreviousValue()` and `isNetChange()` hold the rule. The comparison uses **raw strings**, so a breakpoint crossing and an attribute write are the same kind of event.
@@ -0,0 +1,71 @@
1
+ # The mutation engine
2
+
3
+ One internal engine owns **one** MutationObserver, for component discovery, lifecycle, mount strategies, ref invalidation and declared options.
4
+
5
+ ## What it watches
6
+
7
+ Its `attributeFilter` holds:
8
+
9
+ - the fixed framework attributes — `data-component`, `data-mount`, `data-ref`;
10
+ - the exact responsive `data-component:<breakpoint>` spellings of the configured breakpoints;
11
+ - the option names of every registered config, and the negated spelling of each boolean option, with one scoped spelling per breakpoint.
12
+
13
+ Writes to `class`, `style` and ARIA attributes create **no record**. That is the point of an exact filter: the attributes a page churns are not the attributes the framework reads.
14
+
15
+ **The `attributeFilter` and the relevance test of the engine are one set.** A record is relevant if and only if it names an attribute the observer holds. A change to that set drains the records of the previous filter first.
16
+
17
+ ## The fixed order of a batch
18
+
19
+ The engine snapshots the membership of a removed subtree when the records enter its queue, then processes each batch in a fixed order:
20
+
21
+ 1. **unmount** removed subtrees and dispose their strategies;
22
+ 2. **reconcile** the final plain and scoped `data-component` attributes, and `data-mount`;
23
+ 3. **deliver** coalesced declared-option changes to the mounted instances that stay;
24
+ 4. **scan** added subtrees once and schedule their registered component tokens;
25
+ 5. **report** coalesced attribute changes to the elements that asked to watch them.
26
+
27
+ Teardown before setup, and the elements that stay hear about their options before anything new is built. Step 5 is last, which is why a component that stops its watcher during the same batch in which its own declaration is withdrawn hears nothing about the attribute change.
28
+
29
+ ## What each transition means
30
+
31
+ | Transition | Effect |
32
+ | --------------------------------------------------------- | ------------------------------------------------------------------------- |
33
+ | the element is **disconnected** | `$unmount()`, and the instance stays on its element for a later insertion |
34
+ | a component token is **removed** from a connected element | `$unmount()`, then the instance is dropped from the element |
35
+ | a breakpoint **withdraws** a declaration | the same: unmount, then drop |
36
+ | the node is **moved** | one unmount and one mount cycle, same identity |
37
+
38
+ The token later declared again gives a **new** instance, because the DOM no longer declared that identity.
39
+
40
+ ## Ref invalidation
41
+
42
+ Ref lookups are cached, and the cache is invalidated by a counter this observer increases. Reading that counter drains the pending records with `takeRecords()`, so a read is current even inside the same task as the mutation. Detached elements are never cached.
43
+
44
+ That is the whole implementation of "refs are live", and it is why there is no `$update()`.
45
+
46
+ ## `whenDOMSettled()`
47
+
48
+ It is the completion boundary for morphing, fetch updates and breakpoint crossings. It:
49
+
50
+ - drains the pending records;
51
+ - follows the mutation chains of eager lifecycle work, so markup a `mounted()` inserts is covered too;
52
+ - resolves after the eager mounts and the teardown.
53
+
54
+ It does **not** wait for visibility, interaction, idle or media conditions, and it does **not** await the promises returned by `mounted()`. An eager lazy component _is_ covered: the import promise joins the lifecycle-work set for the eager trigger only.
55
+
56
+ ## The lanes it runs in
57
+
58
+ Framework work runs through the scheduler's **background** lane: a 5 ms slice per turn, posted through `scheduler.postTask({ priority: 'background' })` with a `MessageChannel` fallback. Background work alone never requests an animation frame.
59
+
60
+ Breakpoint work runs there too, so `whenDOMSettled()` includes the teardown, import and mount work of a crossing.
61
+
62
+ See [The scheduler](../going-further/scheduling-work.md).
63
+
64
+ ## Watching something else
65
+
66
+ The engine sees only what it can name. Two opt-ins cover the rest, and both are described in [The attribute grammar](attribute-grammar.md):
67
+
68
+ - [`watchAttributes()`](../../api/dom/watchAttributes.md) — every attribute of one element, through a second observer, with the records joining the same queue.
69
+ - [`watchAttributeNamespace()`](../../api/dom/watchAttributeNamespace.md) — a namespace whose names cannot be enumerated, with keyed bindings and per-declaration teardown.
70
+
71
+ For a subtree, character data, or a node the framework does not know, [`useMutation()`](../../api/services/useMutation.md) is a general `MutationObserver` as a service. It delivers on the platform's timing; a subscriber that needs the framework's order awaits `whenDOMSettled()` in its callback.
@@ -0,0 +1,78 @@
1
+ # Philosophy
2
+
3
+ > **The registry is the framework. The DOM is the component tree.**
4
+
5
+ An instance exists because its element is in the document and its class is registered. Nothing else creates or unmounts an instance. Parent and child are DOM ancestry only, never ownership. Components find each other through queries and events.
6
+
7
+ ## Five objectives
8
+
9
+ 1. **Components are independent.** No component constructs another.
10
+ 2. **One registry.** One map from name to source, and one scheduling algorithm.
11
+ 3. **The DOM drives mount and unmount.** One MutationObserver, and the element is the truth.
12
+ 4. **Parents listen to child events.** A bubbling `CustomEvent` is the upward channel.
13
+ 5. **Children announce their existence to parents.** A mount is an announcement, not a registration.
14
+
15
+ ## The three forks
16
+
17
+ | Fork | Decision |
18
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
19
+ | **Mount primitive** | `data-component` and one record-based MutationObserver. No tag names, no arbitrary selectors, no custom elements, no directive system. |
20
+ | **Shared state** | provide/inject in core, with the shape of Vue and the mechanics of the WICG context protocol. `provideRootContext()` covers the page-wide case. |
21
+ | **Child events** | The `on<Child><Event>` method names stay. Delegation resolves them against the names in `config.components`. |
22
+
23
+ Custom elements were considered as the mount and lifecycle primitive and refused. So was a separate directive registry: a behaviour is a component on the one registry, so nothing more is needed.
24
+
25
+ ## What was removed, and what replaces it
26
+
27
+ | Removed | Replacement |
28
+ | -------------------------------------------------------------- | ------------------------------------------------------------------------- |
29
+ | `$parent`, `$children`, `$root`, `createApp()` | `$query()`, `$closest()`, `$watchChildren()`, `registerComponent()` |
30
+ | `$update()` | nothing — refs are live |
31
+ | `config.emits` | the `$emits` props type, which leaves no runtime trace |
32
+ | `withExtraConfig()` | a class, since `$config` merges along the prototype chain |
33
+ | `withMountWhenInView` and friends | [mount strategies](../going-further/mount-strategies.md) |
34
+ | `data-load` and `loadStrategy` | one knob: `data-mount` |
35
+ | `withGroup` | `provideRootContext()` and `createGroup()` |
36
+ | `LoadService` | nothing — it was not ported |
37
+ | `domScheduler`, `SmartQueue`, the `RafService` loop | [one scheduler](../going-further/scheduling-work.md) |
38
+ | `EVENTS.error` and its detail types | [one diagnostic protocol](../going-further/handling-diagnostics.md) |
39
+ | `tween` and `animate` | not shipped — a separate `ui-animation` package |
40
+ | the `<tk-name>` tag sugar and arbitrary-selector registrations | `data-component` only |
41
+
42
+ `config.use` and `config.siblings` are not planned.
43
+
44
+ ## Why "one" keeps recurring
45
+
46
+ Three subsystems of v3 each had their own copy of the same work. v4 replaces each set with one.
47
+
48
+ **One registry** replaces three mounting systems: the global registry observer, `ChildrenManager` and the autoload loader. A name resolves to one source, and one controller holds the current decision for an element/component pair. The arrival of a lazy class is a _change of source_, which is why an import trigger and a mount trigger are the same code.
49
+
50
+ **One mutation engine** owns one MutationObserver for component discovery, lifecycle, mount strategies, ref invalidation and declared options. Its `attributeFilter` and its relevance test are the same set, so a record is relevant if and only if it names an attribute the observer holds.
51
+
52
+ **One scheduler** is the clock. `RafService` subscribes to the tick instead of owning a loop; the scroll service coalesces into one `read` per frame; the view-transition helper batches within a flush. No service owns a loop.
53
+
54
+ ## Sugar is never a requirement
55
+
56
+ Every decorator is a thin wrapper over a function API that works without it, because no engine ships stage-3 decorators. A page loaded from an ESM CDN with no build step keeps `registerComponent`, `$provide`, `$watchChildren`, `$read`, `$write` and the `on<Child><Event>` names.
57
+
58
+ The same principle sets the limit of the sugar. A mixin names **one** hook per class; `on<Event>` names one handler per class. A component whose subscriptions are one per markup declaration has no method to name and no fixed count, so it subscribes itself and returns the release from `mounted()`. Reaching for that deliberately is cheaper than discovering that a hook cannot be dynamic.
59
+
60
+ ## Inputs and state are different things
61
+
62
+ An option is an **input**. `$options` is a read-only view whose every property derives its value from the element and the viewport on access. Refs are live for the same reason: the DOM is the source, so reading it is the honest implementation and a cache that has to be refreshed is not.
63
+
64
+ What follows from that: to change what the DOM says, write the attribute — the same statement the markup makes. To keep what the component has worked out, keep a field. The two are never the same value.
65
+
66
+ ## Failures are a channel, not a print
67
+
68
+ Everything the framework recovers from is dispatched as one cancelable event before any console output, and nothing in core calls `console.warn()` or `console.error()` directly. Monitoring can read every code and suppress the noise without changing a single framework decision.
69
+
70
+ Failures a **caller** owns stay throws and rejections: decorator misuse, a manifest adapter given the wrong shape, `swap()`, `viewTransition()`. A channel is for what the framework decided to survive.
71
+
72
+ ## Read next
73
+
74
+ - [The registry](the-registry.md) — how one map and one controller cover eager, lazy and conditional mounting.
75
+ - [The attribute grammar](attribute-grammar.md) — one shape for every attribute the framework reads.
76
+ - [The mutation engine](mutation-engine.md) — the fixed order of one batch.
77
+
78
+ The full design document and the decision record live in the repository: [DESIGN.md](https://github.com/studiometa/js-toolkit/blob/main/packages/js-toolkit/DESIGN.md) states what v4 does, [RATIONALE.md](https://github.com/studiometa/js-toolkit/blob/main/packages/js-toolkit/RATIONALE.md) states why, which options were refused, and what the measurements are.
@@ -0,0 +1,110 @@
1
+ # The registry
2
+
3
+ One registry replaces the three mounting systems of v3: the global registry observer, `ChildrenManager` and the autoload loader.
4
+
5
+ ## The entry
6
+
7
+ ```
8
+ RegistryEntry = {
9
+ name,
10
+ source: constructor | lazy loader (manifest entry),
11
+ mountStrategy, // when to mount each instance
12
+ }
13
+ ```
14
+
15
+ - `registerComponent(Ctor)` registers an **eager** entry.
16
+ - `registerManifest(…)` registers **lazy** entries in the same map.
17
+ - A thunk in `config.components` becomes a lazy entry under its key.
18
+
19
+ **One name gives one entry**, as with `customElements.define()`. A collision gives a `registry.conflict` warning and the entry is ignored — except for a lazy child declared by several parents, which is the normal case and wins quietly.
20
+
21
+ ## The controller
22
+
23
+ **One element and component pair gives one controller.** The controller holds the strategy of the pair and the source it was scheduled against.
24
+
25
+ That single object is what makes eager, lazy and conditional mounting one algorithm:
26
+
27
+ - The arrival of a lazy class is a **change of source**, so the pass that replaces a controller turns a spent import trigger into a mount.
28
+ - A trigger stands down without a teardown of its own: a controller marks itself spent, a spent controller is not the current pair, and so a second trigger is inert. The teardown runs from the ordinary controller replacement.
29
+ - Controller identity guards a queued callback of a disposed strategy.
30
+
31
+ There is no `loadStrategy` and no `data-load`. Deferring the import and deferring the mount are one decision:
32
+
33
+ ```
34
+ data-mount > manifest entry mountStrategy > config.mountStrategy > 'eager'
35
+ ```
36
+
37
+ ## What the registry constructs, and what it does not
38
+
39
+ **The registry is the only code that constructs an instance.** `ChildrenManager` is gone and constructs nothing, because it does not exist. Autoload never touches `new`, the instance map, or a mount hook — an import ends in `registerComponent(ComponentClass)` and then the ordinary pipeline runs.
40
+
41
+ `registerComponent()` walks `config.components` in one `registerFamily()` loop, so one call covers a whole tree. The recursive registration of v3's manifest `children` arrays is dead data.
42
+
43
+ ## Where the instances live
44
+
45
+ An element publishes its instances under `Symbol.for('@studiometa/js-toolkit/instances')`. It is **not public API** — the four lookups are, and between them they express every read the map answers.
46
+
47
+ In a console, one line reads it:
48
+
49
+ ```js
50
+ $0[Symbol.for('@studiometa/js-toolkit/instances')];
51
+ ```
52
+
53
+ ### The four lookups
54
+
55
+ ```js
56
+ getMountedInstances('Dialog'); // the live ones
57
+ getInstances('Dialog', section); // every one built in a region
58
+ getUnmountedInstances('Dialog'); // built, then stood down
59
+ getInstance(el, 'Dialog'); // the one on this element, mounted or not
60
+ getInstances(el); // everything on one element
61
+ ```
62
+
63
+ They derive the answer from the DOM and keep **no registry of instances**.
64
+
65
+ - **The name says which population it answers.** `getInstances()` returns every instance that exists; `getMountedInstances()` is the safe list to call a method on; `getUnmountedInstances()` is what a reversible `in-view` or `media:` strategy has stood down, plus a construct-then-mount-failure.
66
+ - **A matching element with no instance is skipped, and that is the whole narrowing.** `selectorFor(name)` over-matches on purpose — it lists the responsive spellings of `data-component` too — and the instance-map read is what removes an inactive declaration, because a breakpoint-withdrawn component is destroyed _and_ deleted from the map.
67
+ - **There is no `getMountedInstance`.** The singular returns one object; a caller reads `.$isMounted` on it.
68
+ - `root` is a `ParentNode` and the call is `querySelectorAll`, so an element root searches its **descendants** and never matches itself.
69
+ - The element overload is the only form that reaches a **detached** element, since `querySelectorAll()` does not see one.
70
+
71
+ ## Responsive declarations
72
+
73
+ The plain `data-component` token set is always active. One responsive set is added with the spelling of responsive options:
74
+
75
+ ```html
76
+ <div
77
+ data-component="Action Analytics"
78
+ data-component:xxs="MobileMenu MobileSearch"
79
+ data-component:m="DesktopMenu DesktopSearch"></div>
80
+ ```
81
+
82
+ - At the active breakpoint the registry walks from the widest active suffix **down** and takes the first attribute present.
83
+ - That value is the **complete** responsive set. A wider value replaces every lower value; it does not merge. An empty value is a stop: `data-component:s="TabletFeature" data-component:l=""` runs `TabletFeature` at `s` and `m`, and removes it at `l`.
84
+ - The effective declaration is the **union** of the unconditional set and the selected responsive set, without duplicates.
85
+ - A crossing compares that effective set against the element's current state. A shared name keeps its controller and its instance. A name no longer declared is unmounted and dropped from the element, so a crossing back gives a new identity. A new name enters the normal pipeline, so mount strategies, `data-mount`, lazy entries and lifecycle events keep their meaning. An inactive lazy declaration imports nothing.
86
+ - The document observer registers the exact `data-component:<breakpoint>` names and replaces that slice of the filter after `setBreakpoints()`.
87
+ - Connected elements with a scoped declaration share **one** reference-counted `useBreakpoint()` subscription. A page with plain declarations opens none.
88
+ - Breakpoint work runs through the background lane, so `whenDOMSettled()` includes the teardown, import and mount work of a crossing.
89
+ - A suffix naming no configured breakpoint is ignored with one warning. That includes the v3 list syntax `data-component:xxs:xs:s`. There is no range form and no breakpoint-list form.
90
+
91
+ ## The matching surface
92
+
93
+ It is smaller than v3's, on purpose. Only plain and configured scoped `data-component` declarations are discovered, with whitespace-separated tokens for several components on one element.
94
+
95
+ Removed: the `<tk-name>` tag sugar, the breakpoint-list suffixes, and the lowercase arbitrary-selector registrations.
96
+
97
+ `data-component` still improves native elements — `<form>`, `<a>`, `<details>` and table markup all take it.
98
+
99
+ ## Duplicate copies of the package
100
+
101
+ `shared-runtime.ts` owns `globalThis[Symbol.for('@studiometa/js-toolkit/runtime')]` and gives each subsystem a typed slot with a revision. Independently evaluated copies reuse the canonical `defaultScheduler`, the registry maps and controllers, the DOM mutation observer and queue, the root-context state, the breakpoint state and every built-in service cache.
102
+
103
+ `Base` carries a separate inherited `Symbol.for` brand on its constructor, so family and imported-module resolution recognise a component from another copy. An incompatible root or slot revision throws. This is same-realm coordination only, and a `createService()` or `perTarget()` call of a consumer stays owned by that consumer.
104
+
105
+ ## What is not built
106
+
107
+ - **Composing and overriding manifests.** v4 is first-wins-and-warn. An `{ override: true }` option is about five lines; the decision is the expensive part.
108
+ - **A scoped `root`.** The registry is document-wide by construction, and no consumer has asked.
109
+ - **Informational manifest metadata** (`packageName`, `subpath`, `exportName`, `group`, `styles`, `integrations`). It belongs in the output type of the generator, not in core.
110
+ - **A `data-load` compatibility shim.** A page that used `data-load` renames one attribute.