@plitzi/sdk-server 0.32.16 → 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 (62) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/helpers/serverLog.js +3 -3
  3. package/dist/modules/mcp/apps/render/view.tsx +107 -3
  4. package/dist/modules/mcp/apps/shared/shell.ejs +19 -2
  5. package/dist/modules/mcp/helpers/guide.js +9 -2
  6. package/dist/modules/mcp/helpers/space.js +1 -1
  7. package/dist/modules/mcp/resources/renderGuide.js +205 -18
  8. package/dist/modules/mcp/server.js +1 -1
  9. package/dist/modules/mcp/tools/apply/dispatch.js +2 -0
  10. package/dist/modules/mcp/tools/apply/index.js +17 -3
  11. package/dist/modules/mcp/tools/operations/index.js +4 -0
  12. package/dist/modules/mcp/tools/operations/schema/elements/patchElement.js +1 -1
  13. package/dist/modules/mcp/tools/operations/schema/elements/repeatElement.js +148 -0
  14. package/dist/modules/mcp/tools/operations/schema/interactions/deleteInteraction.js +1 -1
  15. package/dist/modules/mcp/tools/operations/schema/interactions/patchInteractionNode.js +1 -1
  16. package/dist/modules/mcp/tools/operations/schema/interactions/upsertInteractionFlow.js +1 -1
  17. package/dist/modules/mcp/tools/operations/schema/operations.js +2 -0
  18. package/dist/modules/mcp/tools/operations/schema/pages/upsertPage.js +1 -1
  19. package/dist/modules/mcp/tools/operations/schema/shared.js +8 -5
  20. package/dist/modules/mcp/tools/operations/schemaIds.js +54 -0
  21. package/dist/modules/mcp/tools/operations/style/definitions/patchDefinition.js +1 -1
  22. package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinition.js +1 -1
  23. package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinitions.js +40 -0
  24. package/dist/modules/mcp/tools/operations/style/globalStyles/patchGlobalStyle.js +1 -1
  25. package/dist/modules/mcp/tools/operations/style/globalStyles/upsertGlobalStyle.js +1 -1
  26. package/dist/modules/mcp/tools/operations/style/idStyles/patchIdStyle.js +1 -1
  27. package/dist/modules/mcp/tools/operations/style/idStyles/upsertIdStyle.js +1 -1
  28. package/dist/modules/mcp/tools/operations/style/operations.js +2 -0
  29. package/dist/modules/mcp/tools/operations/style/shared.js +1 -1
  30. package/dist/modules/mcp/tools/render.js +70 -8
  31. package/dist/modules/mcp/tools/shared/expandOperations.js +44 -0
  32. package/dist/modules/mcp/tools/shared/validator/audit.js +3 -0
  33. package/dist/modules/mcp/tools/shared/validator/batch.js +13 -4
  34. package/dist/modules/mcp/tools/shared/validator/index.js +15 -0
  35. package/dist/modules/mcp/tools/validate.js +11 -3
  36. package/dist/modules/oauth/metadata.js +11 -4
  37. package/dist/src/helpers/serverLog.d.ts +1 -1
  38. package/dist/src/modules/mcp/apps/render/heldBatch.d.ts +10 -0
  39. package/dist/src/modules/mcp/apps/render/heldBatch.test.d.ts +1 -0
  40. package/dist/src/modules/mcp/e2e/index.d.ts +2 -2
  41. package/dist/src/modules/mcp/e2e/renderingHost.d.ts +16 -1
  42. package/dist/src/modules/mcp/helpers/guide.d.ts +1 -1
  43. package/dist/src/modules/mcp/helpers/space.d.ts +1 -1
  44. package/dist/src/modules/mcp/tests/schemaIds.test.d.ts +1 -0
  45. package/dist/src/modules/mcp/tools/apply/index.d.ts +50 -0
  46. package/dist/src/modules/mcp/tools/operations/index.d.ts +100 -0
  47. package/dist/src/modules/mcp/tools/operations/schema/elements/repeatElement.d.ts +55 -0
  48. package/dist/src/modules/mcp/tools/operations/schema/index.d.ts +1 -0
  49. package/dist/src/modules/mcp/tools/operations/schema/operations.d.ts +19 -0
  50. package/dist/src/modules/mcp/tools/operations/schema/shared.d.ts +15 -0
  51. package/dist/src/modules/mcp/tools/operations/schemaIds.d.ts +6 -0
  52. package/dist/src/modules/mcp/tools/operations/style/definitions/upsertDefinitions.d.ts +41 -0
  53. package/dist/src/modules/mcp/tools/operations/style/index.d.ts +1 -0
  54. package/dist/src/modules/mcp/tools/operations/style/operations.d.ts +33 -0
  55. package/dist/src/modules/mcp/tools/operations/style/shared.d.ts +4 -3
  56. package/dist/src/modules/mcp/tools/preview.d.ts +50 -0
  57. package/dist/src/modules/mcp/tools/render.d.ts +421 -363
  58. package/dist/src/modules/mcp/tools/screenshot.d.ts +50 -0
  59. package/dist/src/modules/mcp/tools/shared/expandOperations.d.ts +13 -0
  60. package/dist/src/modules/mcp/tools/validate.d.ts +50 -0
  61. package/dist/src/modules/oauth/metadata.d.ts +11 -4
  62. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
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
+
13
+ ## 0.32.17
14
+
15
+ ### Patch Changes
16
+
17
+ - v0.32.17
18
+ - Updated dependencies
19
+ - @plitzi/plitzi-sdk@0.32.17
20
+ - @plitzi/sdk-schema@0.32.17
21
+ - @plitzi/sdk-shared@0.32.17
22
+
3
23
  ## 0.32.16
4
24
 
5
25
  ### Patch Changes
@@ -8,12 +8,12 @@ var renderRequest = (event) => {
8
8
  };
9
9
  var renderTool = (event) => {
10
10
  const args = event.argsSummary ? ` ${event.argsSummary}` : "";
11
- return `[mcp] tools/call ${event.name}${args} ${Math.round(event.durationMs)}ms ${outcomeOf(event)}`;
11
+ return `[MCP] tools/call ${event.name}${args} ${Math.round(event.durationMs)}ms ${outcomeOf(event)}`;
12
12
  };
13
- var renderResource = (event) => `[mcp] resources/read ${event.name} ${Math.round(event.durationMs)}ms ${outcomeOf(event)}`;
13
+ var renderResource = (event) => `[MCP] resources/read ${event.name} ${Math.round(event.durationMs)}ms ${outcomeOf(event)}`;
14
14
  /** One line for any {@link ServerLogEvent}: an HTTP request reads as an access-log line
15
15
  * (`[SSR] 203.0.113.7 GET /pricing 200 12ms ok`), the MCP events as what happened inside one
16
- * (`[mcp] tools/call plitzi_apply {operations:[3]} 41ms ok`). Rendering is a pure format — the dispatcher
16
+ * (`[MCP] tools/call plitzi_apply {operations:[3]} 41ms ok`). Rendering is a pure format — the dispatcher
17
17
  * already stripped query values, collected no headers, cookies or tokens and summarised tool args by shape;
18
18
  * the client IP it does carry is personal data, so a sink that persists these lines must say so. */
19
19
  var renderLogEvent = (event) => {
@@ -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 = () => ({});
@@ -10,17 +10,34 @@
10
10
  <title><%= title %></title>
11
11
 
12
12
  <style>
13
+ /*
14
+ The page inherits the HOST's theme, which may be dark: nothing here paints a surface, so an app that sets no
15
+ colours would otherwise draw the browser's black default text over the host's own background — unreadable in
16
+ a dark chat. `light-dark()` picks the right side of every pair; `color-scheme` is what tells it which side,
17
+ and it follows the OS until useHostStyles replaces it with the theme the host actually reported (that hook
18
+ also fills the --color-* variables in, so the var() fallbacks below only apply on a host that sends none).
19
+ */
20
+ html {
21
+ color-scheme: light dark;
22
+ }
23
+
13
24
  html,
14
25
  body {
15
26
  margin: 0;
16
27
  }
28
+
29
+ body {
30
+ background-color: transparent;
31
+ color: var(--color-text-primary, light-dark(#0f172a, #e8eaed));
32
+ font-family: var(--font-sans, system-ui, sans-serif);
33
+ }
17
34
  /* The app renders nothing until the host pushes the tool result in. */
18
35
  #app:empty::after {
19
36
  content: 'Rendering…';
20
37
  display: block;
21
38
  padding: 16px;
22
- font: 14px system-ui, sans-serif;
23
- color: #64748b;
39
+ font-size: 14px;
40
+ color: var(--color-text-tertiary, light-dark(#64748b, #9aa4b2));
24
41
  }
25
42
  </style>
26
43
  <style>
@@ -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":
@@ -280,7 +280,7 @@ var pageRefOfElement = (schema, el) => {
280
280
  /** Total number of descendant elements under a subtree (excluding the root). */
281
281
  var descendantCount = (schema, rootId) => descendantIds(schema, rootId).length;
282
282
  var emptySpaceMessage = "Space data not available";
283
- var unauthorizedSpaceMessage = "This tool/resource needs a space, but no spaceId could be resolved from the Authorization token";
283
+ var unauthorizedSpaceMessage = "This connection has no space attached (a guest or widgets-only grant, or a token that carries no space), so NOTHING in a space can be read or edited — every other space tool will fail the same way, do not retry them. Use plitzi_render instead: it builds a self-contained widget offline, with no space, backend or account (read plitzi://render/guide). To edit a real space, the user must reconnect the integration and grant access to one.";
284
284
  var generateObjectId = () => {
285
285
  return `${Math.floor(Date.now() / 1e3).toString(16).padStart(8, "0")}${Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join("")}`;
286
286
  };
@@ -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,28 +76,172 @@ 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
62
80
 
63
- Styling is separate from structure: declare a class, then attach it by ref.
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\`:
64
84
 
65
85
  \`\`\`json
66
- { "type": "upsertDefinition", "ref": "card", "desktop": { "display": "flex", "flex-direction": "column", "gap": "8px", "padding": "24px", "border-radius": "12px" } }
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
+ }
67
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
144
+
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.
148
+
149
+ \`\`\`json
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
+ } }
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
- - Lay containers out with flexbox: \`{ "display": "flex", "flex-direction": "column", "gap": "12px" }\`.
161
+ - Lay containers out with flexbox or grid pick the direction on purpose, see **Fit the panel** below.
162
+ - **You are not styling from zero.** Each type lands on the page with CSS you did not write, and it is the usual
163
+ reason a widget does not look like the definitions say:
164
+ - The per-type rule the SDK ships — the one that changes layouts is \`container\`, which carries
165
+ \`min-width: 50px; min-height: 50px\`. A rail, a divider, a dot, a spacer or a narrow cell will NOT go below
166
+ 50px until you say \`"min-width": "0"\` (and/or \`"min-height": "0"\`) on it: a 2px timeline line renders 50px
167
+ wide otherwise. Any flex child that must be allowed to shrink needs it too.
168
+ - The BROWSER's own defaults for the rest: \`heading\` keeps its UA font-size and ~0.67em top/bottom margins,
169
+ \`paragraph\` ~1em margins, \`list\` a 40px \`padding-left\`, \`button\` its native chrome, \`image\` its intrinsic
170
+ size, \`link\` its own colour and underline. In a compact widget set these explicitly — usually
171
+ \`"margin-top": "0"\`, \`"margin-bottom": "0"\` — and space things with the parent's \`gap\` instead.
172
+ - Borders start at \`0 solid\`, so \`border-color\` alone paints nothing: give \`border-width\` (and the colour).
173
+ - The \`defaultStyle\` a type reports in the EDITING catalog (\`plitzi://types\`) is authoring metadata for the
174
+ builder — it does not paint here, so do not count on it in a widget.
72
175
  - **Mind the intrinsic display.** Some types start non-block: \`text\` is \`display: inline\`, so to stack or size it,
73
176
  wrap it in a \`container\` (or set \`display: block\`). \`heading\` and \`paragraph\` are already block.
74
- - **Use atomic longhands.** \`padding\`, \`margin\`, \`border\`, \`border-radius\` are fine (they expand cleanly), but
75
- \`flex\`, \`background\` and \`font\` are rejected write \`display\`+\`flex-direction\`, \`background-color\`,
76
- \`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.
77
181
  - **Responsive:** add \`tablet\` and/or \`mobile\` blocks next to \`desktop\` (same shape); they override desktop on
78
182
  smaller screens — \`{ "desktop": { "font-size": "36px" }, "mobile": { "font-size": "24px" } }\`.
79
183
  - **Interactive states:** nest under \`states\` keyed by pseudo-class, each with its own breakpoint block —
80
184
  \`{ "desktop": { "background-color": "#3b82f6" }, "states": { "hover": { "desktop": { "background-color": "#2563eb" } } } }\`
81
185
  (\`hover\`, \`active\`, \`focus\`).
82
186
 
187
+ ## Fit the panel — go wide, stay short
188
+
189
+ The widget renders in a **side panel** (Claude Desktop, ChatGPT, the Plitzi builder), so it gets a usable width but
190
+ very little height: everything past the first screenful costs the user a scroll. Height is the scarce resource —
191
+ spend width instead.
192
+
193
+ Plain containers are blocks, so doing nothing stacks children **vertically** and produces exactly the tall,
194
+ half-empty widget to avoid. Choose the axis every time:
195
+
196
+ - **Peers side by side** — metrics, plans, options, a comparison, an image next to its text: a row, wrapping when
197
+ it runs out of width, children sharing it (no fixed widths).
198
+ \`\`\`json
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
+ } }
203
+ \`\`\`
204
+ \`flex-grow: 1\` + \`flex-basis: 0%\` splits the row evenly; \`min-width\` is the wrap threshold — under it the item
205
+ drops to the next line by itself, so a narrow panel degrades gracefully with no breakpoints.
206
+ - **Many uniform items** — cards, tiles, a gallery: one grid line does it all.
207
+ \`\`\`json
208
+ { "type": "upsertDefinitions", "definitions": { "grid": { "desktop": { "display": "grid", "grid-template-columns": "repeat(auto-fit, minmax(160px, 1fr))", "gap": "12px" } } } }
209
+ \`\`\`
210
+ - **Label + value pairs** stay on one line (\`display: flex\`, \`justify-content: space-between\`) instead of two.
211
+ - **Vertical is right** for reading order: a heading over its paragraph, a form, a step list, long prose.
212
+ - Force a stack on tiny screens with the \`mobile\` block: \`"mobile": { "flex-direction": "column" }\`.
213
+
214
+ Keep it compact, and the numbers low: \`padding\` 12–16px (24+ only on a single hero card), \`gap\` 8–12px,
215
+ \`font-size\` 13–15px for body and 16–20px for headings. Let the outer container **fill** the panel — no \`width\` on
216
+ it — and reach for \`max-width\` only to stop one lone card from stretching across the whole panel.
217
+
218
+ ## Match the host theme — never hardcode a light palette
219
+
220
+ The widget is embedded in the host's own UI (Claude Desktop, ChatGPT, the builder), and that UI **may be in dark
221
+ mode**. A widget painted with fixed light colours is the most common way to ship something unusable: a white card
222
+ in a dark chat glares, and — worse — text left at a dark default disappears against the host's dark background.
223
+
224
+ The host publishes its palette as CSS variables on the page, so use them for every colour, with a
225
+ \`light-dark(<light>, <dark>)\` fallback for hosts that send none:
226
+
227
+ \`\`\`json
228
+ { "type": "upsertDefinitions", "definitions": { "card": { "desktop": {
229
+ "background-color": "var(--color-background-secondary, light-dark(#ffffff, #1f2430))",
230
+ "color": "var(--color-text-primary, light-dark(#0f172a, #e8eaed))",
231
+ "border-width": "1px", "border-style": "solid",
232
+ "border-color": "var(--color-border-primary, light-dark(#e2e8f0, #333a48))"
233
+ } } } }
234
+ \`\`\`
235
+
236
+ - Surfaces: \`--color-background-primary\` (the page), \`--color-background-secondary\` / \`--color-background-tertiary\`
237
+ (cards, raised areas). Text: \`--color-text-primary\`, \`--color-text-secondary\`, \`--color-text-tertiary\` (muted).
238
+ Borders: \`--color-border-primary\` / \`--color-border-secondary\`. Status pairs: \`--color-background-danger\` |
239
+ \`success\` | \`warning\` | \`info\` with the matching \`--color-text-…\`. Also \`--font-sans\`, \`--border-radius-md\` /
240
+ \`lg\` / \`full\`, \`--shadow-sm\` / \`md\`.
241
+ - **Set \`color\` wherever you set \`background-color\`** — the pair is what stays legible, either one alone is a
242
+ gamble. Same rule for a brand accent: a CTA on your own blue must state its own text colour (\`#ffffff\`).
243
+ - Prefer a border to a drop shadow for separation: shadows all but vanish on a dark surface.
244
+
83
245
  ## Element types (type → what to set)
84
246
 
85
247
  | type | renders | set |
@@ -103,21 +265,46 @@ that is not here (lists, tabs, dialogs, forms, icons…).
103
265
  \`image\`/\`video\` \`src\` accepts any \`https\` URL, or a \`data:\`/\`blob:\` URI for a fully self-contained graphic
104
266
  (e.g. an inline SVG icon or a base64 image) — both render with no extra setup.
105
267
 
106
- ## Full worked example — a pricing card
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
+
281
+ ## Full worked example — two plans side by side
282
+
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
284
+ narrow panel \`min-width\` drops the second card under the first on its own.
107
285
 
108
286
  \`\`\`json
109
287
  {
110
288
  "operations": [
111
- { "type": "upsertDefinition", "ref": "card", "desktop": { "display": "flex", "flex-direction": "column", "gap": "8px", "padding": "24px", "background-color": "#ffffff", "border-radius": "12px", "width": "260px", "text-align": "center", "box-shadow": "0 4px 20px rgba(0,0,0,0.08)" } },
112
- { "type": "upsertDefinition", "ref": "price", "desktop": { "font-size": "36px", "font-weight": "800", "color": "#3b82f6" } },
113
- { "type": "upsertDefinition", "ref": "cta", "desktop": { "background-color": "#3b82f6", "color": "#ffffff", "padding": "12px 20px", "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
+ } },
114
295
  { "type": "upsertElement", "pageRef": "render", "element": {
115
- "ref": "card", "type": "container", "style": { "base": ["card"] },
296
+ "ref": "plans", "type": "container", "style": { "base": ["plans"] },
116
297
  "children": [
117
- { "ref": "plan", "type": "heading", "subType": "h3", "props": { "content": "Pro" } },
118
- { "ref": "amount", "type": "text", "props": { "content": "$29/mo" }, "style": { "base": ["price"] } },
119
- { "ref": "feat", "type": "paragraph", "props": { "content": "Unlimited projects" } },
120
- { "ref": "buy", "type": "button", "props": { "content": "Start free trial" }, "style": { "base": ["cta"] } }
298
+ { "ref": "free", "type": "container", "style": { "base": ["card"] }, "children": [
299
+ { "ref": "free-plan", "type": "heading", "subType": "h3", "props": { "content": "Starter" } },
300
+ { "ref": "free-amount", "type": "text", "props": { "content": "$0" }, "style": { "base": ["price"] } },
301
+ { "ref": "free-buy", "type": "button", "props": { "content": "Start free" }, "style": { "base": ["cta"] } }
302
+ ] },
303
+ { "ref": "pro", "type": "container", "style": { "base": ["card"] }, "children": [
304
+ { "ref": "pro-plan", "type": "heading", "subType": "h3", "props": { "content": "Pro" } },
305
+ { "ref": "pro-amount", "type": "text", "props": { "content": "$29/mo" }, "style": { "base": ["price"] } },
306
+ { "ref": "pro-buy", "type": "button", "props": { "content": "Start free trial" }, "style": { "base": ["cta"] } }
307
+ ] }
121
308
  ]
122
309
  } }
123
310
  ]
@@ -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.16"
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,