janux 0.1.0 → 0.2.1

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.1",
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,15 @@ 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';
19
+ export { collectPageLinks, createNavigateTool, type PageLink } from './navigate-tool';
@@ -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,55 @@
1
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
3
+ import { GLOW_CLASS } from './glow';
4
+ import { collectPageLinks, createNavigateTool } from './navigate-tool';
5
+
6
+ beforeAll(() => GlobalRegistrator.register({ url: 'https://app.test/docs/guide/components' }));
7
+ afterAll(() => GlobalRegistrator.unregister());
8
+
9
+ beforeEach(() => {
10
+ document.body.innerHTML = `
11
+ <nav>
12
+ <a href="/docs/guide/components">Components</a>
13
+ <a href="/docs/guide/cli-and-deployment">CLI and deployment</a>
14
+ <a href="/docs/guide/cli-and-deployment">CLI (duplicate)</a>
15
+ <a href="https://github.com/aralroca/Janux">GitHub</a>
16
+ </nav>`;
17
+ });
18
+
19
+ describe('collectPageLinks', () => {
20
+ it('collects same-origin links, deduped, keeping the first label', () => {
21
+ const links = collectPageLinks();
22
+
23
+ expect(links).toEqual([
24
+ { path: '/docs/guide/components', label: 'Components' },
25
+ { path: '/docs/guide/cli-and-deployment', label: 'CLI and deployment' },
26
+ ]);
27
+ });
28
+ });
29
+
30
+ describe('createNavigateTool', () => {
31
+ it('glows the pressed link, then navigates', async () => {
32
+ const assign = mock((path: string) => path);
33
+
34
+ (location as any).assign = assign;
35
+ const result = createNavigateTool().execute({ path: '/docs/guide/cli-and-deployment' });
36
+ const anchor = document.querySelector('a[href="/docs/guide/cli-and-deployment"]')!;
37
+
38
+ expect(result).toEqual({ navigated: '/docs/guide/cli-and-deployment', label: 'CLI and deployment' });
39
+ expect(anchor.classList.contains(GLOW_CLASS)).toBe(true);
40
+ expect(assign).not.toHaveBeenCalled();
41
+
42
+ await new Promise((resolve) => setTimeout(resolve, 420));
43
+ expect(assign).toHaveBeenCalledWith('/docs/guide/cli-and-deployment');
44
+ });
45
+
46
+ it('rejects paths not linked on the page and returns the real links', () => {
47
+ const assign = mock((path: string) => path);
48
+
49
+ (location as any).assign = assign;
50
+ const result = createNavigateTool().execute({ path: '/docs/made/up' }) as any;
51
+
52
+ expect(assign).not.toHaveBeenCalled();
53
+ expect(result.links.map((link: any) => link.path)).toContain('/docs/guide/components');
54
+ });
55
+ });
@@ -0,0 +1,78 @@
1
+ import { glowElement, injectGlowStyles } from './glow';
2
+ import type { WebMCPToolDescriptor } from './webmcp';
3
+
4
+ export interface PageLink {
5
+ path: string;
6
+ label: string;
7
+ }
8
+
9
+ /** Bounds the tool payload on link-heavy pages. */
10
+ const MAX_LINKS = 100;
11
+
12
+ function toPageLink(anchor: HTMLAnchorElement): PageLink | undefined {
13
+ const url = new URL(anchor.getAttribute('href') ?? '', location.href);
14
+
15
+ if (url.origin !== location.origin) return undefined;
16
+
17
+ return { path: url.pathname + url.search + url.hash, label: anchor.textContent?.trim() ?? '' };
18
+ }
19
+
20
+ /**
21
+ * The app's real navigation surface: every same-origin link currently in the
22
+ * DOM — which is exactly what the JSX projected, SSR'd or client-rendered,
23
+ * with zero authoring effort.
24
+ */
25
+ export function collectPageLinks(): PageLink[] {
26
+ const anchors = [...document.querySelectorAll<HTMLAnchorElement>('a[href]')];
27
+
28
+ return anchors
29
+ .map(toPageLink)
30
+ .filter((link): link is PageLink => link !== undefined)
31
+ .filter((link, index, all) => all.findIndex((other) => other.path === link.path) === index)
32
+ .slice(0, MAX_LINKS);
33
+ }
34
+
35
+ /** Long enough to see the glow on the link the agent "pressed", short enough to feel instant. */
36
+ const GLOW_BEFORE_NAV_MS = 350;
37
+
38
+ function anchorFor(path: string): HTMLAnchorElement | undefined {
39
+ const matches = [...document.querySelectorAll<HTMLAnchorElement>('a[href]')].filter(
40
+ (anchor) => toPageLink(anchor)?.path === path,
41
+ );
42
+
43
+ // Sidebars often repeat a link in a hidden mobile drawer — glow the visible one.
44
+ return matches.find((anchor) => anchor.getClientRects().length > 0) ?? matches[0];
45
+ }
46
+
47
+ function navigateTo(path: string): unknown {
48
+ const links = collectPageLinks();
49
+ const resolved = new URL(path, location.href);
50
+ const wanted = resolved.pathname + resolved.search + resolved.hash;
51
+ const target = links.find((link) => link.path === wanted);
52
+ const anchor = target ? anchorFor(target.path) : undefined;
53
+
54
+ // Models hallucinate paths; handing back the real links makes the retry self-correcting.
55
+ if (!target) return { error: `No link to "${path}" on this page. Current links:`, links };
56
+ if (anchor) {
57
+ injectGlowStyles();
58
+ glowElement(anchor);
59
+ anchor.scrollIntoView({ block: 'nearest' });
60
+ }
61
+ setTimeout(() => location.assign(target.path), anchor ? GLOW_BEFORE_NAV_MS : 0);
62
+
63
+ return { navigated: target.path, label: target.label };
64
+ }
65
+
66
+ /** The built-in `navigate` WebMCP tool `installWebMCP` registers on every page. */
67
+ export function createNavigateTool(): WebMCPToolDescriptor {
68
+ return {
69
+ name: 'navigate',
70
+ description: 'Navigate this app to one of the links on the current page, by path.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: { path: { type: 'string', description: 'Target path, e.g. /docs/guide/navigation' } },
74
+ required: ['path'],
75
+ },
76
+ execute: (input) => navigateTo(String((input as { path?: unknown })?.path ?? '')),
77
+ };
78
+ }