gbs-add-block 2.0.4 → 2.3.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.
Files changed (129) hide show
  1. package/.gbs/skills/gbs-components/SKILL.md +190 -134
  2. package/.gbs/skills/gbs-components/references/install.md +25 -3
  3. package/.gbs/skills/gbs-components/references/styling.md +246 -201
  4. package/CHANGELOG.md +74 -0
  5. package/README.md +157 -11
  6. package/index.cjs +212 -3
  7. package/package.json +41 -10
  8. package/schema/passport-v1.schema.json +204 -0
  9. package/source/beta-components/accordion/passport.json +259 -0
  10. package/source/beta-components/accordion/styles.css +207 -208
  11. package/source/beta-components/alert/passport.json +250 -0
  12. package/source/beta-components/alert/styles.css +154 -155
  13. package/source/beta-components/avatar/passport.json +294 -0
  14. package/source/beta-components/avatar/styles.css +201 -203
  15. package/source/beta-components/badge/passport.json +332 -0
  16. package/source/beta-components/badge/styles.css +203 -204
  17. package/source/beta-components/breadcrumb/passport.json +243 -0
  18. package/source/beta-components/breadcrumb/styles.css +138 -139
  19. package/source/beta-components/button/passport.json +402 -0
  20. package/source/beta-components/button/passport.manual.json +31 -0
  21. package/source/beta-components/button/styles.css +232 -233
  22. package/source/beta-components/card/passport.json +337 -0
  23. package/source/beta-components/card/styles.css +230 -231
  24. package/source/beta-components/checkbox/passport.json +456 -0
  25. package/source/beta-components/checkbox/styles.css +211 -212
  26. package/source/beta-components/combobox/passport.json +456 -0
  27. package/source/beta-components/combobox/styles.css +419 -417
  28. package/source/beta-components/data-grid/agent/coerce.ts +368 -0
  29. package/source/beta-components/data-grid/agent/contract.ts +410 -0
  30. package/source/beta-components/data-grid/agent/dataset.ts +92 -0
  31. package/source/beta-components/data-grid/agent/engine.ts +470 -0
  32. package/source/beta-components/data-grid/agent/executors.ts +155 -0
  33. package/source/beta-components/data-grid/agent/index.ts +79 -0
  34. package/source/beta-components/data-grid/agent/intent.ts +324 -0
  35. package/source/beta-components/data-grid/agent/operations.ts +335 -0
  36. package/source/beta-components/data-grid/agent/validate.ts +630 -0
  37. package/source/beta-components/data-grid/agent/webmcp.ts +107 -0
  38. package/source/beta-components/data-grid/index.ts +14 -7
  39. package/source/beta-components/data-grid/passport.json +1051 -0
  40. package/source/beta-components/data-grid/passport.manual.json +255 -0
  41. package/source/beta-components/data-grid/react/AskGrid.tsx +164 -0
  42. package/source/beta-components/data-grid/react/DataGrid.tsx +39 -0
  43. package/source/beta-components/data-grid/styles.css +874 -717
  44. package/source/beta-components/date-picker/passport.json +407 -0
  45. package/source/beta-components/date-picker/styles.css +445 -446
  46. package/source/beta-components/dialog/passport.json +344 -0
  47. package/source/beta-components/dialog/styles.css +280 -278
  48. package/source/beta-components/file-uploader/passport.json +518 -0
  49. package/source/beta-components/file-uploader/styles.css +394 -395
  50. package/source/beta-components/input/passport.json +536 -0
  51. package/source/beta-components/input/styles.css +295 -296
  52. package/source/beta-components/menu/passport.json +322 -0
  53. package/source/beta-components/menu/styles.css +224 -222
  54. package/source/beta-components/modal/passport.json +289 -0
  55. package/source/beta-components/modal/styles.css +241 -239
  56. package/source/beta-components/number-input/passport.json +541 -0
  57. package/source/beta-components/number-input/styles.css +230 -231
  58. package/source/beta-components/popover/passport.json +238 -0
  59. package/source/beta-components/popover/styles.css +148 -146
  60. package/source/beta-components/progress/passport.json +270 -0
  61. package/source/beta-components/progress/styles.css +200 -202
  62. package/source/beta-components/radio-group/passport.json +477 -0
  63. package/source/beta-components/radio-group/styles.css +241 -242
  64. package/source/beta-components/shared/core/agent/adapter.ts +65 -0
  65. package/source/beta-components/shared/core/agent/history.ts +120 -0
  66. package/source/beta-components/shared/core/agent/index.ts +46 -0
  67. package/source/beta-components/shared/core/agent/numbers.ts +217 -0
  68. package/source/beta-components/shared/core/agent/schema.ts +180 -0
  69. package/source/beta-components/shared/core/agent/types.ts +169 -0
  70. package/source/beta-components/shared/core/agent/webmcp.ts +328 -0
  71. package/source/beta-components/shared/index.ts +9 -0
  72. package/source/beta-components/shared/react/GramproAIProvider.tsx +50 -0
  73. package/source/beta-components/shared/react/useAskAgent.ts +217 -0
  74. package/source/beta-components/shared/styles.css +79 -0
  75. package/source/beta-components/shared/version.json +4 -4
  76. package/source/beta-components/shared/version.ts +6 -6
  77. package/source/beta-components/skeleton/passport.json +251 -0
  78. package/source/beta-components/skeleton/styles.css +185 -186
  79. package/source/beta-components/spinner/passport.json +245 -0
  80. package/source/beta-components/spinner/styles.css +145 -146
  81. package/source/beta-components/switch/passport.json +421 -0
  82. package/source/beta-components/switch/styles.css +194 -196
  83. package/source/beta-components/tabs/passport.json +315 -0
  84. package/source/beta-components/tabs/styles.css +234 -235
  85. package/source/beta-components/textarea/passport.json +382 -0
  86. package/source/beta-components/textarea/styles.css +158 -159
  87. package/source/beta-components/toaster/passport.json +221 -0
  88. package/source/beta-components/toaster/styles.css +282 -283
  89. package/source/beta-components/tooltip/passport.json +170 -0
  90. package/source/beta-components/tooltip/styles.css +71 -72
  91. package/tools/env.cjs +61 -0
  92. package/tools/passport/cli.cjs +79 -0
  93. package/tools/passport/extract.cjs +493 -0
  94. package/tools/passport/index.cjs +185 -0
  95. package/tools/passport/merge.cjs +131 -0
  96. package/tools/passport/policy.cjs +65 -0
  97. package/tools/passport/validate.cjs +277 -0
  98. package/tools/ts-require.cjs +79 -0
  99. package/source/beta-components/accordion/__tests__/core.test.ts +0 -58
  100. package/source/beta-components/alert/__tests__/core.test.ts +0 -17
  101. package/source/beta-components/avatar/__tests__/core.test.ts +0 -88
  102. package/source/beta-components/badge/__tests__/core.test.ts +0 -46
  103. package/source/beta-components/breadcrumb/__tests__/core.test.ts +0 -58
  104. package/source/beta-components/button/__tests__/core.test.ts +0 -31
  105. package/source/beta-components/card/__tests__/core.test.ts +0 -57
  106. package/source/beta-components/checkbox/__tests__/core.test.ts +0 -40
  107. package/source/beta-components/combobox/__tests__/core.test.ts +0 -134
  108. package/source/beta-components/data-grid/__tests__/core.test.ts +0 -356
  109. package/source/beta-components/data-grid/__tests__/export.test.ts +0 -70
  110. package/source/beta-components/data-grid/__tests__/pdf.test.ts +0 -209
  111. package/source/beta-components/date-picker/__tests__/core.test.ts +0 -273
  112. package/source/beta-components/dialog/__tests__/core.test.ts +0 -86
  113. package/source/beta-components/file-uploader/__tests__/core.test.ts +0 -395
  114. package/source/beta-components/input/__tests__/core.test.ts +0 -75
  115. package/source/beta-components/menu/__tests__/core.test.ts +0 -120
  116. package/source/beta-components/modal/__tests__/core.test.ts +0 -55
  117. package/source/beta-components/number-input/__tests__/core.test.ts +0 -151
  118. package/source/beta-components/progress/__tests__/core.test.ts +0 -56
  119. package/source/beta-components/radio-group/__tests__/core.test.ts +0 -64
  120. package/source/beta-components/shared/__tests__/boundaries.test.ts +0 -95
  121. package/source/beta-components/shared/__tests__/core.test.ts +0 -55
  122. package/source/beta-components/shared/__tests__/position.test.ts +0 -143
  123. package/source/beta-components/skeleton/__tests__/core.test.ts +0 -41
  124. package/source/beta-components/spinner/__tests__/core.test.ts +0 -48
  125. package/source/beta-components/switch/__tests__/core.test.ts +0 -64
  126. package/source/beta-components/tabs/__tests__/core.test.ts +0 -51
  127. package/source/beta-components/textarea/__tests__/core.test.ts +0 -38
  128. package/source/beta-components/toaster/__tests__/core.test.ts +0 -256
  129. package/source/beta-components/tooltip/__tests__/core.test.ts +0 -42
@@ -0,0 +1,630 @@
1
+ /*
2
+ * Five layers between an intent and anything happening.
3
+ *
4
+ * 1 schema is this a shape this instance accepts?
5
+ * 2 reference do the column, option and rows it names exist?
6
+ * 3 coercion what do its values actually mean?
7
+ * 4 policy is it allowed, given what the host has forbidden?
8
+ * 5 plausibility does the result look like what was meant?
9
+ *
10
+ * The layers are run in that order, and a failure stops there — later layers
11
+ * would only produce noise about a command that is already dead. The layer a
12
+ * check belongs to is a property of the check, not of when it runs: a couple
13
+ * of reference and policy checks happen before the schema, because
14
+ * "`email` cannot be filtered" is a better message than "matched no branch",
15
+ * and the schema would only have said the latter.
16
+ *
17
+ * Nothing here executes. The result is a command the executor can run, or a
18
+ * refusal with a reason a person can read.
19
+ */
20
+
21
+ import { checkSchema, fail, ok, type ValidationIssue, type ValidationResult } from "../../shared/core/agent";
22
+ import type { ColumnFilter, GridState } from "../core/types";
23
+ import { coerceFilter, nearest, type CoercionNote } from "./coerce";
24
+ import type { GridAgentPolicy, GridContractColumn, GridRuntimeContract } from "./contract";
25
+ import type { GridDataset } from "./dataset";
26
+ import { COLUMN_REQUIREMENT, type GeneratedIntentSchema, type GridIntent } from "./intent";
27
+ import { GRID_OPERATIONS, isGridOperation, type GridOperationName } from "./operations";
28
+
29
+ export interface GridExplanation {
30
+ /** One line a person can read: "Filter Revenue greater than ₹1,00,000". */
31
+ summary: string;
32
+ /** The same thing in the grid's own terms: "revenue > 100000". */
33
+ interpretation: string;
34
+ /** Rows that would be shown afterwards. Null when only the server knows. */
35
+ affectedRows: number | null;
36
+ /** Rows shown now, for comparison. */
37
+ currentRows: number | null;
38
+ }
39
+
40
+ export interface GridCommand {
41
+ operation: GridOperationName;
42
+ /** The intent after coercion — exactly what the executor receives. */
43
+ intent: GridIntent;
44
+ explain: GridExplanation;
45
+ }
46
+
47
+ export interface ValidateContext<T> {
48
+ contract: GridRuntimeContract;
49
+ schema: GeneratedIntentSchema;
50
+ dataset: GridDataset<T>;
51
+ state: GridState;
52
+ policy: GridAgentPolicy;
53
+ locale?: string;
54
+ now?: Date;
55
+ }
56
+
57
+ const issue = (
58
+ layer: ValidationIssue["layer"],
59
+ code: string,
60
+ message: string,
61
+ extra: Partial<ValidationIssue> = {},
62
+ ): ValidationIssue => ({ layer, code, message, ...extra });
63
+
64
+ // --------------------------------------------------------------- explanation
65
+
66
+ const OPERATOR_WORDS: Record<string, string> = {
67
+ contains: "contains",
68
+ notContains: "does not contain",
69
+ equals: "is",
70
+ notEquals: "is not",
71
+ startsWith: "starts with",
72
+ endsWith: "ends with",
73
+ gt: "greater than",
74
+ gte: "at least",
75
+ lt: "less than",
76
+ lte: "at most",
77
+ between: "between",
78
+ before: "before",
79
+ after: "after",
80
+ in: "is one of",
81
+ isEmpty: "is empty",
82
+ isNotEmpty: "is not empty",
83
+ };
84
+
85
+ const isCurrencyCode = (unit: string | undefined): unit is string => /^[A-Z]{3}$/.test(unit ?? "");
86
+
87
+ function formatValue(column: GridContractColumn, value: unknown, locale?: string): string {
88
+ if (value === null || value === undefined) return "";
89
+ if (Array.isArray(value)) return value.map((item) => formatValue(column, item, locale)).join(", ");
90
+ if (typeof value === "boolean") return value ? "yes" : "no";
91
+ if (typeof value === "number") {
92
+ if (isCurrencyCode(column.unit)) {
93
+ return new Intl.NumberFormat(locale, {
94
+ style: "currency",
95
+ currency: column.unit,
96
+ maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
97
+ }).format(value);
98
+ }
99
+ const text = new Intl.NumberFormat(locale).format(value);
100
+ return column.unit === "%" || column.percentBasis ? `${text}%` : text;
101
+ }
102
+ const option = column.options?.find((entry) => entry.value === value);
103
+ return option ? option.label : String(value);
104
+ }
105
+
106
+ function describe(
107
+ intent: GridIntent,
108
+ contract: GridRuntimeContract,
109
+ locale: string | undefined,
110
+ ): { summary: string; interpretation: string } {
111
+ const column = (id: string) => contract.columns.find((entry) => entry.id === id);
112
+ const label = (id: string) => column(id)?.label ?? id;
113
+
114
+ switch (intent.action) {
115
+ case "search":
116
+ return {
117
+ summary: intent.text === "" ? "Clear the search" : `Search for "${intent.text}"`,
118
+ interpretation: `search = ${JSON.stringify(intent.text)}`,
119
+ };
120
+ case "filter": {
121
+ const col = column(intent.column);
122
+ const word = OPERATOR_WORDS[intent.operator] ?? intent.operator;
123
+ const value = col ? formatValue(col, intent.value, locale) : String(intent.value ?? "");
124
+ const upper = col && intent.value2 !== undefined ? formatValue(col, intent.value2, locale) : "";
125
+ const phrase =
126
+ intent.operator === "between"
127
+ ? `${word} ${value} and ${upper}`
128
+ : intent.operator === "isEmpty" || intent.operator === "isNotEmpty"
129
+ ? word
130
+ : `${word} ${value}`;
131
+ return {
132
+ summary: `Filter ${label(intent.column)} ${phrase}`,
133
+ interpretation: `${intent.column} ${intent.operator} ${JSON.stringify(intent.value ?? null)}${
134
+ intent.value2 !== undefined ? ` .. ${JSON.stringify(intent.value2)}` : ""
135
+ }`,
136
+ };
137
+ }
138
+ case "clearFilters":
139
+ return { summary: "Remove every filter", interpretation: "filters = [], search = \"\"" };
140
+ case "sort":
141
+ return {
142
+ summary: `Sort by ${label(intent.column)}, ${
143
+ intent.direction === "asc" ? "lowest first" : "highest first"
144
+ }${intent.append ? ", keeping the existing sort" : ""}`,
145
+ interpretation: `sort ${intent.column} ${intent.direction}${intent.append ? " (append)" : ""}`,
146
+ };
147
+ case "clearSort":
148
+ return { summary: "Return to the original order", interpretation: "sorting = []" };
149
+ case "selectRows":
150
+ return {
151
+ summary: `${intent.value === false ? "Deselect" : "Select"} ${intent.rowIds.length} row(s)`,
152
+ interpretation: `selectRows ${intent.rowIds.length}`,
153
+ };
154
+ case "selectAll":
155
+ return {
156
+ summary: intent.value === false ? "Clear the selection" : "Select every matching row",
157
+ interpretation: `selectAll ${intent.value !== false}`,
158
+ };
159
+ case "clearSelection":
160
+ return { summary: "Clear the selection", interpretation: "rowSelection = {}" };
161
+ case "setColumnVisibility":
162
+ return {
163
+ summary: `${intent.visible ? "Show" : "Hide"} ${label(intent.column)}`,
164
+ interpretation: `columnVisibility.${intent.column} = ${intent.visible}`,
165
+ };
166
+ case "pinColumn":
167
+ return {
168
+ summary: intent.side ? `Pin ${label(intent.column)} to the ${intent.side}` : `Unpin ${label(intent.column)}`,
169
+ interpretation: `pin ${intent.column} ${intent.side ?? "none"}`,
170
+ };
171
+ case "moveColumn":
172
+ return {
173
+ summary: `Move ${label(intent.column)} ${intent.placement} ${label(intent.target)}`,
174
+ interpretation: `move ${intent.column} ${intent.placement} ${intent.target}`,
175
+ };
176
+ case "setColumnWidth":
177
+ return {
178
+ summary:
179
+ intent.width === null
180
+ ? `Restore ${label(intent.column)} to its default width`
181
+ : `Set ${label(intent.column)} to ${intent.width}px`,
182
+ interpretation: `width ${intent.column} = ${intent.width ?? "default"}`,
183
+ };
184
+ case "resetColumns":
185
+ return { summary: "Restore the default columns", interpretation: "resetColumns" };
186
+ case "setPage":
187
+ return { summary: `Go to page ${intent.index + 1}`, interpretation: `page = ${intent.index}` };
188
+ case "setPageSize":
189
+ return { summary: `Show ${intent.size} rows per page`, interpretation: `pageSize = ${intent.size}` };
190
+ case "setDensity":
191
+ return { summary: `Use ${intent.density} rows`, interpretation: `density = ${intent.density}` };
192
+ case "export":
193
+ return {
194
+ summary: `Export the ${intent.scope ?? "filtered"} rows as ${intent.format.toUpperCase()}`,
195
+ interpretation: `export ${intent.format} ${intent.scope ?? "filtered"}`,
196
+ };
197
+ case "print":
198
+ return {
199
+ summary: `Print the ${intent.scope ?? "filtered"} rows`,
200
+ interpretation: `print ${intent.scope ?? "filtered"}`,
201
+ };
202
+ case "copy":
203
+ return { summary: "Copy the selected rows", interpretation: "copy" };
204
+ case "undo":
205
+ return { summary: "Undo the last change", interpretation: "undo" };
206
+ case "redo":
207
+ return { summary: "Redo the last undone change", interpretation: "redo" };
208
+ }
209
+ }
210
+
211
+ // ------------------------------------------------------------- dry-run query
212
+
213
+ /**
214
+ * What the query would be after this intent. Only three operations change
215
+ * which rows are shown, so this is a projection rather than a second
216
+ * implementation of the grid's state machine.
217
+ */
218
+ export function projectQuery(
219
+ current: { filters: readonly ColumnFilter[]; globalFilter: string },
220
+ intent: GridIntent,
221
+ ): { filters: ColumnFilter[]; globalFilter: string } {
222
+ const filters = [...current.filters];
223
+ switch (intent.action) {
224
+ case "filter": {
225
+ const rest = filters.filter((entry) => entry.columnId !== intent.column);
226
+ rest.push({
227
+ columnId: intent.column,
228
+ operator: intent.operator,
229
+ ...(intent.value !== undefined ? { value: intent.value } : {}),
230
+ ...(intent.value2 !== undefined ? { value2: intent.value2 } : {}),
231
+ });
232
+ return { filters: rest, globalFilter: current.globalFilter };
233
+ }
234
+ case "clearFilters":
235
+ return { filters: [], globalFilter: "" };
236
+ case "search":
237
+ return { filters, globalFilter: intent.text };
238
+ default:
239
+ return { filters, globalFilter: current.globalFilter };
240
+ }
241
+ }
242
+
243
+ // ------------------------------------------------------------------ validate
244
+
245
+ const EXPORT_SCOPES = ["filtered", "all", "selected", "page"] as const;
246
+
247
+ export function validateIntent<T>(raw: unknown, context: ValidateContext<T>): ValidationResult<GridCommand> {
248
+ const { contract, schema, dataset, state, policy, locale, now } = context;
249
+ const warnings: ValidationIssue[] = [];
250
+
251
+ // ----------------------------------------------------------------- layer 1
252
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
253
+ return fail(issue("schema", "not-an-object", "An intent must be a JSON object."));
254
+ }
255
+ const input = raw as Record<string, unknown>;
256
+ const action = input.action;
257
+ if (typeof action !== "string") {
258
+ return fail(issue("schema", "missing-action", 'An intent needs a string "action".', { path: "action" }));
259
+ }
260
+ if (!isGridOperation(action)) {
261
+ const guess = nearest(action, contract.operations);
262
+ return fail(
263
+ issue("schema", "unknown-operation", `"${action}" is not an operation this component has.`, {
264
+ path: "action",
265
+ ...(guess ? { suggestion: `Did you mean "${guess}"?` } : {}),
266
+ }),
267
+ );
268
+ }
269
+ if (!contract.operations.includes(action)) {
270
+ const denied = policy.denyOperations?.includes(action) === true;
271
+ return fail(
272
+ issue(
273
+ denied ? "policy" : "reference",
274
+ denied ? "operation-denied" : "operation-unavailable",
275
+ denied
276
+ ? `"${action}" has been disabled for this grid.`
277
+ : `"${action}" is not available: this grid has the feature turned off.`,
278
+ { path: "action" },
279
+ ),
280
+ );
281
+ }
282
+
283
+ // --------------------------------------------- layer 2/4: column triage
284
+ const columnField = COLUMN_REQUIREMENT[action];
285
+ let column: GridContractColumn | undefined;
286
+ if (columnField) {
287
+ const id = input.column;
288
+ if (typeof id !== "string") {
289
+ return fail(issue("schema", "missing-column", '"column" is required and must be a string.', { path: "column" }));
290
+ }
291
+ column = contract.columns.find((entry) => entry.id === id);
292
+ if (!column) {
293
+ const guess = nearest(
294
+ id,
295
+ contract.columns.flatMap((entry) => [entry.id, entry.label, ...(entry.synonyms ?? [])]),
296
+ );
297
+ const resolved = guess && contract.columns.find((entry) => entry.id === guess || entry.label === guess || entry.synonyms?.includes(guess));
298
+ return fail(
299
+ issue("reference", "unknown-column", `There is no column "${id}".`, {
300
+ path: "column",
301
+ ...(resolved ? { suggestion: `Did you mean "${resolved.id}"?` } : {}),
302
+ }),
303
+ );
304
+ }
305
+ if (!column[columnField]) {
306
+ return fail(
307
+ issue("policy", `not-${columnField}`, `${column.label} cannot be ${PARTICIPLE[columnField]}.`, {
308
+ path: "column",
309
+ ...(column.restricted
310
+ ? { suggestion: "This column has been restricted by the application." }
311
+ : {}),
312
+ }),
313
+ );
314
+ }
315
+ }
316
+
317
+ // ------------------------------------------------- layer 1: generated schema
318
+ const branch = schema.byAction.get(action);
319
+ if (branch) {
320
+ const problems = checkSchema(input, branch);
321
+ if (problems.length > 0) {
322
+ const first = problems[0];
323
+ return fail(
324
+ issue("schema", first.code, `${first.path || "intent"}: ${first.message}`, { path: first.path }),
325
+ problems.map((p) => issue("schema", p.code, `${p.path || "intent"}: ${p.message}`, { path: p.path })),
326
+ );
327
+ }
328
+ }
329
+
330
+ // The schema has now proved the shape; from here the intent is typed.
331
+ let intent = { ...input } as GridIntent;
332
+
333
+ // --------------------------------------------- layers 2 and 3: references
334
+ if (intent.action === "filter" && column) {
335
+ const coerced = coerceFilter(column, intent.operator, intent.value, intent.value2, { now });
336
+ if (!coerced.ok) {
337
+ return fail(
338
+ issue(coerced.code === "unknown-option" ? "reference" : "coercion", coerced.code, coerced.message, {
339
+ path: "value",
340
+ ...(coerced.suggestion ? { suggestion: coerced.suggestion } : {}),
341
+ }),
342
+ );
343
+ }
344
+ if (
345
+ coerced.value === undefined &&
346
+ intent.operator !== "isEmpty" &&
347
+ intent.operator !== "isNotEmpty"
348
+ ) {
349
+ return fail(
350
+ issue("reference", "missing-value", `"${intent.operator}" needs a value.`, { path: "value" }),
351
+ );
352
+ }
353
+ if (intent.operator === "between" && coerced.value2 === undefined) {
354
+ return fail(
355
+ issue("reference", "missing-value2", '"between" needs an upper bound in "value2".', { path: "value2" }),
356
+ );
357
+ }
358
+ pushNotes(warnings, coerced.notes);
359
+ intent = {
360
+ action: "filter",
361
+ column: intent.column,
362
+ operator: coerced.operator,
363
+ ...(coerced.value !== undefined ? { value: coerced.value } : {}),
364
+ ...(coerced.value2 !== undefined ? { value2: coerced.value2 } : {}),
365
+ };
366
+ }
367
+
368
+ if (intent.action === "moveColumn") {
369
+ if (intent.target === intent.column) {
370
+ return fail(issue("reference", "same-column", "A column cannot be moved relative to itself.", { path: "target" }));
371
+ }
372
+ }
373
+
374
+ if (intent.action === "selectRows") {
375
+ const missing = intent.rowIds.filter((id) => !dataset.hasRowId(id));
376
+ if (missing.length > 0) {
377
+ return fail(
378
+ issue(
379
+ "reference",
380
+ "unknown-rows",
381
+ `${missing.length} of ${intent.rowIds.length} row id(s) are not in the grid: ${missing
382
+ .slice(0, 3)
383
+ .join(", ")}${missing.length > 3 ? "…" : ""}.`,
384
+ { path: "rowIds" },
385
+ ),
386
+ );
387
+ }
388
+ const cap = policy.maxSelectRows ?? 1000;
389
+ if (intent.rowIds.length > cap) {
390
+ return fail(
391
+ issue("policy", "too-many-rows", `A single selection may name at most ${cap} rows.`, { path: "rowIds" }),
392
+ );
393
+ }
394
+ }
395
+
396
+ // ------------------------------------------------------------ layer 4: policy
397
+ if (intent.action === "search" && intent.text !== "") {
398
+ /*
399
+ * The global search is not column-scoped: the grid matches it against
400
+ * every column whose definition says `searchable`. Policy here cannot
401
+ * narrow that, because there is no per-search API to narrow. Say so
402
+ * rather than implying an enforcement that does not exist — the real fix
403
+ * is `searchable: false` on the column definition.
404
+ */
405
+ const exposed = contract.columns.filter((entry) => entry.restricted);
406
+ if (exposed.length > 0) {
407
+ warnings.push(
408
+ issue(
409
+ "policy",
410
+ "search-covers-restricted",
411
+ `A global search also matches ${exposed
412
+ .map((entry) => entry.label)
413
+ .join(", ")}, which policy restricts. Set \`searchable: false\` on those columns to stop it.`,
414
+ ),
415
+ );
416
+ }
417
+ }
418
+
419
+ if (intent.action === "export" || intent.action === "print") {
420
+ const scope = intent.scope ?? "filtered";
421
+ if (!EXPORT_SCOPES.includes(scope)) {
422
+ return fail(issue("schema", "bad-scope", `"${scope}" is not a row scope.`, { path: "scope" }));
423
+ }
424
+ if (scope === "selected" && contract.stats.selectedRows === 0) {
425
+ return fail(
426
+ issue("reference", "nothing-selected", "No rows are selected, so there is nothing to export.", {
427
+ path: "scope",
428
+ suggestion: 'Select rows first, or use scope "filtered".',
429
+ }),
430
+ );
431
+ }
432
+ const unexportable = contract.columns.filter((entry) => entry.restricted);
433
+ if (unexportable.length > 0) {
434
+ warnings.push(
435
+ issue(
436
+ "policy",
437
+ "columns-withheld",
438
+ `${unexportable.map((entry) => entry.label).join(", ")} will not be included.`,
439
+ ),
440
+ );
441
+ }
442
+ }
443
+
444
+ // -------------------------------------------------------- layer 5: plausibility
445
+ const currentRows = contract.stats.filteredRows;
446
+ let affectedRows = currentRows;
447
+ let confirm: { code: string; message: string } | null = null;
448
+
449
+ if (intent.action === "filter" || intent.action === "search" || intent.action === "clearFilters") {
450
+ const projected = projectQuery({ filters: state.filters, globalFilter: state.globalFilter }, intent);
451
+ affectedRows = dataset.count(projected.filters, projected.globalFilter);
452
+
453
+ if (affectedRows !== null && policy.warnOnEmptyResult !== false && affectedRows === 0) {
454
+ warnings.push(
455
+ issue("plausibility", "empty-result", "Nothing matches, so the grid would be empty.", {
456
+ suggestion: "Loosen the filter, or check the value.",
457
+ }),
458
+ );
459
+ }
460
+ if (
461
+ affectedRows !== null &&
462
+ currentRows !== null &&
463
+ intent.action === "filter" &&
464
+ affectedRows === currentRows &&
465
+ affectedRows > 0
466
+ ) {
467
+ warnings.push(
468
+ issue("plausibility", "no-op", "Every row already shown matches, so nothing would change."),
469
+ );
470
+ }
471
+ if (
472
+ affectedRows !== null &&
473
+ dataset.total !== null &&
474
+ intent.action === "filter" &&
475
+ dataset.total > 0 &&
476
+ affectedRows >= dataset.total * 0.95 &&
477
+ affectedRows < dataset.total
478
+ ) {
479
+ warnings.push(
480
+ issue("plausibility", "barely-narrows", `This keeps ${affectedRows} of ${dataset.total} rows.`),
481
+ );
482
+ }
483
+ }
484
+
485
+ if (intent.action === "export") {
486
+ const rows = exportRowCount(intent.scope ?? "filtered", contract, dataset);
487
+ affectedRows = rows;
488
+ const max = policy.maxExportRows;
489
+ if (max !== undefined && rows !== null && rows > max) {
490
+ return fail(
491
+ issue("policy", "export-too-large", `Exports are limited to ${max} rows; this one is ${rows}.`, {
492
+ suggestion: "Filter further, or export the current page.",
493
+ }),
494
+ );
495
+ }
496
+ if (rows === 0) {
497
+ return fail(issue("plausibility", "empty-export", "There are no rows to export."));
498
+ }
499
+ const threshold = policy.confirmExportRows ?? 5000;
500
+ if (rows !== null && rows > threshold) {
501
+ confirm = { code: "large-export", message: `This exports ${rows} rows. Continue?` };
502
+ }
503
+ }
504
+
505
+ const definition = GRID_OPERATIONS[intent.action];
506
+ if (confirm === null && definition.requiresConfirmation) {
507
+ confirm = { code: "irreversible", message: `${describe(intent, contract, locale).summary}. This cannot be undone.` };
508
+ }
509
+
510
+ if (intent.action === "undo" && !contract.state.canUndo) {
511
+ return fail(issue("reference", "nothing-to-undo", "There is nothing to undo."));
512
+ }
513
+ if (intent.action === "redo" && !contract.state.canRedo) {
514
+ return fail(issue("reference", "nothing-to-redo", "There is nothing to redo."));
515
+ }
516
+
517
+ const { summary, interpretation } = describe(intent, contract, locale);
518
+ return ok<GridCommand>(
519
+ {
520
+ operation: intent.action,
521
+ intent,
522
+ explain: { summary, interpretation, affectedRows, currentRows },
523
+ },
524
+ warnings,
525
+ confirm,
526
+ );
527
+ }
528
+
529
+ const PARTICIPLE: Record<string, string> = {
530
+ filterable: "filtered",
531
+ sortable: "sorted",
532
+ hideable: "hidden or shown",
533
+ pinnable: "pinned",
534
+ reorderable: "moved",
535
+ resizable: "resized",
536
+ };
537
+
538
+ function pushNotes(warnings: ValidationIssue[], notes: CoercionNote[]): void {
539
+ for (const note of notes) warnings.push(issue("coercion", note.code, note.message));
540
+ }
541
+
542
+ function exportRowCount<T>(
543
+ scope: string,
544
+ contract: GridRuntimeContract,
545
+ dataset: GridDataset<T>,
546
+ ): number | null {
547
+ switch (scope) {
548
+ case "all":
549
+ return dataset.total;
550
+ case "selected":
551
+ return contract.stats.selectedRows;
552
+ case "page":
553
+ return contract.stats.pageRows;
554
+ default:
555
+ return contract.stats.filteredRows;
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Validates a list of intents as one unit.
561
+ *
562
+ * The plausibility layer sees them together: "customers in Kerala with revenue
563
+ * over 1 lakh" is two filters, and the useful number is how many rows survive
564
+ * both, not how many survive each.
565
+ */
566
+ export function validateBatch<T>(
567
+ raws: readonly unknown[],
568
+ context: ValidateContext<T>,
569
+ ): ValidationResult<GridCommand[]> {
570
+ if (raws.length === 0) {
571
+ return fail(issue("schema", "empty-batch", "There is nothing to do."));
572
+ }
573
+
574
+ const columnsFiltered = new Set<string>();
575
+ for (const raw of raws) {
576
+ const entry = raw as { action?: string; column?: string };
577
+ if (entry?.action !== "filter" || typeof entry.column !== "string") continue;
578
+ if (columnsFiltered.has(entry.column)) {
579
+ /*
580
+ * The grid holds one filter per column, so the second would replace the
581
+ * first and the caller would get half of what they asked for without
582
+ * being told. "Mumbai or Pune" is the usual way to arrive here, and the
583
+ * honest answer is that this grid cannot express it.
584
+ */
585
+ return fail(
586
+ issue(
587
+ "reference",
588
+ "duplicate-column-filter",
589
+ `Two filters were asked for on "${entry.column}", and a column holds only one.`,
590
+ {
591
+ suggestion:
592
+ "Use a single filter with a range or a list, or filter on a different column.",
593
+ },
594
+ ),
595
+ );
596
+ }
597
+ columnsFiltered.add(entry.column);
598
+ }
599
+
600
+ const commands: GridCommand[] = [];
601
+ const warnings: ValidationIssue[] = [];
602
+ let confirm: { code: string; message: string } | null = null;
603
+ let query = { filters: context.state.filters as readonly ColumnFilter[], globalFilter: context.state.globalFilter };
604
+
605
+ for (const raw of raws) {
606
+ const scoped: ValidateContext<T> = {
607
+ ...context,
608
+ state: { ...context.state, filters: [...query.filters], globalFilter: query.globalFilter },
609
+ contract: {
610
+ ...context.contract,
611
+ stats: {
612
+ ...context.contract.stats,
613
+ filteredRows: context.dataset.count(query.filters, query.globalFilter),
614
+ },
615
+ },
616
+ };
617
+ const result = validateIntent(raw, scoped);
618
+ if (!result.ok) return result;
619
+ commands.push(result.command);
620
+ warnings.push(...result.warnings);
621
+ confirm ??= result.confirm;
622
+ query = projectQuery(query, result.command.intent);
623
+ }
624
+
625
+ // The last command's count is the count for the batch as a whole.
626
+ const affectedRows = context.dataset.count(query.filters, query.globalFilter);
627
+ for (const command of commands) command.explain.affectedRows = affectedRows;
628
+
629
+ return ok(commands, warnings, confirm);
630
+ }