@grampro/kit 2.7.1 → 2.7.3

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.
Files changed (80) hide show
  1. package/.gbs/skills/gbs-components/SKILL.md +233 -207
  2. package/.gbs/skills/gbs-components/references/data-grid.md +9 -0
  3. package/.gbs/skills/gbs-components/references/forms.md +12 -0
  4. package/.gbs/skills/gbs-components/references/install.md +42 -5
  5. package/CHANGELOG.md +963 -798
  6. package/README.md +13 -4
  7. package/index.cjs +250 -138
  8. package/package.json +1 -1
  9. package/source/components/accordion/passport.json +1 -1
  10. package/source/components/alert/passport.json +1 -1
  11. package/source/components/avatar/passport.json +1 -1
  12. package/source/components/badge/passport.json +1 -1
  13. package/source/components/breadcrumb/passport.json +1 -1
  14. package/source/components/button/passport.json +1 -1
  15. package/source/components/card/passport.json +1 -1
  16. package/source/components/checkbox/passport.json +2 -2
  17. package/source/components/checkbox/passport.manual.json +1 -1
  18. package/source/components/checkbox/react/Checkbox.tsx +14 -0
  19. package/source/components/combobox/passport.json +3 -2
  20. package/source/components/combobox/passport.manual.json +1 -1
  21. package/source/components/combobox/react/MultiSelect.tsx +13 -0
  22. package/source/components/combobox/react/Select.tsx +13 -0
  23. package/source/components/combobox/react/surface.ts +70 -0
  24. package/source/components/data-grid/passport.json +6 -6
  25. package/source/components/data-grid/react/DataGrid.tsx +353 -351
  26. package/source/components/date-picker/passport.json +3 -2
  27. package/source/components/date-picker/passport.manual.json +1 -1
  28. package/source/components/date-picker/react/DatePicker.tsx +16 -2
  29. package/source/components/date-picker/react/DateRangePicker.tsx +16 -2
  30. package/source/components/date-picker/react/surface.ts +68 -0
  31. package/source/components/dialog/passport.json +1 -1
  32. package/source/components/file-uploader/passport.json +1 -1
  33. package/source/components/input/passport.json +2 -2
  34. package/source/components/input/passport.manual.json +1 -1
  35. package/source/components/input/react/Input.tsx +21 -0
  36. package/source/components/menu/passport.json +1 -1
  37. package/source/components/modal/passport.json +1 -1
  38. package/source/components/number-input/passport.json +2 -2
  39. package/source/components/number-input/passport.manual.json +1 -1
  40. package/source/components/number-input/react/NumberInput.tsx +30 -0
  41. package/source/components/popover/passport.json +1 -1
  42. package/source/components/progress/passport.json +1 -1
  43. package/source/components/radio-group/passport.json +2 -2
  44. package/source/components/radio-group/passport.manual.json +1 -1
  45. package/source/components/radio-group/react/RadioGroup.tsx +33 -0
  46. package/source/components/shared/core/agent/field-executors.ts +173 -137
  47. package/source/components/shared/core/agent/index.ts +21 -0
  48. package/source/components/shared/core/agent/surface.ts +437 -0
  49. package/source/components/shared/core/agent/webmcp.ts +332 -328
  50. package/source/components/shared/core/agent/write.ts +62 -58
  51. package/source/components/shared/index.ts +7 -0
  52. package/source/components/shared/react/AgentSurface.tsx +145 -0
  53. package/source/components/shared/react/agentSurfaceContext.ts +92 -0
  54. package/source/components/shared/react/useFieldAgent.ts +62 -88
  55. package/source/components/shared/version.json +4 -4
  56. package/source/components/shared/version.ts +6 -6
  57. package/source/components/skeleton/passport.json +1 -1
  58. package/source/components/spinner/passport.json +1 -1
  59. package/source/components/switch/passport.json +2 -2
  60. package/source/components/switch/passport.manual.json +1 -1
  61. package/source/components/switch/react/Switch.tsx +16 -0
  62. package/source/components/tabs/passport.json +1 -1
  63. package/source/components/textarea/passport.json +2 -2
  64. package/source/components/textarea/passport.manual.json +1 -1
  65. package/source/components/textarea/react/Textarea.tsx +16 -0
  66. package/source/components/toaster/passport.json +1 -1
  67. package/source/components/tooltip/passport.json +1 -1
  68. package/tools/doctor/baseline.cjs +228 -0
  69. package/tools/doctor/contract.cjs +252 -0
  70. package/tools/doctor/format.cjs +160 -0
  71. package/tools/doctor/index.cjs +246 -0
  72. package/tools/doctor/usage.cjs +95 -0
  73. package/tools/jsx-tags.cjs +120 -0
  74. package/tools/mcp/server.cjs +60 -41
  75. package/tools/passport/extract.cjs +22 -40
  76. package/tools/passport/hash.cjs +56 -0
  77. package/tools/passport/index.cjs +4 -2
  78. package/tools/passport/typescript.cjs +220 -0
  79. package/tools/prompt.cjs +205 -0
  80. package/tools/ui.cjs +67 -0
@@ -0,0 +1,437 @@
1
+ /*
2
+ * A page that describes itself to an agent.
3
+ *
4
+ * Browser agents operate apps today by reading pixels or the accessibility
5
+ * tree and guessing. Neither says what a control will accept — a date field
6
+ * is "textbox", not "2025 only" — and neither stops a wrong click. A page
7
+ * built from these components already knows, per instance, what each one
8
+ * holds, what it accepts and what it refuses. This collects that into one
9
+ * live manifest, and routes every action back through the component's own
10
+ * validator.
11
+ *
12
+ * Framework-free. Members arrive as `ProjectableAgent`s plus a `describe()`;
13
+ * the React side (shared/react/AgentSurface.tsx) is what turns mounted
14
+ * components into members. Nothing here knows about the DOM.
15
+ *
16
+ * The guarantees are the ones `runProjected` already gives a single component:
17
+ * no repair, no retry, confirmation before anything irreversible, refusals
18
+ * named by the layer that made them. The surface adds addressing, not a
19
+ * second path in.
20
+ */
21
+
22
+ import { fieldOperations, type FieldCommand, type FieldContract, type FieldIntent } from "./field";
23
+ import type { FieldAgent } from "./field-agent";
24
+ import {
25
+ runProjected,
26
+ toToolResult,
27
+ type ModelContext,
28
+ type ProjectableAgent,
29
+ type ProjectedOutcome,
30
+ type RegistrationResult,
31
+ type ToolCallLog,
32
+ type ToolResultPayload,
33
+ } from "./webmcp";
34
+ import type { JsonSchema, ValidationIssue } from "./types";
35
+
36
+ export const SURFACE_VERSION = 1;
37
+ export const SURFACE_TOOL_NAME = "operate_page";
38
+ const MAX_INTENTS = 8;
39
+
40
+ // ------------------------------------------------------------------ members
41
+
42
+ export interface SurfaceMember {
43
+ readonly kind: "field" | "grid";
44
+ /** The component, as the docs name it: "DatePicker", "Select". */
45
+ readonly component: string;
46
+ /** What a person sees it called. Used to build its id. */
47
+ readonly label?: string;
48
+ /** The agent every action goes through. */
49
+ readonly agent: ProjectableAgent;
50
+ /** Live: constraints, operations and their input schemas, as plain data. */
51
+ describe(): Record<string, unknown>;
52
+ /** Extra fields for a successful result, e.g. rows now showing. */
53
+ summarise?(): Record<string, unknown>;
54
+ }
55
+
56
+ export interface SurfaceEntry {
57
+ readonly id: string;
58
+ readonly member: SurfaceMember;
59
+ }
60
+
61
+ export interface SurfaceManifest {
62
+ readonly surfaceVersion: number;
63
+ readonly members: readonly Record<string, unknown>[];
64
+ }
65
+
66
+ export interface AgentSurfaceHandle {
67
+ /** Add a member. Its id is derived from its label and stays fixed while it is mounted. */
68
+ register(member: SurfaceMember): { id: string; unregister(): void };
69
+ entries(): readonly SurfaceEntry[];
70
+ /** Everything on the page that can be operated, with what each accepts. */
71
+ manifest(): SurfaceManifest;
72
+ /** Validate, confirm and execute against one member. Never bypasses its validator. */
73
+ run(target: string, intents: unknown): Promise<ToolResultPayload>;
74
+ /** Called whenever a member arrives or leaves. Returns an unsubscribe. */
75
+ subscribe(listener: () => void): () => void;
76
+ }
77
+
78
+ /** "Due date" → "due-date". Readable ids an agent can quote back. */
79
+ const slug = (text: string) =>
80
+ text
81
+ .normalize("NFKD")
82
+ .toLowerCase()
83
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
84
+ .replace(/^-+|-+$/g, "")
85
+ .slice(0, 48) || "member";
86
+
87
+ export function createAgentSurface(): AgentSurfaceHandle {
88
+ const members = new Map<string, SurfaceMember>();
89
+ const listeners = new Set<() => void>();
90
+ const notify = () => {
91
+ for (const listener of [...listeners]) {
92
+ try {
93
+ listener();
94
+ } catch {
95
+ /* a broken observer must not break registration */
96
+ }
97
+ }
98
+ };
99
+
100
+ const nearest = (wanted: string) => {
101
+ const ids = [...members.keys()];
102
+ const lower = wanted.toLowerCase();
103
+ return ids.find((id) => id.includes(lower) || lower.includes(id)) ?? null;
104
+ };
105
+
106
+ return {
107
+ register(member) {
108
+ const base = slug(member.label || member.component);
109
+ let id = base;
110
+ for (let n = 2; members.has(id); n += 1) id = `${base}-${n}`;
111
+ members.set(id, member);
112
+ notify();
113
+ return {
114
+ id,
115
+ unregister() {
116
+ if (members.get(id) === member) {
117
+ members.delete(id);
118
+ notify();
119
+ }
120
+ },
121
+ };
122
+ },
123
+
124
+ entries: () => [...members].map(([id, member]) => ({ id, member })),
125
+
126
+ manifest: () => ({
127
+ surfaceVersion: SURFACE_VERSION,
128
+ members: [...members].map(([id, member]) => ({
129
+ id,
130
+ kind: member.kind,
131
+ component: member.component,
132
+ ...(member.label ? { label: member.label } : {}),
133
+ ...member.describe(),
134
+ })),
135
+ }),
136
+
137
+ async run(target, intents) {
138
+ const member = typeof target === "string" ? members.get(target) : undefined;
139
+ if (!member) {
140
+ const near = typeof target === "string" ? nearest(target) : null;
141
+ return {
142
+ ok: false,
143
+ status: "rejected",
144
+ code: "unknown-target",
145
+ layer: "reference",
146
+ message: `Nothing on this page is called ${JSON.stringify(target)}.`,
147
+ ...(near ? { suggestion: `Did you mean "${near}"?` } : {}),
148
+ available: [...members.keys()],
149
+ };
150
+ }
151
+ // The tool's schema says at most eight; a caller that ignores the schema
152
+ // is held to it here, not trusted to have read it.
153
+ if (Array.isArray(intents) && intents.length > MAX_INTENTS) {
154
+ return {
155
+ ok: false,
156
+ status: "rejected",
157
+ code: "too-many-intents",
158
+ layer: "schema",
159
+ message: `At most ${MAX_INTENTS} operations per call; this one had ${intents.length}.`,
160
+ };
161
+ }
162
+ return runProjected(member.agent, intents, {
163
+ summarise: member.summarise ? () => member.summarise!() : undefined,
164
+ });
165
+ },
166
+
167
+ subscribe(listener) {
168
+ listeners.add(listener);
169
+ return () => listeners.delete(listener);
170
+ },
171
+ };
172
+ }
173
+
174
+ // ---------------------------------------------------------- field adaptation
175
+
176
+ const quote = (value: unknown) => {
177
+ if (Array.isArray(value)) return value.map((v) => JSON.stringify(v)).join(", ");
178
+ if (value && typeof value === "object") {
179
+ const range = value as { start?: unknown; end?: unknown };
180
+ if ("start" in range) return `${range.start} to ${range.end}`;
181
+ }
182
+ return JSON.stringify(value);
183
+ };
184
+
185
+ /** One accepted field command, in words. */
186
+ function summarise(label: string, command: FieldCommand): string {
187
+ switch (command.action) {
188
+ case "setValue":
189
+ return `Set ${label} to ${quote(command.value)}`;
190
+ case "clear":
191
+ return `Cleared ${label}`;
192
+ case "focus":
193
+ return `Focused ${label}`;
194
+ case "open":
195
+ return `Opened ${label}`;
196
+ case "close":
197
+ return `Closed ${label}`;
198
+ default:
199
+ return `${command.action} ${label}`;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * A field's agent in the shape a surface or a WebMCP tool expects.
205
+ *
206
+ * `getAgent` is called per request, so a field whose props changed since it
207
+ * mounted — now disabled, now with different options — is judged by what it
208
+ * is now.
209
+ */
210
+ export function projectFieldAgent(getAgent: () => FieldAgent): ProjectableAgent {
211
+ const label = (agent: FieldAgent) => agent.contract.label || agent.contract.component;
212
+
213
+ return {
214
+ schema: () => {
215
+ const operations = fieldOperations(getAgent().contract);
216
+ return {
217
+ oneOf: Object.values(operations).map((op) => ({
218
+ type: "object",
219
+ required: ["action"],
220
+ properties: {
221
+ action: { const: op.name, description: op.summary },
222
+ ...(op.input?.properties ?? {}),
223
+ },
224
+ ...(op.input?.required ? { required: ["action", ...op.input.required] } : {}),
225
+ })),
226
+ } as JsonSchema;
227
+ },
228
+
229
+ validate(intents) {
230
+ const agent = getAgent();
231
+ const list = intents as FieldIntent[];
232
+ const commands = [];
233
+ const warnings: ValidationIssue[] = [];
234
+ for (const intent of list) {
235
+ const verdict = agent.check(intent);
236
+ if (!verdict.ok) {
237
+ return {
238
+ status: "rejected",
239
+ reason: verdict.reason ?? "Refused.",
240
+ code: verdict.code ?? "rejected",
241
+ layer: verdict.layer ?? "policy",
242
+ ...(verdict.suggestion ? { suggestion: verdict.suggestion } : {}),
243
+ } as ProjectedOutcome;
244
+ }
245
+ warnings.push(...verdict.warnings);
246
+ commands.push({ intent, explain: { summary: summarise(label(agent), verdict.command!) } });
247
+ }
248
+ return { status: "done", commands, warnings };
249
+ },
250
+
251
+ async execute(intents) {
252
+ const agent = getAgent();
253
+ const commands = [];
254
+ const warnings: ValidationIssue[] = [];
255
+ for (const intent of intents as FieldIntent[]) {
256
+ const result = await agent.run(intent);
257
+ if (!result.ok) {
258
+ return {
259
+ status: "rejected",
260
+ reason: result.reason ?? "Refused.",
261
+ code: result.code ?? "rejected",
262
+ layer: result.layer ?? "policy",
263
+ ...(result.suggestion ? { suggestion: result.suggestion } : {}),
264
+ } as ProjectedOutcome;
265
+ }
266
+ warnings.push(...result.warnings);
267
+ commands.push({ intent, explain: { summary: summarise(label(agent), result.command!) } });
268
+ }
269
+ return { status: "done", commands, warnings };
270
+ },
271
+ };
272
+ }
273
+
274
+ /** What a field tells an agent about itself: what it holds, what it accepts. */
275
+ export function describeFieldContract(contract: FieldContract): Record<string, unknown> {
276
+ const constraints: Record<string, unknown> = { holds: contract.kind };
277
+ for (const key of ["required", "disabled", "readOnly"] as const) if (contract[key]) constraints[key] = true;
278
+ if (contract.min !== undefined) constraints.min = contract.min;
279
+ if (contract.max !== undefined) constraints.max = contract.max;
280
+ if (contract.step !== undefined) constraints.step = contract.step;
281
+ if (contract.maxLength !== undefined) constraints.maxLength = contract.maxLength;
282
+ if (contract.pattern !== undefined) constraints.pattern = contract.pattern;
283
+ if (contract.options) {
284
+ constraints.options = contract.options.map((o) => ({
285
+ value: o.value,
286
+ label: o.label,
287
+ ...(o.disabled ? { disabled: true } : {}),
288
+ }));
289
+ }
290
+ if (contract.maxSelected !== undefined) constraints.maxSelected = contract.maxSelected;
291
+
292
+ const operations = fieldOperations(contract);
293
+ return {
294
+ constraints,
295
+ operations: Object.fromEntries(
296
+ Object.values(operations).map((op) => [op.name, { summary: op.summary, ...(op.input ? { input: op.input } : {}) }]),
297
+ ),
298
+ };
299
+ }
300
+
301
+ // ------------------------------------------------------------ WebMCP tool
302
+
303
+ /*
304
+ * One tool for the page, registered once.
305
+ *
306
+ * WebMCP has no way to unregister a tool, throws on a duplicate name, and
307
+ * React's StrictMode runs effects twice — so the tool is registered once per
308
+ * (modelContext, name) and looks up whichever surface is current when it is
309
+ * called. A surface that unmounts and remounts takes the tool over rather
310
+ * than fighting it.
311
+ *
312
+ * Its schema is fixed, because the page is not: components mount and
313
+ * unmount, and a registered schema cannot change. `describe` returns the live
314
+ * manifest, including each member's own input schema; `operate` sends intents
315
+ * to one member by id.
316
+ */
317
+ const registered = new WeakMap<ModelContext, Map<string, { current: () => AgentSurfaceHandle | null }>>();
318
+
319
+ export const SURFACE_TOOL_SCHEMA: JsonSchema = {
320
+ type: "object",
321
+ additionalProperties: false,
322
+ required: ["action"],
323
+ properties: {
324
+ action: {
325
+ type: "string",
326
+ enum: ["describe", "operate"],
327
+ description:
328
+ "describe: list everything on this page that can be operated, what each holds and accepts. " +
329
+ "operate: send intents to one of them.",
330
+ },
331
+ target: { type: "string", description: "With operate: a member id from describe." },
332
+ intents: {
333
+ type: "array",
334
+ minItems: 1,
335
+ maxItems: MAX_INTENTS,
336
+ description:
337
+ "With operate: operations for that member, in the shape its `input` schema from describe gives, applied in order.",
338
+ items: { type: "object" },
339
+ },
340
+ },
341
+ };
342
+
343
+ const SURFACE_DESCRIPTION =
344
+ "Operate the form fields and data grids on this page. Call describe first: it lists each " +
345
+ "control with what it holds and accepts — options, date ranges, limits — and the operations it " +
346
+ "supports. Then operate one by id. Every request is validated by the control itself before " +
347
+ "anything changes; a request it cannot accept is refused with the reason and, where there is " +
348
+ "one, the nearest valid value. Nothing irreversible runs without confirmation.";
349
+
350
+ export interface RegisterSurfaceToolOptions {
351
+ name?: string;
352
+ modelContext?: ModelContext;
353
+ onCall?: (entry: ToolCallLog) => void;
354
+ }
355
+
356
+ /** Point the page's tool at `getSurface`, registering it if this is the first time. */
357
+ export function registerSurfaceTool(
358
+ getSurface: () => AgentSurfaceHandle | null,
359
+ options: RegisterSurfaceToolOptions = {},
360
+ ): RegistrationResult & { release(): void } {
361
+ const name = options.name ?? SURFACE_TOOL_NAME;
362
+ const modelContext =
363
+ options.modelContext ?? (typeof document === "undefined" ? undefined : document.modelContext);
364
+
365
+ if (typeof modelContext?.registerTool !== "function") {
366
+ return {
367
+ registered: false,
368
+ toolName: name,
369
+ reason: "document.modelContext is unavailable in this browser.",
370
+ release() {},
371
+ };
372
+ }
373
+
374
+ let byName = registered.get(modelContext);
375
+ if (!byName) {
376
+ byName = new Map();
377
+ registered.set(modelContext, byName);
378
+ }
379
+
380
+ const pointer = { current: getSurface };
381
+ const existing = byName.get(name);
382
+ if (existing) {
383
+ existing.current = getSurface;
384
+ } else {
385
+ byName.set(name, pointer);
386
+ const slot = pointer;
387
+ modelContext.registerTool({
388
+ name,
389
+ description: SURFACE_DESCRIPTION,
390
+ inputSchema: SURFACE_TOOL_SCHEMA,
391
+ async execute(rawInput: unknown) {
392
+ const input = (rawInput ?? {}) as { action?: unknown; target?: unknown; intents?: unknown };
393
+ const surface = slot.current();
394
+ let payload: ToolResultPayload;
395
+ if (!surface) {
396
+ payload = { ok: false, status: "declined", message: "This page has no operable controls right now." };
397
+ } else if (input.action === "describe") {
398
+ const manifest = surface.manifest();
399
+ payload = {
400
+ ok: true,
401
+ status: "done",
402
+ message: `${manifest.members.length} operable control(s): ${manifest.members
403
+ .map((m) => `${m.id} (${m.component}${m.label ? `, "${m.label}"` : ""})`)
404
+ .join("; ")}.`,
405
+ manifest,
406
+ };
407
+ } else if (input.action === "operate") {
408
+ payload = await surface.run(String(input.target ?? ""), input.intents);
409
+ } else {
410
+ payload = {
411
+ ok: false,
412
+ status: "rejected",
413
+ code: "unknown-action",
414
+ message: 'action must be "describe" or "operate".',
415
+ };
416
+ }
417
+ try {
418
+ options.onCall?.({ input: rawInput, result: payload });
419
+ } catch {
420
+ /* observers never break a call */
421
+ }
422
+ return toToolResult(payload);
423
+ },
424
+ });
425
+ }
426
+
427
+ const slot = byName.get(name)!;
428
+ return {
429
+ registered: true,
430
+ toolName: name,
431
+ inputSchema: SURFACE_TOOL_SCHEMA,
432
+ release() {
433
+ // Leave the tool registered (it cannot be removed) but stop it pointing here.
434
+ if (slot.current === getSurface) slot.current = () => null;
435
+ },
436
+ };
437
+ }