+
diff --git a/packages/v4/docs/.vitepress/theme/composables/useAllLinks.ts b/packages/v4/docs/.vitepress/theme/composables/useAllLinks.ts
new file mode 100644
index 000000000..360d1125e
--- /dev/null
+++ b/packages/v4/docs/.vitepress/theme/composables/useAllLinks.ts
@@ -0,0 +1,81 @@
+import type { Ref } from 'vue';
+import { ref, unref } from 'vue';
+import { useData } from 'vitepress';
+
+interface Link {
+ text: string;
+ link: string;
+ parent?: Link;
+ root?: Link;
+ keywords?: string[];
+}
+
+interface VitepressLink {
+ text: string;
+ link?: string;
+ items?: VitepressLink[];
+ keywords?: string[];
+}
+
+/**
+ * Add links to the list of links.
+ */
+function addLinks(
+ links: Ref,
+ linksSet: Set,
+ item: VitepressLink,
+ parent?: VitepressLink,
+ root?: VitepressLink,
+) {
+ if (item.link) {
+ let { text, link, keywords = [] } = item;
+
+ if (!linksSet.has(link)) {
+ const newLink: Link = {
+ text,
+ link,
+ keywords,
+ };
+
+ if (parent) {
+ newLink.parent = {
+ text: parent.text,
+ link: parent.link,
+ };
+ }
+
+ if (root) {
+ newLink.root = {
+ text: root.text,
+ link: root.link,
+ };
+ }
+
+ links.value.push(newLink);
+ linksSet.add(link);
+ }
+ }
+
+ if (Array.isArray(item.items)) {
+ item.items.forEach((child) => addLinks(links, linksSet, child, item, parent));
+ }
+}
+
+export function useAllLinks() {
+ const { theme } = useData();
+ const { nav, sidebar } = unref(theme);
+
+ const links = ref([]);
+ const linkSet = new Set();
+
+ nav.forEach((item) => addLinks(links, linkSet, item));
+
+ Object.entries(sidebar).forEach(([name, item]) => {
+ const parent = nav.find((item) => name.startsWith(item.link));
+ item.forEach((link) => addLinks(links, linkSet, link, parent));
+ });
+
+ return {
+ links,
+ };
+}
diff --git a/packages/v4/docs/.vitepress/theme/composables/useObserver.ts b/packages/v4/docs/.vitepress/theme/composables/useObserver.ts
new file mode 100644
index 000000000..2bfdd277a
--- /dev/null
+++ b/packages/v4/docs/.vitepress/theme/composables/useObserver.ts
@@ -0,0 +1,38 @@
+let callbacks: Array = [];
+let observer: MutationObserver;
+
+function mainCallback(mutations: MutationRecord[]) {
+ callbacks.forEach((callback) => {
+ callback(mutations, observer);
+ });
+}
+
+export default function useObserver(callback: MutationCallback) {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ if (!observer) {
+ observer = new MutationObserver(mainCallback);
+ }
+
+ callbacks.push(callback);
+
+ function cleanup() {
+ callbacks = callbacks.filter((cb) => cb !== callback);
+ }
+
+ function observe(element: Node, options: MutationObserverInit) {
+ observer.observe(element, options);
+ }
+
+ function disconnect() {
+ observer.disconnect();
+ }
+
+ return {
+ observe,
+ cleanup,
+ disconnect,
+ };
+}
diff --git a/packages/v4/docs/.vitepress/theme/index.ts b/packages/v4/docs/.vitepress/theme/index.ts
new file mode 100644
index 000000000..b4b75afc3
--- /dev/null
+++ b/packages/v4/docs/.vitepress/theme/index.ts
@@ -0,0 +1,13 @@
+import DefaultTheme from 'vitepress/theme';
+import TwoslashFloatingVue from '@shikijs/vitepress-twoslash/client';
+import PreviewIframe from './components/PreviewIframe.vue';
+import './styles.css';
+
+/** @type {import('vitepress').Theme} */
+export default {
+ extends: DefaultTheme,
+ enhanceApp({ app }) {
+ app.component('PreviewIframe', PreviewIframe);
+ app.use(TwoslashFloatingVue);
+ },
+};
diff --git a/packages/v4/docs/.vitepress/theme/styles.css b/packages/v4/docs/.vitepress/theme/styles.css
new file mode 100644
index 000000000..a2fecebd0
--- /dev/null
+++ b/packages/v4/docs/.vitepress/theme/styles.css
@@ -0,0 +1,65 @@
+/* Layers */
+/*@layer theme, preflight, legacy, base, components, utilities;*/
+
+@import '@shikijs/vitepress-twoslash/style.css';
+@import 'tailwindcss';
+@import '@studiometa/tailwind-config';
+
+/* VitePress uses .dark class, not media query */
+@custom-variant dark (&:where(.dark, .dark *));
+
+@theme {
+ --color-vp-text-1: var(--vp-c-text-1);
+ --color-vp-bg: var(--vp-c-bg);
+ --color-vp-bg-alt: var(--vp-c-bg-alt);
+ --color-vp-bg-soft: var(--vp-c-bg-soft);
+ --color-vp-code-block-bg: var(--vp-code-block-bg);
+ --color-vp-c-indigo: var(--vp-c-indigo);
+ --color-vp-sidebar-bg: var(--vp-sidebar-bg-color);
+ --color-vp-divider: var(--vp-c-divider);
+ --color-brand: #3eaf7c;
+ --color-brand-light: #42d392;
+ --color-code-bg: var(--code-bg-color);
+ --font-size-2xs: 0.625rem;
+ --font-size-2xs--line-height: 1;
+}
+
+:root {
+ --vp-home-hero-name-color: var(--vp-c-brand-2);
+}
+
+.VPHomeHero .name,
+.VPHomeHero .text {
+ max-width: none !important;
+}
+
+.VPHomeHero .image-container .image-bg {
+ display: none !important;
+}
+
+.dark .VPHomeHero .image-container {
+ background-color: var(--vp-c-bg);
+}
+
+.VPHomeHero .image-src.dark {
+ mix-blend-mode: lighten;
+}
+
+@media (min-width: 960px) {
+ .VPHero.has-image .main {
+ max-width: 40rem !important;
+ }
+
+ .VPHomeHero .image-container {
+ position: absolute;
+ width: 550px !important;
+ height: 30rem !important;
+ transform: translateY(-8%);
+ transform-origin: center center;
+ }
+
+ .VPHomeHero .image-src {
+ max-width: 500px !important;
+ max-height: 500px !important;
+ }
+}
diff --git a/packages/v4/docs/api/configuration.md b/packages/v4/docs/api/configuration.md
new file mode 100644
index 000000000..3aae6bff7
--- /dev/null
+++ b/packages/v4/docs/api/configuration.md
@@ -0,0 +1,176 @@
+# Configuration
+
+`static config` declares everything the framework needs to know about a component before an instance exists.
+
+```ts
+interface BaseConfig {
+ name: string;
+ components?: Record;
+ refs?: string[];
+ options?: Record;
+ mountStrategy?: MountStrategy;
+}
+```
+
+[[toc]]
+
+## `config.name`
+
+**Required.** The name the registry registers under, the token `data-component` writes, and the prefix of every instance's [`$id`](/api/instance-properties.html#id).
+
+The name comes from the **merged** config, so a subclass that extends a component and forgets to rename registers under the name it inherited — and collides — rather than under `undefined`.
+
+## `config.refs`
+
+The refs the component declares. A list ref keeps its `[]`:
+
+```js twoslash
+// @twoslash-cache: {"v":1,"hash":"f9f2b84f87e4c484a476ef8c1fee6955124e6cdb91c6a0229ae8555121484242","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808AKAQwBsBLZuASgAIBjVlzi8AQlxgAeACq86NMFBHi4MAAqkIWEQF4xEjVrgA+ADph2AWywRSafasog4aZnaQBOKqxhgA5mj4SAAsVK6kfjAMiCAquN7sYLiIAAxU/PhuzPw05IgeAL4U6NjJBMRkTjT0eILCvNLMAEYIVC5u0QCsAOzevgFBiKHUbpHRII0tThxJSABM6Zmk2blIAGxFJTh4hCTkYfJMbJw8AkJwyhIycvS+Sg7qmtq8enGG2mYW1rb2cU7t7kQ3TSIB8/kCITCoyieD+CVmiAWIAyWRylUQAEZCsVqKUdhV9tRDjFGFgnmRMHxJnAAHT8CBgABm7D8iF4wHMvC5vDAzEsMDZLlIiT8AG5OdzSDBGXBBWhhf5kABdcVgAr/cLRAAcaz64MGupGERhMXpTJZ00SyQAzItUat8ptcdsYrtKgcaiSyVoKRg+Lz+XKFX4NR0kBiUhi9QNIUaxngA/FQVbYyjlmi8sEnZgXeU9lViSBSeS7H7eFKZUGRcrQ4CMVjoxChlDjeMK61kwjOnb0w6s0r0tAylYbHZ2Q9eAVeIzNJZeAByAACLgArlB2BB+a4APQAKzgAFo0BAIKwANbsNAHojBeeq8x1C4NZoieR3S6qdkS9podj8AQMsyfgvF+YDcjyfICgu1LzhQEpcu2bLIPOHAuLBC6uC0yrofOWDMEkrBwNhA4SgUqrqlQW7MEgoBvmAcAbmAeD7iABQFEAA"}
+import { Base } from '@studiometa/js-toolkit-v4';
+
+class Tabs extends Base {
+ static config = {
+ name: 'Tabs',
+ refs: ['list', 'tabs[]', 'panels[]'],
+ };
+}
+```
+
+See [Refs](/guide/introduction/managing-refs.html) and [`data-ref`](/api/html/data-ref.html).
+
+## `config.options`
+
+Each entry is a type, a tuple of types, or an object with `type` and `default`:
+
+```js twoslash
+// @twoslash-cache: {"v":1,"hash":"c0fe69516b685802e37b645c90f8d24aa77a4686e363bd0a8d66510328794f14","data":"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="}
+import { Base } from '@studiometa/js-toolkit-v4';
+
+class Slider extends Base {
+ static config = {
+ name: 'Slider',
+ options: {
+ label: String, // short form
+ speed: { type: Number, default: 1 }, // primitive default
+ loop: { type: Boolean, default: true },
+ tween: { type: Object, default: () => ({}) }, // factory required
+ offset: [Number, Array], // a union, in order
+ },
+ };
+}
+```
+
+The five option types are `String`, `Number`, `Boolean`, `Array` and `Object`. `Function` is not one, which is what makes a function `default` unambiguously a factory.
+
+See [Options](/guide/introduction/managing-options.html) and [`data-option-`](/api/html/data-option.html).
+
+## `config.components`
+
+The declared **family**. It does two jobs, and neither is ownership:
+
+1. it registers those names when this component registers, so one `registerComponent()` call covers a whole tree;
+2. it gives the name set that `on` resolution needs.
+
+```js
+static config = {
+ name: 'Accordion',
+ components: {
+ AccordionItem, // a class
+ Icon: () => import('./Icon.js'), // a thunk — its own chunk
+ },
+};
+```
+
+- **The key supplies the name**, so a lazy child is a name the registry knows with nothing downloaded.
+- A thunk is deferred rather than resolved, and becomes a lazy entry of the same registry.
+- **First wins, quietly.** Several parents declaring the same lazy child is the normal case.
+- A value written with `class` that does not extend `Base` is reported as `component.invalid-family-declaration`, where it is declared.
+
+See [Autoloading](/guide/going-further/autoloading.html).
+
+## `config.mountStrategy`
+
+The component's default answer to _when_. Any element overrides it with `data-mount`:
+
+```js twoslash
+// @twoslash-cache: {"v":1,"hash":"c43cf6369c04b4267f9048b177e1e2a893703a1798c2e9ee68cb22e24f907263","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808AKAQwBsBLZuASgAIBjVlzi8AQlxgAeACq86NMFBHi4MAAqkIWEQF4xEjVrgA+ADph2AWywRSafasog4aZnaQBOKqxhgA5mj4SAAsVK6kfjAMiCAquN7sYLiIAAxU/PhuzPw05IgeAL4U6NjJBMRkTjT0eILCvACyzFhOLm7RAKwA7N6+AUGIAMxhbpHRIE0tCUlIAEzpmaTZuUgAbEUlOHiEJORh8kxsnDwCQnDKEjJy9L5KDuqa2rx6cYbaZhbWtvZxreHRqy8IB8/kCIRGESieF+02S8xAGSyOUqiAAjAAODbUUrbCp7agHGKMLCPMiYPiTAB0/AgYAAZuw/IheMBzLx2bwwMxLDBmS5SIk/ABuNkcywQACuYDQAGU0EsaH4MHz5YKRWACn92kh0R1eqCBqsIWNarSGX4nBwZkMFkiVvksZgtjEdpV9jUiSStGSMHwuTyVQL/Fr3GiUmlgX0wYhQtRRlCYv74sDEslY4ilsi8sFHTiXXiqoSQMTSXZfbxxVLZfLmIrlbx+YKQ9FUaiepGDUhUcM45DxpXpXKFTAlZbU0g9QjFssUTmALrpaBlKw2Ows+68Aq8OmaSy8ADkAAEXBKoOwIDzXAB6ABWcAAtGgIBBWABrdhoe9EYL79XmOrnI0zTXAodxxCyoptGg7D8AIZqMs8EFgBynLcryB6TPuFCiuyA7VsOSrMvuRDsHA7AAEY+FhooFOqmpUJezBIKA8i+GRtJ4HeIAFAUQA==="}
+import { Base } from '@studiometa/js-toolkit-v4';
+
+class Map extends Base {
+ static config = {
+ name: 'Map',
+ mountStrategy: 'visible',
+ };
+}
+```
+
+See [Mount strategies](/guide/going-further/mount-strategies.html) and [`data-mount`](/api/html/data-mount.html).
+
+## Merging along the prototype chain
+
+`$config` walks the prototype chain and merges every config it finds:
+
+| Key | Merge rule |
+| --------------- | --------------------------- |
+| `refs` | union |
+| `options` | entry by entry |
+| `components` | entry by entry |
+| `name` | the most derived class wins |
+| `mountStrategy` | the most derived class wins |
+
+A subclass that states a `components` key again wins for **that key only**.
+
+```js twoslash
+// @twoslash-cache: {"v":1,"hash":"a46d9d32239f674899a5ba06de3ddb3b1cca96176e4af335dd676de119683d5c","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808AKAQwBsBLZuASgAIBjVlzi8AQlxgAeACq86NMFBHi4MAAqkIWEQF4xEjVrgA+ADph2AWywRSafasog4aZnaQBOKqxhgA5mj4SAAsVK6kfjAMiCAquN7sYLiIAAxU/PhuzPw05IgeAL4U6NjJBMRkTjT0eILCvACCAEYupNloAMIQYGiarE4ubtEArADs3r4BQYgAjMNhbpHRIM2t7V09fU4cSUgATOmZbTmViABsRSU4eIQk5GHyTGycPAJCcMoSMnL0vkoO6k02l4ejihm0Zgs1ls9jiA3C0QAHDMJv5AiEFhEong4QldogDiAMlkTnkAMwpS7UUo3Cr3aiPGKMLBAsiYPirXrrbq9CCsAB0/G6ADN2H5ELxgOZeDLeGBmJYYBLWok/ABuaWy0gwYVwZW9VXIAC6GrAst4WjQ7G6eslmvNMqETRgrAlAGUDf4Nq0AK45Wym80FU0FeFDTzjEA+NHTM6Ypa1EVi7aJZJkw4k3KeKmYa4xW6VB41JksrRsjB8eWK/WkVVh9yzGaE6NTDHURbYmJV+JR1Nt4nHLOIYI5mn5ulVRkgZmsuwV3ja3U1w1G+vRGbBFFRybo4fxzsgRcIPHJeZEo7tU4j4rUvPlO6T4vT0s4Od8S3WsC2qVm2VOl3up6fjer0fpoAG5ihlQgwNjMZzptuMZIJG4QJjEH42im+JxuemZXqOd4FvS1TLDOZZvrw/6urwHq1l6Nqgf69IweuoyhIhraIGeqEHlRWHJDhA6XnkcwEWURGPssRBuDRQGAXRwEMaQYG2E4UAQPwCAxA0rCsBAADuIiWMwFhYD6QhWt0vAmVAvDCrYxloFa/gWsKvAkbwKr+CINm8LAuSWIkzCWWavl6fwwWfq5nk+i0QEiPp7CBIknnxfya5IDMyKopxOE8cstF1ieSCCRepL7HsYm0g+RbLHUHy8AAcswRBipF3QbLy/TQQi+wpGeLa7jMKEdsszWtX47VgJ1WzFbMaS4YOpwXDeubiROtW1O8IicoOM18hlBJklug3THM+7LLt3KbAdc0zAtQnlYgZKVatY73oWDJPmRr7sk1LVtSF+0CkKYCiuKdq/jK3bLv4gZajqtpeX4xrwzKGFfhKP4OrKcCEPpXTWMIEqiBAfIwCZIHKUxaO8MGkGHXswyIjlu55aNiZg8md1bo9Q6FG9hEbV9pEvuWlYKkqqUKYzowIadbb5Xg3b8ZlhJ8/hgvrTVItMGLFFHrDKOrj14bPSk7EK3u7ZYssR6q7MCEa3k15XNrn0kXrs5/Rj372p5+OE1gxNiGTPiU0pKmkCGh1kmcC1WyNtt4L7DsbhmS0u1V44657Jbe/OeMGUHIek+TEdfox4HMb1z2jCdO5nVuSsxEXBMQETHxp+xzuZcM2cfcRU7SaQofl2AJNhxT02R0xseIpbjfIRdOJTyZ3cZ8J+yIgURrpNAZRWDYdiSgCdN2Zoli8AA5AAAi4PpQNaiquAA9AAVnAAC04F8gA1klL+RBgjX1NOYeqO04p7R5H0H4Ch/hxEhjKQYVp+ACCTH4EESDZQwxvldHIwNr4UH9obXgyBr5NB9E5bo1897+19ljSizBnTUUKi5Iomp6ZgAKOYcB21/oTSmsDOBfxIFrAITAvk2CUHsDQaDcGWDsY4MlhKa+41AafkIcQqGC5EYSnIUKTucBaEUF4K/V+vBFRYigPoihVDwJgCITfQxwcPi0PoVgEK34A7Fw7q420Zdw5miKGYixVjIi2USoEXgAADKiMTOExyoC/ZgSBQDyF8HAT8eBP4gAKAUIAA==="}
+import { Base } from '@studiometa/js-toolkit-v4';
+
+class AbstractControl extends Base {
+ static config = {
+ name: 'AbstractControl',
+ refs: ['button'],
+ options: { label: String },
+ };
+}
+
+class NavigationControl extends AbstractControl {
+ static config = {
+ name: 'NavigationControl',
+ refs: ['compass'], // merged: ['button', 'compass']
+ options: { showCompass: Boolean }, // merged with `label`
+ };
+}
+```
+
+An intermediate class should declare `static config: BaseConfig`, or let TypeScript infer a literal type every subclass matches.
+
+**The registry reads the merged config too**, before any instance exists, through `resolveConfig()`. That is how it knows the mount strategy of a pair, the family to register, and the name a class registers under.
+
+## There is no `withExtraConfig()`
+
+To extend a component with a different config, declare a class. To extend one you cannot edit, do it in expression position:
+
+```js
+registerComponent(
+ class extends Vendor {
+ static config = { name: 'CompactVendor', options: { compact: Boolean } };
+ },
+);
+```
+
+## `@component()`
+
+The decorator writes `static config` and calls `registerComponent()` in one step, and the two forms merge:
+
+```ts twoslash
+// @twoslash-cache: {"v":1,"hash":"1f66761ff5247a2369bbd71728cd58e12f5f436e7814e2532471ce6859397be9","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808AKAQwBsBLZuASgAIBjVlzi8AQlxgAeACq86NMFBHi4MAAqkIWEQF4xEjVrgA+ADph2AWywRSafasog4aZnaQBOKqxhgA5mj4SAAsVK6kfjAMiCAquN7sYLiIAAxU/PhuzPw05IgeAL4U6NjJBMRkTjT0TGycPLwAZgCuYDnsEGACENadvmiM/J2N7H6IDjAAwsOj3OMycvS+ShPTYC6kzTm2xoxEbM0w49IU3WDVaOOTQnBwACIwQ6TMaLZrFzLGfDrGvEQQ7Cg5isNjs3V6SXOTigEH4CBiAGUorxAjBwTZIfYhmARn5eMxFLxSDA/OwXGQUfg0YJhLwAO5Urrsexk3iwEZJKAAOicLjc0QAjABWby+AJBfJhNyRaIgIYQ/pODhJJBpOWZZ45SqIYVFEo4PCEEjkMLyWocLh8eUY/qDGZjVb2ua8BbyZbKCRrDZbV6kXb7ViHY6nbEXK43e6PWwvN6dD7SL68H5/AFAiy9MHWvpQqgwuF4JH2VHo7NY+34wnE0nk0iU6kR+mM3jM5sidmJGDc3nhaIANgAzKL/IFPFKIlE8FnMUqO0gAEzpDXZXJIAV66ilQ0VE3UM0xRhYTQ4OwYPhxNa4rlgZiWI68DaJPzd/lIADsIpAPmHEtC1GlE5ia9bxnFVEAXdUsi1PIBX7ddMANGIjUqU0an3Q8tDITAz09e0uWJRo4AAfnGB9/GQABdXgAB9eFadtOWfdx8gFIdxRCMcZTwfCEASUDwIySCVzAwpig3BDymNKo9zlBsEQ4WAdz5JiBWFViR0QXsOIAkA5MBZDP1nRBBwgzUhN7ODN0Q7cpNQkAWAtBoaVuCZXSWRQPVUQxtCTCYvJMYEM3sOJGMFfs1S/NjEF/cJOJiYLeOSYyBNM7U5zXUT4LKJCdwuJh0OPLDeF0hSuWxXFxmAcxeGq3ggLvUi/AAbiqmqtDQDp1gqlqapquAcE7cYADlmksAAjMgvTQTZtlIZquhqgo5oKELV2CTTPzFdT1pi7SytGEDkl/ZLl21ET9Sy6yUNlA8j0w09apveqpsfFadSFV81J/LTZTqg72JMk68jnFILPE7KbOu/K7r4NqOrgLr5uqvqYAG3hhrGibOm9GaltegVX2MiL1I+v9x1lWGsb+qLF0E7V+3MjLLIk/TcrQ26Tz4ZHUfR8bSEm6bfTxgAOX8iYlD8dtlLmoCpo6lygkJX1Bi7JKuvB9lrHmyCGkbef5n1bGhWF4RAABBLoIFGgArR4i0yexiUPeB+hEZhat1ikIEaCsMF4ABrRJuV4U3WFYXgAClmH2BF+FIdgsHsMAPdIV3iV4XtggAWlGltGlYCAXkfTObESRPk7gHkqCUwUPBYjbvyQbb/1lLWd2VQ6aZSvIhWCApyPSaAyhBWx7GACYQx6G1zl4Aomk0SxeAAcgAARcZooA6W9XAAeituBM9eCBWADtBM6IYJF7m8xl6nW0x7q8ZF+KshF9ObjxmQReknoRfKIKbhzBOREM/Wsbp3ITF4JVeafJ2r8DOLiHy99HqPxAa/XgFNOqQPvP1KAOsMa1lnotcwy0qBb2YEgUAYC4AdTwGgBABQChAA="}
+import { Base, component } from '@studiometa/js-toolkit-v4';
+
+@component({ name: 'Slider', refs: ['next'] })
+class Slider extends Base {
+ static config = { name: 'Slider', options: { speed: Number } };
+}
+```
+
+They merge in a class initializer, which runs after the fields and inside the class definition, so `registerComponent()` reads the finished config. A key both sides declare differently is reported as `component.config-conflict`.
+
+See [`@component`](/api/decorators/component.html).
diff --git a/packages/v4/docs/api/context/createContext.md b/packages/v4/docs/api/context/createContext.md
new file mode 100644
index 000000000..6d424f871
--- /dev/null
+++ b/packages/v4/docs/api/context/createContext.md
@@ -0,0 +1,47 @@
+# createContext
+
+```ts
+createContext(description?: string): ContextKey
+```
+
+Creates a typed injection key.
+
+## Usage
+
+```ts twoslash
+// @twoslash-cache: {"v":1,"hash":"6bc8df682560756ec1fbbd58c74f31047697857bd77945bf8cdf924158eff577","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808AKAQwBsBLZuASgAIAzAK5gAxmnYQwvEaRjMaAYUk16AHgAqvALy9hAa0gB3MAD5GsODPZZxkgPyJecNKXZgA5t0dKwKtAGkYDA0TAB0wdgBbLAhSNGlZeRgfP0oQKAgRBEQQBUSaXmZeTBwoaWU6eL0ggDo052Y4pABOKlYYDzR8JABGAGYqNEb3GAYcmTlFCvo0jjBcRAAGKhF8RuYxMhaAXwp0bAWCYi3ByqY2Th5eNxpSfg2YXgBldncwNg1tXTADCGMwiLRWLxF5vNhpDJZPAAQV4iTE7BIvCIbEEj0M7C6TgwonwpEkEEEcCcggARpZXDYJGA4HUqA0mogAEytEDtTrdRAANkGw1GeFB71YszcC2WIFW6025GZzV2+xweEIJHIpxmORuZHuIkeTw4sFI0Kw7HqQ0ZAA4BmyOu4ui1eaQRmMQHr2AajSa2qKkEyVmtSBtbr1FvLqAclcdVdQzjlGFh8Tg4hg+K73caag0aI5BR9gOFeAXrmBYLRHGBBJFSWQANzhbYmU2NMbNADsbRtdsQAFYHU68JncF75kgrZKA9KfaGSodlSdo+qQCwOFw+Jq7g9nq8hZ8dPojKZwlEYnFN2DhVRIdkQLD4eIkSjWGjeBisXAcat8ZAiSTyVYqZJaUbRkekWAAWdsOSQHlqD5Z0c3PNlvUQUd/UDLZEDlPYw0VHJZyjVJY3jCBE0wVdizoMsKyrKMGTGHp+gg21OR7GDHX5DVyNoEVh2Qv0pSDRAenNKdw1wyM0gIxdIlGQgoBTfUyA9Gp3AgAA5SpGC8ZEIDdIC6L6K12SYqDe3YkAVPUmYhwWcCJVQidmREnCjhVCSYwlACQQU0gUkqbxpgCIJVFTRTjQbekzTorkWKMzsGNYvschCnyAu4hY2zs/j0P6JyZ3EtVnSXS4+AmJJfLUZKPTMCw/1sMAHCcFw3E8fzfEqQJgkqsLDyBE9SqmNqrPSTIrzySZHiKacyhEALeGqDA6RAWjenNHpGLiq0zUSiV8mSVLrKQDKxzQmUWVyiNXIKvA121XVvI9PTemaGKO05VktrMrrPUQnijvsgTQL6bYAF0VmgQ4j2BXhgAScbyrQChigOU8hV4bYBHxSJeAAcgAAWcQQoAkaShgAegAKzgABaNAIAgVg9ExKmiFA7HazAQ82vXHVnnu41ofzRqkmzLdcyLEtKMrMg0ZMdmCwsjStKIHSoHZ7ZwnCOhj3iGaaS8t0yHhr5+r2wa0GCvn2DMbG4G87HuGrNISeYJBQEqDo4GpPA0AQbZtiAA==="}
+import { createContext, type Signal } from '@studiometa/js-toolkit-v4';
+
+interface SliderApi {
+ state: Signal<{ index: number }>;
+ goNext(): void;
+}
+
+export const SliderContext = createContext('slider');
+```
+
+**Parameters**
+
+- `description` (`string`, optional) — for debugging only.
+
+**Return value**
+
+- `ContextKey` — an opaque value carrying the phantom type `T`.
+
+## Why a value and not a string
+
+**The identity of the key is what resolves.** Two keys with the same description are two different keys, so nothing collides — and the type parameter is what makes `$inject(key)` give a typed value with no cast at the call site.
+
+Declare the key beside the interface it carries, and export both:
+
+```ts
+// slider-context.ts
+export interface SliderApi { … }
+export const SliderContext = createContext('slider');
+```
+
+Provider and consumer then import one module and agree by construction.
+
+## The default is `unknown`
+
+`createContext('name')` with no type parameter gives `ContextKey`, so `$inject()` resolves with `unknown` and the consumer has to narrow. Give it the type.
diff --git a/packages/v4/docs/api/context/createGroup.md b/packages/v4/docs/api/context/createGroup.md
new file mode 100644
index 000000000..deae58eb0
--- /dev/null
+++ b/packages/v4/docs/api/context/createGroup.md
@@ -0,0 +1,69 @@
+# createGroup
+
+```ts
+createGroup(): Group
+```
+
+A membership set whose members list is a reactive value.
+
+```ts
+interface Group {
+ readonly members: Signal;
+ join(member: T): () => void;
+}
+```
+
+A `GroupMember` is anything with a `readonly $el: Element` — which every `Base` instance is.
+
+## Usage
+
+The coordinator owns the membership and gives out the ways in:
+
+```ts twoslash
+// @twoslash-cache: {"v":1,"hash":"396193c27ff865d199027f84b0eb71c889f6eded56700d4a9ba2b0ed66ed8790","data":"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"}
+import { Base, createContext, createGroup, type Signal } from '@studiometa/js-toolkit-v4';
+
+interface GroupApi {
+ members: Signal;
+ join(peer: Base): () => void;
+ open(peer: Base): void;
+}
+const DisclosureGroupContext = createContext('disclosure-group');
+// ---cut---
+class DisclosureGroup extends Base {
+ static config = { name: 'DisclosureGroup' };
+
+ #peers = createGroup();
+
+ api = this.$provide(DisclosureGroupContext, {
+ members: this.#peers.members, // the members to read, in document order
+ join: (peer: Base) => this.#peers.join(peer), // returns the leave function
+ open: (peer: Base) => this.open(peer), // the invariant stays here
+ });
+
+ mounted() {
+ // The membership is a value: re-check the invariant on each change.
+ return this.#peers.members.subscribe((members) => this.enforce(members));
+ }
+
+ open(peer: Base) {}
+
+ enforce(members: readonly Base[]) {}
+}
+```
+
+A member joins the nearest group whenever that group appears — see [`subscribeContext()`](./subscribeContext.html).
+
+## What it does, and what it does not
+
+- **`join()` returns its own `leave`**, so a member that moves to a nearer group leaves the old one first.
+- **The membership is a value.** A coordinator subscribes to it and re-checks its invariant on each change, rather than being called back on a mutation.
+- **Document order is the tie-breaker**, so the markup decides which peer keeps its state.
+- **Nothing sweeps disconnected members.** The teardown of the member removes it.
+- **It names no group and resolves no scope.** Scope comes from nearest-provider-wins, so a nested group takes its own members only.
+
+The helper holds a `Set` and a [`Signal`](./signal.html), and nothing else. That is the whole implementation.
+
+## It replaces `withGroup`
+
+`withGroup` is not ported. A group is a provided value now, so its scope is the provider's subtree rather than a name in a global table — and a nested group is a nested provider, with nothing to configure.
diff --git a/packages/v4/docs/api/context/index.md b/packages/v4/docs/api/context/index.md
new file mode 100644
index 000000000..cf653f60d
--- /dev/null
+++ b/packages/v4/docs/api/context/index.md
@@ -0,0 +1,73 @@
+# Shared state
+
+provide/inject in core, with the shape of Vue and the mechanics of the WICG context protocol.
+
+[[toc]]
+
+## The functions
+
+| Function | Does |
+| -------------------------------------------------------------- | --------------------------------------------- |
+| [`createContext(description?)`](./createContext.html) | a typed key |
+| [`signal(initialValue)`](./signal.html) | a reactive value |
+| [`provideContext(el, key, value)`](./provideContext.html) | provide on an element |
+| [`provideRootContext(key, create)`](./provideRootContext.html) | provide page-wide, created at most once |
+| [`injectContext(el, key)`](./injectContext.html) | a promise for the nearest value |
+| [`injectContextSync(el, key)`](./injectContextSync.html) | the nearest value, or `undefined` |
+| [`subscribeContext(el, key, cb)`](./subscribeContext.html) | every answer, as providers come and go |
+| [`createGroup()`](./createGroup.html) | a membership set with a reactive members list |
+
+From inside a component, `$provide()`, `$inject()` and `$injectSync()` are the same three, with the element filled in. The [`@provide`](/api/decorators/provide.html) and [`@inject`](/api/decorators/inject.html) decorators are field sugar over them.
+
+## The model
+
+- **The injection key is typed**, so strings cannot collide.
+- **The scope is the subtree**, and the nearest provider wins.
+- **The value is provided as it is.** Nothing is wrapped, so the type of the key is the contract from end to end. A reactive value is a provided `Signal`. A command surface is a provided object.
+
+```ts twoslash
+// @twoslash-cache: {"v":1,"hash":"2b957c7c0eac9a01c1d9bf452acdc14f26704889c27a0932af19e2aadaa1f80e","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808BjAGwEM44ACAZWYEtY5KnDStSDRADYqzGGADmafEmnUx8mBJC8BZSiH5hciAAxVG+Ma0Y1yUgL4V02YwWJ6qNengAUrfuwAlJws7FwAQuwwADwAKpx0NGBQEVEACqQQWFwAvJyRcDAZWXAAfAA6YHwAtlgQ4vlR+iJiEgBMAIwycorKiAAsnuqaeAW4MnxGSGYgFlY2eoidjs44eIQkQtSJvliZOOIYwTqCAHSMEGAAZnzyiJzAlZzPnGCs1TD3IqST8gDclXszVE4iQAGYAOzdBRKFRDUgaLQXa63fSGYxdWaWUjWWxIACcK2oLnW7i2Xi0Pj2WTImGCbw+XzQPwUwNaSH6AA5ob0OfDEXgGeMDJMMeZsbjFm0TETMGtEG5NvoKbt9rSjjx+GdWFg+PdHmAXpwWjR7txbm9mNEDUajZNYLR7mAAK7VABGZABhttzzQEFEzCdro9pC9RvspTDL3kEAAcol7j5gjlSpwiBABF6gcIQRJJPiebDEGD+SMFTq+GjReDxfM8UtZSSFRsPNtvAqfB8lNBgmNomNitlSqcACTUoi6a1PF4mz48C3+Kfen32uhB92e6c+v0B9chqPPCMHzgx+P0RPJ1PpzOA0o+ADWMAw9wAwpcKQBpJ9Lo2zs0Lq0bR9ThV0dV5g03ZdbR3fw90g8NIy3E84wTTgk04FM0wzKAs1KCg038Z05yA54/3neRLR/YDQLg0MkKNGDA3Ajc6Kgo8kNPVD0Mw68cMBQJ9SQsjzQoxcSNtGjmP3eiXkY2jj3YqDOPPNDLywm8wGzEAoAgRgEAVYoJ1gThWAI5giLTMg3VYNAak4P17PwGBjWdN1mRgZyrnqEyuGYS55B8xznJgWQPjANBOH4Eg4FOSpYic14YDEeAIvHXRSE4AB3SYYsqOKErSwRjT4NAuEuIKElCuQ0HwyAIvKpRnOqCBnXCkIMBYGB8LgCAQIi1gwDgTKyDgSolEyZ15HwBJNgwThWua1r+uSThFvC05OFjf18F+ThSBCpLCi4EqEjEZhnxMkIIFqS5qsqTLCEKThYFCHFbPKvguGypQoBxTLDUfGBsk4QrdqWvhmD6rgDrCiKY3gU42VBRBORmWQYT6bk1ARMsQDHTIjOFdEayxOtFg6MFG3lRVWxVBVkRETV0rfcLUJZz9vxOMgAEFdVKJGJE5AsDB6IsKdLLQudIdmdgmKZi1rHEFjsNo2ip1wW3JHYO2pA46WNURTXIyjxMkl0WOPOSpMgiMBaQDoOjR0W+gAVglvBZyreXBlJpX636dXSSVTxtZAPwAjgYI4AAqjnjNiDWIY/1YOt1iIx8SYSr4fwADVCOIpD44tpCrfN6TNIE42xML5I11Ty3k6YsubYqKoboaaPROYfQdL0vAX32mznNMwebD4EgzKI2KwEqAB1H4aC4Qo0DQWRjQwMALEySBnTgc6Nu5vaPPCnE2syhfnLgZ0DkKWAuEakJnVIfa2tgKKyAwbresgFy3TgRgfghk4BAP+ZBoomUNBAZgRUiD5xMlcWwl0jDDQyrdRGOZ2SIAdlCEWGM4TYwFAqTulovbGB9nMP25MOiB2bGSZUocqRqkOMEIuIY7ZYLaKodGvJEBuwIbjUCpC+S+0lCrGUThiTU01vQ9sYddbqmCKXBO7CKZcOdkgPhIJCHUEbkIgYitRHgnEasDWdCQ6yMYTSZhyEzxoAvBhK82EVH5kLH0VQWjcbKQYHLYwfCKGGIGPYAAuuYaArhQgcCZkVRIcgUiNCekBE0fBGBXRRAFPIwBXjvDnAAciljkzg9gvTTgrBhRyn1RyFRgD4KWMt6D4XEmRYh/gfCZMkmYeyjd7gmEKYEfCAB6fpWVLARTmAoeAHEUIqW4qmYAjhOCDOGTZS6yJmRQJCANK61RqgDSgNOewgQsz6C7KwJAoAYmDT4JcPApUQD2HsEAA=="}
+import { Base, createContext, signal, type Signal } from '@studiometa/js-toolkit-v4';
+
+interface SliderApi {
+ state: Signal<{ index: number; total: number }>;
+ goNext(): void;
+}
+const SliderContext = createContext('slider');
+// ---cut---
+class Slider extends Base {
+ static config = { name: 'Slider' };
+
+ api = this.$provide(SliderContext, {
+ state: signal({ index: 0, total: 0 }), // what changes
+ goNext: () => {}, // what a control can command
+ });
+}
+```
+
+## Two ways to ask
+
+| Form | Resolves | When nothing provides |
+| ------------------ | --------------------------------- | ----------------------------------------------------- |
+| `$inject(key)` | a promise, awaited in `mounted()` | it never settles: a missing provider means "not yet". |
+| `$injectSync(key)` | the value, synchronously | `undefined`: the caller falls back or does nothing. |
+
+The pending request of the async form is **unmount-scoped**. A new mount runs `mounted()` again and asks again. The `@inject` field decorator asks once, at construction; a consumer that can wait through several cycles calls `$inject()` from `mounted()`.
+
+## The mechanics
+
+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.
+
+Duplicate bundles share the basic provider and pending-request state through the `context` shared-runtime slot at schema revision 2. The optional owner map, weak index and listener flag use the `context-subscription` slot at revision 1 — and `context.ts` and the `Base` graph import none of that optional state.
+
+## Scopes
+
+`$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.
+
+A **root** provider cannot be disposed and it outlives the instance that asked first, because it is page state. `withGroup` is not ported.
+
+## Read next
+
+[Shared state](/guide/going-further/sharing-state.html) walks the whole pattern, from a coordinator's provided API to a member joining the nearest group.
diff --git a/packages/v4/docs/api/context/injectContext.md b/packages/v4/docs/api/context/injectContext.md
new file mode 100644
index 000000000..c81bcee60
--- /dev/null
+++ b/packages/v4/docs/api/context/injectContext.md
@@ -0,0 +1,62 @@
+# injectContext
+
+```ts
+injectContext(el: Element, key: ContextKey): { promise: Promise; cancel: () => void }
+```
+
+Asks the nearest provider for a value, once.
+
+## Usage
+
+```ts twoslash
+// @twoslash-cache: {"v":1,"hash":"1a3d045e1721b371559044ab01033b094d33a0fdec51f4bdc6f0c345fdb20cf5","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808BjCMONAAi1IgFsBLODETsACt36CAPGACuPAEZkAfJRBsAhqQaIAHFQA2MMAHM0+JAHYqaTcZjaQXXgNwG+YXIgAMVRvk3qjDTkugC+FOjYngTEZKo09EwsbOyM6mCMMPrCABQAlOwAvErsRBB8UKoaWkgAjFYghiZmSABs1rb2TOmZ+qr67p4+IH4BQXGI9eGROHiEJOTWdA456gPqcAXuAFYwQQDCLAlo0nKKpEo5WcIAooY8RmgU7ADWMBjCh2DHANLvpwplHlhMAADpgdiQzjiFzCMTOKSyQEXADc4KhqR613Y+SKJTKFTRYFC4L4PCwEC07B2ezQX2OqigEEYCEQIAASvAIPoSOwzDB2B5NPAOE4iBUYFBSmsZAKAGaU9gAAzeGCVz0gAHd2IrNfgjOx1NDiBLSOD1FgcJo4AA6dgAFXwAnYpBgAEdZSlnSwYABaOCENDCfnsOV8UgpdJwTVkdiCNBoQxwPn640SAXpKDg108WLJkOuj0im3g8GOgVOdNxmwYZM4MBQdzGdh6viGQUQY3i2Ckdh0ARoW3KtIZLL5JUumC5kj5/xocH1xsmSdFyMN9hM+AdsxNw1y4KGiFRmOkG1VGw1RAAJj0jSMpnMkwAzB1SHYHDSDkdlv1BkhhqMpCBMESA3tM1BRHMsSLNQP5sswrAcNiAAS9oALIADJ3FOjznpo2gACxXgY94tNer7vngWS/h4/6+P4QHjCERHgZgsxsvMcRLIk8HJBwfwfOw9LLAJALnCoVDVIR7R3s0j4vtQnQOAJNFDPRYwgYgBGtKEAC6vjQNECEpMAaYuM8I69OwoRFNSYC7F+3zLFc+jPAJeQoqoDw2EgoDLEYcB8CweCDiAoShEAA==="}
+import { createContext, injectContext } from '@studiometa/js-toolkit-v4';
+
+const Key = createContext('answer');
+const el = document.body;
+// ---cut---
+const { promise, cancel } = injectContext(el, Key);
+```
+
+**Return value**
+
+- `promise` — resolves with the nearest provided value.
+- `cancel` — withdraws the request.
+
+## It never settles when nothing provides
+
+**A missing provider means "not yet", not "no".** The request stays pending, and it resolves when a provider appears and replays it — which is what makes mount order irrelevant.
+
+If you need an answer now, or none, use [`injectContextSync()`](./injectContextSync.html).
+
+## From a component
+
+`$inject()` is the same call with the element filled in, and it returns the promise directly:
+
+```ts twoslash
+// @twoslash-cache: {"v":1,"hash":"e3403a50c1bd8d48de05417e30ab3d64cc7baa937733a37cc1f76c5ad65c6a9b","data":"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="}
+import { Base, createContext, type Signal } from '@studiometa/js-toolkit-v4';
+
+const CountContext = createContext>('count');
+// ---cut---
+class Output extends Base {
+ static config = { name: 'Output' };
+
+ async mounted() {
+ const count = await this.$inject(CountContext);
+ return count.subscribe((value) => {
+ this.$el.textContent = String(value);
+ });
+ }
+}
+```
+
+::: tip The pending request is unmount-scoped
+`$unmount()` cancels it, and a new mount runs `mounted()` again and asks again. That is why `mounted()` is the right place: a component that outlives several cycles asks once per cycle, and never holds a request for a scope it has left.
+:::
+
+The [`@inject`](/api/decorators/inject.html) field decorator asks once, at construction, instead.
+
+## It is one-shot
+
+`injectContext()` and `$inject()` resolve with the **first** answer and stop. To follow providers as they come and go, use [`subscribeContext()`](./subscribeContext.html).
diff --git a/packages/v4/docs/api/context/injectContextSync.md b/packages/v4/docs/api/context/injectContextSync.md
new file mode 100644
index 000000000..2fd0a9ab8
--- /dev/null
+++ b/packages/v4/docs/api/context/injectContextSync.md
@@ -0,0 +1,48 @@
+# injectContextSync
+
+```ts
+injectContextSync(el: Element, key: ContextKey): T | undefined
+```
+
+The nearest provided value, synchronously, or `undefined`.
+
+## Usage
+
+```ts twoslash
+// @twoslash-cache: {"v":1,"hash":"1883f7d929e308a74ab1a7d4a5d7145faea3f6f9ab9761c148a7f23c41a4afef","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808BjCMONAAiIEMAbAVxiJ2YPgFsARmXYAfdnzCwAZgEswMKJRBsupBogBsVHjDABzNPiQBWKmh2mYekN364jq3IgAMVRvh1cjDTkBgC+FOjYngTEZJo09HgAFLzKXHAAlOyqAFYwQQDCLAloAMoYYIwAPCISZAB8STA8QgCixqImaBTsANYwGEJFYCUA0gM1YpKk9RlCtdMycgowKmpQADpgyqJYELrZYHmFxXRlFYyaUBCMCIggAErwEDwk7BYwwjA68BxYpMRlLAoJxeAJ2HALvgAZA+HAeBgevt2KRHHxSGB2AADeRKDxQLEAOk02l0SAAjAAWIwmcyWRDkgDstnsjjwuXyaGGJXKlU0PA8SB8ID8ASCcQZlPCkRweEIJHItjOTBYbHYzSEAAkACoAWQAMu0YJ0RiS7GTEABmABMNLMFiQtuorKczX5gu8vn8pECwSQNul1CictiiuoyvuzFYHHGg3Y3LOscmdRmZp0ekthhAxnt9MtLNIDicsfdaiFXrFfqt+kDmFl93lcSViUjqo4LgE8ymUlkuNW+LTFspzOztIdiBszsLbPuHbc2Y9TtFPvFISlESD9ZiCviEecOmWePWg70VhHObpSAAnAWi3g+2t1KXPEvvb6JVTQgBdXzQaJRtU53YABeQ5ji5U56F5Rgmh4HpYwyABuLYtmURR2CSIDgOww9+3WLJgC2dh2AAehI4QIHYf5AVgUh2EAFAJ2EUXgeHYcRAl6JE6OuCiLFUUwtlCTROjsJBQDOEw4GUFg8DQBBQlCIA=="}
+import { createContext, injectContextSync } from '@studiometa/js-toolkit-v4';
+
+const Key = createContext('answer');
+const el = document.body;
+// ---cut---
+const value = injectContextSync(el, Key);
+
+if (value === undefined) {
+ // no provider — fall back, or do nothing
+}
+```
+
+## When to reach for it
+
+When the answer is **optional and the caller has a fallback**. `undefined` means "no provider right now", which is a different statement from `$inject()`'s "not yet".
+
+The canonical pair is a look-up-or-create:
+
+```ts twoslash
+// @twoslash-cache: {"v":1,"hash":"7a09b1e934c8f24c028d97c0833656c3dc157d430c624d5e9f11985b13787b64","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808BjCMONAAkfwEMwwYANnETsAstywAeXhgrsZAPkog23Ug0QA2KgJhgA5mnxIAHFTRr9MDSC69+Q5QICW/JAAYqd0t0Y1yWgC+FOjYuIgExGTKNPR4ABTcLtxwAJTsrgBWMH4AwiyxaADKGGCMkuJSbKSu+nIArmAA1pAA7mAKCvGCIgCiugC2emhyTTAYIvlghQDS4xUSktW1Dc1tHQqpIpVLaDUGqy0Q7QrsAD7sjbAAZq4wUAA6YM4DWBDqGWDZeQV0xaWMZRQCCMBARABK8AgAhI7CMMHY/DU8A4WFIxGcsCg7CISXqCLgAPw6Mg9TgAlk7He7FI1nqpDA7AABlcYLd+FAmQA6ZSqdRIACMAE4dHpDMZEAKAOzmSzWPBZHJoKaFEplJx3QVeHg+PzRRAAJmCoRweEIJHI5j+TBYbHYPXYAAkACqiAAy/RgQ2mvIs/MQAGYZSBdAYjEgDbLSFYbIINW5Jdq1L5/BH3MbqGEzVFLdRrRFmKwOAARbgWXI8PiCYTsFV/OYYBZVPYrS5rY4bX1qDQAFgForDEoFkeocpspfLlYcCB0msTth1Kf1Bp7Gcwpoi5uiVriEUSyTS7DRGNg4IgEGVv3oTekYEpii6YwmtavaAbN5kcgfckYtLLMBEeJ0gAXlOHZP3kO9Nm2RYIMUJ4XjeD5jyITEYDPC86ziKhgVBPAAAV0VQ2B5HYXD6m9NAAFpWjQnE8QRX8YH/bEWEYBEcFIdgny5dgADlmNpLiULQ0g4CpC0ahI5w0C5J4ngw1EiNE8TkSPbgrColxrmsF4EV4bFGHsC92AAIwRKBnDgN44HuHkqD5DQAFYzBDMVw0lVy/RjPARNPc9L2ma1ZwTfsF2TPUAj7Ncs03HMYnzWxbRLMtuArexq0mV93x2ZYDjbI4TiUBy/Q0KVg1DcVBRHbz5QiCc0qnat43CMLvCXAJNHTEJMw3SILQS3cQFxLjKhgrApmqeo/HeJ5+FadggPYUCxFgu8vygxaAGoAwksgBAgbgoDSLt/QFdwwsqjyAyjHyIkqFqtXC3VU0QExVwAXS8aBwiSotOCaoRlqedhPm+QLVQBboBDkBr0qrIR0gAfiRo9lP8zDX3iOHAbgOQlpW+bVqwIDUgAbmUIYLCQUA/j0OBnBYPA0AQQJAiAA=="}
+import { createContext, injectContextSync, provideRootContext } from '@studiometa/js-toolkit-v4';
+
+const DataChannels = createContext