@terreno/blocks 57.12.0 → 57.14.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 (58) hide show
  1. package/dist/errors.d.ts +2 -0
  2. package/dist/errors.js +2 -0
  3. package/dist/index.d.ts +7 -2
  4. package/dist/index.js +13 -1
  5. package/dist/interactive.d.ts +26 -0
  6. package/dist/interactive.js +62 -0
  7. package/dist/limits.d.ts +26 -0
  8. package/dist/limits.js +26 -0
  9. package/dist/lint.d.ts +18 -1
  10. package/dist/lint.js +186 -26
  11. package/dist/plainText.d.ts +17 -0
  12. package/dist/plainText.js +56 -0
  13. package/dist/prompt.d.ts +10 -1
  14. package/dist/prompt.js +73 -3
  15. package/dist/schema.d.ts +113 -2
  16. package/dist/schema.js +121 -5
  17. package/dist/validate.js +26 -2
  18. package/package.json +1 -1
  19. package/src/__snapshots__/prompt.test.ts.snap +530 -0
  20. package/src/errors.ts +4 -0
  21. package/src/fixtures/golden/sunday-roast.yaml +174 -0
  22. package/src/fixtures/invalid/card-eyebrow-empty.yaml +7 -0
  23. package/src/fixtures/invalid/card-eyebrow-too-long.yaml +7 -0
  24. package/src/fixtures/invalid/checklist-duplicate-item.yaml +9 -0
  25. package/src/fixtures/invalid/checklist-item-id-too-long.yaml +7 -0
  26. package/src/fixtures/invalid/checklist-reserved-id.yaml +16 -0
  27. package/src/fixtures/invalid/copy-missing-text-and-target.yaml +10 -0
  28. package/src/fixtures/invalid/copy-target.yaml +14 -0
  29. package/src/fixtures/invalid/gallery-caption-too-long.yaml +9 -0
  30. package/src/fixtures/invalid/gallery-image-host.yaml +10 -0
  31. package/src/fixtures/invalid/gallery-too-few.yaml +6 -0
  32. package/src/fixtures/invalid/list-image-host.yaml +12 -0
  33. package/src/fixtures/invalid/list-missing-title.yaml +6 -0
  34. package/src/fixtures/invalid/list-too-many.yaml +17 -0
  35. package/src/fixtures/invalid/stepper-decimals.yaml +14 -0
  36. package/src/fixtures/invalid/stepper-out-of-range.yaml +10 -0
  37. package/src/fixtures/invalid/stepper-reserved-id.yaml +19 -0
  38. package/src/fixtures/valid/card-eyebrow.yaml +8 -0
  39. package/src/fixtures/valid/checklist.yaml +29 -0
  40. package/src/fixtures/valid/copy.yaml +85 -0
  41. package/src/fixtures/valid/gallery.yaml +22 -0
  42. package/src/fixtures/valid/list.yaml +24 -0
  43. package/src/fixtures/valid/stepper.yaml +35 -0
  44. package/src/golden.test.ts +234 -0
  45. package/src/index.test.ts +9 -0
  46. package/src/index.ts +24 -0
  47. package/src/interactive.test.ts +141 -0
  48. package/src/interactive.ts +75 -0
  49. package/src/limits.ts +26 -0
  50. package/src/lint.test.ts +672 -0
  51. package/src/lint.ts +264 -33
  52. package/src/parse.test.ts +40 -0
  53. package/src/plainText.test.ts +158 -0
  54. package/src/plainText.ts +81 -0
  55. package/src/prompt.test.ts +325 -2
  56. package/src/prompt.ts +87 -2
  57. package/src/schema.ts +259 -6
  58. package/src/validate.ts +26 -2
@@ -0,0 +1,234 @@
1
+ import {describe, expect, it} from "bun:test";
2
+ import {readFileSync} from "node:fs";
3
+ import {join} from "node:path";
4
+
5
+ import {parseBlocks} from "./parse";
6
+ import {blockPlainText} from "./plainText";
7
+ import type {Block, BlocksDocument} from "./schema";
8
+ import {validateBlocks} from "./validate";
9
+
10
+ const goldenPath = join(import.meta.dir, "fixtures", "golden", "sunday-roast.yaml");
11
+
12
+ // The roast reply needs these options: https images and both host callbacks.
13
+ const goldenOptions = {
14
+ checklistActions: ["toggleChecklist"],
15
+ hostActions: ["scaleStepper", "toggleChecklist"],
16
+ imageHosts: ["images.example.com"],
17
+ stepperActions: ["scaleStepper"],
18
+ };
19
+
20
+ const loadGolden = (): BlocksDocument => {
21
+ const parsed = parseBlocks(readFileSync(goldenPath, "utf8"));
22
+ if (!parsed.ok) {
23
+ throw new Error(`sunday-roast.yaml did not parse: ${JSON.stringify(parsed.errors)}`);
24
+ }
25
+ const validated = validateBlocks(parsed.value, goldenOptions);
26
+ if (!validated.ok) {
27
+ throw new Error(`sunday-roast.yaml did not validate: ${JSON.stringify(validated.errors)}`);
28
+ }
29
+ return validated.doc;
30
+ };
31
+
32
+ const allBlocks = (blocks: readonly Block[]): Block[] => {
33
+ return blocks.flatMap((block) =>
34
+ block.type === "card" || block.type === "columns" ? [block, ...block.children] : [block]
35
+ );
36
+ };
37
+
38
+ const blocksOfType = <T extends Block["type"]>(
39
+ doc: BlocksDocument,
40
+ type: T
41
+ ): Extract<Block, {type: T}>[] => {
42
+ return allBlocks(doc.blocks).filter(
43
+ (block): block is Extract<Block, {type: T}> => block.type === type
44
+ );
45
+ };
46
+
47
+ describe("sunday roast golden document", () => {
48
+ it("validates with the roast host options and no warnings", () => {
49
+ const parsed = parseBlocks(readFileSync(goldenPath, "utf8"));
50
+ expect(parsed.ok).toBe(true);
51
+ if (!parsed.ok) {
52
+ return;
53
+ }
54
+ const validated = validateBlocks(parsed.value, goldenOptions);
55
+ expect(validated.ok).toBe(true);
56
+ expect(validated.warnings).toEqual([]);
57
+ expect(allBlocks(loadGolden().blocks).length).toBeLessThanOrEqual(50);
58
+ });
59
+
60
+ it("fails without imageHosts, so it stays out of the option-less valid/ loop", () => {
61
+ const parsed = parseBlocks(readFileSync(goldenPath, "utf8"));
62
+ expect(parsed.ok).toBe(true);
63
+ if (!parsed.ok) {
64
+ return;
65
+ }
66
+ const validated = validateBlocks(parsed.value);
67
+ expect(validated.ok).toBe(false);
68
+ if (validated.ok) {
69
+ return;
70
+ }
71
+ expect(new Set(validated.errors.map((error) => error.code))).toEqual(
72
+ new Set(["IMAGE_HOST_NOT_ALLOWED"])
73
+ );
74
+ });
75
+
76
+ it("rejects the stepper and checklist callbacks when the host lacks those actions", () => {
77
+ const parsed = parseBlocks(readFileSync(goldenPath, "utf8"));
78
+ expect(parsed.ok).toBe(true);
79
+ if (!parsed.ok) {
80
+ return;
81
+ }
82
+ const validated = validateBlocks(parsed.value, {
83
+ checklistActions: [],
84
+ hostActions: [],
85
+ imageHosts: ["images.example.com"],
86
+ stepperActions: [],
87
+ });
88
+ expect(validated.ok).toBe(false);
89
+ if (validated.ok) {
90
+ return;
91
+ }
92
+ expect(validated.errors.map((error) => error.code)).toEqual([
93
+ "UNKNOWN_HOST_ACTION",
94
+ "UNKNOWN_HOST_ACTION",
95
+ ]);
96
+ });
97
+
98
+ it("reproduces the GPT-6 reply: title, gallery, summary card, and photo menu", () => {
99
+ const doc = loadGolden();
100
+ expect(doc.blocks[0]).toEqual({size: "lg", text: "Your Sunday lamb roast 🍷", type: "heading"});
101
+
102
+ const [gallery] = blocksOfType(doc, "gallery");
103
+ expect(gallery?.images.map((image) => image.alt)).toEqual([
104
+ "Roast lamb",
105
+ "Crispy roast potatoes",
106
+ "Sunday roast inspiration",
107
+ ]);
108
+
109
+ const summary = blocksOfType(doc, "card").find((card) => card.eyebrow !== undefined);
110
+ expect(summary?.eyebrow).toBe("Your dinner plan");
111
+ expect(summary?.title).toBe("Sunday roast with friends");
112
+ expect(summary?.children[0]?.type).toBe("text");
113
+
114
+ const [menu] = blocksOfType(doc, "list");
115
+ expect(menu?.items).toHaveLength(5);
116
+ for (const item of menu?.items ?? []) {
117
+ expect(item.image?.src).toStartWith("https://images.example.com/");
118
+ expect(item.text).toBeDefined();
119
+ }
120
+ });
121
+
122
+ it("puts the stepper and its copy button in one card", () => {
123
+ const doc = loadGolden();
124
+ const card = blocksOfType(doc, "card").find((block) =>
125
+ block.children.some((child) => child.type === "stepper")
126
+ );
127
+ expect(card).toBeDefined();
128
+ const stepper = card?.children.find((child) => child.type === "stepper");
129
+ if (stepper?.type !== "stepper") {
130
+ throw new Error("expected a stepper in the shopping card");
131
+ }
132
+ expect(stepper.itemsTitle).toBe("Your shopping quantities");
133
+ expect(stepper.items).toHaveLength(6);
134
+ expect(stepper.callback).toEqual({name: "scaleStepper"});
135
+ expect([stepper.value, stepper.min, stepper.max, stepper.unit]).toEqual([5, 1, 20, "People"]);
136
+
137
+ const copyActions = card?.children
138
+ .filter((child) => child.type === "actions")
139
+ .flatMap((child) => child.elements)
140
+ .filter((element) => element.type === "button" && element.action.kind === "copy");
141
+ expect(copyActions).toHaveLength(1);
142
+ const [copyButton] = copyActions ?? [];
143
+ expect(copyButton?.type === "button" ? copyButton.text : undefined).toBe("Copy shopping list");
144
+ expect(copyButton?.type === "button" ? copyButton.action : undefined).toEqual({
145
+ kind: "copy",
146
+ target: stepper.id,
147
+ });
148
+
149
+ expect(blockPlainText(stepper)).toBe(
150
+ [
151
+ "Number of people: 5 People",
152
+ "Bone-in leg of lamb: 2.0 kg",
153
+ "Potatoes: 1500 g",
154
+ "Carrots: 8",
155
+ "Parsnips: 5",
156
+ "Tenderstem broccoli: 625 g",
157
+ "Apples for crumble: 4",
158
+ ].join("\n")
159
+ );
160
+ });
161
+
162
+ it("has the 8-item cooking checklist with the first item ticked, then the USDA note", () => {
163
+ const doc = loadGolden();
164
+ const [checklist] = blocksOfType(doc, "checklist");
165
+ expect(checklist?.title).toBe("Cooking checklist");
166
+ expect(checklist?.callback).toEqual({name: "toggleChecklist"});
167
+ expect(checklist?.items).toHaveLength(8);
168
+ expect(checklist?.items.map((item) => item.checked === true)).toEqual([
169
+ true,
170
+ false,
171
+ false,
172
+ false,
173
+ false,
174
+ false,
175
+ false,
176
+ false,
177
+ ]);
178
+ expect(checklist?.items.map((item) => item.meta)).toEqual([
179
+ "Saturday",
180
+ "12:30 PM",
181
+ "1:30 PM",
182
+ "2:30 PM",
183
+ "3:30 PM",
184
+ "4:15 PM",
185
+ "4:45 PM",
186
+ "5:00 PM",
187
+ ]);
188
+
189
+ const index = doc.blocks.findIndex((block) => block.type === "checklist");
190
+ const note = doc.blocks[index + 1];
191
+ expect(note?.type).toBe("context");
192
+ expect(note?.type === "context" ? note.text : "").toContain("USDA");
193
+ });
194
+
195
+ it("includes the GPT-5.6 'How much lamb?' table bound to an inline dataset", () => {
196
+ const doc = loadGolden();
197
+ const [table] = blocksOfType(doc, "table");
198
+ expect(table?.title).toBe("How much lamb?");
199
+ const dataset = table?.data === undefined ? undefined : doc.datasets?.[table.data];
200
+ expect(dataset).toEqual({
201
+ columns: [
202
+ {name: "guests", type: "number"},
203
+ {name: "bone_in_lamb", type: "string"},
204
+ {name: "potatoes", type: "string"},
205
+ ],
206
+ rows: [
207
+ [4, "3–4 lb", "2 lb"],
208
+ [6, "4–5 lb", "3 lb"],
209
+ [8, "5–6 lb", "4 lb"],
210
+ [10, "6–7½ lb", "5 lb"],
211
+ ],
212
+ });
213
+ });
214
+
215
+ it("ends with the hosting tips as text and three follow-up reply buttons", () => {
216
+ const doc = loadGolden();
217
+ const tips = blocksOfType(doc, "text").find((block) => block.id === "hosting_tips");
218
+ expect(tips?.markdown).toContain("**Buy the lamb last:**");
219
+
220
+ const last = doc.blocks[doc.blocks.length - 1];
221
+ if (last?.type !== "actions") {
222
+ throw new Error("expected the reply to end with follow-up actions");
223
+ }
224
+ expect(
225
+ last.elements.map((element) =>
226
+ element.type === "button" && element.action.kind === "reply" ? element.action.text : ""
227
+ )
228
+ ).toEqual([
229
+ "Help me adjust the lamb roast plan for 5 guests",
230
+ "Add vegetarian side dish options for the lamb roast menu",
231
+ "Advise on making the lamb roast plan gluten-free",
232
+ ]);
233
+ });
234
+ });
package/src/index.test.ts CHANGED
@@ -16,16 +16,25 @@ describe("@terreno/blocks public exports", () => {
16
16
  "CALLOUT_STATUSES",
17
17
  "CHART_HEIGHTS",
18
18
  "CHART_KINDS",
19
+ "COPY_TARGET_TYPES",
19
20
  "DATASET_COLUMN_TYPES",
20
21
  "DATASET_GRAINS",
21
22
  "HEADING_SIZES",
22
23
  "HTML_HEIGHTS",
23
24
  "METRIC_TRENDS",
25
+ "STEPPER_ROUNDING",
26
+ "applyChecklistState",
27
+ "blockPlainText",
24
28
  "blocksJsonSchema",
25
29
  "blocksPromptSection",
26
30
  "blocksSchema",
31
+ "checklistElementId",
32
+ "isStepperValueAllowed",
27
33
  "parseBlocks",
28
34
  "parseBlocksPartial",
35
+ "scaleStepperBlock",
36
+ "stepperElementIds",
37
+ "unknownChecklistItemIds",
29
38
  "validateBlocks",
30
39
  "wrapAsTextDocument",
31
40
 
package/src/index.ts CHANGED
@@ -118,13 +118,23 @@ export {validateAskInput} from "./asks/validateInput";
118
118
  export {validateAskResponse} from "./asks/validateResponse";
119
119
  export type {BlockError, BlockErrorCode, BlockWarningCode} from "./errors";
120
120
  export {BLOCK_ERROR_CODES, BLOCK_WARNING_CODES} from "./errors";
121
+ export type {ChecklistTick} from "./interactive";
122
+ export {
123
+ applyChecklistState,
124
+ isStepperValueAllowed,
125
+ scaleStepperBlock,
126
+ unknownChecklistItemIds,
127
+ } from "./interactive";
121
128
  export {blocksJsonSchema} from "./jsonSchema";
122
129
  export {BLOCK_LIMITS} from "./limits";
123
130
  export type {KnownDataset, LintBlocksOptions} from "./lint";
131
+ export {checklistElementId, stepperElementIds} from "./lint";
124
132
  export type {ParseBlocksResult} from "./parse";
125
133
  export {parseBlocks} from "./parse";
126
134
  export type {ParseBlocksPartialResult} from "./parsePartial";
127
135
  export {parseBlocksPartial} from "./parsePartial";
136
+ export type {BlockPlainTextOptions} from "./plainText";
137
+ export {blockPlainText} from "./plainText";
128
138
  export {blocksPromptSection} from "./prompt";
129
139
  export type {
130
140
  ActionsBlock,
@@ -137,23 +147,35 @@ export type {
137
147
  CalloutBlock,
138
148
  CardBlock,
139
149
  ChartBlock,
150
+ ChecklistBlock,
151
+ ChecklistCallback,
152
+ ChecklistItem,
140
153
  ColumnsBlock,
141
154
  ContextBlock,
155
+ CopyAction,
142
156
  Dataset,
143
157
  DatasetColumn,
144
158
  DetailsBlock,
145
159
  DividerBlock,
160
+ GalleryBlock,
161
+ GalleryImage,
146
162
  HeadingBlock,
147
163
  HtmlBlock,
148
164
  ImageBlock,
149
165
  InlineDataset,
150
166
  LeafBlock,
167
+ ListBlock,
168
+ ListItem,
169
+ ListItemImage,
151
170
  MetricBlock,
152
171
  OpenAction,
153
172
  RefDataset,
154
173
  ReplyAction,
155
174
  SegmentedElement,
156
175
  SelectAction,
176
+ StepperBlock,
177
+ StepperCallback,
178
+ StepperItem,
157
179
  TableBlock,
158
180
  TextBlock,
159
181
  } from "./schema";
@@ -164,11 +186,13 @@ export {
164
186
  CALLOUT_STATUSES,
165
187
  CHART_HEIGHTS,
166
188
  CHART_KINDS,
189
+ COPY_TARGET_TYPES,
167
190
  DATASET_COLUMN_TYPES,
168
191
  DATASET_GRAINS,
169
192
  HEADING_SIZES,
170
193
  HTML_HEIGHTS,
171
194
  METRIC_TRENDS,
195
+ STEPPER_ROUNDING,
172
196
  wrapAsTextDocument,
173
197
  } from "./schema";
174
198
  export type {ValidateBlocksResult} from "./validate";
@@ -0,0 +1,141 @@
1
+ import {describe, expect, it} from "bun:test";
2
+
3
+ import {
4
+ applyChecklistState,
5
+ isStepperValueAllowed,
6
+ scaleStepperBlock,
7
+ unknownChecklistItemIds,
8
+ } from "./interactive";
9
+ import type {Block, BlocksDocument, ChecklistBlock, StepperBlock} from "./schema";
10
+ import {validateBlocks} from "./validate";
11
+
12
+ // Built key by key because validateBlocks wants `v` before `blocks`.
13
+ const documentOf = (block: Block): BlocksDocument => {
14
+ const doc = {v: 1} as BlocksDocument;
15
+ doc.blocks = [block];
16
+ return doc;
17
+ };
18
+
19
+ const STEPPER: StepperBlock = {
20
+ callback: {name: "scaleStepper", payload: {recipe: "roast"}},
21
+ id: "guests",
22
+ items: [
23
+ {amount: 2, decimals: 1, label: "Lamb", unit: "kg"},
24
+ {amount: 7, label: "Parsnips", round: "up"},
25
+ {amount: 625, label: "Broccoli", unit: "g"},
26
+ ],
27
+ label: "Number of people",
28
+ max: 20,
29
+ min: 1,
30
+ note: "Generous portions.",
31
+ type: "stepper",
32
+ unit: "People",
33
+ value: 5,
34
+ };
35
+
36
+ const CHECKLIST: ChecklistBlock = {
37
+ callback: {name: "toggleChecklist"},
38
+ id: "cooking",
39
+ items: [
40
+ {checked: true, id: "oven", meta: "1:00 pm", text: "Preheat the oven"},
41
+ {id: "lamb_in", text: "Put the lamb in"},
42
+ {id: "rest", text: "Rest the lamb"},
43
+ ],
44
+ title: "Cooking checklist",
45
+ type: "checklist",
46
+ };
47
+
48
+ describe("scaleStepperBlock", () => {
49
+ it("scales every item linearly from the original by its decimals and rounding", () => {
50
+ const scaled = scaleStepperBlock(STEPPER, 6);
51
+ expect(scaled.value).toBe(6);
52
+ expect(scaled.items?.map((item) => item.amount)).toEqual([2.4, 9, 750]);
53
+ expect(scaled.items?.[0]).toEqual({amount: 2.4, decimals: 1, label: "Lamb", unit: "kg"});
54
+ expect(scaled.note).toBe("Generous portions.");
55
+ expect(validateBlocks(documentOf(scaled)).ok).toBe(true);
56
+ });
57
+
58
+ it("returns the agent's amounts when scaled back to the original value", () => {
59
+ expect(scaleStepperBlock(STEPPER, 5)).toEqual(STEPPER);
60
+ });
61
+
62
+ it("does not mutate the original", () => {
63
+ const before = structuredClone(STEPPER);
64
+ scaleStepperBlock(STEPPER, 10);
65
+ expect(STEPPER).toEqual(before);
66
+ });
67
+
68
+ it("keeps the amounts as written when the original value is 0", () => {
69
+ const zero: StepperBlock = {...STEPPER, min: 0, value: 0};
70
+ expect(scaleStepperBlock(zero, 3).items?.map((item) => item.amount)).toEqual([2, 7, 625]);
71
+ });
72
+
73
+ it("only changes the value when there are no items", () => {
74
+ const {items: _items, ...bare} = STEPPER;
75
+ expect(scaleStepperBlock(bare, 9)).toEqual({...bare, value: 9});
76
+ });
77
+ });
78
+
79
+ describe("isStepperValueAllowed", () => {
80
+ it("accepts a finite value within the range on the step grid from the original", () => {
81
+ expect(isStepperValueAllowed(STEPPER, 1)).toBe(true);
82
+ expect(isStepperValueAllowed(STEPPER, 20)).toBe(true);
83
+ expect(isStepperValueAllowed({...STEPPER, step: 0.5}, 5.5)).toBe(true);
84
+ });
85
+
86
+ it("rejects values outside the range, off the grid, or not finite", () => {
87
+ expect(isStepperValueAllowed(STEPPER, 0)).toBe(false);
88
+ expect(isStepperValueAllowed(STEPPER, 21)).toBe(false);
89
+ expect(isStepperValueAllowed({...STEPPER, step: 2}, 6)).toBe(false);
90
+ expect(isStepperValueAllowed(STEPPER, Number.NaN)).toBe(false);
91
+ expect(isStepperValueAllowed(STEPPER, Number.POSITIVE_INFINITY)).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe("applyChecklistState", () => {
96
+ it("takes every item's checked from state, with the ticked item taking checked", () => {
97
+ const ticked = applyChecklistState(CHECKLIST, {
98
+ checked: true,
99
+ itemId: "lamb_in",
100
+ state: {lamb_in: false, oven: true},
101
+ });
102
+ expect(ticked.items.map((item) => item.checked)).toEqual([true, true, false]);
103
+ expect(ticked.items[0]).toEqual({
104
+ checked: true,
105
+ id: "oven",
106
+ meta: "1:00 pm",
107
+ text: "Preheat the oven",
108
+ });
109
+ expect(ticked.title).toBe("Cooking checklist");
110
+ expect(validateBlocks(documentOf(ticked)).ok).toBe(true);
111
+ });
112
+
113
+ it("unchecks an item missing from state, even one the agent wrote as checked", () => {
114
+ const ticked = applyChecklistState(CHECKLIST, {checked: true, itemId: "rest", state: {}});
115
+ expect(ticked.items.map((item) => item.checked)).toEqual([false, false, true]);
116
+ });
117
+
118
+ it("does not mutate the original", () => {
119
+ const before = structuredClone(CHECKLIST);
120
+ applyChecklistState(CHECKLIST, {checked: false, itemId: "oven", state: {}});
121
+ expect(CHECKLIST).toEqual(before);
122
+ });
123
+ });
124
+
125
+ describe("unknownChecklistItemIds", () => {
126
+ it("lists each id in the tick or state that the checklist does not have, once", () => {
127
+ expect(
128
+ unknownChecklistItemIds(CHECKLIST, {
129
+ checked: true,
130
+ itemId: "gravy",
131
+ state: {gravy: true, oven: true, salad: false},
132
+ })
133
+ ).toEqual(["gravy", "salad"]);
134
+ });
135
+
136
+ it("is empty for a tick over known items", () => {
137
+ expect(
138
+ unknownChecklistItemIds(CHECKLIST, {checked: true, itemId: "oven", state: {rest: true}})
139
+ ).toEqual([]);
140
+ });
141
+ });
@@ -0,0 +1,75 @@
1
+ import type {ChecklistBlock, StepperBlock, StepperItem} from "./schema";
2
+
3
+ /** Tolerance for float noise when checking the step grid and rounding up. */
4
+ const EPSILON = 1e-9;
5
+
6
+ /** A checklist tick as the renderer sends it: the ticked item and every item's state. */
7
+ export interface ChecklistTick {
8
+ checked: boolean;
9
+ itemId: string;
10
+ state: Record<string, boolean>;
11
+ }
12
+
13
+ const roundAmount = (amount: number, item: StepperItem): number => {
14
+ const factor = 10 ** (item.decimals ?? 0);
15
+ const scaled = amount * factor;
16
+ const rounded = item.round === "up" ? Math.ceil(scaled - EPSILON) : Math.round(scaled);
17
+ return Number((rounded / factor).toFixed(item.decimals ?? 0));
18
+ };
19
+
20
+ /**
21
+ * True when `value` is finite, within `min`..`max`, and a whole number of `step`s from the
22
+ * stepper's own `value`.
23
+ */
24
+ export const isStepperValueAllowed = (stepper: StepperBlock, value: number): boolean => {
25
+ if (!Number.isFinite(value) || value < stepper.min || value > stepper.max) {
26
+ return false;
27
+ }
28
+ const steps = (value - stepper.value) / (stepper.step ?? 1);
29
+ return Math.abs(steps - Math.round(steps)) < EPSILON;
30
+ };
31
+
32
+ /**
33
+ * Returns `original` at `value` with every item scaled linearly from `original`, rounded by its
34
+ * `decimals` and `round`. Always scale from the block the agent wrote, not the last result, so
35
+ * rounding never drifts tap after tap. An original `value` of 0 keeps the amounts as written.
36
+ * Check the value with `isStepperValueAllowed` first.
37
+ */
38
+ export const scaleStepperBlock = (original: StepperBlock, value: number): StepperBlock => {
39
+ if (original.items === undefined) {
40
+ return {...original, value};
41
+ }
42
+ const ratio = original.value === 0 ? 1 : value / original.value;
43
+ return {
44
+ ...original,
45
+ items: original.items.map((item) => ({
46
+ ...item,
47
+ amount: roundAmount(item.amount * ratio, item),
48
+ })),
49
+ value,
50
+ };
51
+ };
52
+
53
+ /** The ids in a tick (its `itemId` and `state` keys) that `checklist` has no item for, once each. */
54
+ export const unknownChecklistItemIds = (
55
+ checklist: ChecklistBlock,
56
+ tick: ChecklistTick
57
+ ): string[] => {
58
+ const itemIds = new Set(checklist.items.map((item) => item.id));
59
+ return [...new Set([tick.itemId, ...Object.keys(tick.state)])].filter((id) => !itemIds.has(id));
60
+ };
61
+
62
+ /**
63
+ * Returns `checklist` with every item's `checked` taken from `tick.state`. An item missing from
64
+ * `state` is unchecked, and the ticked item always takes `tick.checked`.
65
+ */
66
+ export const applyChecklistState = (
67
+ checklist: ChecklistBlock,
68
+ tick: ChecklistTick
69
+ ): ChecklistBlock => ({
70
+ ...checklist,
71
+ items: checklist.items.map((item) => ({
72
+ ...item,
73
+ checked: item.id === tick.itemId ? tick.checked : tick.state[item.id] === true,
74
+ })),
75
+ });
package/src/limits.ts CHANGED
@@ -6,16 +6,35 @@ export const BLOCK_LIMITS = {
6
6
  badgeTextMaxLength: 80,
7
7
  barCategoryWarning: 60,
8
8
  blockTextMaxLength: 4_000,
9
+ cardEyebrowMaxLength: 60,
9
10
  cardTitleMaxLength: 120,
11
+ checklistIdMaxLength: 31,
12
+ checklistItemDetailMaxLength: 280,
13
+ checklistItemIdMaxLength: 32,
14
+ checklistItemMetaMaxLength: 40,
15
+ checklistItemsMax: 30,
16
+ checklistItemsMin: 1,
17
+ checklistItemTextMaxLength: 120,
18
+ checklistTitleMaxLength: 120,
10
19
  columnsMax: 4,
11
20
  columnsMin: 2,
12
21
  contextTextMaxLength: 280,
22
+ copyTextMaxLength: 4_000,
13
23
  datasetColumnMax: 12,
14
24
  datasetRowMax: 500,
15
25
  documentTextMaxLength: 20_000,
16
26
  donutSliceWarning: 8,
27
+ galleryAltMaxLength: 200,
28
+ galleryCaptionMaxLength: 120,
29
+ galleryImagesMax: 6,
30
+ galleryImagesMin: 2,
17
31
  headingTextMaxLength: 200,
18
32
  htmlMaxBytes: 100_000,
33
+ listItemMetaMaxLength: 40,
34
+ listItemsMax: 12,
35
+ listItemsMin: 1,
36
+ listItemTextMaxLength: 500,
37
+ listItemTitleMaxLength: 120,
19
38
  maxBlocks: 50,
20
39
  maxDatasets: 8,
21
40
  maxDepth: 2,
@@ -24,4 +43,11 @@ export const BLOCK_LIMITS = {
24
43
  metricLabelMaxLength: 80,
25
44
  metricValueMaxLength: 80,
26
45
  refLimitMax: 1_000,
46
+ stepperDecimalsMax: 3,
47
+ stepperIdMaxLength: 54,
48
+ stepperItemsMax: 12,
49
+ stepperItemUnitMaxLength: 20,
50
+ stepperLabelMaxLength: 80,
51
+ stepperNoteMaxLength: 280,
52
+ stepperUnitMaxLength: 40,
27
53
  } as const;