@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 CHANGED
@@ -1,3 +1,26 @@
1
- # Temporary Holding Version
1
+ # @affordances/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,3 @@
1
+ export declare const AffordancesDevtools: ({ shortcut }: {
2
+ shortcut?: string;
3
+ }) => import("react").JSX.Element | null;
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 };
@@ -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,4 @@
1
+ import { Affordance } from "./affordance.js";
2
+ import { getModelContext } from "./model-context.js";
3
+ import { AffordancesProvider, useAffordances } from "./provider.js";
4
+ export { Affordance, AffordancesProvider, getModelContext, useAffordances };
@@ -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,6 @@
1
+ // src/model-context.ts
2
+ const getModelContext = () => {
3
+ if (typeof document === "undefined") return;
4
+ return document.modelContext ?? navigator.modelContext;
5
+ };
6
+ export { getModelContext };
@@ -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;
@@ -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-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }