@affordances/react 0.0.0-stage → 0.0.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 +25 -2
- package/dist/affordance.d.ts +11 -0
- package/dist/affordance.js +14 -0
- package/dist/dev.d.ts +3 -0
- package/dist/dev.js +140 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/model-context.d.ts +18 -0
- package/dist/model-context.js +6 -0
- package/dist/provider.d.ts +15 -0
- package/dist/provider.js +94 -0
- package/package.json +44 -4
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @affordances/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Affordances for React. `<AffordancesProvider>` loads the current page's graph and registers each available action as a WebMCP tool for browser agents; the tools call the page's Affordances endpoint, so they pass the same checks as any agent. `<Affordance>` marks the controls a person would use for an action, for agents that read the DOM. `<AffordancesDevtools>` from `@affordances/react/dev` outlines them in development.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
vlt install @affordances/react
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
'use client'
|
|
11
|
+
|
|
12
|
+
import { usePathname, useRouter } from 'next/navigation'
|
|
13
|
+
import { AffordancesProvider } from '@affordances/react'
|
|
14
|
+
|
|
15
|
+
export const Agents = ({ children }: { children: React.ReactNode }) => {
|
|
16
|
+
const pathname = usePathname()
|
|
17
|
+
const router = useRouter()
|
|
18
|
+
return (
|
|
19
|
+
<AffordancesProvider path={pathname} onDelta={() => router.refresh()}>
|
|
20
|
+
{children}
|
|
21
|
+
</AffordancesProvider>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Docs: [https://github.com/vltpkg/affordances/blob/main/apps/docs/content/react.mdx](https://github.com/vltpkg/affordances/blob/main/apps/docs/content/react.mdx)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type AffordanceProps = {
|
|
2
|
+
children: React.ReactNode;
|
|
3
|
+
input?: Record<string, unknown>;
|
|
4
|
+
} & ({
|
|
5
|
+
action: (...args: never[]) => unknown;
|
|
6
|
+
name?: never;
|
|
7
|
+
} | {
|
|
8
|
+
name: string;
|
|
9
|
+
action?: never;
|
|
10
|
+
});
|
|
11
|
+
export declare const Affordance: ({ action, name, input, children }: AffordanceProps) => import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
// src/affordance.tsx
|
|
3
|
+
const AFFORDANCES = Symbol.for("affordances.action");
|
|
4
|
+
const Affordance = ({ action, name, input, children }) => {
|
|
5
|
+
const resolved = name ?? action?.[AFFORDANCES]?.name;
|
|
6
|
+
if (!resolved) throw new Error("<Affordance action> needs an action from defineAction, used in a server component. In a client component, pass name=\"…\" instead.");
|
|
7
|
+
return /* @__PURE__ */ jsx("span", {
|
|
8
|
+
"data-affordances-action": resolved,
|
|
9
|
+
"data-affordances-input": input && JSON.stringify(input),
|
|
10
|
+
style: { display: "contents" },
|
|
11
|
+
children
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
export { Affordance };
|
package/dist/dev.d.ts
ADDED
package/dist/dev.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useAffordances } from "./provider.js";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
// src/dev.tsx
|
|
6
|
+
const mono = "var(--font-geist-mono, ui-monospace, SFMono-Regular, monospace)";
|
|
7
|
+
const measure = (marker) => {
|
|
8
|
+
const rects = [...marker.children].flatMap((child) => [...child.getClientRects()]);
|
|
9
|
+
if (!rects.length) return;
|
|
10
|
+
const top = Math.min(...rects.map((r) => r.top));
|
|
11
|
+
const left = Math.min(...rects.map((r) => r.left));
|
|
12
|
+
return {
|
|
13
|
+
top,
|
|
14
|
+
left,
|
|
15
|
+
width: Math.max(...rects.map((r) => r.right)) - left,
|
|
16
|
+
height: Math.max(...rects.map((r) => r.bottom)) - top
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
const AffordancesDevtools = ({ shortcut = "KeyA" }) => {
|
|
20
|
+
const { graph, lastDelta } = useAffordances();
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
const [boxes, setBoxes] = useState([]);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const onKey = (event) => {
|
|
25
|
+
if (event.altKey && event.code === shortcut) setOpen((value) => !value);
|
|
26
|
+
};
|
|
27
|
+
addEventListener("keydown", onKey);
|
|
28
|
+
return () => removeEventListener("keydown", onKey);
|
|
29
|
+
}, [shortcut]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!open) return;
|
|
32
|
+
let frame = 0;
|
|
33
|
+
const update = () => {
|
|
34
|
+
cancelAnimationFrame(frame);
|
|
35
|
+
frame = requestAnimationFrame(() => {
|
|
36
|
+
const actions = new Map(graph?.actions.map((a) => [a.name, a]));
|
|
37
|
+
setBoxes([...document.querySelectorAll("[data-affordances-action]")].flatMap((marker) => {
|
|
38
|
+
const rect = measure(marker);
|
|
39
|
+
const name = marker.getAttribute("data-affordances-action");
|
|
40
|
+
const action = actions.get(name);
|
|
41
|
+
return rect ? [{
|
|
42
|
+
name,
|
|
43
|
+
available: action?.available ?? false,
|
|
44
|
+
reason: action?.reason,
|
|
45
|
+
...rect
|
|
46
|
+
}] : [];
|
|
47
|
+
}));
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
update();
|
|
51
|
+
const observer = new MutationObserver(update);
|
|
52
|
+
observer.observe(document.body, {
|
|
53
|
+
subtree: true,
|
|
54
|
+
childList: true,
|
|
55
|
+
attributes: true
|
|
56
|
+
});
|
|
57
|
+
addEventListener("scroll", update, {
|
|
58
|
+
passive: true,
|
|
59
|
+
capture: true
|
|
60
|
+
});
|
|
61
|
+
addEventListener("resize", update);
|
|
62
|
+
return () => {
|
|
63
|
+
cancelAnimationFrame(frame);
|
|
64
|
+
observer.disconnect();
|
|
65
|
+
removeEventListener("scroll", update, { capture: true });
|
|
66
|
+
removeEventListener("resize", update);
|
|
67
|
+
};
|
|
68
|
+
}, [open, graph]);
|
|
69
|
+
if (!open) return null;
|
|
70
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
71
|
+
"data-affordances-devtools": "",
|
|
72
|
+
"aria-live": "polite",
|
|
73
|
+
style: {
|
|
74
|
+
position: "fixed",
|
|
75
|
+
inset: 0,
|
|
76
|
+
pointerEvents: "none",
|
|
77
|
+
zIndex: 2147483647,
|
|
78
|
+
fontFamily: mono,
|
|
79
|
+
fontSize: 11
|
|
80
|
+
},
|
|
81
|
+
children: [boxes.map((box, i) => /* @__PURE__ */ jsx("div", {
|
|
82
|
+
style: {
|
|
83
|
+
position: "fixed",
|
|
84
|
+
top: box.top - 3,
|
|
85
|
+
left: box.left - 3,
|
|
86
|
+
width: box.width + 6,
|
|
87
|
+
height: box.height + 6,
|
|
88
|
+
border: `1px ${box.available ? "solid" : "dashed"} currentColor`,
|
|
89
|
+
borderRadius: 6,
|
|
90
|
+
color: "var(--foreground, #111)"
|
|
91
|
+
},
|
|
92
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
93
|
+
style: {
|
|
94
|
+
position: "absolute",
|
|
95
|
+
bottom: "100%",
|
|
96
|
+
left: -1,
|
|
97
|
+
marginBottom: 2,
|
|
98
|
+
padding: "1px 5px",
|
|
99
|
+
borderRadius: 4,
|
|
100
|
+
background: "var(--foreground, #111)",
|
|
101
|
+
color: "var(--background, #fff)",
|
|
102
|
+
whiteSpace: "nowrap",
|
|
103
|
+
lineHeight: "16px"
|
|
104
|
+
},
|
|
105
|
+
children: [box.name, !box.available && ` · ${box.reason ?? "unavailable"}`]
|
|
106
|
+
})
|
|
107
|
+
}, i)), /* @__PURE__ */ jsxs("section", {
|
|
108
|
+
"aria-label": "Affordances devtools",
|
|
109
|
+
style: {
|
|
110
|
+
position: "fixed",
|
|
111
|
+
right: 16,
|
|
112
|
+
bottom: 16,
|
|
113
|
+
width: 320,
|
|
114
|
+
maxHeight: "40vh",
|
|
115
|
+
overflow: "auto",
|
|
116
|
+
pointerEvents: "auto",
|
|
117
|
+
padding: 12,
|
|
118
|
+
borderRadius: 8,
|
|
119
|
+
border: "1px solid var(--border, #e5e5e5)",
|
|
120
|
+
background: "var(--background, #fff)",
|
|
121
|
+
color: "var(--foreground, #111)"
|
|
122
|
+
},
|
|
123
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
124
|
+
style: {
|
|
125
|
+
margin: 0,
|
|
126
|
+
color: "var(--muted-foreground, #666)"
|
|
127
|
+
},
|
|
128
|
+
children: graph ? `${graph.route} · ${graph.actions.length} actions · ${boxes.length} on screen` : "No graph for this page"
|
|
129
|
+
}), /* @__PURE__ */ jsx("pre", {
|
|
130
|
+
style: {
|
|
131
|
+
margin: "8px 0 0",
|
|
132
|
+
whiteSpace: "pre-wrap",
|
|
133
|
+
wordBreak: "break-word"
|
|
134
|
+
},
|
|
135
|
+
children: lastDelta ? JSON.stringify(lastDelta, null, 2) : "Call an action to see its delta."
|
|
136
|
+
})]
|
|
137
|
+
})]
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
export { AffordancesDevtools };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Affordance, AffordanceProps } from "./affordance.js";
|
|
2
|
+
import { AffordancesContext, AffordancesProvider, AffordancesProviderProps, useAffordances } from "./provider.js";
|
|
3
|
+
import { ModelContext, ModelContextTool, getModelContext } from "./model-context.js";
|
|
4
|
+
export { Affordance, type AffordanceProps, type AffordancesContext, AffordancesProvider, type AffordancesProviderProps, type ModelContext, type ModelContextTool, getModelContext, useAffordances };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ModelContextTool = {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
inputSchema: Record<string, unknown>;
|
|
5
|
+
annotations?: {
|
|
6
|
+
readOnlyHint?: boolean;
|
|
7
|
+
consequentialHint?: boolean;
|
|
8
|
+
};
|
|
9
|
+
execute: (input: unknown, options: {
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}) => Promise<unknown>;
|
|
12
|
+
};
|
|
13
|
+
export type ModelContext = {
|
|
14
|
+
registerTool: (tool: ModelContextTool, options?: {
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}) => unknown;
|
|
17
|
+
};
|
|
18
|
+
export declare const getModelContext: () => ModelContext | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Delta, Graph } from "@affordances/protocol";
|
|
2
|
+
export type AffordancesContext = {
|
|
3
|
+
graph: Graph | null;
|
|
4
|
+
call: (action: string, input?: unknown) => Promise<Delta>;
|
|
5
|
+
lastDelta: Delta | null;
|
|
6
|
+
refresh: () => Promise<void>;
|
|
7
|
+
};
|
|
8
|
+
export type AffordancesProviderProps = {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
path: string;
|
|
11
|
+
onDelta?: (delta: Delta) => void;
|
|
12
|
+
confirm?: (message: string) => boolean | Promise<boolean>;
|
|
13
|
+
};
|
|
14
|
+
export declare const AffordancesProvider: ({ children, path, onDelta, confirm }: AffordancesProviderProps) => import("react").JSX.Element;
|
|
15
|
+
export declare const useAffordances: () => AffordancesContext;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { getModelContext } from "./model-context.js";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
import { createContext, use, useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { MEDIA_TYPE } from "@affordances/protocol";
|
|
6
|
+
// src/provider.tsx
|
|
7
|
+
const Context = createContext(null);
|
|
8
|
+
const post = (path, body) => fetch(path, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: {
|
|
11
|
+
"content-type": MEDIA_TYPE,
|
|
12
|
+
accept: MEDIA_TYPE
|
|
13
|
+
},
|
|
14
|
+
body: JSON.stringify(body)
|
|
15
|
+
}).then((response) => response.json());
|
|
16
|
+
const AffordancesProvider = ({ children, path, onDelta, confirm = (message) => window.confirm(message) }) => {
|
|
17
|
+
const [graph, setGraph] = useState(null);
|
|
18
|
+
const [lastDelta, setLastDelta] = useState(null);
|
|
19
|
+
const handlers = useRef({
|
|
20
|
+
onDelta,
|
|
21
|
+
confirm
|
|
22
|
+
});
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
handlers.current = {
|
|
25
|
+
onDelta,
|
|
26
|
+
confirm
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
const load = useCallback(async (signal) => {
|
|
30
|
+
const response = await fetch(path, {
|
|
31
|
+
headers: { accept: MEDIA_TYPE },
|
|
32
|
+
signal
|
|
33
|
+
});
|
|
34
|
+
const isGraph = response.ok && response.headers.get("content-type")?.includes(MEDIA_TYPE);
|
|
35
|
+
setGraph(isGraph ? await response.json() : null);
|
|
36
|
+
}, [path]);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
load(controller.signal).catch(() => {});
|
|
40
|
+
return () => controller.abort();
|
|
41
|
+
}, [load]);
|
|
42
|
+
const call = useCallback(async (action, input) => {
|
|
43
|
+
let delta = await post(path, {
|
|
44
|
+
action,
|
|
45
|
+
input
|
|
46
|
+
});
|
|
47
|
+
if (delta.error?.code === "confirm_required" && await handlers.current.confirm(delta.error.message)) delta = await post(path, {
|
|
48
|
+
action,
|
|
49
|
+
input,
|
|
50
|
+
confirm: true
|
|
51
|
+
});
|
|
52
|
+
setLastDelta(delta);
|
|
53
|
+
handlers.current.onDelta?.(delta);
|
|
54
|
+
await load().catch(() => {});
|
|
55
|
+
return delta;
|
|
56
|
+
}, [path, load]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const modelContext = getModelContext();
|
|
59
|
+
if (!modelContext || !graph) return;
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
for (const action of graph.actions) {
|
|
62
|
+
if (!action.available) continue;
|
|
63
|
+
try {
|
|
64
|
+
Promise.resolve(modelContext.registerTool({
|
|
65
|
+
name: action.name,
|
|
66
|
+
description: action.description,
|
|
67
|
+
inputSchema: action.input,
|
|
68
|
+
annotations: {
|
|
69
|
+
readOnlyHint: action.readOnly ?? false,
|
|
70
|
+
consequentialHint: action.expose === "confirm"
|
|
71
|
+
},
|
|
72
|
+
execute: async (input) => JSON.stringify(await call(action.name, input))
|
|
73
|
+
}, { signal: controller.signal })).catch(() => {});
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
return () => controller.abort();
|
|
77
|
+
}, [graph, call]);
|
|
78
|
+
const refresh = useCallback(() => load(), [load]);
|
|
79
|
+
return /* @__PURE__ */ jsx(Context, {
|
|
80
|
+
value: {
|
|
81
|
+
graph,
|
|
82
|
+
call,
|
|
83
|
+
lastDelta,
|
|
84
|
+
refresh
|
|
85
|
+
},
|
|
86
|
+
children
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
const useAffordances = () => {
|
|
90
|
+
const context = use(Context);
|
|
91
|
+
if (!context) throw new Error("useAffordances needs an <AffordancesProvider> above it");
|
|
92
|
+
return context;
|
|
93
|
+
};
|
|
94
|
+
export { AffordancesProvider, useAffordances };
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@affordances/react",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Affordances for React: WebMCP tools from the page graph, and <Affordance> markers",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/vltpkg/affordances.git",
|
|
9
|
+
"directory": "packages/react"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./dev": {
|
|
22
|
+
"types": "./dist/dev.d.ts",
|
|
23
|
+
"default": "./dist/dev.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsdown",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run --passWithNoTests"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@affordances/protocol": "0.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@affordances/core": "0.0.0",
|
|
36
|
+
"@types/react": "^19.3.0",
|
|
37
|
+
"@types/react-dom": "^19.3.0",
|
|
38
|
+
"happy-dom": "^21.0.4",
|
|
39
|
+
"react": "^19.3.1",
|
|
40
|
+
"react-dom": "^19.3.1",
|
|
41
|
+
"zod": "^4.6.7"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": ">=19"
|
|
45
|
+
}
|
|
46
|
+
}
|