janux 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # janux
2
+
3
+ Core of **Janux** — the agent-native fullstack UI framework. One component, two faces: a live view for humans, typed MCP tools & resources for AI agents, generated from the same definition.
4
+
5
+ This package ships the schema types, signals, bifacial component/store runtime (intents, guards, effects, sources, `settled()`), SSR islands, the manifest builder and the client resume runtime + `window.janux` bridge.
6
+
7
+ ```bash
8
+ bunx create-janux my-app
9
+ ```
10
+
11
+ Docs & source: https://github.com/aralroca/Janux · RFC: https://github.com/aralroca/Janux/issues/1
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "janux",
3
+ "version": "0.1.0",
4
+ "description": "The agent-native UI framework core. One component, two faces: a view for humans, typed MCP tools & resources for AI agents.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/aralroca/Janux.git"
8
+ },
9
+ "license": "MIT",
10
+ "author": {
11
+ "name": "Aral Roca Gomez",
12
+ "email": "contact@aralroca.com"
13
+ },
14
+ "type": "module",
15
+ "main": "./src/index.ts",
16
+ "exports": {
17
+ ".": "./src/index.ts",
18
+ "./types": "./src/schema/index.ts",
19
+ "./jsx-runtime": "./src/jsx-runtime.ts",
20
+ "./jsx-dev-runtime": "./src/jsx-dev-runtime.ts",
21
+ "./server": "./src/render/server.ts",
22
+ "./client": "./src/client/index.ts",
23
+ "./manifest": "./src/manifest/index.ts"
24
+ },
25
+ "devDependencies": {
26
+ "@happy-dom/global-registrator": "^15.11.0"
27
+ },
28
+ "homepage": "https://github.com/aralroca/Janux#readme",
29
+ "bugs": "https://github.com/aralroca/Janux/issues",
30
+ "keywords": [
31
+ "janux",
32
+ "agentic-ui",
33
+ "mcp",
34
+ "webmcp",
35
+ "ai-agents",
36
+ "fullstack",
37
+ "framework",
38
+ "bun",
39
+ "vite",
40
+ "resumability",
41
+ "copilot"
42
+ ],
43
+ "files": [
44
+ "src"
45
+ ]
46
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Client stub for an api() server function (~100 bytes once bundled).
3
+ * The compiler swaps `*.api.ts` imports for these in client bundles.
4
+ */
5
+ export function clientApi(name: string): (input?: unknown) => Promise<unknown> {
6
+ return async (input?: unknown) => {
7
+ const response = await fetch(`/_janux/api/${name}`, {
8
+ method: 'POST',
9
+ headers: { 'content-type': 'application/json' },
10
+ body: JSON.stringify(input ?? {}),
11
+ });
12
+ const body: any = await response.json();
13
+
14
+ if (!body.ok) throw new Error(body.error ?? `api ${name} failed`);
15
+
16
+ return body.result;
17
+ };
18
+ }
@@ -0,0 +1,185 @@
1
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
3
+ import { component, intent, source, store } from '../define/factories';
4
+ import { jsx } from '../jsx-runtime';
5
+ import { int, list, schema, str } from '../schema';
6
+ import { renderToString } from '../render/server';
7
+ import { boot, type JanuxClient } from './boot';
8
+
9
+ beforeAll(() => GlobalRegistrator.register());
10
+ afterAll(() => GlobalRegistrator.unregister());
11
+
12
+ const viewRenders = mock(() => {});
13
+
14
+ const session = store({
15
+ name: 'session',
16
+ state: schema({ locale: str().default('en') }),
17
+ intents: {
18
+ setLocale: intent({
19
+ input: schema({ locale: str() }),
20
+ run: ({ state, input }) => (state.locale = input.locale),
21
+ }),
22
+ },
23
+ });
24
+
25
+ const counter = component({
26
+ name: 'counter',
27
+ state: schema({ n: int(), history: list({ v: int() }) }),
28
+ use: { session },
29
+ intents: {
30
+ inc: intent({ run: ({ state }) => (state.n += 1) }),
31
+ reset: intent({ guard: 'confirm', run: ({ state }) => (state.n = 0) }),
32
+ },
33
+ view: ({ state, intents }: any) => {
34
+ viewRenders();
35
+
36
+ return jsx('div', {
37
+ children: [
38
+ jsx('output', { children: `n=${state.n}` }),
39
+ jsx('button', { on: intents.inc, children: '+1' }),
40
+ ],
41
+ });
42
+ },
43
+ });
44
+
45
+ async function serveAndBoot(): Promise<JanuxClient> {
46
+ const { html, snapshots } = await renderToString(jsx(counter as any, {}), {
47
+ initialState: { 'ui://counter#default': { n: 5, history: [] } },
48
+ storeDefs: { session },
49
+ });
50
+ const scripts = snapshots
51
+ .map(
52
+ (s) =>
53
+ `<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
54
+ )
55
+ .join('');
56
+
57
+ document.body.innerHTML = html + scripts;
58
+ viewRenders.mockClear();
59
+
60
+ return boot({ defs: [counter, session] });
61
+ }
62
+
63
+ describe('client boot (resume without hydration)', () => {
64
+ beforeEach(() => {
65
+ document.body.innerHTML = '';
66
+ });
67
+
68
+ it('executes zero component code until first interaction', async () => {
69
+ await serveAndBoot();
70
+
71
+ expect(viewRenders).toHaveBeenCalledTimes(0);
72
+ expect(document.querySelector('output')!.textContent).toBe('n=5');
73
+ });
74
+
75
+ it('resumes on delegated click: mounts from snapshot and runs the intent', async () => {
76
+ const client = await serveAndBoot();
77
+
78
+ document.querySelector('button')!.click();
79
+ await client.settled();
80
+ expect(document.querySelector('output')!.textContent).toBe('n=6');
81
+ expect(viewRenders.mock.calls.length).toBeGreaterThan(0);
82
+ });
83
+
84
+ it('preserves the SSR DOM nodes across resume (morph, not replace)', async () => {
85
+ const client = await serveAndBoot();
86
+ const ssrOutput = document.querySelector('output');
87
+
88
+ document.querySelector('button')!.click();
89
+ await client.settled();
90
+ expect(document.querySelector('output')).toBe(ssrOutput);
91
+ });
92
+
93
+ it('serves agent reads from the live resource', async () => {
94
+ const client = await serveAndBoot();
95
+ const resource: any = await client.read('ui://counter');
96
+
97
+ expect(resource.state.n).toBe(5);
98
+ expect(resource.uri).toBe('ui://counter#default');
99
+ });
100
+
101
+ it('routes agent tool calls through guards: auto runs, confirm proposes', async () => {
102
+ const client = await serveAndBoot();
103
+ const proposalEvents: any[] = [];
104
+
105
+ document.addEventListener('janux:proposal', (event: any) => proposalEvents.push(event.detail));
106
+ await client.call('counter.inc');
107
+ await client.settled();
108
+ expect(document.querySelector('output')!.textContent).toBe('n=6');
109
+
110
+ const proposal: any = await client.call('counter.reset');
111
+
112
+ expect(proposal.status).toBe('proposal');
113
+ expect(document.querySelector('output')!.textContent).toBe('n=6');
114
+ expect(proposalEvents).toHaveLength(1);
115
+ await client.approve(proposal.id);
116
+ await client.settled();
117
+ expect(document.querySelector('output')!.textContent).toBe('n=0');
118
+ });
119
+
120
+ it('exposes store tools and state through the bridge', async () => {
121
+ const client = await serveAndBoot();
122
+
123
+ await client.call('session.setLocale', { locale: 'es' });
124
+ const resource: any = await client.read('store://session');
125
+
126
+ expect(resource.state.locale).toBe('es');
127
+ });
128
+
129
+ it('resumes source values from the snapshot — no double-fetch, ready intents work on first click', async () => {
130
+ const clientQuery = mock(async () => ['should-not-run']);
131
+ const shopDef = component({
132
+ name: 'minishop',
133
+ state: schema({ picks: int() }),
134
+ sources: { catalog: source({ query: clientQuery }) },
135
+ intents: {
136
+ pick: intent({
137
+ ready: ({ sources: s }: any) => !s.catalog.pending,
138
+ run: ({ state }: any) => (state.picks += 1),
139
+ }),
140
+ },
141
+ view: ({ state, intents }: any) =>
142
+ jsx('div', {
143
+ children: [
144
+ jsx('output', { children: String(state.picks) }),
145
+ jsx('button', { on: intents.pick, children: 'pick' }),
146
+ ],
147
+ }),
148
+ });
149
+ const { html, snapshots } = await renderToString(jsx(shopDef as any, {}));
150
+
151
+ clientQuery.mockClear();
152
+ const scripts = snapshots
153
+ .map(
154
+ (s) =>
155
+ `<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources })}</script>`,
156
+ )
157
+ .join('');
158
+
159
+ document.body.innerHTML = html + scripts;
160
+ const client = boot({ defs: [shopDef] });
161
+
162
+ document.querySelector('button')!.click();
163
+ await client.settled();
164
+ expect(document.querySelector('output')!.textContent).toBe('1');
165
+ expect(clientQuery).toHaveBeenCalledTimes(0);
166
+ });
167
+
168
+ it('survives a malformed state snapshot (boot regression)', async () => {
169
+ document.body.innerHTML =
170
+ '<script type="application/janux+state" data-uri="ui://broken">{not json</script>';
171
+ const client = boot({ defs: [counter] });
172
+
173
+ expect(client.manifest().tools).toEqual([]);
174
+ });
175
+
176
+ it('builds a live manifest from the mounted tree', async () => {
177
+ const client = await serveAndBoot();
178
+
179
+ await client.mount('counter#default');
180
+ const manifest = client.manifest();
181
+
182
+ expect(manifest.tools.map((t) => t.name)).toContain('counter.inc');
183
+ expect(manifest.resources.map((r) => r.uri)).toContain('ui://counter');
184
+ });
185
+ });
@@ -0,0 +1,130 @@
1
+ import { createBus } from '../runtime/bus';
2
+ import type { ComponentDef } from '../define/types';
3
+ import type { Proposal } from '../runtime/intents';
4
+ import { createBridge, type JanuxBridge } from './bridge';
5
+ import { mountIsland, type MountContext } from './mount';
6
+ import { createClientRegistry, registerDef, type IslandLoader } from './registry';
7
+
8
+ export interface BootOptions {
9
+ islands?: Record<string, IslandLoader>;
10
+ defs?: ComponentDef[];
11
+ ctx?: Record<string, unknown>;
12
+ }
13
+
14
+ export interface JanuxClient extends JanuxBridge {
15
+ mount(id: string): Promise<unknown>;
16
+ proposals: Map<string, Proposal>;
17
+ }
18
+
19
+ function readSnapshots(mount: MountContext): void {
20
+ document.querySelectorAll('script[type="application/janux+state"]').forEach((script) => {
21
+ const uri = script.getAttribute('data-uri');
22
+
23
+ if (!uri) return;
24
+ try {
25
+ mount.registry.snapshots.set(uri, JSON.parse(script.textContent ?? '{}'));
26
+ } catch {
27
+ reportIntentError(`invalid state snapshot for ${uri}`);
28
+ }
29
+ });
30
+ }
31
+
32
+ function markerTarget(
33
+ event: Event,
34
+ attr: string,
35
+ ): { marker: string; root: Element; el: Element } | undefined {
36
+ const el = (event.target as Element | null)?.closest?.(`[${attr}]`);
37
+ const root = el?.closest('janux-island[data-jx]');
38
+
39
+ if (!el || !root) return undefined;
40
+
41
+ return { marker: el.getAttribute(attr)!, root, el };
42
+ }
43
+
44
+ function elementInput(el: Element): unknown {
45
+ const raw = el.getAttribute('data-input');
46
+
47
+ return raw ? JSON.parse(raw) : undefined;
48
+ }
49
+
50
+ async function invokeMarker(marker: string, root: Element, mount: MountContext, input?: unknown) {
51
+ const [id = '', intentName = ''] = marker.split(':');
52
+ const instance = await mountIsland(id, root, mount);
53
+
54
+ return instance.intents[intentName]?.(input);
55
+ }
56
+
57
+ function formInput(form: HTMLFormElement): Record<string, unknown> {
58
+ return Object.fromEntries(new FormData(form).entries());
59
+ }
60
+
61
+ function trackInflight(mount: MountContext, work: Promise<unknown>): void {
62
+ mount.inflight.add(work);
63
+ work.catch(reportIntentError).finally(() => mount.inflight.delete(work));
64
+ }
65
+
66
+ function listen(mount: MountContext): void {
67
+ document.addEventListener('click', (event) => {
68
+ const found = markerTarget(event, 'data-jxa');
69
+
70
+ if (!found) return;
71
+ event.preventDefault();
72
+ trackInflight(mount, invokeMarker(found.marker, found.root, mount, elementInput(found.el)));
73
+ });
74
+ document.addEventListener('submit', (event) => {
75
+ const found = markerTarget(event, 'data-jxform');
76
+
77
+ if (!found) return;
78
+ event.preventDefault();
79
+ const input = formInput(event.target as HTMLFormElement);
80
+
81
+ trackInflight(mount, invokeMarker(found.marker, found.root, mount, input));
82
+ });
83
+ }
84
+
85
+ function reportIntentError(error: unknown): void {
86
+ document.dispatchEvent(new CustomEvent('janux:error', { detail: String(error) }));
87
+ }
88
+
89
+ /**
90
+ * Resumes a server-rendered page: indexes islands and state snapshots,
91
+ * installs delegated listeners, and exposes the gui-agent bridge.
92
+ * No component code executes until first interaction or agent call.
93
+ */
94
+ export function boot(options: BootOptions = {}): JanuxClient {
95
+ const registry = createClientRegistry();
96
+ const proposals = new Map<string, Proposal>();
97
+ const mount: MountContext = {
98
+ registry,
99
+ bus: createBus(),
100
+ ctx: options.ctx ?? {},
101
+ inflight: new Set(),
102
+ onProposal: (proposal) => {
103
+ proposals.set((proposal as Proposal).id, proposal as Proposal);
104
+ document.dispatchEvent(new CustomEvent('janux:proposal', { detail: proposal }));
105
+ },
106
+ };
107
+
108
+ Object.entries(options.islands ?? {}).forEach(([name, loader]) => {
109
+ registry.loaders.set(name, loader);
110
+ });
111
+ (options.defs ?? []).forEach((def) => registerDef(registry, def));
112
+ readSnapshots(mount);
113
+ listen(mount);
114
+ const bridge = createBridge(mount, proposals);
115
+ const client: JanuxClient = {
116
+ ...bridge,
117
+ proposals,
118
+ mount(id: string) {
119
+ const root = document.querySelector(`janux-island[data-jx="${id}"]`);
120
+
121
+ if (!root) throw new Error(`Janux: island "${id}" not found in the document`);
122
+
123
+ return mountIsland(id, root, mount);
124
+ },
125
+ };
126
+
127
+ if (typeof window !== 'undefined') (window as any).janux = client;
128
+
129
+ return client;
130
+ }
@@ -0,0 +1,106 @@
1
+ import { buildManifest, type Manifest } from '../manifest';
2
+ import type { JanuxInstance } from '../runtime/instance';
3
+ import type { Proposal } from '../runtime/intents';
4
+ import { ensureStore, mountIsland, type MountContext } from './mount';
5
+ import type { ClientRegistry } from './registry';
6
+
7
+ export interface JanuxBridge {
8
+ read(uri: string): Promise<Record<string, unknown>>;
9
+ call(tool: string, input?: unknown): Promise<unknown>;
10
+ approve(id: string): Promise<unknown>;
11
+ reject(id: string): boolean;
12
+ settled(scope?: string): Promise<void>;
13
+ subscribe(event: string, handler: (payload: unknown) => void): () => void;
14
+ manifest(): Manifest;
15
+ }
16
+
17
+ function islandIdFor(component: string): string | undefined {
18
+ const exact = document.querySelector(`janux-island[data-jx^="${component}#"]`);
19
+
20
+ return exact?.getAttribute('data-jx') ?? undefined;
21
+ }
22
+
23
+ async function instanceFor(component: string, mount: MountContext): Promise<JanuxInstance> {
24
+ const storeInstance = mount.registry.stores.get(component);
25
+
26
+ if (storeInstance) return storeInstance;
27
+ const registeredDef = mount.registry.defs.get(component);
28
+
29
+ if (registeredDef?.kind === 'store') return ensureStore(registeredDef, mount);
30
+ const id = islandIdFor(component);
31
+
32
+ if (!id) throw new Error(`Janux: no mounted surface for "${component}"`);
33
+ const root = document.querySelector(`janux-island[data-jx="${id}"]`)!;
34
+
35
+ return mountIsland(id, root, mount);
36
+ }
37
+
38
+ function liveInstances(registry: ClientRegistry): JanuxInstance[] {
39
+ return [...registry.mounted.values(), ...registry.stores.values()];
40
+ }
41
+
42
+ /** The gui-agent bridge: `ui.read / ui.call / ui.settled / ui.subscribe` over the mounted tree. */
43
+ export function createBridge(mount: MountContext, proposals: Map<string, Proposal>): JanuxBridge {
44
+ const { registry } = mount;
45
+
46
+ return {
47
+ async read(uri) {
48
+ const [, name = ''] = /^(?:ui|store):\/\/([^#]+)/.exec(uri) ?? [];
49
+ const instance = await instanceFor(name, mount);
50
+
51
+ return instance.resource();
52
+ },
53
+
54
+ async call(tool, input) {
55
+ const [component = '', intentName = ''] = tool.split('.');
56
+ const instance = await instanceFor(component, mount);
57
+ const invoke = instance.intents[intentName];
58
+
59
+ if (!invoke) throw new Error(`Janux: unknown tool "${tool}"`);
60
+
61
+ return invoke(input, { origin: 'agent' });
62
+ },
63
+
64
+ async approve(id) {
65
+ const proposal = proposals.get(id);
66
+
67
+ if (!proposal) throw new Error(`Janux: unknown proposal "${id}"`);
68
+ proposals.delete(id);
69
+
70
+ return proposal.execute();
71
+ },
72
+
73
+ reject(id) {
74
+ return proposals.delete(id);
75
+ },
76
+
77
+ async settled(scope) {
78
+ do {
79
+ await Promise.all([...mount.inflight]);
80
+ const targets = scope
81
+ ? [await instanceFor(scope.replace(/^(ui|store):\/\//, '').split('#')[0]!, mount)]
82
+ : liveInstances(registry);
83
+
84
+ await Promise.all(targets.map((instance) => instance.settled()));
85
+ } while (mount.inflight.size > 0);
86
+ },
87
+
88
+ subscribe(event, handler) {
89
+ return mount.bus.on(event, handler);
90
+ },
91
+
92
+ manifest() {
93
+ const mountedEntries = [...registry.mounted.values()].map((instance) => ({
94
+ def: instance.def,
95
+ key: instance.uri.split('#')[1],
96
+ instance,
97
+ }));
98
+ const storeEntries = [...registry.stores.values()].map((instance) => ({
99
+ def: instance.def,
100
+ instance,
101
+ }));
102
+
103
+ return buildManifest([...mountedEntries, ...storeEntries], mount.ctx);
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,47 @@
1
+ import { Fragment, type JanuxNode } from '../jsx-runtime';
2
+ import { attrEntries } from '../render/html';
3
+ import type { ComponentDef } from '../define/types';
4
+
5
+ function isComponentDef(type: unknown): type is ComponentDef {
6
+ return typeof type === 'object' && type !== null && 'kind' in (type as any);
7
+ }
8
+
9
+ function setAttr(el: Element, name: string, value: unknown): void {
10
+ if (value === false || value === null || value === undefined) return;
11
+
12
+ el.setAttribute(name, value === true ? '' : String(value));
13
+ }
14
+
15
+ function appendChildren(el: Element, children: unknown): void {
16
+ toDomNodes(children).forEach((node) => el.appendChild(node));
17
+ }
18
+
19
+ function elementFor(node: JanuxNode): Element {
20
+ const el = document.createElement(node.$t as string);
21
+
22
+ attrEntries(node.$p).forEach(([name, value]) => setAttr(el, name, value));
23
+ if (typeof node.$p.dangerHTML === 'string') el.innerHTML = node.$p.dangerHTML;
24
+ else appendChildren(el, node.$p.children);
25
+
26
+ return el;
27
+ }
28
+
29
+ /** Expands a client view tree (static fns inline) into real DOM nodes. */
30
+ export function toDomNodes(node: unknown): Node[] {
31
+ if (node === null || node === undefined || typeof node === 'boolean') return [];
32
+ if (typeof node === 'string' || typeof node === 'number') {
33
+ return [document.createTextNode(String(node))];
34
+ }
35
+ if (Array.isArray(node)) return node.flatMap(toDomNodes);
36
+ const jsxNode = node as JanuxNode;
37
+
38
+ if (jsxNode.$t === Fragment) return toDomNodes(jsxNode.$p.children);
39
+ if (typeof jsxNode.$t === 'function') return toDomNodes((jsxNode.$t as any)(jsxNode.$p));
40
+ if (isComponentDef(jsxNode.$t)) {
41
+ throw new Error(
42
+ `Janux: nested island <${jsxNode.$t.name}> inside another island is not supported yet`,
43
+ );
44
+ }
45
+
46
+ return [elementFor(jsxNode)];
47
+ }
@@ -0,0 +1,7 @@
1
+ export { boot, type BootOptions, type JanuxClient } from './boot';
2
+ export { createBridge, type JanuxBridge } from './bridge';
3
+ export { registerDef, createClientRegistry, type ClientRegistry } from './registry';
4
+ export { mountIsland, type MountContext } from './mount';
5
+ export { morph } from './morph';
6
+ export { toDomNodes } from './dom';
7
+ export { clientApi } from './api-stub';
@@ -0,0 +1,65 @@
1
+ function syncAttrs(from: Element, to: Element): void {
2
+ [...from.getAttributeNames()]
3
+ .filter((name) => !to.hasAttribute(name))
4
+ .forEach((name) => from.removeAttribute(name));
5
+ to.getAttributeNames().forEach((name) => {
6
+ if (from.getAttribute(name) !== to.getAttribute(name)) {
7
+ from.setAttribute(name, to.getAttribute(name)!);
8
+ }
9
+ });
10
+ }
11
+
12
+ function syncValue(from: Element, to: Element): void {
13
+ if (from instanceof HTMLInputElement && to instanceof HTMLInputElement) {
14
+ if (from.value !== to.value && document.activeElement !== from) from.value = to.value;
15
+ }
16
+ }
17
+
18
+ function sameKind(a: Node, b: Node): boolean {
19
+ if (a.nodeType !== b.nodeType) return false;
20
+ if (a.nodeType !== Node.ELEMENT_NODE) return true;
21
+
22
+ return (a as Element).tagName === (b as Element).tagName;
23
+ }
24
+
25
+ function morphChildren(from: Element, to: Element): void {
26
+ const fromKids = [...from.childNodes];
27
+ const toKids = [...to.childNodes];
28
+
29
+ toKids.forEach((toKid, index) => {
30
+ const fromKid = fromKids[index];
31
+
32
+ if (!fromKid) {
33
+ from.appendChild(toKid);
34
+
35
+ return;
36
+ }
37
+ if (!sameKind(fromKid, toKid)) {
38
+ from.replaceChild(toKid, fromKid);
39
+
40
+ return;
41
+ }
42
+ morphNode(fromKid, toKid);
43
+ });
44
+ fromKids.slice(toKids.length).forEach((extra) => from.removeChild(extra));
45
+ }
46
+
47
+ function morphNode(from: Node, to: Node): void {
48
+ if (from.nodeType === Node.TEXT_NODE) {
49
+ if (from.textContent !== to.textContent) from.textContent = to.textContent;
50
+
51
+ return;
52
+ }
53
+ if (from.nodeType !== Node.ELEMENT_NODE) return;
54
+ syncAttrs(from as Element, to as Element);
55
+ syncValue(from as Element, to as Element);
56
+ morphChildren(from as Element, to as Element);
57
+ }
58
+
59
+ /** Patches `root`'s children in place to match `nextChildren` (index+tag matching). */
60
+ export function morph(root: Element, nextChildren: Node[]): void {
61
+ const target = document.createElement(root.tagName);
62
+
63
+ nextChildren.forEach((child) => target.appendChild(child));
64
+ morphChildren(root, target);
65
+ }