@studiometa/js-toolkit 4.0.0-alpha.1 → 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 (125) hide show
  1. package/README.md +10 -0
  2. package/package.json +5 -2
  3. package/skills/studiometa-js-toolkit/SKILL.md +87 -0
  4. package/skills/studiometa-js-toolkit/references/api/configuration.md +169 -0
  5. package/skills/studiometa-js-toolkit/references/api/context/createContext.md +46 -0
  6. package/skills/studiometa-js-toolkit/references/api/context/createGroup.md +67 -0
  7. package/skills/studiometa-js-toolkit/references/api/context/index.md +69 -0
  8. package/skills/studiometa-js-toolkit/references/api/context/injectContext.md +58 -0
  9. package/skills/studiometa-js-toolkit/references/api/context/injectContextSync.md +44 -0
  10. package/skills/studiometa-js-toolkit/references/api/context/provideContext.md +61 -0
  11. package/skills/studiometa-js-toolkit/references/api/context/provideRootContext.md +42 -0
  12. package/skills/studiometa-js-toolkit/references/api/context/signal.md +85 -0
  13. package/skills/studiometa-js-toolkit/references/api/context/subscribeContext.md +71 -0
  14. package/skills/studiometa-js-toolkit/references/api/decorators/children.md +92 -0
  15. package/skills/studiometa-js-toolkit/references/api/decorators/component.md +75 -0
  16. package/skills/studiometa-js-toolkit/references/api/decorators/index.md +41 -0
  17. package/skills/studiometa-js-toolkit/references/api/decorators/inject.md +73 -0
  18. package/skills/studiometa-js-toolkit/references/api/decorators/on.md +149 -0
  19. package/skills/studiometa-js-toolkit/references/api/decorators/provide.md +77 -0
  20. package/skills/studiometa-js-toolkit/references/api/decorators/read-write.md +93 -0
  21. package/skills/studiometa-js-toolkit/references/api/diagnostics/DIAGNOSTICS.md +137 -0
  22. package/skills/studiometa-js-toolkit/references/api/diagnostics/EVENTS.md +45 -0
  23. package/skills/studiometa-js-toolkit/references/api/diagnostics/index.md +58 -0
  24. package/skills/studiometa-js-toolkit/references/api/diagnostics/reportDiagnostic.md +67 -0
  25. package/skills/studiometa-js-toolkit/references/api/diagnostics/warn.md +54 -0
  26. package/skills/studiometa-js-toolkit/references/api/dom/breakpoints.md +81 -0
  27. package/skills/studiometa-js-toolkit/references/api/dom/domUpdate.md +70 -0
  28. package/skills/studiometa-js-toolkit/references/api/dom/emitExtendable.md +68 -0
  29. package/skills/studiometa-js-toolkit/references/api/dom/index.md +26 -0
  30. package/skills/studiometa-js-toolkit/references/api/dom/swap.md +105 -0
  31. package/skills/studiometa-js-toolkit/references/api/dom/watchAttributeNamespace.md +96 -0
  32. package/skills/studiometa-js-toolkit/references/api/dom/watchAttributes.md +56 -0
  33. package/skills/studiometa-js-toolkit/references/api/dom/whenDOMSettled.md +41 -0
  34. package/skills/studiometa-js-toolkit/references/api/html/data-component.md +105 -0
  35. package/skills/studiometa-js-toolkit/references/api/html/data-mount.md +69 -0
  36. package/skills/studiometa-js-toolkit/references/api/html/data-option.md +106 -0
  37. package/skills/studiometa-js-toolkit/references/api/html/data-ref.md +63 -0
  38. package/skills/studiometa-js-toolkit/references/api/html/index.md +32 -0
  39. package/skills/studiometa-js-toolkit/references/api/index.md +73 -0
  40. package/skills/studiometa-js-toolkit/references/api/instance-events.md +75 -0
  41. package/skills/studiometa-js-toolkit/references/api/instance-methods.md +267 -0
  42. package/skills/studiometa-js-toolkit/references/api/instance-properties.md +90 -0
  43. package/skills/studiometa-js-toolkit/references/api/methods-hooks-events.md +149 -0
  44. package/skills/studiometa-js-toolkit/references/api/methods-hooks-lifecycle.md +90 -0
  45. package/skills/studiometa-js-toolkit/references/api/methods-hooks-options.md +96 -0
  46. package/skills/studiometa-js-toolkit/references/api/methods-hooks-services.md +164 -0
  47. package/skills/studiometa-js-toolkit/references/api/registry/defineManifest.md +57 -0
  48. package/skills/studiometa-js-toolkit/references/api/registry/fromMetaGlob.md +33 -0
  49. package/skills/studiometa-js-toolkit/references/api/registry/fromWebpackContext.md +37 -0
  50. package/skills/studiometa-js-toolkit/references/api/registry/getInstance.md +52 -0
  51. package/skills/studiometa-js-toolkit/references/api/registry/getInstances.md +48 -0
  52. package/skills/studiometa-js-toolkit/references/api/registry/getMountedInstances.md +39 -0
  53. package/skills/studiometa-js-toolkit/references/api/registry/getUnmountedInstances.md +46 -0
  54. package/skills/studiometa-js-toolkit/references/api/registry/index.md +49 -0
  55. package/skills/studiometa-js-toolkit/references/api/registry/registerComponent.md +93 -0
  56. package/skills/studiometa-js-toolkit/references/api/registry/registerComponents.md +37 -0
  57. package/skills/studiometa-js-toolkit/references/api/registry/registerManifest.md +72 -0
  58. package/skills/studiometa-js-toolkit/references/api/scheduler/defaultScheduler.md +104 -0
  59. package/skills/studiometa-js-toolkit/references/api/scheduler/index.md +27 -0
  60. package/skills/studiometa-js-toolkit/references/api/scheduler/nextFrame.md +31 -0
  61. package/skills/studiometa-js-toolkit/references/api/scheduler/viewTransition.md +53 -0
  62. package/skills/studiometa-js-toolkit/references/api/services/createService.md +95 -0
  63. package/skills/studiometa-js-toolkit/references/api/services/createServiceMixin.md +81 -0
  64. package/skills/studiometa-js-toolkit/references/api/services/index.md +94 -0
  65. package/skills/studiometa-js-toolkit/references/api/services/mixins.md +148 -0
  66. package/skills/studiometa-js-toolkit/references/api/services/perTarget.md +65 -0
  67. package/skills/studiometa-js-toolkit/references/api/services/toggle.md +80 -0
  68. package/skills/studiometa-js-toolkit/references/api/services/until.md +44 -0
  69. package/skills/studiometa-js-toolkit/references/api/services/useBreakpoint.md +42 -0
  70. package/skills/studiometa-js-toolkit/references/api/services/useDrag.md +119 -0
  71. package/skills/studiometa-js-toolkit/references/api/services/useInView.md +64 -0
  72. package/skills/studiometa-js-toolkit/references/api/services/useKey.md +105 -0
  73. package/skills/studiometa-js-toolkit/references/api/services/useMediaQuery.md +45 -0
  74. package/skills/studiometa-js-toolkit/references/api/services/useMutation.md +69 -0
  75. package/skills/studiometa-js-toolkit/references/api/services/usePointer.md +91 -0
  76. package/skills/studiometa-js-toolkit/references/api/services/usePrefersReducedMotion.md +42 -0
  77. package/skills/studiometa-js-toolkit/references/api/services/useRaf.md +91 -0
  78. package/skills/studiometa-js-toolkit/references/api/services/useResize.md +60 -0
  79. package/skills/studiometa-js-toolkit/references/api/services/useScroll.md +88 -0
  80. package/skills/studiometa-js-toolkit/references/api/services/useScrollProgress.md +88 -0
  81. package/skills/studiometa-js-toolkit/references/api/services/useWindowScroll.md +25 -0
  82. package/skills/studiometa-js-toolkit/references/api/services/useWindowSize.md +25 -0
  83. package/skills/studiometa-js-toolkit/references/api/storage/createStorage.md +126 -0
  84. package/skills/studiometa-js-toolkit/references/api/storage/index.md +33 -0
  85. package/skills/studiometa-js-toolkit/references/api/storage/presets.md +58 -0
  86. package/skills/studiometa-js-toolkit/references/api/storage/providers.md +109 -0
  87. package/skills/studiometa-js-toolkit/references/api/test/index.md +214 -0
  88. package/skills/studiometa-js-toolkit/references/guide/concepts/attribute-grammar.md +93 -0
  89. package/skills/studiometa-js-toolkit/references/guide/concepts/mutation-engine.md +71 -0
  90. package/skills/studiometa-js-toolkit/references/guide/concepts/philosophy.md +78 -0
  91. package/skills/studiometa-js-toolkit/references/guide/concepts/the-registry.md +110 -0
  92. package/skills/studiometa-js-toolkit/references/guide/going-further/autoloading.md +104 -0
  93. package/skills/studiometa-js-toolkit/references/guide/going-further/handling-diagnostics.md +104 -0
  94. package/skills/studiometa-js-toolkit/references/guide/going-further/mount-strategies.md +95 -0
  95. package/skills/studiometa-js-toolkit/references/guide/going-further/scheduling-work.md +124 -0
  96. package/skills/studiometa-js-toolkit/references/guide/going-further/sharing-state.md +218 -0
  97. package/skills/studiometa-js-toolkit/references/guide/going-further/swapping-content.md +92 -0
  98. package/skills/studiometa-js-toolkit/references/guide/going-further/testing-components.md +137 -0
  99. package/skills/studiometa-js-toolkit/references/guide/going-further/typing-components.md +190 -0
  100. package/skills/studiometa-js-toolkit/references/guide/going-further/using-decorators.md +168 -0
  101. package/skills/studiometa-js-toolkit/references/guide/going-further/using-storage.md +125 -0
  102. package/skills/studiometa-js-toolkit/references/guide/index.md +163 -0
  103. package/skills/studiometa-js-toolkit/references/guide/introduction/installation.md +144 -0
  104. package/skills/studiometa-js-toolkit/references/guide/introduction/lifecycle-hooks.md +174 -0
  105. package/skills/studiometa-js-toolkit/references/guide/introduction/managing-components.md +229 -0
  106. package/skills/studiometa-js-toolkit/references/guide/introduction/managing-options.md +235 -0
  107. package/skills/studiometa-js-toolkit/references/guide/introduction/managing-refs.md +174 -0
  108. package/skills/studiometa-js-toolkit/references/guide/introduction/using-services.md +218 -0
  109. package/skills/studiometa-js-toolkit/references/guide/introduction/working-with-events.md +251 -0
  110. package/skills/studiometa-js-toolkit/references/guide/migration/v3-to-v4.md +373 -0
  111. package/skills/studiometa-js-toolkit/references/utils/css.md +98 -0
  112. package/skills/studiometa-js-toolkit/references/utils/dom.md +54 -0
  113. package/skills/studiometa-js-toolkit/references/utils/easings.md +217 -0
  114. package/skills/studiometa-js-toolkit/references/utils/focus.md +84 -0
  115. package/skills/studiometa-js-toolkit/references/utils/history.md +69 -0
  116. package/skills/studiometa-js-toolkit/references/utils/index.md +53 -0
  117. package/skills/studiometa-js-toolkit/references/utils/is.md +84 -0
  118. package/skills/studiometa-js-toolkit/references/utils/load.md +59 -0
  119. package/skills/studiometa-js-toolkit/references/utils/math.md +129 -0
  120. package/skills/studiometa-js-toolkit/references/utils/motion.md +218 -0
  121. package/skills/studiometa-js-toolkit/references/utils/objects.md +68 -0
  122. package/skills/studiometa-js-toolkit/references/utils/scroll.md +128 -0
  123. package/skills/studiometa-js-toolkit/references/utils/strings.md +176 -0
  124. package/skills/studiometa-js-toolkit/references/utils/timing.md +111 -0
  125. package/skills/studiometa-js-toolkit/references/utils/transitions.md +122 -0
@@ -0,0 +1,104 @@
1
+ # Autoloading
2
+
3
+ A page that declares its components in the markup does not have to import them all up front. Map each `data-component` token to a dynamic import and the registry downloads a chunk when an element needs one.
4
+
5
+ ## `registerManifest()`
6
+
7
+ ```js
8
+ import { registerManifest } from '@studiometa/js-toolkit';
9
+
10
+ registerManifest({
11
+ Accordion: () => import('./components/Accordion.js'),
12
+ Slider: () => import('./components/Slider.js'),
13
+ Map: { load: () => import('./components/Map.js'), mountStrategy: 'visible' },
14
+ });
15
+ ```
16
+
17
+ An entry is either the importer itself, or an object with `load` and an optional `mountStrategy`.
18
+
19
+ - **One name, one entry**, across eager classes and manifests alike. A token an eager class or an earlier manifest owns gives a `manifest.duplicate-token` warning and is ignored.
20
+ - **No dependency and no bundler knowledge.** The value is a function that returns a promise. `import.meta.glob`, `import.meta.webpackContext` and a generated manifest all produce that shape.
21
+ - **The registry stays the only constructor.** The import ends in `registerComponent(ComponentClass)`.
22
+ - **One import per name, one failure report per name.** A failure emits one `component.load-failed` diagnostic and is never retried.
23
+ - **A class whose `config.name` differs from its token** gives a `registry.lazy-name-mismatch` warning.
24
+ - The class is resolved out of the imported module for you, so a hand-written entry is `Slider: () => import('./Slider.js')` with nothing to unwrap.
25
+
26
+ ## One knob, not two
27
+
28
+ Deferring the import and deferring the mount are **one decision**:
29
+
30
+ ```
31
+ data-mount > manifest entry mountStrategy > 'eager'
32
+ ```
33
+
34
+ The manifest entry stands in the middle slot for the `config.mountStrategy` of a class that cannot be read yet. Once the class registers, the chain reads `config.mountStrategy` and the entry is deleted from the map.
35
+
36
+ There is **no `data-load`** and no compatibility shim for it. A page that used `data-load` renames one attribute.
37
+
38
+ ### What that buys
39
+
40
+ One scheduling algorithm covers both halves. A name resolves to one source, and one controller holds the current decision of the element/component pair. The arrival of the class is a _change of source_, so the pass that replaces a controller turns a spent import trigger into a mount:
41
+
42
+ - a **one-shot** trigger (`visible`, `idle`, `interaction`) mounts on the import that proved its condition;
43
+ - a **reversible** one (`in-view`, `media:`) is observed again.
44
+
45
+ An unloaded declaration is invisible to `$query()`, `$closest()`, `$watchChildren()` and `getInstances()`, because nothing is constructed at discovery.
46
+
47
+ `whenDOMSettled()` covers an **eager** lazy component: the import promise joins the lifecycle-work set only for the eager trigger, so [`swap()`](../../api/dom/swap.md) waits for download, registration and mount — and still never waits on a viewport, an idle callback, an interaction or a media query.
48
+
49
+ ## Generating a manifest from a glob
50
+
51
+ `defineManifest()` turns a bundler glob into a manifest, deriving each token from the path:
52
+
53
+ ```js
54
+ import { defineManifest, fromMetaGlob } from '@studiometa/js-toolkit';
55
+
56
+ registerManifest(
57
+ defineManifest({
58
+ modules: fromMetaGlob(import.meta.glob('./components/*/index.js')),
59
+ }),
60
+ );
61
+ ```
62
+
63
+ - `index.js` falls back to the parent directory name, so `./components/Slider/index.js` is `Slider`.
64
+ - A package-wide `mountStrategy` emits entry wrappers only when it is not `eager`.
65
+ - Duplicate tokens give a warning and keep the first path.
66
+
67
+ For webpack, [`fromWebpackContext()`](../../api/registry/fromWebpackContext.md) takes an `import.meta.webpackContext` result. Neither adapter adds a bundler dependency — core names none of them.
68
+
69
+ ## Lazy children
70
+
71
+ `config.components` takes a dynamic import too, and the key supplies the name:
72
+
73
+ ```js
74
+ static config = {
75
+ name: 'Accordion',
76
+ components: {
77
+ AccordionItem: () => import('./AccordionItem.js'),
78
+ Icon: IconClass,
79
+ },
80
+ };
81
+ ```
82
+
83
+ `registerComponent()` defers a thunk instead of resolving it, and the value becomes a lazy entry of the same registry under its key. Everything after that is identical to a manifest entry.
84
+
85
+ - **The key supplies the name**, so a lazy child is a name the registry knows with nothing downloaded — and `on<Child><Event>` resolution works on it immediately.
86
+ - **A manifest can declare the parent only.** A child behind a thunk is its own chunk, so a family splits where the author splits it.
87
+ - **First wins, quietly**, unlike `registerManifest()`. Several parents declaring the same lazy child is the normal case.
88
+ - **The entry gets no `mountStrategy` field.** Until the class arrives the chain is `data-mount > eager`; after it, the usual chain reads the merged config, so a lazy child that is a subclass inherits the strategy of its base.
89
+ - A value written with `class` that does not extend `Base` is reported as `component.invalid-family-declaration`, where it is declared.
90
+
91
+ ## What v3's autoload did that v4 does not need
92
+
93
+ v3 shipped 1033 source lines of autoload across seven modules. The registry and the mount strategies absorb almost all of it:
94
+
95
+ | v3 | v4 |
96
+ | ------------------------------------------------------ | ------------------------------------------------------------- |
97
+ | `ComponentLoader.start()` discovery observer | the one document observer |
98
+ | the four load triggers of `__schedule()` | `mount-strategies.ts` |
99
+ | per-element and per-record cleanup bookkeeping | one controller map holding both halves |
100
+ | recursive registration of configured children | one `registerFamily()` loop over `config.components` |
101
+ | the `ComponentRecord` state machine | one `Map<string, Promise<void>>` — the promise _is_ the state |
102
+ | `readEagerTokens` and `<meta name="js-toolkit:eager">` | `data-mount` on the element |
103
+
104
+ The `children` arrays of a v3 manifest are dead data.
@@ -0,0 +1,104 @@
1
+ # Diagnostics
2
+
3
+ Everything the framework recovers from is reported on **one** cancelable event, and nothing in core calls `console.warn()` or `console.error()` directly.
4
+
5
+ ## The protocol
6
+
7
+ `EVENTS.diagnostic` is `'js-toolkit:diagnostic'`. It carries a `ToolkitDiagnosticDetail`:
8
+
9
+ | Field | Type | Notes |
10
+ | ----------- | ----------------------- | ------------------------------------------------- |
11
+ | `severity` | `'warning' \| 'error'` | |
12
+ | `code` | `ToolkitDiagnosticCode` | a namespaced string from `DIAGNOSTICS` |
13
+ | `message` | `string` | |
14
+ | `component` | `string \| undefined` | the reporting component's name, when there is one |
15
+ | `error` | `unknown` | **required** for `error`, absent for `warning` |
16
+
17
+ Every diagnostic starts on its relevant connected element, or on `document` when there is none, and is dispatched with `{ bubbles: true, composed: true, cancelable: true }`.
18
+
19
+ ## Listening
20
+
21
+ ```ts
22
+ import { DIAGNOSTICS, EVENTS, type ToolkitDiagnosticDetail } from '@studiometa/js-toolkit';
23
+
24
+ declare function monitor(code: string, detail: ToolkitDiagnosticDetail): void;
25
+ document.addEventListener(EVENTS.diagnostic, (rawEvent) => {
26
+ const event = rawEvent as CustomEvent<ToolkitDiagnosticDetail>;
27
+ monitor(event.detail.code, event.detail);
28
+
29
+ if (event.detail.code === DIAGNOSTICS.responsive.unknownBreakpoint) {
30
+ event.preventDefault();
31
+ }
32
+ });
33
+ ```
34
+
35
+ **Dispatch always happens before the default output.**
36
+
37
+ - An uncancelled **warning** calls `console.warn()` once with exactly `[js-toolkit:<code>] <message>`.
38
+ - An uncancelled **error** calls `reportError(detail.error)` with the original value.
39
+ - `preventDefault()` suppresses that output **only**. It changes no framework decision.
40
+
41
+ ## The codes
42
+
43
+ `DIAGNOSTICS` is a deeply frozen object, and `ToolkitDiagnosticCode` is the exact union of its strings:
44
+
45
+ ```js
46
+ import { DIAGNOSTICS } from '@studiometa/js-toolkit';
47
+
48
+ DIAGNOSTICS.component.loadFailed; // 'component.load-failed'
49
+ DIAGNOSTICS.callback.serviceFailed; // 'callback.service-failed'
50
+ DIAGNOSTICS.protocol.lateRegistration; // 'protocol.late-registration'
51
+ ```
52
+
53
+ See the [full list](../../api/diagnostics/DIAGNOSTICS.md).
54
+
55
+ ## Report once, or report and continue
56
+
57
+ | Behaviour | Cases |
58
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | **Reported exactly once** | recovered load, mount, invalid-strategy and `Base` lifecycle failures |
60
+ | **Report and continue** | isolated signal, context subscription and teardown, attribute watcher, service, scheduler tick and task, DOM-update runner and extendable-event callbacks |
61
+ | **Thrown or rejected** | decorator and manifest-adapter misuse, shared-runtime incompatibility, service startup rollback, caller-owned teardown, `viewTransition()` and `swap()` |
62
+
63
+ A scheduled task also **rejects its own promise** with the same value. Direct, caller-owned failures stay throws: they are the caller's to handle, not a channel's.
64
+
65
+ Warning deduplication is stored in a revisioned shared-runtime slot, keyed by weak owner plus a misuse key, so it works across independently evaluated copies of the package without retaining instances, elements, declarations, runners or manifest inputs.
66
+
67
+ ## Reporting your own
68
+
69
+ `warn()` and `reportDiagnostic()` are public, and a consumer code is any `'<namespace>.<name>'` string:
70
+
71
+ ```ts
72
+ import { reportDiagnostic, warn } from '@studiometa/js-toolkit';
73
+
74
+ warn('carousel.no-slides', 'A Carousel with no slide does nothing.', { component: 'Carousel' });
75
+
76
+ try {
77
+ JSON.parse('{');
78
+ } catch (error) {
79
+ reportDiagnostic('carousel.bad-config', 'The config attribute is not valid JSON.', error);
80
+ }
81
+ ```
82
+
83
+ From inside a component, `$warn()` and `$error()` fill the component name in for you:
84
+
85
+ ```js
86
+ this.$warn('carousel.no-slides', 'A Carousel with no slide does nothing.');
87
+ ```
88
+
89
+ ## What was removed
90
+
91
+ `EVENTS.error`, `ToolkitErrorDetail` and `ToolkitErrorStage` are removed with **no alias**. `DIAGNOSTICS`, `ToolkitDiagnosticSeverity`, `ToolkitDiagnosticCode` and `ToolkitDiagnosticDetail` replace them.
92
+
93
+ ## In tests
94
+
95
+ Asserting on `console.warn` cannot see the code, the severity or the reporting component, and it passes for the wrong diagnostic. [`captureDiagnostics()`](../../api/test/index.md) reads the channel instead — and cancelling each event as it arrives is the same act that silences the console, so collecting and silencing are one step:
96
+
97
+ ```js
98
+ import { captureDiagnostics } from '@studiometa/js-toolkit/test';
99
+
100
+ const diagnostics = captureDiagnostics();
101
+ // … exercise the component
102
+ expect(diagnostics.codes).toContain('ref.mismatch');
103
+ diagnostics.stop();
104
+ ```
@@ -0,0 +1,95 @@
1
+ # Mount strategies
2
+
3
+ A mount strategy decides **when** the registry mounts an instance. It constructs nothing and it wraps nothing.
4
+
5
+ ## The seven values
6
+
7
+ | Strategy | Mounts when | Reversible |
8
+ | ------------------------ | -------------------------------------- | ---------- |
9
+ | `eager` (default) | the element enters the document | no |
10
+ | `visible[:<rootMargin>]` | it first intersects the viewport | no |
11
+ | `in-view[:<rootMargin>]` | it intersects the viewport | **yes** |
12
+ | `idle` | the main thread becomes idle | no |
13
+ | `interaction` | the user first aims at **it** | no |
14
+ | `interaction:page` | the user first interacts with the page | no |
15
+ | `media:<query>` | the query is not empty and it matches | **yes** |
16
+
17
+ **One-shot and reversible are separate values.** `visible` mounts once and stays. `in-view` mounts and unmounts on each crossing.
18
+
19
+ ## Declaring one
20
+
21
+ A component declares its default in its config; any element overrides it with `data-mount`:
22
+
23
+ ```js
24
+ import { Base } from '@studiometa/js-toolkit';
25
+
26
+ class Map extends Base {
27
+ static config = {
28
+ name: 'Map',
29
+ mountStrategy: 'visible',
30
+ };
31
+ }
32
+ ```
33
+
34
+ ```html
35
+ <!-- uses the component default -->
36
+ <div data-component="Map"></div>
37
+
38
+ <!-- overridden per element -->
39
+ <div data-component="Map" data-mount="eager"></div>
40
+ <div data-component="Map" data-mount="visible:200px"></div>
41
+ <div data-component="Chat" data-mount="interaction:page"></div>
42
+ <div data-component="Parallax" data-mount="media:(prefers-reduced-motion: no-preference)"></div>
43
+ ```
44
+
45
+ The resolution chain is:
46
+
47
+ ```
48
+ data-mount > manifest entry mountStrategy > config.mountStrategy > 'eager'
49
+ ```
50
+
51
+ ## The grammar
52
+
53
+ The accepted values are exactly `eager`, `visible`, `visible:`, `visible:<rootMargin>`, `in-view`, `in-view:`, `in-view:<rootMargin>`, `idle`, `interaction`, `interaction:`, `interaction:page` and `media:<non-empty query>`.
54
+
55
+ - A viewport suffix is passed straight through as `IntersectionObserverInit.rootMargin`.
56
+ - The empty suffix behaves as the bare strategy.
57
+ - `page` is the only scope the interaction strategy takes.
58
+ - There is **no** threshold, no root element, no JSON options and no second attribute.
59
+
60
+ An unknown value, an empty media query, or a `rootMargin` the browser refuses leaves the component **unmounted** and reports `component.invalid-mount-strategy` once. The inert controller stays current while the declaration does not change, so it is not reported again; a corrected attribute replaces it. A failed strategy cannot stop the rest of a reconciliation.
61
+
62
+ ## `interaction` — intent on the element
63
+
64
+ `pointerenter`, `pointerdown` and `focusin`, bound to the element itself, so the component mounts **before** the click arrives. Two of the three bubble, so an interaction anywhere inside the subtree counts; `pointerenter` fires for the root element only.
65
+
66
+ ## `interaction:page` — the visit, not the element
67
+
68
+ This is the deferred-widget case: a chat panel, an embed, a third-party script, where nothing about the element predicts the moment and any sign of a live user does.
69
+
70
+ - The events are the deliberate ones: `pointerdown`, `keydown` and `focusin`. Hovering is deliberately **not** one of them — aiming is intent for one element and noise for a document.
71
+ - **One listener set for the page**, in the shared runtime, not one per waiting element. They attach with the first waiting element and release on the first interaction.
72
+ - **They are captured**, so a handler that stops propagation cannot hide the visit.
73
+ - **The signal is a fact about the visit.** An element that arrives after the interaction mounts at once — but only when the strategy was already listening, since nothing observes a page that has asked for nothing.
74
+
75
+ ## What a waiting component is
76
+
77
+ **A component that waits has no instance.** It is invisible to `$query()`, `$closest()`, `$watchChildren()` and `getInstances()`, and it announces nothing. There is nothing to guard against and nothing to check.
78
+
79
+ Once it has mounted and been stood down again — which only `in-view` and `media:` do — the instance exists and is unmounted, so it is what [`getUnmountedInstances()`](../../api/registry/getUnmountedInstances.md) answers.
80
+
81
+ ## Lifetime
82
+
83
+ - **Several components on one element share that element's `data-mount`.** A component that needs its own policy declares it in its config.
84
+ - **Teardown follows the element.** A strategy is disposed when its element leaves the document. A move ends as an unmount and a mount of the same instance.
85
+ - **The attribute is live.** A change to `data-mount` disposes the old strategy and applies the final one. Controller identity guards a queued callback of a disposed strategy.
86
+
87
+ ## What this replaces
88
+
89
+ The `withMountWhenInView`, `withMountOnMediaQuery` and `withMountWhenPrefersMotion` decorators of v3 are **deleted**. A decorator that wraps a constructor to decide whether to mount is the registry's job, and the registry does it without touching the class.
90
+
91
+ `data-load` and `loadStrategy` are gone too: deferring the import and deferring the mount are one decision. See [Autoloading](autoloading.md).
92
+
93
+ **`withInView` is a different thing**
94
+
95
+ [`withInView`](../../api/services/mixins.md) observes a component that is **already mounted** and calls `intersected()`. It does not replace the `visible` or `in-view` mount strategy.
@@ -0,0 +1,124 @@
1
+ # The scheduler
2
+
3
+ One frame-aligned scheduler is the clock of the framework. It replaces `domScheduler`, the `RafService` loop, `SmartQueue` and the view-transition scheduler of `@studiometa/ui`.
4
+
5
+ ## The lanes
6
+
7
+ ```
8
+ frame start (rAF)
9
+ 1. tick — fan out to the subscribers of the clock
10
+ 2. read — measure: layout reads only
11
+ 3. write — mutate: DOM writes only
12
+ style / layout / paint
13
+
14
+ between frames, on its own turns
15
+ background — time-sliced lane: mount and update lifecycle work,
16
+ mutation-record processing, manifest loading
17
+ ```
18
+
19
+ - **Frame alignment.** One flush per frame, at rAF. Every read runs before every write, once, before paint.
20
+ - **No thrashing.** A `read` scheduled from a `write` runs in the **next** frame. A `write` scheduled from a `read` runs in the **same** frame.
21
+ - **Bounded phases.** Each queue array is swapped for an empty one when its phase starts, so a task scheduled into the running phase lands in the next frame's batch. The `write` batch is taken after the reads run.
22
+ - **Error isolation.** One try/catch per task. A task that throws is reported and dropped; the flush continues and the scheduler never deadlocks.
23
+ - **Queued execution only.** There is no synchronous escape.
24
+
25
+ ## Reading and writing from a component
26
+
27
+ `this.$read(fn)` and `this.$write(fn)` tie tasks to the instance, and unmount cancels the pending ones:
28
+
29
+ ```js
30
+ import { Base } from '@studiometa/js-toolkit';
31
+
32
+ class Reveal extends Base {
33
+ static config = { name: 'Reveal' };
34
+
35
+ async mounted() {
36
+ const box = await this.$read(() => this.$el.getBoundingClientRect()).promise;
37
+ await this.$write(() => {
38
+ this.$el.style.setProperty('--height', `${box?.height}px`);
39
+ }).promise;
40
+ }
41
+ }
42
+ ```
43
+
44
+ Scheduling returns a cancelable **handle** whose promise resolves with the return value of the task:
45
+
46
+ ```ts
47
+ import { defaultScheduler } from '@studiometa/js-toolkit';
48
+
49
+ const el = document.body;
50
+ async function measure() {
51
+ const task = defaultScheduler.read(() => el.getBoundingClientRect());
52
+ const box = await task.promise;
53
+ task.cancel(); // idempotent
54
+ return box;
55
+ }
56
+ ```
57
+
58
+ ## The tick
59
+
60
+ `scheduler.tick(callback)` subscribes to the clock:
61
+
62
+ ```js
63
+ import { defaultScheduler } from '@studiometa/js-toolkit';
64
+
65
+ const unsubscribe = defaultScheduler.tick(({ time, delta }) => {
66
+ console.log(time, delta);
67
+ });
68
+ ```
69
+
70
+ - Tick callbacks run at the **start** of the flush, before `read`, so what they schedule belongs to the same frame. A callback measures in `read` and the render function it returns mutates in `write`.
71
+ - **The subscription is the only handle, and it keeps the loop alive.** The scheduler requests the next frame when a queue inside the frame is not empty, or when a tick subscriber stays. There is no permanent rAF loop.
72
+ - Tick subscribers are not queued work, so `whenIdle()` ignores them.
73
+ - A tick callback that throws is reported and skipped, never unsubscribed.
74
+ - **`delta` is clamped** to `[1, 40]` ms, and the first tick after the loop wakes reports `1000/60`. `time` stays the raw rAF timestamp.
75
+
76
+ A component that needs the loop for part of a cycle uses [`toggle()`](../../api/services/toggle.md) rather than subscribing and unsubscribing by hand.
77
+
78
+ ## The background lane
79
+
80
+ It posts its own turns through `scheduler.postTask({ priority: 'background' })`, and falls back to a `MessageChannel` message. Each turn runs a **5 ms slice** measured from the start of the drain, then gives the thread back and posts the next turn.
81
+
82
+ Background work alone never requests an animation frame. `whenIdle()` counts background tasks and resolves at the end of a background drain as well as at the end of a flush.
83
+
84
+ ## `nextFrame()`
85
+
86
+ ```js
87
+ import { nextFrame } from '@studiometa/js-toolkit';
88
+
89
+ async function afterPaint() {
90
+ await nextFrame();
91
+ }
92
+ ```
93
+
94
+ ## `afterWrite` is gone
95
+
96
+ rAF callbacks run before style, layout and paint, so **no phase inside the frame can read post-layout geometry**. Measure in the `read` phase of the next frame, or use a `ResizeObserver`.
97
+
98
+ ## View transitions
99
+
100
+ `viewTransition(update)` is a standalone export with a progressive-enhancement contract: where the platform has no `startViewTransition`, the update simply runs.
101
+
102
+ ```ts
103
+ import { viewTransition } from '@studiometa/js-toolkit';
104
+
105
+ async function swapPanel(el: Element, html: string) {
106
+ await viewTransition(() => {
107
+ el.innerHTML = html;
108
+ });
109
+ }
110
+ ```
111
+
112
+ - Updates queued in the **same flush** batch into one `startViewTransition()` call, so a backdrop and a panel animate as one transition. Each later batch is appended to one promise tail, so several flushes during one transition stay serialized.
113
+ - The scheduler flushes the pending `write` tasks before the snapshot. Writes scheduled inside the update callback run within the transition.
114
+ - The helper is standalone. `Base` has no view-transition method and no import of one.
115
+
116
+ It composes with a negotiated `domUpdate()`, where the ancestor chooses the lane because it knows whether the region animates:
117
+
118
+ | claim | effect |
119
+ | --------------------------------------------- | -------------------------------------------------------------------- |
120
+ | `wrap(viewTransition)` | the change plays as one batched native view transition |
121
+ | `wrap((apply) => this.$write(apply).promise)` | the change lands in the `write` phase, batched, cancelled on unmount |
122
+ | `wrap(motionView)` | any object with `update(mutate)` |
123
+
124
+ Neither runner is the default.
@@ -0,0 +1,218 @@
1
+ # Shared state
2
+
3
+ Two components that must agree on something share it through **provide/inject** — the shape of Vue's `provide`/`inject`, with the mechanics of the WICG context protocol.
4
+
5
+ ## A typed key
6
+
7
+ A key is a value, not a string, so keys cannot collide:
8
+
9
+ ```ts
10
+ import { createContext, type Signal } from '@studiometa/js-toolkit';
11
+
12
+ interface SliderApi {
13
+ state: Signal<{ index: number; total: number }>;
14
+ goNext(): void;
15
+ }
16
+
17
+ export const SliderContext = createContext<SliderApi>('slider');
18
+ ```
19
+
20
+ The description is for debugging only. The identity of the key is what resolves.
21
+
22
+ ## Providing
23
+
24
+ The coordinator provides what a control can ask for. **The value is provided as it is** — nothing is wrapped, so the type of the key is the contract from end to end:
25
+
26
+ ```ts
27
+ import { Base, createContext, signal, type Signal } from '@studiometa/js-toolkit';
28
+
29
+ interface SliderApi {
30
+ state: Signal<{ index: number; total: number }>;
31
+ goNext(): void;
32
+ }
33
+ const SliderContext = createContext<SliderApi>('slider');
34
+ class Slider extends Base {
35
+ static config = { name: 'Slider' };
36
+
37
+ api = this.$provide(SliderContext, {
38
+ state: signal({ index: 0, total: 0 }), // what changes
39
+ goNext: () => this.goNext(), // what a control can command
40
+ });
41
+
42
+ goNext() {
43
+ this.api.state.value = {
44
+ ...this.api.state.value,
45
+ index: this.api.state.value.index + 1,
46
+ };
47
+ }
48
+ }
49
+ ```
50
+
51
+ - The **scope is the subtree**, and the nearest provider wins.
52
+ - A reactive value is a provided `Signal`. A command surface is a provided object.
53
+ - `$provide()` in a field initializer is **instance-scoped**: it is never released and dies with the element. A component whose declaration is withdrawn keeps providing until its element goes.
54
+
55
+ ## Injecting
56
+
57
+ | Form | Resolves | When nothing provides |
58
+ | ------------------ | --------------------------------- | ----------------------------------------------------- |
59
+ | `$inject(key)` | a promise, awaited in `mounted()` | it never settles: a missing provider means "not yet". |
60
+ | `$injectSync(key)` | the value, synchronously | `undefined`: the caller falls back or does nothing. |
61
+
62
+ ```ts
63
+ import { Base, createContext, type Signal } from '@studiometa/js-toolkit';
64
+
65
+ interface SliderApi {
66
+ state: Signal<{ index: number; total: number }>;
67
+ goNext(): void;
68
+ }
69
+ const SliderContext = createContext<SliderApi>('slider');
70
+ class SliderNext extends Base {
71
+ static config = { name: 'SliderNext' };
72
+
73
+ async mounted() {
74
+ const api = await this.$inject(SliderContext);
75
+ return api.state.subscribe(({ index, total }) => {
76
+ this.$el.toggleAttribute('disabled', index >= total - 1);
77
+ });
78
+ }
79
+ }
80
+ ```
81
+
82
+ The pending request of the async form is **unmount-scoped**. A new mount runs `mounted()` again and asks again.
83
+
84
+ **Which form to reach for**
85
+
86
+ `$inject()` from `mounted()` is the default: it waits, so mount order does not matter. `$injectSync()` is for the case where the answer is optional and the caller has a fallback. The [`@inject`](../../api/decorators/inject.md) field decorator asks once, at construction.
87
+
88
+ ## Page-wide state
89
+
90
+ `provideRootContext(key, create)` makes the page-wide case the outermost scope of the same mechanism. The value is provided on `document.documentElement`, so a request from anywhere reaches it by bubbling — and a nearer provider still wins:
91
+
92
+ ```ts
93
+ import { createContext, injectContextSync, provideRootContext } from '@studiometa/js-toolkit';
94
+
95
+ const DataChannels = createContext<Map<string, unknown>>('channels');
96
+ const el = document.body;
97
+ // Scoped or page-wide, resolved the same way, nearest first.
98
+ const channels =
99
+ injectContextSync(el, DataChannels) ?? provideRootContext(DataChannels, () => new Map());
100
+ ```
101
+
102
+ `create` runs at most once per key, and nothing is created at import time. A root provider cannot be disposed and it outlives the instance that asked first, because it is page state.
103
+
104
+ `withGroup` is not ported.
105
+
106
+ ## `Signal`
107
+
108
+ `signal(initialValue)` is a factory over a closure. The accessor is `.value`:
109
+
110
+ ```ts
111
+ import { signal } from '@studiometa/js-toolkit';
112
+
113
+ const count = signal(0);
114
+
115
+ const unsubscribe = count.subscribe((value) => console.log(value), { immediate: true });
116
+
117
+ count.value = 1;
118
+ count.value += 1;
119
+
120
+ unsubscribe();
121
+ ```
122
+
123
+ **A write settles synchronously and the newest value wins.** The delivery loop re-reads the value after each callback; if the value moved, the loop abandons the round and starts again on the new value, so a subscriber not reached yet skips the old value. Delivery stays in the same task.
124
+
125
+ **A subscriber that writes on every delivery live-locks the loop**
126
+
127
+ Guard the write, or move it out of the subscriber.
128
+
129
+ ## Reacting to a provider that appears later
130
+
131
+ `subscribeContext(el, key, onProvide)` is the subscription behaviour of the WICG protocol. The callback runs synchronously for each answer and receives the value and the same unsubscribe function the helper returns:
132
+
133
+ ```ts
134
+ import { Base, createContext, subscribeContext } from '@studiometa/js-toolkit';
135
+
136
+ interface GroupApi {
137
+ join(peer: Base): () => void;
138
+ }
139
+ const DisclosureGroupContext = createContext<GroupApi>('disclosure-group');
140
+ class Disclosure extends Base {
141
+ static config = { name: 'Disclosure' };
142
+
143
+ group?: GroupApi;
144
+
145
+ mounted() {
146
+ return subscribeContext(this.$el, DisclosureGroupContext, (group) => {
147
+ this.group = group;
148
+ const leave = group.join(this);
149
+ // The teardown for *this* value.
150
+ return () => {
151
+ leave();
152
+ this.group = undefined;
153
+ };
154
+ });
155
+ }
156
+ }
157
+ ```
158
+
159
+ - **The trigger is the mount announcement**, not a broadcast from the provider, and it runs after `mounted()`.
160
+ - **A new answer replaces; it never accumulates.** The teardown runs before the next _different_ value and on unsubscribe. An identical value is not an answer.
161
+ - **The registry holds nothing.** A subscription is anchored on its consumer element through a `WeakMap`, and the iterable index holds `WeakRef`s that the sweep prunes.
162
+ - Callback and teardown failures are isolated, so one consumer cannot stop the shared sweep.
163
+ - Two `contains()` calls bound the cost per mount. A mount that changes nothing checks nothing.
164
+
165
+ ## Groups of peers
166
+
167
+ `createGroup()` holds a `Set` and a `Signal`. It names no group and resolves no scope — the coordinator owns the membership and gives out the ways in:
168
+
169
+ ```ts
170
+ import { Base, createContext, createGroup, type Signal } from '@studiometa/js-toolkit';
171
+
172
+ interface GroupApi {
173
+ members: Signal<readonly Base[]>;
174
+ join(peer: Base): () => void;
175
+ open(peer: Base): void;
176
+ }
177
+ const DisclosureGroupContext = createContext<GroupApi>('disclosure-group');
178
+ class DisclosureGroup extends Base {
179
+ static config = { name: 'DisclosureGroup' };
180
+
181
+ #peers = createGroup<Base>();
182
+
183
+ api = this.$provide(DisclosureGroupContext, {
184
+ members: this.#peers.members, // the members to read, in document order
185
+ join: (peer: Base) => this.#peers.join(peer), // returns the leave function
186
+ open: (peer: Base) => this.open(peer), // the invariant stays here
187
+ });
188
+
189
+ open(peer: Base) {}
190
+ }
191
+ ```
192
+
193
+ - `join()` returns its own `leave`, so a member that moves to a nearer group leaves the old one first.
194
+ - Scope comes from nearest-provider-wins, so a nested group takes its own members only.
195
+ - **The membership is a value.** A coordinator subscribes to it and re-checks its invariant on each change.
196
+ - **Document order is the tie-breaker**, so the markup decides which peer keeps its state.
197
+ - Nothing sweeps disconnected members. The teardown of the member removes it.
198
+
199
+ ## How the mechanics work
200
+
201
+ The consumer dispatches a bubbling, **module-private** `js-toolkit:context:request` event carrying a key, a callback and a subscription marker. It is deliberately not part of public `EVENTS`. The nearest mounted provider answers and stops propagation. `provideContext()` replays the requests that have no first answer yet, which is what makes mount order irrelevant. `injectContext()` and `$inject()` are one-shot.
202
+
203
+ Outside a component the same three functions work on a bare element:
204
+
205
+ ```ts
206
+ import {
207
+ createContext,
208
+ injectContext,
209
+ injectContextSync,
210
+ provideContext,
211
+ } from '@studiometa/js-toolkit';
212
+
213
+ const Key = createContext<number>('key');
214
+ const el = document.body;
215
+ const { value, dispose } = provideContext(el, Key, 42);
216
+ const { promise, cancel } = injectContext(el, Key);
217
+ const maybe = injectContextSync(el, Key);
218
+ ```