@grampro/kit 2.6.3 → 2.7.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/.gbs/skills/gbs-components/SKILL.md +5 -4
- package/.gbs/skills/gbs-components/references/install.md +17 -14
- package/CHANGELOG.md +164 -0
- package/README.md +19 -6
- package/index.cjs +59 -59
- package/package.json +2 -2
- package/schema/passport-overlay-v1.schema.json +67 -0
- package/source/components/accordion/passport.json +45 -8
- package/source/components/accordion/passport.manual.json +46 -0
- package/source/components/alert/passport.json +41 -8
- package/source/components/alert/passport.manual.json +42 -0
- package/source/components/avatar/passport.json +40 -7
- package/source/components/avatar/passport.manual.json +43 -0
- package/source/components/badge/passport.json +43 -7
- package/source/components/badge/passport.manual.json +46 -0
- package/source/components/breadcrumb/passport.json +36 -7
- package/source/components/breadcrumb/passport.manual.json +39 -0
- package/source/components/button/passport.json +1 -1
- package/source/components/button/passport.manual.json +1 -1
- package/source/components/card/passport.json +45 -8
- package/source/components/card/passport.manual.json +47 -0
- package/source/components/checkbox/core/agent.ts +33 -0
- package/source/components/checkbox/core/index.ts +2 -0
- package/source/components/checkbox/passport.json +83 -10
- package/source/components/checkbox/passport.manual.json +82 -0
- package/source/components/combobox/core/agent.ts +38 -0
- package/source/components/combobox/core/index.ts +2 -0
- package/source/components/combobox/passport.json +103 -11
- package/source/components/combobox/passport.manual.json +101 -0
- package/source/components/combobox/react/props.ts +14 -0
- package/source/components/combobox/react/useCombobox.ts +7 -1
- package/source/components/data-grid/export/xlsx.ts +9 -2
- package/source/components/data-grid/passport.json +2 -2
- package/source/components/data-grid/passport.manual.json +1 -1
- package/source/components/date-picker/core/agent.ts +42 -0
- package/source/components/date-picker/core/index.ts +2 -0
- package/source/components/date-picker/passport.json +102 -11
- package/source/components/date-picker/passport.manual.json +100 -0
- package/source/components/date-picker/react/DatePicker.tsx +6 -0
- package/source/components/date-picker/react/DateRangePicker.tsx +4 -0
- package/source/components/date-picker/react/props.ts +17 -0
- package/source/components/dialog/passport.json +54 -9
- package/source/components/dialog/passport.manual.json +54 -0
- package/source/components/file-uploader/passport.json +65 -10
- package/source/components/file-uploader/passport.manual.json +64 -0
- package/source/components/input/core/agent.ts +36 -0
- package/source/components/input/core/index.ts +2 -0
- package/source/components/input/passport.json +89 -12
- package/source/components/input/passport.manual.json +86 -0
- package/source/components/menu/passport.json +64 -9
- package/source/components/menu/passport.manual.json +64 -0
- package/source/components/modal/passport.json +52 -9
- package/source/components/modal/passport.manual.json +52 -0
- package/source/components/number-input/core/agent.ts +38 -0
- package/source/components/number-input/core/index.ts +2 -0
- package/source/components/number-input/passport.json +92 -11
- package/source/components/number-input/passport.manual.json +90 -0
- package/source/components/popover/passport.json +47 -8
- package/source/components/popover/passport.manual.json +48 -0
- package/source/components/progress/passport.json +37 -8
- package/source/components/progress/passport.manual.json +39 -0
- package/source/components/radio-group/core/agent.ts +34 -0
- package/source/components/radio-group/core/index.ts +2 -0
- package/source/components/radio-group/passport.json +77 -10
- package/source/components/radio-group/passport.manual.json +76 -0
- package/source/components/shared/core/agent/field-agent.ts +101 -0
- package/source/components/shared/core/agent/field-executors.ts +137 -0
- package/source/components/shared/core/agent/field.ts +649 -0
- package/source/components/shared/core/agent/index.ts +30 -0
- package/source/components/shared/core/agent/write.ts +58 -0
- package/source/components/shared/react/useFieldAgent.ts +88 -0
- package/source/components/skeleton/passport.json +37 -6
- package/source/components/skeleton/passport.manual.json +39 -0
- package/source/components/spinner/passport.json +34 -7
- package/source/components/spinner/passport.manual.json +37 -0
- package/source/components/switch/core/agent.ts +31 -0
- package/source/components/switch/core/index.ts +2 -0
- package/source/components/switch/passport.json +68 -10
- package/source/components/switch/passport.manual.json +67 -0
- package/source/components/tabs/passport.json +50 -9
- package/source/components/tabs/passport.manual.json +50 -0
- package/source/components/textarea/core/agent.ts +34 -0
- package/source/components/textarea/core/index.ts +2 -0
- package/source/components/textarea/passport.json +83 -10
- package/source/components/textarea/passport.manual.json +82 -0
- package/source/components/toaster/passport.json +41 -8
- package/source/components/toaster/passport.manual.json +42 -0
- package/source/components/tooltip/passport.json +39 -6
- package/source/components/tooltip/passport.manual.json +41 -0
- package/tools/mcp/design.cjs +6 -1
- package/tools/mcp/figma.cjs +49 -9
- package/tools/mcp/install.cjs +6 -1
- package/tools/mcp/server.cjs +14 -2
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What a field knows how to do, and whether it may be asked to do it.
|
|
3
|
+
*
|
|
4
|
+
* The DataGrid got a runtime of its own because a grid's operations are a
|
|
5
|
+
* language: filter, sort, pin, paginate, export. A field has one verb. Writing
|
|
6
|
+
* twenty lines of that verb eight times — once for Input, once for Textarea,
|
|
7
|
+
* once for Switch — would be eight places for the same bug, so the verb lives
|
|
8
|
+
* here and each component supplies only two things: a contract describing what
|
|
9
|
+
* this instance will accept, and an executor that knows how to write it.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here imports React or touches the DOM. A contract can be built and
|
|
12
|
+
* an intent validated on a server, before anything is sent to a browser, which
|
|
13
|
+
* is the same property the grid's runtime has and for the same reason: the
|
|
14
|
+
* validator is the security boundary, and a boundary that only exists in the
|
|
15
|
+
* browser is not one.
|
|
16
|
+
*
|
|
17
|
+
* The five layers are the grid's five layers, in the same order, because they
|
|
18
|
+
* are not grid-specific:
|
|
19
|
+
*
|
|
20
|
+
* schema is it the right shape
|
|
21
|
+
* reference does what it names exist
|
|
22
|
+
* coercion can what they wrote be read as the type this field holds
|
|
23
|
+
* policy is this field accepting writes at all
|
|
24
|
+
* plausibility is the result suspicious enough to mention
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { checkSchema } from "./schema";
|
|
28
|
+
import { fail, ok } from "./types";
|
|
29
|
+
import type {
|
|
30
|
+
JsonSchema,
|
|
31
|
+
OperationDefinition,
|
|
32
|
+
ValidationIssue,
|
|
33
|
+
ValidationResult,
|
|
34
|
+
} from "./types";
|
|
35
|
+
|
|
36
|
+
// ------------------------------------------------------------------- kinds
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What a field holds. This decides how `setValue` reads its input and which
|
|
40
|
+
* policy checks apply, and it is the only switch in this file — everything
|
|
41
|
+
* else is shared across every kind.
|
|
42
|
+
*/
|
|
43
|
+
export type FieldKind =
|
|
44
|
+
| "text"
|
|
45
|
+
| "number"
|
|
46
|
+
| "boolean"
|
|
47
|
+
| "date"
|
|
48
|
+
| "dateRange"
|
|
49
|
+
| "choice"
|
|
50
|
+
| "multiChoice";
|
|
51
|
+
|
|
52
|
+
export interface FieldOption {
|
|
53
|
+
readonly value: string;
|
|
54
|
+
readonly label: string;
|
|
55
|
+
readonly disabled?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What one field instance will accept, right now.
|
|
60
|
+
*
|
|
61
|
+
* Built per render from the component's own props, so a field that has just
|
|
62
|
+
* become `readOnly` stops accepting writes without anything else being told.
|
|
63
|
+
*/
|
|
64
|
+
export interface FieldContract {
|
|
65
|
+
/** The component's name, for messages a person will read. */
|
|
66
|
+
readonly component: string;
|
|
67
|
+
readonly kind: FieldKind;
|
|
68
|
+
/** The field's accessible name, used in confirmations and refusals. */
|
|
69
|
+
readonly label?: string;
|
|
70
|
+
readonly disabled?: boolean;
|
|
71
|
+
readonly readOnly?: boolean;
|
|
72
|
+
readonly required?: boolean;
|
|
73
|
+
/** Operations this instance exposes. A field with no disclosure omits open/close. */
|
|
74
|
+
readonly operations: readonly FieldOperationName[];
|
|
75
|
+
|
|
76
|
+
/** text: the cap the field enforces. */
|
|
77
|
+
readonly maxLength?: number;
|
|
78
|
+
/** text: a pattern the value must match, as a RegExp source. */
|
|
79
|
+
readonly pattern?: string;
|
|
80
|
+
|
|
81
|
+
/** number, date, dateRange: inclusive bounds. Dates are ISO yyyy-mm-dd. */
|
|
82
|
+
readonly min?: number | string;
|
|
83
|
+
readonly max?: number | string;
|
|
84
|
+
/** number: the increment the field snaps to. */
|
|
85
|
+
readonly step?: number;
|
|
86
|
+
|
|
87
|
+
/** choice, multiChoice: what may be picked. */
|
|
88
|
+
readonly options?: readonly FieldOption[];
|
|
89
|
+
/** multiChoice: the most that may be selected at once. */
|
|
90
|
+
readonly maxSelected?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -------------------------------------------------------------- operations
|
|
94
|
+
|
|
95
|
+
export type FieldOperationName = "setValue" | "clear" | "focus" | "open" | "close";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The input schema for `setValue`, narrowed to the kind of field being
|
|
99
|
+
* described. A constrained decoder given this cannot even express selecting an
|
|
100
|
+
* option that is not in the list.
|
|
101
|
+
*/
|
|
102
|
+
export function setValueSchema(contract: FieldContract): JsonSchema {
|
|
103
|
+
const { kind, options } = contract;
|
|
104
|
+
|
|
105
|
+
if (kind === "boolean") {
|
|
106
|
+
return {
|
|
107
|
+
type: "object",
|
|
108
|
+
required: ["value"],
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
properties: { value: { type: "boolean" } },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (kind === "number") {
|
|
115
|
+
const value: JsonSchema = { type: "number" };
|
|
116
|
+
if (typeof contract.min === "number") (value as { minimum?: number }).minimum = contract.min;
|
|
117
|
+
if (typeof contract.max === "number") (value as { maximum?: number }).maximum = contract.max;
|
|
118
|
+
return {
|
|
119
|
+
type: "object",
|
|
120
|
+
required: ["value"],
|
|
121
|
+
additionalProperties: false,
|
|
122
|
+
properties: { value },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (kind === "date") {
|
|
127
|
+
return {
|
|
128
|
+
type: "object",
|
|
129
|
+
required: ["value"],
|
|
130
|
+
additionalProperties: false,
|
|
131
|
+
properties: {
|
|
132
|
+
value: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "ISO yyyy-mm-dd" },
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (kind === "dateRange") {
|
|
138
|
+
const day: JsonSchema = { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" };
|
|
139
|
+
return {
|
|
140
|
+
type: "object",
|
|
141
|
+
required: ["value"],
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
properties: {
|
|
144
|
+
value: {
|
|
145
|
+
type: "object",
|
|
146
|
+
required: ["start", "end"],
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
properties: { start: day, end: day },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (kind === "choice" || kind === "multiChoice") {
|
|
155
|
+
const enumerated = options?.map((option) => option.value);
|
|
156
|
+
const one: JsonSchema = enumerated?.length ? { type: "string", enum: enumerated } : { type: "string" };
|
|
157
|
+
return {
|
|
158
|
+
type: "object",
|
|
159
|
+
required: ["value"],
|
|
160
|
+
additionalProperties: false,
|
|
161
|
+
properties: {
|
|
162
|
+
value:
|
|
163
|
+
kind === "choice"
|
|
164
|
+
? one
|
|
165
|
+
: { type: "array", items: one, ...(contract.maxSelected ? { maxItems: contract.maxSelected } : {}) },
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// text
|
|
171
|
+
const value: JsonSchema = { type: "string" };
|
|
172
|
+
if (contract.maxLength) (value as { maxLength?: number }).maxLength = contract.maxLength;
|
|
173
|
+
if (contract.pattern) (value as { pattern?: string }).pattern = contract.pattern;
|
|
174
|
+
return { type: "object", required: ["value"], additionalProperties: false, properties: { value } };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The static operation set. `setValue` carries no schema here because its
|
|
179
|
+
* shape depends on the instance; `fieldOperations` fills it in.
|
|
180
|
+
*/
|
|
181
|
+
const BASE: Record<FieldOperationName, OperationDefinition> = {
|
|
182
|
+
setValue: {
|
|
183
|
+
name: "setValue",
|
|
184
|
+
summary: "Write a value into the field, as though a person had entered it.",
|
|
185
|
+
effects: ["replaces the field's current value", "fires the host's change handler"],
|
|
186
|
+
reversible: true,
|
|
187
|
+
},
|
|
188
|
+
clear: {
|
|
189
|
+
name: "clear",
|
|
190
|
+
summary: "Empty the field.",
|
|
191
|
+
effects: ["discards the field's current value", "fires the host's change handler"],
|
|
192
|
+
reversible: true,
|
|
193
|
+
},
|
|
194
|
+
focus: {
|
|
195
|
+
name: "focus",
|
|
196
|
+
summary: "Move keyboard focus to the field.",
|
|
197
|
+
effects: ["moves focus"],
|
|
198
|
+
reversible: false,
|
|
199
|
+
},
|
|
200
|
+
open: {
|
|
201
|
+
name: "open",
|
|
202
|
+
summary: "Open the field's calendar or option list.",
|
|
203
|
+
effects: ["shows the overlay"],
|
|
204
|
+
reversible: true,
|
|
205
|
+
},
|
|
206
|
+
close: {
|
|
207
|
+
name: "close",
|
|
208
|
+
summary: "Close the field's calendar or option list.",
|
|
209
|
+
effects: ["hides the overlay"],
|
|
210
|
+
reversible: true,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** The operations this instance publishes, with `setValue` narrowed to it. */
|
|
215
|
+
export function fieldOperations(contract: FieldContract): Record<string, OperationDefinition> {
|
|
216
|
+
const out: Record<string, OperationDefinition> = {};
|
|
217
|
+
for (const name of contract.operations) {
|
|
218
|
+
out[name] = name === "setValue" ? { ...BASE.setValue, input: setValueSchema(contract) } : BASE[name];
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --------------------------------------------------------------- the intent
|
|
224
|
+
|
|
225
|
+
export interface FieldIntent {
|
|
226
|
+
readonly action: FieldOperationName;
|
|
227
|
+
readonly value?: unknown;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface FieldCommand {
|
|
231
|
+
readonly action: FieldOperationName;
|
|
232
|
+
/** Present for setValue, already coerced to the field's own type. */
|
|
233
|
+
readonly value?: string | number | boolean | string[] | { start: string; end: string };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ------------------------------------------------------------------ coercion
|
|
237
|
+
|
|
238
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
239
|
+
|
|
240
|
+
/** A real calendar day, not merely four-two-two digits. 2025-02-31 is not one. */
|
|
241
|
+
function isRealDay(text: string): boolean {
|
|
242
|
+
if (!DAY.test(text)) return false;
|
|
243
|
+
const [y, m, d] = text.split("-").map(Number);
|
|
244
|
+
if (m < 1 || m > 12 || d < 1) return false;
|
|
245
|
+
const date = new Date(Date.UTC(y, m - 1, d));
|
|
246
|
+
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Read what the producer sent as the type this field holds.
|
|
251
|
+
*
|
|
252
|
+
* Deliberately narrow. "true" becomes true for a boolean field because a model
|
|
253
|
+
* emitting JSON through a text channel does that constantly; "yes" does not,
|
|
254
|
+
* because guessing at natural language is the adapter's job and this is the
|
|
255
|
+
* layer that is supposed to catch the adapter being wrong.
|
|
256
|
+
*/
|
|
257
|
+
function coerce(
|
|
258
|
+
value: unknown,
|
|
259
|
+
contract: FieldContract,
|
|
260
|
+
notes: ValidationIssue[],
|
|
261
|
+
): { ok: true; value: FieldCommand["value"] } | { ok: false; issue: ValidationIssue } {
|
|
262
|
+
const note = (message: string) =>
|
|
263
|
+
notes.push({ layer: "coercion", code: "coerced", message, path: "/value" });
|
|
264
|
+
|
|
265
|
+
const bad = (message: string, suggestion?: string) => ({
|
|
266
|
+
ok: false as const,
|
|
267
|
+
issue: { layer: "coercion" as const, code: "wrong-type", message, path: "/value", ...(suggestion ? { suggestion } : {}) },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
switch (contract.kind) {
|
|
271
|
+
case "boolean": {
|
|
272
|
+
if (typeof value === "boolean") return { ok: true, value };
|
|
273
|
+
if (value === "true" || value === "false") {
|
|
274
|
+
note(`read ${JSON.stringify(value)} as the boolean ${value === "true"}`);
|
|
275
|
+
return { ok: true, value: value === "true" };
|
|
276
|
+
}
|
|
277
|
+
return bad(`${contract.component} holds true or false, not ${JSON.stringify(value)}.`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case "number": {
|
|
281
|
+
if (typeof value === "number" && Number.isFinite(value)) return { ok: true, value };
|
|
282
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
283
|
+
const parsed = Number(value.trim());
|
|
284
|
+
if (Number.isFinite(parsed)) {
|
|
285
|
+
note(`read the string ${JSON.stringify(value)} as the number ${parsed}`);
|
|
286
|
+
return { ok: true, value: parsed };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return bad(`${contract.component} holds a number, and ${JSON.stringify(value)} is not one.`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
case "date": {
|
|
293
|
+
if (typeof value !== "string") return bad("A date must be a string, as yyyy-mm-dd.");
|
|
294
|
+
if (!isRealDay(value)) {
|
|
295
|
+
return bad(
|
|
296
|
+
`${JSON.stringify(value)} is not a calendar date.`,
|
|
297
|
+
"Dates are ISO yyyy-mm-dd. Resolve anything relative before sending it.",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return { ok: true, value };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case "dateRange": {
|
|
304
|
+
const range = value as { start?: unknown; end?: unknown } | null;
|
|
305
|
+
if (!range || typeof range !== "object") return bad("A range must be an object with start and end.");
|
|
306
|
+
const { start, end } = range;
|
|
307
|
+
if (typeof start !== "string" || !isRealDay(start)) return bad("`start` is not a calendar date.");
|
|
308
|
+
if (typeof end !== "string" || !isRealDay(end)) return bad("`end` is not a calendar date.");
|
|
309
|
+
if (start > end) {
|
|
310
|
+
note(`start was after end, so the two were swapped`);
|
|
311
|
+
return { ok: true, value: { start: end, end: start } };
|
|
312
|
+
}
|
|
313
|
+
return { ok: true, value: { start, end } };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
case "choice": {
|
|
317
|
+
if (typeof value !== "string") return bad("A choice must be one option's value, as a string.");
|
|
318
|
+
return { ok: true, value };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
case "multiChoice": {
|
|
322
|
+
if (typeof value === "string") {
|
|
323
|
+
note("read a single option as a list of one");
|
|
324
|
+
return { ok: true, value: [value] };
|
|
325
|
+
}
|
|
326
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
327
|
+
return bad("A multiple choice must be a list of option values.");
|
|
328
|
+
}
|
|
329
|
+
const unique = [...new Set(value as string[])];
|
|
330
|
+
if (unique.length !== value.length) note("removed repeated options");
|
|
331
|
+
return { ok: true, value: unique };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
default: {
|
|
335
|
+
if (typeof value === "string") return { ok: true, value };
|
|
336
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
337
|
+
note(`read ${JSON.stringify(value)} as text`);
|
|
338
|
+
return { ok: true, value: String(value) };
|
|
339
|
+
}
|
|
340
|
+
return bad(`${contract.component} holds text, and ${JSON.stringify(value)} is not text.`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ------------------------------------------------------------- the validator
|
|
346
|
+
|
|
347
|
+
/** Levenshtein distance, capped — only used to suggest a near-miss option. */
|
|
348
|
+
function distance(a: string, b: string): number {
|
|
349
|
+
const rows = a.length + 1;
|
|
350
|
+
const cols = b.length + 1;
|
|
351
|
+
let previous = Array.from({ length: cols }, (_, i) => i);
|
|
352
|
+
for (let i = 1; i < rows; i += 1) {
|
|
353
|
+
const current = [i];
|
|
354
|
+
for (let j = 1; j < cols; j += 1) {
|
|
355
|
+
current[j] = Math.min(
|
|
356
|
+
previous[j] + 1,
|
|
357
|
+
current[j - 1] + 1,
|
|
358
|
+
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
previous = current;
|
|
362
|
+
}
|
|
363
|
+
return previous[cols - 1];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The closest option to what was asked for, when one is close enough to mean it. */
|
|
367
|
+
function nearestOption(wanted: string, options: readonly FieldOption[]): FieldOption | null {
|
|
368
|
+
const needle = wanted.trim().toLowerCase();
|
|
369
|
+
let best: FieldOption | null = null;
|
|
370
|
+
let bestScore = Infinity;
|
|
371
|
+
for (const option of options) {
|
|
372
|
+
for (const candidate of [option.value, option.label]) {
|
|
373
|
+
const score = distance(needle, candidate.toLowerCase());
|
|
374
|
+
if (score < bestScore) {
|
|
375
|
+
bestScore = score;
|
|
376
|
+
best = option;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return bestScore <= Math.max(2, Math.floor(needle.length / 3)) ? best : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Validate one intent against one field.
|
|
385
|
+
*
|
|
386
|
+
* Returns a command only when every layer passed. A refusal names the layer
|
|
387
|
+
* that produced it, because "it did not work" and "that column is off-limits"
|
|
388
|
+
* are different things to the person reading it.
|
|
389
|
+
*/
|
|
390
|
+
export function validateFieldIntent(
|
|
391
|
+
intent: FieldIntent,
|
|
392
|
+
contract: FieldContract,
|
|
393
|
+
): ValidationResult<FieldCommand> {
|
|
394
|
+
const warnings: ValidationIssue[] = [];
|
|
395
|
+
|
|
396
|
+
// 1. schema — is this even the right shape
|
|
397
|
+
if (!intent || typeof intent !== "object" || typeof intent.action !== "string") {
|
|
398
|
+
return fail({ layer: "schema", code: "malformed", message: "An intent needs an `action`." });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 2. reference — does this field do that
|
|
402
|
+
if (!contract.operations.includes(intent.action)) {
|
|
403
|
+
return fail({
|
|
404
|
+
layer: "reference",
|
|
405
|
+
code: "unknown-operation",
|
|
406
|
+
message: `${contract.component} does not support "${intent.action}".`,
|
|
407
|
+
suggestion: `It supports: ${contract.operations.join(", ")}.`,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// focus, open and close carry nothing and change no value.
|
|
412
|
+
if (intent.action !== "setValue") {
|
|
413
|
+
if (intent.action === "clear" && contract.required) {
|
|
414
|
+
warnings.push({
|
|
415
|
+
layer: "plausibility",
|
|
416
|
+
code: "clearing-required-field",
|
|
417
|
+
message: `${contract.label ?? contract.component} is required, so clearing it leaves the form invalid.`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const blocked = policyRefusal(contract, intent.action);
|
|
421
|
+
return blocked ?? ok({ action: intent.action }, warnings);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/*
|
|
425
|
+
* Only the coarse shape is checked here, not the narrowed schema.
|
|
426
|
+
*
|
|
427
|
+
* `setValueSchema` exists to constrain a decoder, where refusing to emit
|
|
428
|
+
* "enginering" at all is exactly right. Running it as the validator's first
|
|
429
|
+
* gate would be a different thing: every interesting case — a near-miss
|
|
430
|
+
* option, a stringified boolean, one option too many — would fail as
|
|
431
|
+
* "schema" before the layer that can explain it ever ran, and the caller
|
|
432
|
+
* would get "does not match enum" instead of "did you mean Engineering?".
|
|
433
|
+
*
|
|
434
|
+
* So the order is shape, then type, then existence, then permission. Each
|
|
435
|
+
* check still happens; it happens in the layer whose name it should carry.
|
|
436
|
+
*/
|
|
437
|
+
const shape = checkSchema(
|
|
438
|
+
{ value: intent.value },
|
|
439
|
+
{ type: "object", required: ["value"], properties: {} },
|
|
440
|
+
);
|
|
441
|
+
if (shape.length > 0) {
|
|
442
|
+
const [first] = shape;
|
|
443
|
+
return fail({
|
|
444
|
+
layer: "schema",
|
|
445
|
+
code: "schema",
|
|
446
|
+
message: `setValue needs a \`value\`. ${first.message}`.trim(),
|
|
447
|
+
path: first.path,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 3. coercion — can it be read as this field's type
|
|
452
|
+
const read = coerce(intent.value, contract, warnings);
|
|
453
|
+
if (!read.ok) return fail(read.issue);
|
|
454
|
+
let value = read.value;
|
|
455
|
+
|
|
456
|
+
// 2b. reference, again — a named option has to exist
|
|
457
|
+
if (contract.options?.length) {
|
|
458
|
+
const wanted = contract.kind === "multiChoice" ? (value as string[]) : [value as string];
|
|
459
|
+
const known = new Map(contract.options.map((option) => [option.value, option]));
|
|
460
|
+
for (const entry of wanted) {
|
|
461
|
+
const option = known.get(entry);
|
|
462
|
+
if (!option) {
|
|
463
|
+
const near = nearestOption(entry, contract.options);
|
|
464
|
+
return fail({
|
|
465
|
+
layer: "reference",
|
|
466
|
+
code: "unknown-option",
|
|
467
|
+
message: `${contract.component} has no option ${JSON.stringify(entry)}.`,
|
|
468
|
+
path: "/value",
|
|
469
|
+
...(near ? { suggestion: `Did you mean ${JSON.stringify(near.value)} (${near.label})?` } : {}),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (option.disabled) {
|
|
473
|
+
return fail({
|
|
474
|
+
layer: "policy",
|
|
475
|
+
code: "option-disabled",
|
|
476
|
+
message: `${option.label} is disabled on this field.`,
|
|
477
|
+
path: "/value",
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 4. policy — is this field accepting writes
|
|
484
|
+
const blocked = policyRefusal(contract, "setValue");
|
|
485
|
+
if (blocked) return blocked;
|
|
486
|
+
|
|
487
|
+
if (contract.kind === "number") {
|
|
488
|
+
const n = value as number;
|
|
489
|
+
if (typeof contract.min === "number" && n < contract.min) {
|
|
490
|
+
return fail({
|
|
491
|
+
layer: "policy",
|
|
492
|
+
code: "below-minimum",
|
|
493
|
+
message: `${contract.label ?? contract.component} does not go below ${contract.min}.`,
|
|
494
|
+
path: "/value",
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
if (typeof contract.max === "number" && n > contract.max) {
|
|
498
|
+
return fail({
|
|
499
|
+
layer: "policy",
|
|
500
|
+
code: "above-maximum",
|
|
501
|
+
message: `${contract.label ?? contract.component} does not go above ${contract.max}.`,
|
|
502
|
+
path: "/value",
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
if (contract.step && Number.isFinite(contract.step)) {
|
|
506
|
+
const base = typeof contract.min === "number" ? contract.min : 0;
|
|
507
|
+
const steps = (n - base) / contract.step;
|
|
508
|
+
if (Math.abs(steps - Math.round(steps)) > 1e-9) {
|
|
509
|
+
const snapped = base + Math.round(steps) * contract.step;
|
|
510
|
+
warnings.push({
|
|
511
|
+
layer: "coercion",
|
|
512
|
+
code: "snapped-to-step",
|
|
513
|
+
message: `${n} is not a multiple of ${contract.step}; the field will snap it to ${snapped}.`,
|
|
514
|
+
path: "/value",
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if ((contract.kind === "date" || contract.kind === "dateRange") && (contract.min || contract.max)) {
|
|
521
|
+
const days = contract.kind === "date"
|
|
522
|
+
? [value as string]
|
|
523
|
+
: [(value as { start: string }).start, (value as { end: string }).end];
|
|
524
|
+
for (const day of days) {
|
|
525
|
+
if (typeof contract.min === "string" && day < contract.min) {
|
|
526
|
+
return fail({
|
|
527
|
+
layer: "policy",
|
|
528
|
+
code: "before-earliest",
|
|
529
|
+
message: `${contract.label ?? contract.component} does not accept dates before ${contract.min}.`,
|
|
530
|
+
path: "/value",
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
if (typeof contract.max === "string" && day > contract.max) {
|
|
534
|
+
return fail({
|
|
535
|
+
layer: "policy",
|
|
536
|
+
code: "after-latest",
|
|
537
|
+
message: `${contract.label ?? contract.component} does not accept dates after ${contract.max}.`,
|
|
538
|
+
path: "/value",
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/*
|
|
545
|
+
* Text limits are policy, not shape: the field really will refuse these, so
|
|
546
|
+
* a caller needs to be told which rule it hit rather than that its JSON was
|
|
547
|
+
* malformed.
|
|
548
|
+
*/
|
|
549
|
+
if (contract.kind === "text" && typeof value === "string") {
|
|
550
|
+
if (contract.maxLength && value.length > contract.maxLength) {
|
|
551
|
+
return fail({
|
|
552
|
+
layer: "policy",
|
|
553
|
+
code: "too-long",
|
|
554
|
+
message: `${contract.label ?? contract.component} takes at most ${contract.maxLength} characters; this is ${value.length}.`,
|
|
555
|
+
path: "/value",
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (contract.pattern) {
|
|
559
|
+
let matches = true;
|
|
560
|
+
try {
|
|
561
|
+
matches = new RegExp(contract.pattern).test(value);
|
|
562
|
+
} catch {
|
|
563
|
+
// A pattern the field itself could not compile is not the caller's
|
|
564
|
+
// fault, so it is not held against them.
|
|
565
|
+
matches = true;
|
|
566
|
+
}
|
|
567
|
+
if (!matches) {
|
|
568
|
+
return fail({
|
|
569
|
+
layer: "policy",
|
|
570
|
+
code: "pattern-mismatch",
|
|
571
|
+
message: `${contract.label ?? contract.component} does not accept that format.`,
|
|
572
|
+
path: "/value",
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (contract.kind === "multiChoice" && contract.maxSelected) {
|
|
579
|
+
const picked = (value as string[]).length;
|
|
580
|
+
if (picked > contract.maxSelected) {
|
|
581
|
+
return fail({
|
|
582
|
+
layer: "policy",
|
|
583
|
+
code: "too-many-selected",
|
|
584
|
+
message: `${contract.component} takes at most ${contract.maxSelected} option(s); ${picked} were given.`,
|
|
585
|
+
path: "/value",
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 5. plausibility — allowed, but worth saying out loud
|
|
591
|
+
if (contract.kind === "text" && contract.maxLength) {
|
|
592
|
+
const length = (value as string).length;
|
|
593
|
+
if (length === contract.maxLength) {
|
|
594
|
+
warnings.push({
|
|
595
|
+
layer: "plausibility",
|
|
596
|
+
code: "at-limit",
|
|
597
|
+
message: `The value is exactly at the ${contract.maxLength}-character limit, so it may have been truncated.`,
|
|
598
|
+
path: "/value",
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (contract.kind === "text" && typeof value === "string" && value.trim() === "" && (value as string).length > 0) {
|
|
604
|
+
warnings.push({
|
|
605
|
+
layer: "plausibility",
|
|
606
|
+
code: "whitespace-only",
|
|
607
|
+
message: "The value is only whitespace, which reads as empty but is not.",
|
|
608
|
+
path: "/value",
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (contract.required && isEmpty(value)) {
|
|
613
|
+
warnings.push({
|
|
614
|
+
layer: "plausibility",
|
|
615
|
+
code: "emptying-required-field",
|
|
616
|
+
message: `${contract.label ?? contract.component} is required, and this leaves it empty.`,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return ok({ action: "setValue", value }, warnings);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const isEmpty = (value: FieldCommand["value"]): boolean =>
|
|
624
|
+
value === "" || value === null || value === undefined || (Array.isArray(value) && value.length === 0);
|
|
625
|
+
|
|
626
|
+
/** The one policy check every operation shares. */
|
|
627
|
+
function policyRefusal(
|
|
628
|
+
contract: FieldContract,
|
|
629
|
+
action: FieldOperationName,
|
|
630
|
+
): ValidationResult<FieldCommand> | null {
|
|
631
|
+
// Focusing a disabled field is harmless and sometimes useful; writing is not.
|
|
632
|
+
if (action === "focus") return null;
|
|
633
|
+
|
|
634
|
+
if (contract.disabled) {
|
|
635
|
+
return fail({
|
|
636
|
+
layer: "policy",
|
|
637
|
+
code: "disabled",
|
|
638
|
+
message: `${contract.label ?? contract.component} is disabled.`,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
if (contract.readOnly && (action === "setValue" || action === "clear")) {
|
|
642
|
+
return fail({
|
|
643
|
+
layer: "policy",
|
|
644
|
+
code: "read-only",
|
|
645
|
+
message: `${contract.label ?? contract.component} is read-only.`,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
@@ -44,3 +44,33 @@ export type {
|
|
|
44
44
|
AskOutcome,
|
|
45
45
|
AskPhase,
|
|
46
46
|
} from "./adapter";
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* The field runtime. A grid's operations are a language; a field has one verb,
|
|
50
|
+
* so the verb lives here once rather than eight times.
|
|
51
|
+
*/
|
|
52
|
+
export {
|
|
53
|
+
fieldOperations,
|
|
54
|
+
setValueSchema,
|
|
55
|
+
validateFieldIntent,
|
|
56
|
+
type FieldCommand,
|
|
57
|
+
type FieldContract,
|
|
58
|
+
type FieldIntent,
|
|
59
|
+
type FieldKind,
|
|
60
|
+
type FieldOperationName,
|
|
61
|
+
type FieldOption,
|
|
62
|
+
} from "./field";
|
|
63
|
+
export {
|
|
64
|
+
domFieldExecutors,
|
|
65
|
+
handleFieldExecutors,
|
|
66
|
+
type FieldExecutionContext,
|
|
67
|
+
type FieldHandleTarget,
|
|
68
|
+
} from "./field-executors";
|
|
69
|
+
export {
|
|
70
|
+
createFieldAgent,
|
|
71
|
+
type FieldAgent,
|
|
72
|
+
type FieldAgentOptions,
|
|
73
|
+
type FieldExecution,
|
|
74
|
+
} from "./field-agent";
|
|
75
|
+
/* Browser-only: everything above this line runs on a server too. */
|
|
76
|
+
export { writable, writeChecked, writeValue } from "./write";
|