janux 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janux",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The agent-native UI framework core. One component, two faces: a view for humans, typed MCP tools & resources for AI agents.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -42,5 +42,8 @@
42
42
  ],
43
43
  "files": [
44
44
  "src"
45
- ]
45
+ ],
46
+ "dependencies": {
47
+ "diff-dom-streaming": "^0.6.6"
48
+ }
46
49
  }
@@ -165,6 +165,93 @@ describe('client boot (resume without hydration)', () => {
165
165
  expect(clientQuery).toHaveBeenCalledTimes(0);
166
166
  });
167
167
 
168
+ it('emits janux:tool-call events around bridge calls (agent activity)', async () => {
169
+ const client = await serveAndBoot();
170
+ const phases: string[] = [];
171
+ const onTool = (event: any) => phases.push(`${event.detail.tool}:${event.detail.phase}`);
172
+
173
+ document.addEventListener('janux:tool-call', onTool);
174
+ await client.call('counter.inc');
175
+ const proposal: any = await client.call('counter.reset');
176
+
177
+ expect(phases).toEqual([
178
+ 'counter.inc:start',
179
+ 'counter.inc:ok',
180
+ 'counter.reset:start',
181
+ 'counter.reset:proposal',
182
+ ]);
183
+ document.removeEventListener('janux:tool-call', onTool);
184
+ await client.approve(proposal.id);
185
+ });
186
+
187
+ it('glow targets the element carrying the intent marker, falling back to the island', async () => {
188
+ const { html, snapshots } = await renderToString(jsx(counter as any, {}), {
189
+ initialState: { 'ui://counter#default': { n: 5, history: [] } },
190
+ storeDefs: { session },
191
+ });
192
+ const scripts = snapshots
193
+ .map(
194
+ (s) =>
195
+ `<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
196
+ )
197
+ .join('');
198
+
199
+ document.body.innerHTML = html + scripts;
200
+ document.getElementById('janux-glow-styles')?.remove();
201
+ const client = boot({ defs: [counter, session], glow: { duration: 10 } });
202
+ const island = document.querySelector('janux-island')!;
203
+ const incButton = document.querySelector('[data-jxa="counter#default:inc"]')!;
204
+
205
+ // counter.inc has a button in the view → the BUTTON glows, not the island
206
+ const pending = client.call('counter.inc');
207
+
208
+ expect(incButton.classList.contains('janux-agent-glow')).toBe(true);
209
+ expect(island.classList.contains('janux-agent-glow')).toBe(false);
210
+ await pending;
211
+ // the intent mutated state → re-render happened; morph must NOT wipe the class
212
+ expect(document.querySelector('[data-jxa="counter#default:inc"]')!.classList.contains('janux-agent-glow')).toBe(true);
213
+ await new Promise((resolve) => setTimeout(resolve, 30));
214
+
215
+ // confirm-guarded intents do NOT glow on call — nothing executed yet
216
+ const resetPending = client.call('counter.reset');
217
+
218
+ expect(island.classList.contains('janux-agent-glow')).toBe(false);
219
+ const proposal: any = await resetPending;
220
+
221
+ expect(document.querySelectorAll('.janux-agent-glow')).toHaveLength(0);
222
+
223
+ // the glow happens on APPROVAL, when the action actually runs
224
+ const approvePending = client.approve(proposal.id);
225
+
226
+ expect(island.classList.contains('janux-agent-glow')).toBe(true);
227
+ await approvePending;
228
+ });
229
+
230
+ it('boot({ glow: true }) injects styles and glows the operated island', async () => {
231
+ const { html, snapshots } = await renderToString(jsx(counter as any, {}), {
232
+ initialState: { 'ui://counter#default': { n: 1, history: [] } },
233
+ storeDefs: { session },
234
+ });
235
+ const scripts = snapshots
236
+ .map(
237
+ (s) =>
238
+ `<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
239
+ )
240
+ .join('');
241
+
242
+ document.body.innerHTML = html + scripts;
243
+ document.getElementById('janux-glow-styles')?.remove();
244
+ const client = boot({ defs: [counter, session], glow: { duration: 10 } });
245
+
246
+ expect(document.getElementById('janux-glow-styles')).not.toBeNull();
247
+ const pending = client.call('counter.inc');
248
+
249
+ expect(document.querySelectorAll('.janux-agent-glow')).toHaveLength(1);
250
+ await pending;
251
+ await new Promise((resolve) => setTimeout(resolve, 30));
252
+ expect(document.querySelectorAll('.janux-agent-glow')).toHaveLength(0);
253
+ });
254
+
168
255
  it('survives a malformed state snapshot (boot regression)', async () => {
169
256
  document.body.innerHTML =
170
257
  '<script type="application/janux+state" data-uri="ui://broken">{not json</script>';
@@ -4,15 +4,26 @@ import type { Proposal } from '../runtime/intents';
4
4
  import { createBridge, type JanuxBridge } from './bridge';
5
5
  import { mountIsland, type MountContext } from './mount';
6
6
  import { createClientRegistry, registerDef, type IslandLoader } from './registry';
7
+ import { enableAgentGlow, type GlowOptions } from './glow';
8
+ import { mountEagerIslands, performNavigation } from './navigate';
9
+ import { prefetch } from './prefetch';
10
+ import { installWebMCP } from './webmcp';
7
11
 
8
12
  export interface BootOptions {
9
13
  islands?: Record<string, IslandLoader>;
10
14
  defs?: ComponentDef[];
11
15
  ctx?: Record<string, unknown>;
16
+ /** Highlight islands while an agent operates them. `true` or `{ duration }`. */
17
+ glow?: boolean | GlowOptions;
18
+ /** SPA navigation via the Navigation API + streamed DOM diff. Default: true. */
19
+ navigation?: boolean;
20
+ /** Register mounted tools with `document.modelContext` (WebMCP), polyfilled when absent. Default: true. */
21
+ webmcp?: boolean;
12
22
  }
13
23
 
14
24
  export interface JanuxClient extends JanuxBridge {
15
25
  mount(id: string): Promise<unknown>;
26
+ navigate(url: string): Promise<void>;
16
27
  proposals: Map<string, Proposal>;
17
28
  }
18
29
 
@@ -59,8 +70,7 @@ function formInput(form: HTMLFormElement): Record<string, unknown> {
59
70
  }
60
71
 
61
72
  function trackInflight(mount: MountContext, work: Promise<unknown>): void {
62
- mount.inflight.add(work);
63
- work.catch(reportIntentError).finally(() => mount.inflight.delete(work));
73
+ awaitTracked(mount, work).catch(reportIntentError);
64
74
  }
65
75
 
66
76
  function listen(mount: MountContext): void {
@@ -86,6 +96,59 @@ function reportIntentError(error: unknown): void {
86
96
  document.dispatchEvent(new CustomEvent('janux:error', { detail: String(error) }));
87
97
  }
88
98
 
99
+ /** Tracks navigation work in `inflight` (settled() waits it) while propagating failures. */
100
+ async function awaitTracked(mount: MountContext, work: Promise<unknown>): Promise<void> {
101
+ mount.inflight.add(work);
102
+ try {
103
+ await work;
104
+ } finally {
105
+ mount.inflight.delete(work);
106
+ }
107
+ }
108
+
109
+ let nativeClickAt = 0;
110
+
111
+ function shouldIntercept(event: any): boolean {
112
+ if (!event.canIntercept || event.hashChange || event.downloadRequest || event.formData) return false;
113
+ if (new URL(event.destination.url).origin !== location.origin) return false;
114
+ // Prefer the precise source element; fall back to a recent data-native click.
115
+ if (event.sourceElement) return !event.sourceElement.closest?.('[data-native]');
116
+ const wasNative = Date.now() - nativeClickAt < 100;
117
+
118
+ nativeClickAt = 0;
119
+
120
+ return !wasNative;
121
+ }
122
+
123
+ /**
124
+ * Navigation API interception (Baseline 2026). Browsers without it keep
125
+ * native MPA links — which already work — so there is no History fallback.
126
+ */
127
+ function installNavigation(mount: MountContext): void {
128
+ const nav = (window as any).navigation;
129
+
130
+ document.addEventListener(
131
+ 'click',
132
+ (event) => {
133
+ if ((event.target as Element | null)?.closest?.('[data-native]')) nativeClickAt = Date.now();
134
+ },
135
+ true,
136
+ );
137
+ nav?.addEventListener('navigate', (event: any) => {
138
+ if (!shouldIntercept(event)) return;
139
+ event.intercept({
140
+ scroll: 'after-transition',
141
+ handler: () =>
142
+ awaitTracked(mount, performNavigation(event.destination.url, mount, { signal: event.signal })),
143
+ });
144
+ });
145
+ document.addEventListener('mouseover', (event) => {
146
+ const link = (event.target as Element | null)?.closest?.('a[href^="/"]:not([data-native])');
147
+
148
+ if (link) prefetch((link as HTMLAnchorElement).href);
149
+ });
150
+ }
151
+
89
152
  /**
90
153
  * Resumes a server-rendered page: indexes islands and state snapshots,
91
154
  * installs delegated listeners, and exposes the gui-agent bridge.
@@ -111,6 +174,8 @@ export function boot(options: BootOptions = {}): JanuxClient {
111
174
  (options.defs ?? []).forEach((def) => registerDef(registry, def));
112
175
  readSnapshots(mount);
113
176
  listen(mount);
177
+ if (options.glow) enableAgentGlow(options.glow === true ? {} : options.glow);
178
+ if (options.navigation !== false) installNavigation(mount);
114
179
  const bridge = createBridge(mount, proposals);
115
180
  const client: JanuxClient = {
116
181
  ...bridge,
@@ -122,9 +187,18 @@ export function boot(options: BootOptions = {}): JanuxClient {
122
187
 
123
188
  return mountIsland(id, root, mount);
124
189
  },
190
+ async navigate(url: string) {
191
+ const nav = (window as any).navigation;
192
+
193
+ // Through the interceptor when the platform has it; direct SPA otherwise.
194
+ if (nav?.navigate) await nav.navigate(url).finished;
195
+ else await awaitTracked(mount, performNavigation(new URL(url, location.href).href, mount));
196
+ },
125
197
  };
126
198
 
127
199
  if (typeof window !== 'undefined') (window as any).janux = client;
200
+ if (options.webmcp !== false) installWebMCP(bridge);
201
+ mountEagerIslands(mount).catch(reportIntentError);
128
202
 
129
203
  return client;
130
204
  }
@@ -1,4 +1,5 @@
1
1
  import { buildManifest, type Manifest } from '../manifest';
2
+ import { resolveGuard } from '../runtime/intents';
2
3
  import type { JanuxInstance } from '../runtime/instance';
3
4
  import type { Proposal } from '../runtime/intents';
4
5
  import { ensureStore, mountIsland, type MountContext } from './mount';
@@ -39,6 +40,33 @@ function liveInstances(registry: ClientRegistry): JanuxInstance[] {
39
40
  return [...registry.mounted.values(), ...registry.stores.values()];
40
41
  }
41
42
 
43
+ interface ToolEventExtras {
44
+ guard?: string;
45
+ approval?: boolean;
46
+ }
47
+
48
+ /** Resolves an intent's guard synchronously from the registered defs (no mount needed). */
49
+ function guardOf(mount: MountContext, component: string, intentName: string): string {
50
+ const def =
51
+ mount.registry.defs.get(component) ?? mount.registry.stores.get(component)?.def;
52
+ const intentDef = def?.intents?.[intentName];
53
+
54
+ return intentDef ? resolveGuard(intentDef, mount.ctx) : 'auto';
55
+ }
56
+
57
+ /** Emits `janux:tool-call` DOM events so apps can visualize agent activity (glow, chat lines). */
58
+ function emitToolEvent(
59
+ tool: string,
60
+ input: unknown,
61
+ phase: 'start' | 'ok' | 'proposal' | 'error',
62
+ extras: ToolEventExtras = {},
63
+ ): void {
64
+ if (typeof document === 'undefined') return;
65
+ document.dispatchEvent(
66
+ new CustomEvent('janux:tool-call', { detail: { tool, input, phase, ...extras } }),
67
+ );
68
+ }
69
+
42
70
  /** The gui-agent bridge: `ui.read / ui.call / ui.settled / ui.subscribe` over the mounted tree. */
43
71
  export function createBridge(mount: MountContext, proposals: Map<string, Proposal>): JanuxBridge {
44
72
  const { registry } = mount;
@@ -53,12 +81,23 @@ export function createBridge(mount: MountContext, proposals: Map<string, Proposa
53
81
 
54
82
  async call(tool, input) {
55
83
  const [component = '', intentName = ''] = tool.split('.');
56
- const instance = await instanceFor(component, mount);
57
- const invoke = instance.intents[intentName];
84
+ const guard = guardOf(mount, component, intentName);
85
+
86
+ emitToolEvent(tool, input, 'start', { guard });
87
+ try {
88
+ const instance = await instanceFor(component, mount);
89
+ const invoke = instance.intents[intentName];
58
90
 
59
- if (!invoke) throw new Error(`Janux: unknown tool "${tool}"`);
91
+ if (!invoke) throw new Error(`Janux: unknown tool "${tool}"`);
92
+ const result: any = await invoke(input, { origin: 'agent' });
60
93
 
61
- return invoke(input, { origin: 'agent' });
94
+ emitToolEvent(tool, input, result?.status === 'proposal' ? 'proposal' : 'ok', { guard });
95
+
96
+ return result;
97
+ } catch (error) {
98
+ emitToolEvent(tool, input, 'error', { guard });
99
+ throw error;
100
+ }
62
101
  },
63
102
 
64
103
  async approve(id) {
@@ -66,8 +105,18 @@ export function createBridge(mount: MountContext, proposals: Map<string, Proposa
66
105
 
67
106
  if (!proposal) throw new Error(`Janux: unknown proposal "${id}"`);
68
107
  proposals.delete(id);
69
-
70
- return proposal.execute();
108
+ // The approval IS the execution — this is when activity feedback fires.
109
+ emitToolEvent(proposal.tool, proposal.input, 'start', { guard: 'confirm', approval: true });
110
+ try {
111
+ const result = await proposal.execute();
112
+
113
+ emitToolEvent(proposal.tool, proposal.input, 'ok', { guard: 'confirm', approval: true });
114
+
115
+ return result;
116
+ } catch (error) {
117
+ emitToolEvent(proposal.tool, proposal.input, 'error', { guard: 'confirm', approval: true });
118
+ throw error;
119
+ }
71
120
  },
72
121
 
73
122
  reject(id) {
@@ -0,0 +1,74 @@
1
+ export const GLOW_CLASS = 'janux-agent-glow';
2
+
3
+ export interface GlowOptions {
4
+ /** ms the glow lingers after the call finishes. Default 700. */
5
+ duration?: number;
6
+ }
7
+
8
+ /* !important: the glow is runtime-owned feedback and must win over inline
9
+ view styles (e.g. a button that sets its own box-shadow). */
10
+ const GLOW_CSS = `
11
+ .${GLOW_CLASS} {
12
+ box-shadow: 0 0 0 3px var(--janux-glow-ring, rgba(124, 58, 237, 0.55)),
13
+ 0 0 var(--janux-glow-spread, 34px) 4px var(--janux-glow-halo, rgba(34, 211, 238, 0.35)) !important;
14
+ border-radius: var(--janux-glow-radius, 18px);
15
+ transition: box-shadow 0.25s;
16
+ }`;
17
+
18
+ /** Idempotently installs the default glow styles. Override via the --janux-glow-* CSS vars. */
19
+ export function injectGlowStyles(doc: Document = document): void {
20
+ if (doc.getElementById('janux-glow-styles')) return;
21
+ const style = doc.createElement('style');
22
+
23
+ style.id = 'janux-glow-styles';
24
+ style.textContent = GLOW_CSS;
25
+ doc.head.appendChild(style);
26
+ }
27
+
28
+ /** Glows one element now, fading after `duration` ms. */
29
+ export function glowElement(el: Element, duration = 700): void {
30
+ el.classList.add(GLOW_CLASS);
31
+ setTimeout(() => el.classList.remove(GLOW_CLASS), duration);
32
+ }
33
+
34
+ /**
35
+ * The element that carries the intent's delegation marker (`on={intents.x}` /
36
+ * `<form intent>`), so the glow points at the exact control the agent "pressed".
37
+ * Falls back to the whole island when the intent has no element in the view.
38
+ */
39
+ export function glowTargetFor(tool: string, scope: ParentNode = document): Element | undefined {
40
+ const [component = '', intentName = ''] = tool.split('.');
41
+ const island = scope.querySelector(`janux-island[data-jx^="${component}#"]`);
42
+
43
+ if (!island) return undefined;
44
+ const marker = `${island.getAttribute('data-jx')}:${intentName}`;
45
+
46
+ return (
47
+ island.querySelector(`[data-jxa="${marker}"], [data-jxform="${marker}"]`) ?? island
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Highlights the island an agent is operating (gui-agent style): listens to
53
+ * `janux:tool-call` bridge events and glows the matching island. Returns a
54
+ * disposer. `boot({ glow: true })` wires this for you.
55
+ */
56
+ export function enableAgentGlow(options: GlowOptions = {}): () => void {
57
+ const duration = options.duration ?? 700;
58
+ const onToolCall = (event: Event): void => {
59
+ const { tool, phase, guard, approval } = (event as CustomEvent).detail ?? {};
60
+ const target = tool ? glowTargetFor(tool) : undefined;
61
+
62
+ if (!target) return;
63
+ // confirm-guarded calls only PROPOSE — nothing executes, nothing glows.
64
+ // The glow fires on approval, when the action actually runs.
65
+ if (guard === 'confirm' && !approval) return;
66
+ if (phase === 'start') target.classList.add(GLOW_CLASS);
67
+ else setTimeout(() => target.classList.remove(GLOW_CLASS), duration);
68
+ };
69
+
70
+ injectGlowStyles();
71
+ document.addEventListener('janux:tool-call', onToolCall);
72
+
73
+ return () => document.removeEventListener('janux:tool-call', onToolCall);
74
+ }
@@ -5,3 +5,14 @@ export { mountIsland, type MountContext } from './mount';
5
5
  export { morph } from './morph';
6
6
  export { toDomNodes } from './dom';
7
7
  export { clientApi } from './api-stub';
8
+ export { enableAgentGlow, injectGlowStyles, glowElement, GLOW_CLASS, type GlowOptions } from './glow';
9
+ export { performNavigation, mountEagerIslands } from './navigate';
10
+ export { prefetch } from './prefetch';
11
+ export {
12
+ installWebMCP,
13
+ createModelContextPolyfill,
14
+ type ModelContext,
15
+ type ModelContextPolyfill,
16
+ type WebMCPHandle,
17
+ type WebMCPToolDescriptor,
18
+ } from './webmcp';
@@ -1,4 +1,6 @@
1
1
  function syncAttrs(from: Element, to: Element): void {
2
+ const runtimeClasses = [...from.classList].filter((name) => name.startsWith('janux-'));
3
+
2
4
  [...from.getAttributeNames()]
3
5
  .filter((name) => !to.hasAttribute(name))
4
6
  .forEach((name) => from.removeAttribute(name));
@@ -7,6 +9,9 @@ function syncAttrs(from: Element, to: Element): void {
7
9
  from.setAttribute(name, to.getAttribute(name)!);
8
10
  }
9
11
  });
12
+ // `janux-*` classes belong to the runtime (e.g. the agent glow) — views
13
+ // never render them, so re-renders must not wipe them.
14
+ runtimeClasses.forEach((name) => from.classList.add(name));
10
15
  }
11
16
 
12
17
  function syncValue(from: Element, to: Element): void {
@@ -0,0 +1,172 @@
1
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
2
+ import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
3
+ import { component, intent, store } from '../define/factories';
4
+ import { jsx } from '../jsx-runtime';
5
+ import { int, list, schema, str, enums } from '../schema';
6
+ import { renderToString } from '../render/server';
7
+ import { boot } from './boot';
8
+
9
+ beforeAll(() => GlobalRegistrator.register({ url: 'http://localhost:3000/' }));
10
+ afterAll(() => GlobalRegistrator.unregister());
11
+
12
+ const counterDetach = mock(() => {});
13
+
14
+ const theme = store({
15
+ name: 'theme',
16
+ state: schema({ mode: enums(['dark', 'light']) }),
17
+ intents: { toggle: intent({ run: ({ state }: any) => (state.mode = 'light') }) },
18
+ });
19
+
20
+ const counter = component({
21
+ name: 'counter',
22
+ state: schema({ n: int() }),
23
+ lifecycle: { detach: counterDetach },
24
+ intents: { inc: intent({ run: ({ state }: any) => (state.n += 1) }) },
25
+ view: ({ state, intents }: any) =>
26
+ jsx('div', { children: [jsx('output', { children: String(state.n) }), jsx('button', { on: intents.inc, children: '+' })] }),
27
+ });
28
+
29
+ const chat = component({
30
+ name: 'chat',
31
+ state: schema({ messages: list({ text: str() }) }),
32
+ intents: {
33
+ add: intent({ input: schema({ text: str() }), run: ({ state, input }: any) => state.messages.push(input) }),
34
+ },
35
+ view: ({ state }: any) => jsx('ul', { children: state.messages.map((m: any, i: number) => jsx('li', { key: String(i), children: m.text })) }),
36
+ });
37
+
38
+ const editorAttach = mock(() => {});
39
+ const editorDetach = mock(() => {});
40
+
41
+ const editor = component({
42
+ name: 'editor',
43
+ lifecycle: { attach: () => editorAttach(), detach: () => editorDetach() },
44
+ intents: {},
45
+ view: () => jsx('div', { class: 'editor', children: 'ready' }),
46
+ });
47
+
48
+ function snapshotScripts(snapshots: any[]): string {
49
+ return snapshots
50
+ .map(
51
+ (s) =>
52
+ `<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
53
+ )
54
+ .join('');
55
+ }
56
+
57
+ async function pageHtml(title: string, node: unknown): Promise<string> {
58
+ const { html, snapshots } = await renderToString(node, { storeDefs: { theme } });
59
+
60
+ return `<!doctype html><html><head><title>${title}</title></head><body>${html}${snapshotScripts(snapshots)}</body></html>`;
61
+ }
62
+
63
+ describe('SPA navigation (streamed diff)', () => {
64
+ it('applies the three state rules: app stores live, persist islands live, the rest re-resumes', async () => {
65
+ const pageA = await pageHtml('Page A', [
66
+ jsx('h1', { children: 'A' }),
67
+ jsx(counter as any, {}),
68
+ jsx(chat as any, { persist: true }),
69
+ ]);
70
+ const pageB = await pageHtml('Page B', [
71
+ jsx('h1', { children: 'B' }),
72
+ jsx(chat as any, { persist: true }),
73
+ ]);
74
+
75
+ document.write(pageA);
76
+ document.close();
77
+ expect(document.querySelector('janux-island[data-jx="chat#default"]')!.hasAttribute('data-jx-persist')).toBe(true);
78
+
79
+ const client = boot({ defs: [counter, chat, theme] });
80
+
81
+ // live state before navigating
82
+ await client.call('theme.toggle');
83
+ await client.call('chat.add', { text: 'hello' });
84
+ await client.call('counter.inc');
85
+ await client.settled();
86
+ counterDetach.mockClear();
87
+
88
+ (globalThis as any).fetch = mock(async () => ({
89
+ ok: true,
90
+ text: async () => pageB,
91
+ }));
92
+ await client.navigate('/b');
93
+
94
+ // swapped content + title (whole-document diff)
95
+ expect(document.querySelector('h1')!.textContent).toBe('B');
96
+ expect(document.title).toBe('Page B');
97
+
98
+ // rule 1: app-scope store survives with live state
99
+ expect(((await client.read('store://theme')) as any).state.mode).toBe('light');
100
+
101
+ // rule 2: persisted island keeps its live instance state
102
+ expect(((await client.read('ui://chat')) as any).state.messages).toEqual([{ text: 'hello' }]);
103
+ expect(document.querySelector('janux-island[data-jx="chat#default"] li')!.textContent).toBe('hello');
104
+
105
+ // rule 3: the counter was disposed (detach ran) and is gone from the page
106
+ expect(counterDetach).toHaveBeenCalledTimes(1);
107
+ expect(document.querySelector('janux-island[data-jx="counter#default"]')).toBeNull();
108
+ });
109
+
110
+ it('re-mounts an eager island when its page is revisited after navigating away', async () => {
111
+ const pageEditor = await pageHtml('Editor', [jsx('h1', { children: 'E' }), jsx(editor as any, { eager: true })]);
112
+ const pageDoc = await pageHtml('Doc', [jsx('h1', { children: 'D' })]);
113
+
114
+ document.write(pageEditor);
115
+ document.close();
116
+ editorAttach.mockClear();
117
+ editorDetach.mockClear();
118
+ const client = boot({ defs: [editor] });
119
+
120
+ await client.settled();
121
+ expect(editorAttach).toHaveBeenCalledTimes(1); // first visit mounts
122
+
123
+ // navigate away → the island is gone AND torn down (detach ran)
124
+ (globalThis as any).fetch = mock(async () => ({ ok: true, text: async () => pageDoc }));
125
+ await client.navigate('/doc');
126
+ expect(document.querySelector('janux-island[data-jx="editor#default"]')).toBeNull();
127
+ expect(editorDetach).toHaveBeenCalledTimes(1);
128
+
129
+ // revisit → the eager island mounts again from a clean slate (the playground
130
+ // relies on this attach/detach symmetry to reset Monaco)
131
+ (globalThis as any).fetch = mock(async () => ({ ok: true, text: async () => pageEditor }));
132
+ await client.navigate('/editor');
133
+ expect(document.querySelector('janux-island[data-jx="editor#default"]')).not.toBeNull();
134
+ expect(editorAttach).toHaveBeenCalledTimes(2);
135
+ });
136
+
137
+ it('buffers the response into a single chunk before diffing (deterministic swap)', async () => {
138
+ const pageA = await pageHtml('Page A', jsx('h1', { children: 'A' }));
139
+ const pageB = await pageHtml('Page B', jsx('h1', { children: 'B' }));
140
+ let bodyRead = false;
141
+
142
+ document.write(pageA);
143
+ document.close();
144
+ const client = boot({ defs: [] });
145
+
146
+ (globalThis as any).fetch = mock(async () => ({
147
+ ok: true,
148
+ text: async () => pageB,
149
+ get body() {
150
+ bodyRead = true;
151
+
152
+ return new Response(pageB).body;
153
+ },
154
+ }));
155
+ await client.navigate('/b');
156
+
157
+ expect(document.querySelector('h1')!.textContent).toBe('B');
158
+ expect(bodyRead).toBe(false);
159
+ });
160
+
161
+ it('emits janux:error and hard-navigates when the fetch fails', async () => {
162
+ (globalThis as any).fetch = mock(async () => ({ ok: false, status: 500 }));
163
+ document.write(await pageHtml('Page A', jsx(chat as any, {})));
164
+ document.close();
165
+ const client = boot({ defs: [chat] });
166
+ const errors: string[] = [];
167
+
168
+ document.addEventListener('janux:error', (event: any) => errors.push(event.detail));
169
+ await client.navigate('/broken'); // resolves — the fallback owns the failure
170
+ expect(errors.some((message) => /navigation fetch failed/.test(message))).toBe(true);
171
+ });
172
+ });