@plitzi/sdk-server 0.32.17 → 0.32.18

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 (55) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/modules/mcp/apps/render/view.tsx +107 -3
  3. package/dist/modules/mcp/helpers/guide.js +9 -2
  4. package/dist/modules/mcp/resources/renderGuide.js +126 -19
  5. package/dist/modules/mcp/server.js +1 -1
  6. package/dist/modules/mcp/tools/apply/dispatch.js +2 -0
  7. package/dist/modules/mcp/tools/apply/index.js +17 -3
  8. package/dist/modules/mcp/tools/operations/index.js +4 -0
  9. package/dist/modules/mcp/tools/operations/schema/elements/patchElement.js +1 -1
  10. package/dist/modules/mcp/tools/operations/schema/elements/repeatElement.js +148 -0
  11. package/dist/modules/mcp/tools/operations/schema/interactions/deleteInteraction.js +1 -1
  12. package/dist/modules/mcp/tools/operations/schema/interactions/patchInteractionNode.js +1 -1
  13. package/dist/modules/mcp/tools/operations/schema/interactions/upsertInteractionFlow.js +1 -1
  14. package/dist/modules/mcp/tools/operations/schema/operations.js +2 -0
  15. package/dist/modules/mcp/tools/operations/schema/pages/upsertPage.js +1 -1
  16. package/dist/modules/mcp/tools/operations/schema/shared.js +8 -5
  17. package/dist/modules/mcp/tools/operations/schemaIds.js +54 -0
  18. package/dist/modules/mcp/tools/operations/style/definitions/patchDefinition.js +1 -1
  19. package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinition.js +1 -1
  20. package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinitions.js +40 -0
  21. package/dist/modules/mcp/tools/operations/style/globalStyles/patchGlobalStyle.js +1 -1
  22. package/dist/modules/mcp/tools/operations/style/globalStyles/upsertGlobalStyle.js +1 -1
  23. package/dist/modules/mcp/tools/operations/style/idStyles/patchIdStyle.js +1 -1
  24. package/dist/modules/mcp/tools/operations/style/idStyles/upsertIdStyle.js +1 -1
  25. package/dist/modules/mcp/tools/operations/style/operations.js +2 -0
  26. package/dist/modules/mcp/tools/operations/style/shared.js +1 -1
  27. package/dist/modules/mcp/tools/render.js +70 -8
  28. package/dist/modules/mcp/tools/shared/expandOperations.js +44 -0
  29. package/dist/modules/mcp/tools/shared/validator/audit.js +3 -0
  30. package/dist/modules/mcp/tools/shared/validator/batch.js +13 -4
  31. package/dist/modules/mcp/tools/shared/validator/index.js +15 -0
  32. package/dist/modules/mcp/tools/validate.js +11 -3
  33. package/dist/src/modules/mcp/apps/render/heldBatch.d.ts +10 -0
  34. package/dist/src/modules/mcp/apps/render/heldBatch.test.d.ts +1 -0
  35. package/dist/src/modules/mcp/e2e/index.d.ts +2 -2
  36. package/dist/src/modules/mcp/e2e/renderingHost.d.ts +16 -1
  37. package/dist/src/modules/mcp/helpers/guide.d.ts +1 -1
  38. package/dist/src/modules/mcp/tests/schemaIds.test.d.ts +1 -0
  39. package/dist/src/modules/mcp/tools/apply/index.d.ts +50 -0
  40. package/dist/src/modules/mcp/tools/operations/index.d.ts +100 -0
  41. package/dist/src/modules/mcp/tools/operations/schema/elements/repeatElement.d.ts +55 -0
  42. package/dist/src/modules/mcp/tools/operations/schema/index.d.ts +1 -0
  43. package/dist/src/modules/mcp/tools/operations/schema/operations.d.ts +19 -0
  44. package/dist/src/modules/mcp/tools/operations/schema/shared.d.ts +15 -0
  45. package/dist/src/modules/mcp/tools/operations/schemaIds.d.ts +6 -0
  46. package/dist/src/modules/mcp/tools/operations/style/definitions/upsertDefinitions.d.ts +41 -0
  47. package/dist/src/modules/mcp/tools/operations/style/index.d.ts +1 -0
  48. package/dist/src/modules/mcp/tools/operations/style/operations.d.ts +33 -0
  49. package/dist/src/modules/mcp/tools/operations/style/shared.d.ts +4 -3
  50. package/dist/src/modules/mcp/tools/preview.d.ts +50 -0
  51. package/dist/src/modules/mcp/tools/render.d.ts +421 -363
  52. package/dist/src/modules/mcp/tools/screenshot.d.ts +50 -0
  53. package/dist/src/modules/mcp/tools/shared/expandOperations.d.ts +13 -0
  54. package/dist/src/modules/mcp/tools/validate.d.ts +50 -0
  55. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.32.18
4
+
5
+ ### Patch Changes
6
+
7
+ - v0.32.18
8
+ - Updated dependencies
9
+ - @plitzi/plitzi-sdk@0.32.18
10
+ - @plitzi/sdk-schema@0.32.18
11
+ - @plitzi/sdk-shared@0.32.18
12
+
3
13
  ## 0.32.17
4
14
 
5
15
  ### Patch Changes
@@ -1,10 +1,12 @@
1
1
  /* eslint-disable react-refresh/only-export-components -- one bundled entry: components cannot move out. */
2
2
  import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
3
3
  import PlitziSdk from '@plitzi/plitzi-sdk';
4
- import { Component, useEffect, useState } from 'react';
4
+ import { Component, useEffect, useRef, useState } from 'react';
5
5
  import { createRoot } from 'react-dom/client';
6
6
 
7
- import type { McpUiHostContext } from '@modelcontextprotocol/ext-apps';
7
+ import { readHeldBatch, writeHeldBatch } from './heldBatch';
8
+
9
+ import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
8
10
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
9
11
  import type { OfflineDataRaw } from '@plitzi/sdk-shared';
10
12
  import type { ReactNode } from 'react';
@@ -39,17 +41,119 @@ class RenderBoundary extends Component<{ children: ReactNode }, { error?: Error
39
41
  }
40
42
  }
41
43
 
44
+ /** The batch the widget on screen was built from. It lives on the HOST side — this ref plus localStorage — because
45
+ * the server keeps nothing between calls, which is what lets any replica (or an edge deployment) answer any
46
+ * request. A patch is merged into it and sent back through `callServerTool`, so the full batch never enters the
47
+ * model's context. The ref alone would not do: the host gives each tool call its own view, so a patch usually
48
+ * starts from an empty instance and reads the batch back from storage by renderId (see heldBatch.ts). */
49
+ type Held = { renderId?: string; operations: unknown[] };
50
+
51
+ const summarise = (result: CallToolResult): string => {
52
+ const text = result.content.find(entry => entry.type === 'text');
53
+
54
+ return text?.text ?? 'no summary';
55
+ };
56
+
42
57
  const RenderApp = () => {
43
58
  const [result, setResult] = useState<CallToolResult | null>(null);
44
59
  const [cancelled, setCancelled] = useState<string | undefined>(undefined);
45
60
  const [context, setContext] = useState<McpUiHostContext | undefined>(undefined);
61
+ const held = useRef<Held>({ operations: [] });
62
+ const appRef = useRef<App | null>(null);
63
+
64
+ // A patch carries only what changed. Merging it onto the held batch and re-calling the tool is what keeps the
65
+ // server stateless: it re-renders the WHOLE widget (so refs, integrity and the audit are all checked as usual)
66
+ // from a payload that travelled host↔server, and the model hears the outcome through updateModelContext.
67
+ const applyPatch = async (renderId: string, delta: unknown[]): Promise<void> => {
68
+ const app = appRef.current;
69
+ if (!app) {
70
+ return;
71
+ }
72
+
73
+ const base = held.current.renderId === renderId ? held.current.operations : (readHeldBatch(renderId) ?? []);
74
+ if (base.length === 0) {
75
+ await app.updateModelContext({
76
+ content: [
77
+ {
78
+ type: 'text',
79
+ text: `The widget ${renderId} could not be recovered, so nothing was patched. Call plitzi_render again with the complete batch and without \`patch\`.`
80
+ }
81
+ ]
82
+ });
83
+
84
+ return;
85
+ }
86
+
87
+ const merged = [...base, ...delta];
88
+ // The round trip can fail on its own: a host that forwards no tool calls, a connection dropped mid-patch, a
89
+ // view torn down while the answer is in flight. Unreported it would surface as an unhandled rejection inside
90
+ // the sandbox and the model would wait forever for a widget that is never coming.
91
+ try {
92
+ const rendered = await app.callServerTool({
93
+ name: 'plitzi_render',
94
+ arguments: { operations: merged, renderId }
95
+ });
96
+ const offlineData = rendered.structuredContent?.offlineData;
97
+ if (offlineData) {
98
+ const applied = (rendered.structuredContent?.operations as unknown[] | undefined) ?? merged;
99
+ held.current = { renderId, operations: applied };
100
+ writeHeldBatch(renderId, applied);
101
+ setResult(rendered);
102
+ }
103
+
104
+ await app.updateModelContext({
105
+ content: [
106
+ {
107
+ type: 'text',
108
+ text: offlineData
109
+ ? `Widget updated: ${summarise(rendered)}`
110
+ : `The patch did not apply, the widget is unchanged: ${summarise(rendered)}`
111
+ }
112
+ ]
113
+ });
114
+ } catch (reason) {
115
+ await app
116
+ .updateModelContext({
117
+ content: [
118
+ {
119
+ type: 'text',
120
+ text: `The patch could not be delivered (${reason instanceof Error ? reason.message : String(reason)}), so the widget is unchanged. Re-send the full batch without \`patch\` if it needs to change.`
121
+ }
122
+ ]
123
+ })
124
+ .catch(() => undefined);
125
+ }
126
+ };
46
127
 
47
128
  // useApp creates the App, runs onAppCreated so every handler is in place BEFORE the handshake, and connects.
48
129
  const { app, error } = useApp({
49
130
  appInfo: { name: 'Plitzi Widget', version: '1.0.0' },
50
131
  capabilities: {},
51
132
  onAppCreated: instance => {
52
- instance.ontoolresult = setResult;
133
+ appRef.current = instance;
134
+ instance.ontoolresult = toolResult => {
135
+ const renderId = toolResult.structuredContent?.renderId as string | undefined;
136
+ if (toolResult.structuredContent?.patch === true && renderId) {
137
+ void applyPatch(renderId, (toolResult.structuredContent.operations as unknown[] | undefined) ?? []);
138
+
139
+ return;
140
+ }
141
+
142
+ // A result carrying no widget is a failed render or a refused patch. The model already reads its reasons
143
+ // as text, so it must not blank a widget that is on screen — nor drop the batch that widget was built
144
+ // from, which is the only copy of it anywhere. With nothing on screen yet, the error IS the view.
145
+ if (!toolResult.structuredContent?.offlineData && held.current.operations.length > 0) {
146
+ return;
147
+ }
148
+
149
+ const operations = (toolResult.structuredContent?.operations as unknown[] | undefined) ?? [];
150
+ held.current = { renderId, operations };
151
+ if (renderId && operations.length > 0) {
152
+ writeHeldBatch(renderId, operations);
153
+ }
154
+
155
+ setResult(toolResult);
156
+ };
53
157
  instance.ontoolcancelled = params => setCancelled(params.reason ?? 'The host cancelled the render.');
54
158
  instance.onhostcontextchanged = params => setContext(previous => ({ ...previous, ...params }));
55
159
  instance.onteardown = () => ({});
@@ -202,7 +202,12 @@ pointed at the old name is repointed with it, so the element stays wired. You do
202
202
  (prefer a unitless ratio like \`1.5\`, which tracks the font size). Changing one without the other leaves cramped or
203
203
  loosely-spaced text — they are a joint change, not two separate ones.
204
204
  - A definition lives in the **style schema**; an element's \`style.base\` (element schema) is the link that applies
205
- it. Styling an element = upsertDefinition + upsertElement with that ref in \`style.base\`, in one batch.
205
+ it. Styling an element = upsertDefinitions + upsertElement with that ref in \`style.base\`, in one batch.
206
+ - **Repeating siblings**: when a set of siblings shares a shape and differs only in data (a list, cards, rows,
207
+ steps), use \`repeatElement\` — the template once with \`{{item.field}}\` placeholders plus \`items\`, which
208
+ creates the wrapper and numbers each row's refs (\`step-1\`, \`step-2\`…). A list inside each row is the same op:
209
+ the wrapping node carries \`repeat: { items: "{{item.<list>}}", template: … }\` and its refs number both levels
210
+ (\`blk-2-3\`). Copy-pasting the subtree N times costs N times the tokens and drifts.
206
211
  - CSS keys are **kebab-case** (\`background-color\`). camelCase is rejected — read \`plitzi://css-properties\`.
207
212
  - **Write normal CSS — shorthands are accepted and expanded for you.** \`border\`, \`border-{side}\`,
208
213
  \`border-width\`/\`-color\`/\`-style\`, \`border-radius\`, \`padding\`, \`margin\`, \`inset\`, \`gap\`, \`overflow\`,
@@ -232,7 +237,9 @@ pointed at the old name is repointed with it, so the element stays wired. You do
232
237
  - **Three kinds of style live in the style schema — do not confuse them:**
233
238
  - **Definitions** = reusable CSS **classes** (\`upsertDefinition\`/\`patchDefinition\`/\`deleteDefinition\`, keyed by a
234
239
  class \`ref\`). Attach one to an element via \`style.base\` to style **that** element (and anything else that opts in).
235
- This is the **default** way to style one element.
240
+ This is the **default** way to style one element. Declaring MORE than one class in a batch? Use
241
+ \`upsertDefinitions\` — one op carrying \`{ "<class>": { desktop: … }, … }\`, same result as the run of
242
+ \`upsertDefinition\` it replaces, without repeating the envelope once per class.
236
243
  - **Global styles** = the CSS equivalent of a bare element selector like \`button { … }\`
237
244
  (\`upsertGlobalStyle\`/\`patchGlobalStyle\`/\`deleteGlobalStyle\`, keyed by \`componentType\`). They style **every**
238
245
  element of that type at once. Use these for site-wide intent — e.g. "all buttons rounded":
@@ -33,8 +33,26 @@ Most widgets are **presentation only** — the structure + styling below is all
33
33
  runs the live Plitzi SDK though, so it can also **fetch data** (an \`apiContainer\`) and **react to events**
34
34
  (interaction flows); see "Data & interactivity" at the end when a widget needs them.
35
35
 
36
- Each call renders a **fresh** widget with no memory of previous calls — always send **every** operation the widget
37
- needs in the one call. To change a widget, re-send the whole thing.
36
+ Each call renders a **fresh** widget: send **every** operation it needs in the one call.
37
+
38
+ **To CHANGE a widget you already rendered, do not rebuild it** — every render answers with a \`renderId\`; call
39
+ again with that id, \`patch: true\` and only the operations that differ:
40
+
41
+ \`\`\`json
42
+ { "patch": true, "renderId": "r7f3a2c", "operations": [
43
+ { "type": "patchDefinition", "ref": "tl-card", "desktop": { "border-radius": "20px" } },
44
+ { "type": "patchElement", "pageRef": "render", "ref": "title-2", "props": { "content": "EPCOT (día largo)" } }
45
+ ] }
46
+ \`\`\`
47
+ The widget merges the delta into the batch it was built from and reports back what it applied — including the
48
+ errors, if a ref does not exist. Address rows by the refs you already know (\`card-1\`, \`blk-2-3\`). If it reports
49
+ that the widget could not be recovered (a surface that renders none, a host that keeps no storage, a conversation
50
+ resumed elsewhere), send the whole batch again without \`patch\`.
51
+
52
+ **Patch only to modify what is on screen.** A different subject, or a different kind of widget, is a FRESH render
53
+ with no \`patch\` — the delta is merged into the previous widget, so patching a new idea leaves the user looking at
54
+ both at once. Rebuilding when you could have patched only costs tokens; patching when you should have rebuilt costs
55
+ the user a wrong widget.
38
56
 
39
57
  ## Build the whole widget in ONE upsertElement (nest with \`children\`)
40
58
 
@@ -58,20 +76,91 @@ An element is \`{ ref, type, subType?, props?, style?, children? }\`. Children r
58
76
  add elements one-by-one with a top-level \`parentRef: "<existing ref>"\` and optional \`position\` — useful to append to
59
77
  or restructure something you already created — but for a fresh widget the inline \`children\` tree is easier.)
60
78
 
61
- ## Style with reusable classes upsertDefinition
79
+ ## Repeating rows repeatElement, never copy-paste
80
+
81
+ The moment two siblings have the same shape and different data — a list, steps, cards, a table, a timeline —
82
+ **write the shape once** and hand over the rows. \`repeatElement\` creates the wrapper (style it with the row/grid
83
+ class) and renders the template once per entry of \`items\`:
84
+
85
+ \`\`\`json
86
+ { "type": "repeatElement", "pageRef": "render", "ref": "steps", "style": { "base": ["list"] },
87
+ "template": {
88
+ "ref": "step", "type": "container", "style": { "base": ["row"] },
89
+ "children": [
90
+ { "ref": "at", "type": "paragraph", "style": { "base": ["time"] }, "props": { "content": "{{item.time}}" } },
91
+ { "ref": "what", "type": "paragraph", "style": { "base": ["txt"] }, "props": { "content": "{{item.text}}" } }
92
+ ]
93
+ },
94
+ "items": [
95
+ { "time": "08:00", "text": "Doors open, head straight to the main hall." },
96
+ { "time": "10:30", "text": "Workshops in the east wing." },
97
+ { "time": "13:00", "text": "Lunch, then the keynote." }
98
+ ]
99
+ }
100
+ \`\`\`
101
+ - \`{{item.<field>}}\` is replaced by that row's field, anywhere in the template (props, a style ref, a param).
102
+ A placeholder that is the WHOLE value keeps the field's type (\`"{{item.count}}"\` with \`count: 3\` stays the
103
+ number 3); mixed with text it interpolates. Dotted paths work: \`{{item.author.name}}\`.
104
+ - Every ref in the template gets the row number appended — \`step\` becomes \`step-1\`, \`step-2\`… — so rows never
105
+ collide and you can address one later without reading anything back.
106
+ - Other \`{{…}}\` names are left untouched, so schema variables keep working inside a template.
107
+ - A row missing a field the template reads fails the batch and names the row and the fields it does carry.
108
+ - Up to 100 rows per op. Rows that differ in SHAPE (not just data) are not rows — write those as plain elements.
109
+
110
+ **A list inside each row** — a timeline of days each with its own steps, a menu of sections each with its dishes —
111
+ is one op too: give the node that should wrap the sub-list a \`repeat\`, and put the sub-rows in the row's data.
112
+
113
+ \`\`\`json
114
+ { "type": "repeatElement", "pageRef": "render", "ref": "timeline", "style": { "base": ["tl"] },
115
+ "template": {
116
+ "ref": "day", "type": "container", "style": { "base": ["day"] },
117
+ "children": [
118
+ { "ref": "title", "type": "heading", "subType": "h3", "props": { "content": "{{item.park}}" } },
119
+ { "ref": "body", "type": "container", "style": { "base": ["body"] },
120
+ "repeat": { "items": "{{item.blocks}}", "template": {
121
+ "ref": "blk", "type": "container", "style": { "base": ["blk"] },
122
+ "children": [
123
+ { "ref": "at", "type": "paragraph", "props": { "content": "{{item.time}}" } },
124
+ { "ref": "what", "type": "paragraph", "props": { "content": "{{item.text}}" } }
125
+ ]
126
+ } } }
127
+ ]
128
+ },
129
+ "items": [
130
+ { "park": "Magic Kingdom", "blocks": [ { "time": "08:00", "text": "Rope drop." }, { "time": "10:30", "text": "Space Mountain." } ] },
131
+ { "park": "EPCOT", "blocks": [ { "time": "08:30", "text": "Cosmic Rewind." } ] }
132
+ ]
133
+ }
134
+ \`\`\`
135
+ - The node carrying \`repeat\` becomes the WRAPPER of its sub-list (its own \`children\` are ignored), so put the
136
+ sub-list's layout class on it.
137
+ - Inside the sub-template \`{{item.…}}\` reads the SUB-row; a field of the outer row is not reachable from there,
138
+ so repeat it in each sub-row if you need it.
139
+ - Refs number both levels, outer first: \`blk-2-3\` is the third block of the second day.
140
+ - **One level of nesting**: the sub-template is a plain element tree, so it cannot carry another \`repeat\`.
141
+ - The whole op is capped at 500 rows across both levels.
142
+
143
+ ## Style with reusable classes — upsertDefinitions
62
144
 
63
- Styling is separate from structure: declare a class, then attach it by ref.
145
+ Styling is separate from structure: declare the classes, then attach them by ref. **Declare them all in ONE
146
+ \`upsertDefinitions\`**, keyed by class name — a widget usually needs a dozen classes, and one op per class spends a
147
+ noticeable slice of the call on repeated \`{"type":"upsertDefinition","ref":…}\` envelopes.
64
148
 
65
149
  \`\`\`json
66
- { "type": "upsertDefinition", "ref": "card", "desktop": { "display": "flex", "flex-direction": "column", "gap": "8px", "padding": "24px", "border-radius": "12px" } }
150
+ { "type": "upsertDefinitions", "definitions": {
151
+ "card": { "desktop": { "display": "flex", "flex-direction": "column", "gap": "8px", "padding": "16px", "border-radius": "12px" } },
152
+ "title": { "desktop": { "font-size": "16px", "font-weight": "600", "margin-top": "0", "margin-bottom": "0" } }
153
+ } }
67
154
  \`\`\`
155
+ Each value is exactly what \`upsertDefinition\` takes minus \`type\`/\`ref\` (\`desktop\`/\`tablet\`/\`mobile\`, \`states\`,
156
+ \`variants\`, \`slots\`), and the result is identical. The single \`upsertDefinition\` still exists for a one-off class,
157
+ and \`patchDefinition\` still changes only some CSS of one class.
68
158
  - CSS properties in **kebab-case** (\`background-color\`, \`font-size\`, \`border-radius\`), values as plain strings.
69
159
  - Attach to an element via \`style: { "base": ["card"] }\`. Stack classes: \`"base": ["card", "shadow"]\`.
70
160
  - One \`ref\` can name both an element and its class (as above) — they live in different namespaces.
71
161
  - Lay containers out with flexbox or grid — pick the direction on purpose, see **Fit the panel** below.
72
162
  - **You are not styling from zero.** Each type lands on the page with CSS you did not write, and it is the usual
73
- reason a widget does not look like the definitions say. The SDK stylesheet resets almost nothing (\`box-sizing\`,
74
- \`border: 0 solid\`, \`body\` margin), so anything you leave unset comes from one of two places:
163
+ reason a widget does not look like the definitions say:
75
164
  - The per-type rule the SDK ships — the one that changes layouts is \`container\`, which carries
76
165
  \`min-width: 50px; min-height: 50px\`. A rail, a divider, a dot, a spacer or a narrow cell will NOT go below
77
166
  50px until you say \`"min-width": "0"\` (and/or \`"min-height": "0"\`) on it: a 2px timeline line renders 50px
@@ -85,9 +174,10 @@ Styling is separate from structure: declare a class, then attach it by ref.
85
174
  builder — it does not paint here, so do not count on it in a widget.
86
175
  - **Mind the intrinsic display.** Some types start non-block: \`text\` is \`display: inline\`, so to stack or size it,
87
176
  wrap it in a \`container\` (or set \`display: block\`). \`heading\` and \`paragraph\` are already block.
88
- - **Use atomic longhands.** \`padding\`, \`margin\`, \`border\`, \`border-radius\` are fine (they expand cleanly), but
89
- \`flex\`, \`background\` and \`font\` are rejected write \`display\`+\`flex-direction\`, \`background-color\`,
90
- \`font-size\`+\`font-weight\` instead. An unknown property errors with the correct kebab-case key suggested.
177
+ - **Shorthands are welcome** and cheaper to write: \`padding: 8px 16px\`, \`border: 1px solid #ddd\`,
178
+ \`font: bold 16px/1.5 Arial\`, \`flex: 1 1 auto\`, \`transition: opacity 200ms ease\`. They are stored expanded to
179
+ their longhands, so a breakpoint, state or variant can override each property on its own and a read-back shows
180
+ the longhands, not what you wrote. An unknown property errors with the correct kebab-case key suggested.
91
181
  - **Responsive:** add \`tablet\` and/or \`mobile\` blocks next to \`desktop\` (same shape); they override desktop on
92
182
  smaller screens — \`{ "desktop": { "font-size": "36px" }, "mobile": { "font-size": "24px" } }\`.
93
183
  - **Interactive states:** nest under \`states\` keyed by pseudo-class, each with its own breakpoint block —
@@ -106,14 +196,16 @@ half-empty widget to avoid. Choose the axis every time:
106
196
  - **Peers side by side** — metrics, plans, options, a comparison, an image next to its text: a row, wrapping when
107
197
  it runs out of width, children sharing it (no fixed widths).
108
198
  \`\`\`json
109
- { "type": "upsertDefinition", "ref": "row", "desktop": { "display": "flex", "flex-direction": "row", "flex-wrap": "wrap", "gap": "12px", "align-items": "stretch" } }
110
- { "type": "upsertDefinition", "ref": "col", "desktop": { "flex-grow": "1", "flex-basis": "0%", "min-width": "150px" } }
199
+ { "type": "upsertDefinitions", "definitions": {
200
+ "row": { "desktop": { "display": "flex", "flex-direction": "row", "flex-wrap": "wrap", "gap": "12px", "align-items": "stretch" } },
201
+ "col": { "desktop": { "flex-grow": "1", "flex-basis": "0%", "min-width": "150px" } }
202
+ } }
111
203
  \`\`\`
112
204
  \`flex-grow: 1\` + \`flex-basis: 0%\` splits the row evenly; \`min-width\` is the wrap threshold — under it the item
113
205
  drops to the next line by itself, so a narrow panel degrades gracefully with no breakpoints.
114
206
  - **Many uniform items** — cards, tiles, a gallery: one grid line does it all.
115
207
  \`\`\`json
116
- { "type": "upsertDefinition", "ref": "grid", "desktop": { "display": "grid", "grid-template-columns": "repeat(auto-fit, minmax(160px, 1fr))", "gap": "12px" } }
208
+ { "type": "upsertDefinitions", "definitions": { "grid": { "desktop": { "display": "grid", "grid-template-columns": "repeat(auto-fit, minmax(160px, 1fr))", "gap": "12px" } } } }
117
209
  \`\`\`
118
210
  - **Label + value pairs** stay on one line (\`display: flex\`, \`justify-content: space-between\`) instead of two.
119
211
  - **Vertical is right** for reading order: a heading over its paragraph, a form, a step list, long prose.
@@ -133,12 +225,12 @@ The host publishes its palette as CSS variables on the page, so use them for eve
133
225
  \`light-dark(<light>, <dark>)\` fallback for hosts that send none:
134
226
 
135
227
  \`\`\`json
136
- { "type": "upsertDefinition", "ref": "card", "desktop": {
228
+ { "type": "upsertDefinitions", "definitions": { "card": { "desktop": {
137
229
  "background-color": "var(--color-background-secondary, light-dark(#ffffff, #1f2430))",
138
230
  "color": "var(--color-text-primary, light-dark(#0f172a, #e8eaed))",
139
231
  "border-width": "1px", "border-style": "solid",
140
232
  "border-color": "var(--color-border-primary, light-dark(#e2e8f0, #333a48))"
141
- } }
233
+ } } } }
142
234
  \`\`\`
143
235
 
144
236
  - Surfaces: \`--color-background-primary\` (the page), \`--color-background-secondary\` / \`--color-background-tertiary\`
@@ -173,6 +265,19 @@ that is not here (lists, tabs, dialogs, forms, icons…).
173
265
  \`image\`/\`video\` \`src\` accepts any \`https\` URL, or a \`data:\`/\`blob:\` URI for a fully self-contained graphic
174
266
  (e.g. an inline SVG icon or a base64 image) — both render with no extra setup.
175
267
 
268
+ ## Keep the call small
269
+
270
+ Everything in \`operations\` is text you write, and a widget that takes two calls because the first ran long is a
271
+ widget the user waits twice for. Two habits pay for themselves:
272
+
273
+ - **Do not draw pictures in \`data:\` URIs.** A hand-written SVG scene (a castle, a skyline, a logo) costs more than
274
+ the entire rest of the widget and renders worse than nothing at all. Use an \`https\` image the user gave you, a
275
+ flat colour or a two-stop \`linear-gradient\` as a banner, an emoji or an \`fontAwesome\` icon for a glyph — or drop
276
+ the decoration. A small self-contained \`data:\` SVG is fine for a **simple** shape (a check, an arrow, a dot).
277
+ - **One class per look, not per property.** Classes like \`tone-blue\` + \`tone-blue-text\` + \`tone-blue-bg\` for the
278
+ same card triple the declarations and the attachments. Put everything the look needs in one class, add a second
279
+ only for the part that genuinely varies between siblings (a colour), and reuse it — the whole point of a class.
280
+
176
281
  ## Full worked example — two plans side by side
177
282
 
178
283
  The cards sit in a wrapping row and split it evenly, so the widget uses the panel's width and stays short; on a
@@ -181,10 +286,12 @@ narrow panel \`min-width\` drops the second card under the first on its own.
181
286
  \`\`\`json
182
287
  {
183
288
  "operations": [
184
- { "type": "upsertDefinition", "ref": "plans", "desktop": { "display": "flex", "flex-direction": "row", "flex-wrap": "wrap", "gap": "12px", "align-items": "stretch" } },
185
- { "type": "upsertDefinition", "ref": "card", "desktop": { "display": "flex", "flex-direction": "column", "gap": "6px", "flex-grow": "1", "flex-basis": "0%", "min-width": "150px", "padding": "16px", "background-color": "var(--color-background-secondary, light-dark(#ffffff, #1f2430))", "color": "var(--color-text-primary, light-dark(#0f172a, #e8eaed))", "border-width": "1px", "border-style": "solid", "border-color": "var(--color-border-primary, light-dark(#e2e8f0, #333a48))", "border-radius": "var(--border-radius-lg, 12px)", "text-align": "center" } },
186
- { "type": "upsertDefinition", "ref": "price", "desktop": { "font-size": "28px", "font-weight": "800", "color": "#3b82f6" } },
187
- { "type": "upsertDefinition", "ref": "cta", "desktop": { "background-color": "#3b82f6", "color": "#ffffff", "padding": "10px 16px", "border-radius": "8px", "font-weight": "600" }, "states": { "hover": { "desktop": { "background-color": "#2563eb" } } } },
289
+ { "type": "upsertDefinitions", "definitions": {
290
+ "plans": { "desktop": { "display": "flex", "flex-direction": "row", "flex-wrap": "wrap", "gap": "12px", "align-items": "stretch" } },
291
+ "card": { "desktop": { "display": "flex", "flex-direction": "column", "gap": "6px", "flex-grow": "1", "flex-basis": "0%", "min-width": "150px", "padding": "16px", "background-color": "var(--color-background-secondary, light-dark(#ffffff, #1f2430))", "color": "var(--color-text-primary, light-dark(#0f172a, #e8eaed))", "border-width": "1px", "border-style": "solid", "border-color": "var(--color-border-primary, light-dark(#e2e8f0, #333a48))", "border-radius": "var(--border-radius-lg, 12px)", "text-align": "center" } },
292
+ "price": { "desktop": { "font-size": "28px", "font-weight": "800", "color": "#3b82f6" } },
293
+ "cta": { "desktop": { "background-color": "#3b82f6", "color": "#ffffff", "padding": "10px 16px", "border-radius": "8px", "font-weight": "600" }, "states": { "hover": { "desktop": { "background-color": "#2563eb" } } } }
294
+ } },
188
295
  { "type": "upsertElement", "pageRef": "render", "element": {
189
296
  "ref": "plans", "type": "container", "style": { "base": ["plans"] },
190
297
  "children": [
@@ -43,7 +43,7 @@ var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) =>
43
43
  const getSpace = () => spacePromise ??= loadSpace();
44
44
  const server = new McpServer({
45
45
  name: "plitzi-mcp",
46
- version: "0.32.17"
46
+ version: "0.32.18"
47
47
  }, { instructions: serverInstructions });
48
48
  registerResources(server, getSpace, MCP_ENV, log);
49
49
  registerApps(server);
@@ -19,6 +19,7 @@ import { upsertVariable } from "../operations/schema/variables/upsertVariable.js
19
19
  import { deleteDefinition } from "../operations/style/definitions/deleteDefinition.js";
20
20
  import { patchDefinition } from "../operations/style/definitions/patchDefinition.js";
21
21
  import { upsertDefinition } from "../operations/style/definitions/upsertDefinition.js";
22
+ import { upsertDefinitions } from "../operations/style/definitions/upsertDefinitions.js";
22
23
  import { deleteGlobalStyle } from "../operations/style/globalStyles/deleteGlobalStyle.js";
23
24
  import { patchGlobalStyle } from "../operations/style/globalStyles/patchGlobalStyle.js";
24
25
  import { upsertGlobalStyle } from "../operations/style/globalStyles/upsertGlobalStyle.js";
@@ -49,6 +50,7 @@ var executeOp = (space, env, op) => {
49
50
  case "deleteInteraction": return deleteInteraction(space, env, op);
50
51
  case "patchSettings": return patchSettings(space, env, op);
51
52
  case "upsertDefinition": return upsertDefinition(space, env, op);
53
+ case "upsertDefinitions": return upsertDefinitions(space, env, op);
52
54
  case "patchDefinition": return patchDefinition(space, env, op);
53
55
  case "deleteDefinition": return deleteDefinition(space, env, op);
54
56
  case "upsertGlobalStyle": return upsertGlobalStyle(space, env, op);
@@ -2,6 +2,7 @@ import { cloneSpace } from "../../helpers/space.js";
2
2
  import { environment, operations } from "../operations/index.js";
3
3
  import { applyOperations } from "./dispatch.js";
4
4
  import { changedResources, conflictMessage, detectConflicts, resolvedElements } from "./writeResult.js";
5
+ import { expandOperations } from "../shared/expandOperations.js";
5
6
  import { defineTool } from "../shared/tool.js";
6
7
  import { validateOperations } from "../shared/validator/index.js";
7
8
  import { auditResources } from "../shared/validator/audit.js";
@@ -22,7 +23,20 @@ var schemaErrorToValidation = (error) => ({
22
23
  });
23
24
  var apply = async (input, space, persisters) => {
24
25
  const env = input.environment ?? "main";
25
- const validation = validateOperations(space, input.operations);
26
+ const expansion = expandOperations(input.operations);
27
+ if (expansion.errors.length > 0) return {
28
+ applied: false,
29
+ persisted: false,
30
+ summary: {
31
+ created: 0,
32
+ updated: 0,
33
+ deleted: 0
34
+ },
35
+ changed: [],
36
+ errors: expansion.errors
37
+ };
38
+ const ops = expansion.operations;
39
+ const validation = validateOperations(space, ops);
26
40
  if (!validation.valid) return {
27
41
  applied: false,
28
42
  persisted: false,
@@ -51,7 +65,7 @@ var apply = async (input, space, persisters) => {
51
65
  }
52
66
  };
53
67
  const draft = cloneSpace(space);
54
- const outcome = applyOperations(draft, env, input.operations);
68
+ const outcome = applyOperations(draft, env, ops);
55
69
  if (outcome.errors.length > 0) return {
56
70
  applied: false,
57
71
  persisted: false,
@@ -77,7 +91,7 @@ var apply = async (input, space, persisters) => {
77
91
  errors: integrity.errors.map(schemaErrorToValidation),
78
92
  warnings: noWarnings(validation.warnings)
79
93
  };
80
- const audit = auditResources(draft, input.operations);
94
+ const audit = auditResources(draft, ops);
81
95
  const warnings = [...validation.warnings, ...audit.warnings];
82
96
  if (audit.errors.length > 0) return {
83
97
  applied: false,
@@ -1,9 +1,12 @@
1
1
  import { elementOps } from "./schema/operations.js";
2
+ import { registerSharedSchemaIds } from "./schemaIds.js";
2
3
  import { styleOps } from "./style/operations.js";
3
4
  import { z } from "zod";
4
5
  //#region src/modules/mcp/tools/operations/index.ts
6
+ registerSharedSchemaIds();
5
7
  var operation = z.discriminatedUnion("type", [
6
8
  elementOps.upsertElement,
9
+ elementOps.repeatElement,
7
10
  elementOps.patchElement,
8
11
  elementOps.deleteElement,
9
12
  elementOps.moveElement,
@@ -21,6 +24,7 @@ var operation = z.discriminatedUnion("type", [
21
24
  elementOps.deleteInteraction,
22
25
  elementOps.patchSettings,
23
26
  styleOps.upsertDefinition,
27
+ styleOps.upsertDefinitions,
24
28
  styleOps.patchDefinition,
25
29
  styleOps.deleteDefinition,
26
30
  styleOps.upsertGlobalStyle,
@@ -10,7 +10,7 @@ var patchElementOp = z.object({
10
10
  type: z.literal("patchElement"),
11
11
  pageRef: z.string().describe("Page ref or id"),
12
12
  ref: z.string().describe("Existing element ref or id"),
13
- idRef: z.string().optional().describe("Assign or rename the idRef of this element ([A-Za-z0-9_-], starting with a letter, unique in the space). Give an element one to make it bindable: without an idRef it publishes no data source. Renaming one moves its source name with it every binding and interaction across the space that targeted the old name is repointed for you, so the wiring survives the rename."),
13
+ idRef: z.string().optional().describe("Assign or rename this element idRef ([A-Za-z0-9_-], starting with a letter, unique in the space). Without one an element publishes no data source, so it is not bindable. A rename moves the source name with it: every binding and interaction that targeted the old one is repointed for you."),
14
14
  label: z.string().optional(),
15
15
  subType: z.string().optional(),
16
16
  props: z.record(z.string(), z.unknown()).optional().describe("Merged onto existing props: listed keys change, null unsets a key, others are preserved"),