@plitzi/sdk-mcp 0.38.7 → 0.38.8

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 (66) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +9 -9
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +2 -3
  5. package/dist/modules/mcp/helpers/guide.js +37 -21
  6. package/dist/modules/mcp/index.d.ts +2 -2
  7. package/dist/modules/mcp/server.d.ts +4 -4
  8. package/dist/modules/mcp/server.js +10 -5
  9. package/dist/modules/mcp/tests/cheapMistakes.test.d.ts +1 -0
  10. package/dist/modules/mcp/tests/helpers.d.ts +9 -2
  11. package/dist/modules/mcp/tests/intents.test.d.ts +1 -0
  12. package/dist/modules/mcp/tests/look.test.d.ts +1 -0
  13. package/dist/modules/mcp/tools/apply/index.d.ts +64 -50
  14. package/dist/modules/mcp/tools/apply/index.js +70 -20
  15. package/dist/modules/mcp/tools/describeOperation.d.ts +11 -0
  16. package/dist/modules/mcp/tools/describeOperation.js +40 -0
  17. package/dist/modules/mcp/tools/describeOperation.test.d.ts +1 -0
  18. package/dist/modules/mcp/tools/index.d.ts +1 -6
  19. package/dist/modules/mcp/tools/index.js +12 -9
  20. package/dist/modules/mcp/tools/intents.d.ts +5 -0
  21. package/dist/modules/mcp/tools/intents.js +201 -0
  22. package/dist/modules/mcp/tools/look.d.ts +17 -0
  23. package/dist/modules/mcp/tools/look.js +32 -0
  24. package/dist/modules/mcp/tools/operations/closed.d.ts +7 -0
  25. package/dist/modules/mcp/tools/operations/closed.js +29 -0
  26. package/dist/modules/mcp/tools/operations/index.d.ts +229 -180
  27. package/dist/modules/mcp/tools/operations/index.js +72 -51
  28. package/dist/modules/mcp/tools/operations/schema/bindings/upsertBinding.d.ts +1 -1
  29. package/dist/modules/mcp/tools/operations/schema/interactions/upsertInteractionFlow.d.ts +1 -1
  30. package/dist/modules/mcp/tools/operations/schema/operations.d.ts +2 -2
  31. package/dist/modules/mcp/tools/operations/schema/shared.d.ts +2 -2
  32. package/dist/modules/mcp/tools/operations/schema/shared.js +7 -6
  33. package/dist/modules/mcp/tools/operations/schemaIds.js +4 -4
  34. package/dist/modules/mcp/tools/render.d.ts +40 -40
  35. package/dist/modules/mcp/tools/render.js +5 -1
  36. package/dist/modules/mcp/tools/shared/draftBatch.d.ts +3 -2
  37. package/dist/modules/mcp/tools/shared/draftBatch.js +16 -5
  38. package/dist/modules/mcp/tools/shared/effects.d.ts +2 -0
  39. package/dist/modules/mcp/tools/shared/effects.js +155 -0
  40. package/dist/modules/mcp/tools/shared/effects.test.d.ts +1 -0
  41. package/dist/modules/mcp/tools/shared/look.d.ts +32 -0
  42. package/dist/modules/mcp/tools/shared/look.js +128 -0
  43. package/dist/modules/mcp/tools/shared/normalizeOperations.d.ts +6 -0
  44. package/dist/modules/mcp/tools/shared/normalizeOperations.js +37 -0
  45. package/dist/modules/mcp/tools/shared/repeats.d.ts +13 -0
  46. package/dist/modules/mcp/tools/shared/repeats.js +38 -0
  47. package/dist/modules/mcp/tools/shared/tool.d.ts +9 -6
  48. package/dist/modules/mcp/tools/shared/tool.js +2 -1
  49. package/dist/modules/mcp/tools/shared/validator/context.d.ts +0 -1
  50. package/dist/modules/mcp/tools/shared/validator/elements.js +0 -1
  51. package/dist/modules/mcp/tools/shared/validator/index.js +25 -2
  52. package/dist/modules/mcp/types/screenshotTypes.d.ts +3 -3
  53. package/dist/modules/mcp/types/toolTypes.d.ts +2 -4
  54. package/dist/options.d.ts +4 -5
  55. package/dist/preview/createPreview.js +9 -18
  56. package/package.json +7 -7
  57. package/skills/plitzi-render/SKILL.md +8 -184
  58. package/skills/plitzi-render/reference/good-call.md +88 -0
  59. package/skills/plitzi-render/reference/interactive.md +64 -0
  60. package/skills/plitzi-render/reference/looks.md +29 -0
  61. package/dist/modules/mcp/tools/preview.d.ts +0 -1423
  62. package/dist/modules/mcp/tools/preview.js +0 -37
  63. package/dist/modules/mcp/tools/screenshot.d.ts +0 -1708
  64. package/dist/modules/mcp/tools/screenshot.js +0 -127
  65. package/dist/modules/mcp/tools/validate.d.ts +0 -1699
  66. package/dist/modules/mcp/tools/validate.js +0 -32
package/dist/options.d.ts CHANGED
@@ -58,11 +58,10 @@ export type McpOptions = {
58
58
  url: string;
59
59
  secret?: string;
60
60
  };
61
- /** Dedicated headless-browser service for plitzi_screenshot (off unless set). `serviceUrl` is the browser
62
- * service that turns a URL into PNG(s); `renderBaseUrl` is the SSR base the browser navigates to (a page path
63
- * + the one-shot `__pt` token are appended). When absent, plitzi_screenshot is not registered and only the HTML
64
- * plitzi_preview is available; when the service is unreachable at call time the tool degrades to returning the
65
- * HTML preview with a warning. */
61
+ /** Dedicated headless-browser service a look renders an image or an accessibility outline through (off unless
62
+ * set). `serviceUrl` is the browser service that turns a URL into PNG(s); `renderBaseUrl` is the SSR base the
63
+ * browser navigates to (a page path + the one-shot `__pt` token are appended). When absent — or unreachable at call
64
+ * time — a look answers the HTML instead, with a warning. */
66
65
  screenshot?: {
67
66
  serviceUrl: string;
68
67
  renderBaseUrl: string;
@@ -1,7 +1,6 @@
1
1
  import { computeVersion } from "../modules/mcp/helpers/computeVersion.js";
2
- import { cloneSpace, findPageByRef, getPageElements } from "../modules/mcp/helpers/space.js";
3
- import { validateOperations } from "../modules/mcp/tools/shared/validator/index.js";
4
- import { applyOperations } from "../modules/mcp/tools/apply/dispatch.js";
2
+ import { findPageByRef, getPageElements } from "../modules/mcp/helpers/space.js";
3
+ import { draftBatch } from "../modules/mcp/tools/shared/draftBatch.js";
5
4
  import { randomUUID } from "node:crypto";
6
5
  import { buildBody } from "@plitzi/sdk-server/ssr";
7
6
  //#region src/preview/createPreview.ts
@@ -41,30 +40,22 @@ var createPreview = async (body, config, renderFn, pluginManager, caches) => {
41
40
  };
42
41
  let draftOffline = offlineData;
43
42
  if (body.operations && body.operations.length > 0) {
44
- const draft = cloneSpace({
43
+ const batch = draftBatch({
45
44
  schema: offlineData.schema,
46
45
  style: offlineData.style,
47
46
  connectors: [],
48
47
  actions: []
49
- });
50
- const validation = validateOperations(draft, body.operations);
51
- if (!validation.valid) return {
48
+ }, env, body.operations);
49
+ if (!batch.ok) return {
52
50
  ok: false,
53
51
  error: "INVALID_OPERATIONS",
54
- message: "The operations did not validate.",
55
- errors: validation.errors
56
- };
57
- const outcome = applyOperations(draft, env, body.operations);
58
- if (outcome.errors.length > 0) return {
59
- ok: false,
60
- error: "APPLY_FAILED",
61
- message: "The operations could not be applied.",
62
- errors: outcome.errors
52
+ message: "The operations were refused, as plitzi_apply would refuse them.",
53
+ errors: batch.errors
63
54
  };
64
55
  draftOffline = {
65
56
  ...offlineData,
66
- schema: draft.schema,
67
- style: draft.style
57
+ schema: batch.draft.schema,
58
+ style: batch.draft.style
68
59
  };
69
60
  }
70
61
  const pagePath = resolvePagePath(draftOffline.schema, body.pageRef);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-mcp",
3
- "version": "0.38.7",
3
+ "version": "0.38.8",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -30,12 +30,12 @@
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/ext-apps": "^1.7.5",
32
32
  "@modelcontextprotocol/sdk": "^1.32.0",
33
- "@plitzi/plitzi-sdk": "0.38.7",
34
- "@plitzi/sdk-authoring": "0.38.7",
35
- "@plitzi/sdk-schema": "0.38.7",
36
- "@plitzi/sdk-server": "0.38.7",
37
- "@plitzi/sdk-shared": "0.38.7",
38
- "@plitzi/sdk-style": "0.38.7",
33
+ "@plitzi/plitzi-sdk": "0.38.8",
34
+ "@plitzi/sdk-authoring": "0.38.8",
35
+ "@plitzi/sdk-schema": "0.38.8",
36
+ "@plitzi/sdk-server": "0.38.8",
37
+ "@plitzi/sdk-shared": "0.38.8",
38
+ "@plitzi/sdk-style": "0.38.8",
39
39
  "ejs": "^6.0.1",
40
40
  "esbuild": "^0.28.2",
41
41
  "zod": "^4.6.5"
@@ -30,125 +30,6 @@ Read the MCP resource **`plitzi://render/guide`** once per conversation. It is t
30
30
  every operation, the element types and their props, the styling system, worked examples. `plitzi://render/types`
31
31
  lists every element type with its description. Don't reconstruct the schema from memory — read the guide.
32
32
 
33
- ## The shape of a good call
34
-
35
- Everything the widget needs travels in ONE call, as `operations`. Three ops carry almost every widget:
36
-
37
- - **`upsertDefinitions`** — declare ALL the CSS classes at once, one class per *look* (not per property).
38
- - **`upsertElement`** — build the whole tree in a single op, nesting with `children` under `pageRef: "render"`.
39
- - **`repeatElement`** — the moment two siblings differ only in data, write the row ONCE as a template with
40
- `{{item.field}}` placeholders and pass `items`. Rows come out numbered (`tile-1`, `tile-2`…). A list inside each
41
- row is the same op: give a template node `repeat: { items: "{{item.<field>}}", template: … }`.
42
-
43
- ```json
44
- {
45
- "operations": [
46
- {
47
- "type": "upsertDefinitions",
48
- "definitions": {
49
- "panel": {
50
- "desktop": {
51
- "display": "flex",
52
- "flex-direction": "column",
53
- "gap": "12px",
54
- "padding": "16px",
55
- "color": "var(--color-text-primary, light-dark(#0f172a, #e8eaed))"
56
- }
57
- },
58
- "row": {
59
- "desktop": { "display": "flex", "flex-wrap": "wrap", "gap": "12px" },
60
- "mobile": { "flex-direction": "column" }
61
- },
62
- "tile": {
63
- "desktop": {
64
- "flex": "1 1 0%",
65
- "min-width": "160px",
66
- "padding": "12px",
67
- "border-radius": "10px",
68
- "border": "1px solid var(--color-border-primary, light-dark(#e2e8f0, #333a48))",
69
- "background-color": "var(--color-background-secondary, light-dark(#ffffff, #1f2430))"
70
- }
71
- },
72
- "tile-title": { "desktop": { "margin": "0", "font-size": "15px" } },
73
- "tile-price": { "desktop": { "margin": "0", "font-size": "13px", "opacity": "0.75" } }
74
- }
75
- },
76
- {
77
- "type": "upsertElement",
78
- "pageRef": "render",
79
- "element": {
80
- "ref": "panel",
81
- "type": "container",
82
- "style": { "base": ["panel"] },
83
- "children": [{ "ref": "title", "type": "heading", "subType": "h3", "props": { "content": "Plans" } }]
84
- }
85
- },
86
- {
87
- "type": "repeatElement",
88
- "pageRef": "render",
89
- "parentRef": "panel",
90
- "ref": "plans",
91
- "style": { "base": ["row"] },
92
- "template": {
93
- "ref": "tile",
94
- "type": "container",
95
- "style": { "base": ["tile"] },
96
- "children": [
97
- {
98
- "ref": "name",
99
- "type": "heading",
100
- "subType": "h4",
101
- "props": { "content": "{{item.name}}" },
102
- "style": { "base": ["tile-title"] }
103
- },
104
- {
105
- "ref": "price",
106
- "type": "paragraph",
107
- "props": { "content": "{{item.price}}" },
108
- "style": { "base": ["tile-price"] }
109
- }
110
- ]
111
- },
112
- "items": [
113
- { "name": "Starter", "price": "$0 / month" },
114
- { "name": "Team", "price": "$19 / month" },
115
- { "name": "Business", "price": "$49 / month" }
116
- ]
117
- }
118
- ]
119
- }
120
- ```
121
-
122
- ## The six things that decide whether it looks good
123
-
124
- 1. **Width is free, height is scarce.** The widget renders in a side panel. A plain container stacks its children
125
- vertically — that is the tall, half-empty default to avoid. Put peers (metrics, plans, options, image + text) in
126
- a wrapping row (`display:flex` + `flex-wrap:wrap`, children `flex:1 1 0%` with a `min-width` as the wrap
127
- threshold) or a grid with `grid-template-columns: repeat(auto-fit, minmax(160px, 1fr))`. Stack only what reads in
128
- order: heading over paragraph, forms, steps, prose.
129
- 2. **The host may be in dark mode.** Never hardcode a light palette. Take colours from the host variables with a
130
- `light-dark()` fallback, and always set `color` wherever you set `background-color`.
131
- 3. **You are not styling from zero.** The SDK's per-type CSS imposes **no minimum size**: a rail, a divider, a dot
132
- or a narrow cell is exactly as small as you make it, and no `"min-width": "0"` escape hatch is needed. The flip
133
- side is that an element with no content and no size takes no space at all — give a spacer its own `height`.
134
- What does land on your elements is the BROWSER's own defaults: `heading` and `paragraph` keep their UA margins
135
- (zero them, space with the parent's `gap`), `list` a 40px `padding-left`, `button` its native chrome. Borders
136
- start at `0 solid`, so `border-color` alone paints nothing — give `border-width` too.
137
- 4. **Draw with inline SVG, on a budget.** A logo, a sparkline, a badge or a decorative shape goes in a `blockHtml`
138
- element whose `props.content` is an `<svg>` — keep a `viewBox` with `width`/`height` `100%` so the element's
139
- class sizes it, and `fill`/`stroke` `currentColor` so it follows the theme. A handful of paths, drawn once and
140
- reused. Never a `data:` URI, and never a full illustration or a photo-real scene: that costs more than the rest
141
- of the widget, so use an `https` image, a flat colour or a CSS gradient instead. Markup only — `<script>` and
142
- inline `on*` handlers are rejected.
143
- 5. **Only use image URLs you have seen work.** Everything the widget loads from outside is fetched for it by the
144
- render server, so redirects, hotlink rules and missing CORS headers are already handled and there is nothing to
145
- configure — but nothing can guess a URL. Write a direct file URL, never a page about the picture, a search
146
- result, or a pattern assembled from memory: one that 404s leaves a grey box in the middle of a finished layout.
147
- With no URL you trust, draw that block instead (a gradient, a flat colour, an inline `<svg>`).
148
- 6. **Write CSS plainly.** Kebab-case properties, shorthands welcome (`padding: 8px 16px`, `border: 1px solid red`,
149
- `font: bold 16px/1.5 Arial`) — they are expanded and stored as longhands, so a breakpoint or state can override
150
- one property on its own.
151
-
152
33
  ## Iterating on a widget you already rendered
153
34
 
154
35
  Every render answers with a **`renderId`**. To change that widget, do NOT rebuild it: call again with
@@ -170,71 +51,6 @@ storage, a conversation resumed elsewhere), send the whole batch again without `
170
51
  issues. The `path` names the operation, so fix that one and call again — you never lose the rest of the batch. An
171
52
  unknown prop comes back as a warning naming the right one, so probing is safe.
172
53
 
173
- ## Making it interactive
174
-
175
- Widgets are not only static: `upsertInteractionFlow` attaches a flow to an element — a `trigger` node first
176
- (`onClick`), then the steps that run after it, in order.
177
-
178
- **To show and hide, use `toggleState`.** Every element registers `setState` and `toggleState` as `callback`
179
- actions, and `elementId` names the element they act on — the flow's own element by default, or another element's
180
- ref to act on that one. `toggleState` with `category: "state"` and `key: "visibility"` is the show/hide toggle;
181
- there is no separate `toggleVisibility` action. The element starts hidden with `initialState: { "visibility":
182
- false }` on the element itself.
183
-
184
- ```json
185
- {
186
- "operations": [
187
- {
188
- "type": "upsertElement",
189
- "pageRef": "render",
190
- "element": {
191
- "ref": "card",
192
- "type": "container",
193
- "children": [
194
- { "ref": "card-head", "type": "button", "props": { "content": "Details" } },
195
- {
196
- "ref": "card-body",
197
- "type": "container",
198
- "initialState": { "visibility": false },
199
- "children": [
200
- { "ref": "card-text", "type": "paragraph", "props": { "content": "The hidden detail." } }
201
- ]
202
- }
203
- ]
204
- }
205
- },
206
- {
207
- "type": "upsertInteractionFlow",
208
- "pageRef": "render",
209
- "ref": "card-head",
210
- "nodes": [
211
- { "title": "On click", "nodeType": "trigger", "action": "onClick" },
212
- {
213
- "title": "Toggle body",
214
- "nodeType": "callback",
215
- "action": "toggleState",
216
- "elementId": "card-body",
217
- "params": { "category": "state", "key": "visibility" }
218
- }
219
- ]
220
- }
221
- ]
222
- }
223
- ```
224
-
225
- Expand/collapse is **one step on one trigger** — never two `setState` branches under opposite `when` conditions,
226
- which read the state as it was when the flow started and so are always one click behind. For several flows on one
227
- element (an `onClick` and an `onMouseEnter`, or two independent clicks), call `upsertInteractionFlow` again with
228
- the same `ref` and **omit `flowId`**; passing an existing `flowId` replaces that flow instead.
229
-
230
- Two things routinely go wrong. Events **bubble**, so a clickable element inside another clickable element runs
231
- BOTH flows — put the trigger on one of them (the render warns you and names the pair). And a `globalCallback`
232
- (`addNotification`, `navigate`, app-level `setState`) is provided by a module, not by an element, so **omit
233
- `elementId`** on those; a step with the wrong node type resolves against nothing and silently does nothing.
234
-
235
- For data-driven widgets, an `apiContainer` fetches at runtime and `upsertBinding` wires the result into elements —
236
- see "Data & interactivity" in `plitzi://render/guide`, which is also the full reference for everything above.
237
-
238
54
  ## After it renders
239
55
 
240
56
  The widget is shown to the user; you get a compact summary. Don't re-describe what they can already see — a short
@@ -244,3 +60,11 @@ When the widget has flows, the summary includes an `interactions` line per flow
244
60
  (`"card-head onClick → toggleState card-body[visibility]"`). Read it: it is what confirms the flow landed on the
245
61
  element you meant. But it reports the **wiring**, not a click that was performed — this tool authors the widget,
246
62
  it does not drive it. So tell the user what you connected; never claim you verified the behaviour at runtime.
63
+
64
+ ## What to read
65
+
66
+ | The task | Read |
67
+ | --- | --- |
68
+ | Writing the call: the tree, the classes, the order of operations | [the shape of a good call](reference/good-call.md) |
69
+ | Making it look good: spacing, type, colour, images | [the six things](reference/looks.md) |
70
+ | Toggles, expanders, tabs — a widget that reacts | [making it interactive](reference/interactive.md) |
@@ -0,0 +1,88 @@
1
+ # The shape of a good call
2
+
3
+ Everything the widget needs travels in ONE call, as `operations`. Three ops carry almost every widget:
4
+
5
+ - **`upsertDefinitions`** — declare ALL the CSS classes at once, one class per *look* (not per property).
6
+ - **`upsertElement`** — build the whole tree in a single op, nesting with `children` under `pageRef: "render"`.
7
+ - **`repeatElement`** — the moment two siblings differ only in data, write the row ONCE as a template with
8
+ `{{item.field}}` placeholders and pass `items`. Rows come out numbered (`tile-1`, `tile-2`…). A list inside each
9
+ row is the same op: give a template node `repeat: { items: "{{item.<field>}}", template: … }`.
10
+
11
+ ```json
12
+ {
13
+ "operations": [
14
+ {
15
+ "type": "upsertDefinitions",
16
+ "definitions": {
17
+ "panel": {
18
+ "desktop": {
19
+ "display": "flex",
20
+ "flex-direction": "column",
21
+ "gap": "12px",
22
+ "padding": "16px",
23
+ "color": "var(--color-text-primary, light-dark(#0f172a, #e8eaed))"
24
+ }
25
+ },
26
+ "row": {
27
+ "desktop": { "display": "flex", "flex-wrap": "wrap", "gap": "12px" },
28
+ "mobile": { "flex-direction": "column" }
29
+ },
30
+ "tile": {
31
+ "desktop": {
32
+ "flex": "1 1 0%",
33
+ "min-width": "160px",
34
+ "padding": "12px",
35
+ "border-radius": "10px",
36
+ "border": "1px solid var(--color-border-primary, light-dark(#e2e8f0, #333a48))",
37
+ "background-color": "var(--color-background-secondary, light-dark(#ffffff, #1f2430))"
38
+ }
39
+ },
40
+ "tile-title": { "desktop": { "margin": "0", "font-size": "15px" } },
41
+ "tile-price": { "desktop": { "margin": "0", "font-size": "13px", "opacity": "0.75" } }
42
+ }
43
+ },
44
+ {
45
+ "type": "upsertElement",
46
+ "pageRef": "render",
47
+ "element": {
48
+ "ref": "panel",
49
+ "type": "container",
50
+ "style": { "base": ["panel"] },
51
+ "children": [{ "ref": "title", "type": "heading", "subType": "h3", "props": { "content": "Plans" } }]
52
+ }
53
+ },
54
+ {
55
+ "type": "repeatElement",
56
+ "pageRef": "render",
57
+ "parentRef": "panel",
58
+ "ref": "plans",
59
+ "style": { "base": ["row"] },
60
+ "template": {
61
+ "ref": "tile",
62
+ "type": "container",
63
+ "style": { "base": ["tile"] },
64
+ "children": [
65
+ {
66
+ "ref": "name",
67
+ "type": "heading",
68
+ "subType": "h4",
69
+ "props": { "content": "{{item.name}}" },
70
+ "style": { "base": ["tile-title"] }
71
+ },
72
+ {
73
+ "ref": "price",
74
+ "type": "paragraph",
75
+ "props": { "content": "{{item.price}}" },
76
+ "style": { "base": ["tile-price"] }
77
+ }
78
+ ]
79
+ },
80
+ "items": [
81
+ { "name": "Starter", "price": "$0 / month" },
82
+ { "name": "Team", "price": "$19 / month" },
83
+ { "name": "Business", "price": "$49 / month" }
84
+ ]
85
+ }
86
+ ]
87
+ }
88
+ ```
@@ -0,0 +1,64 @@
1
+ # Making it interactive
2
+
3
+ Widgets are not only static: `upsertInteractionFlow` attaches a flow to an element — a `trigger` node first
4
+ (`onClick`), then the steps that run after it, in order.
5
+
6
+ **To show and hide, use `toggleState`.** Every element registers `setState` and `toggleState` as `callback`
7
+ actions, and `elementId` names the element they act on — the flow's own element by default, or another element's
8
+ ref to act on that one. `toggleState` with `category: "state"` and `key: "visibility"` is the show/hide toggle;
9
+ there is no separate `toggleVisibility` action. The element starts hidden with `initialState: { "visibility":
10
+ false }` on the element itself.
11
+
12
+ ```json
13
+ {
14
+ "operations": [
15
+ {
16
+ "type": "upsertElement",
17
+ "pageRef": "render",
18
+ "element": {
19
+ "ref": "card",
20
+ "type": "container",
21
+ "children": [
22
+ { "ref": "card-head", "type": "button", "props": { "content": "Details" } },
23
+ {
24
+ "ref": "card-body",
25
+ "type": "container",
26
+ "initialState": { "visibility": false },
27
+ "children": [
28
+ { "ref": "card-text", "type": "paragraph", "props": { "content": "The hidden detail." } }
29
+ ]
30
+ }
31
+ ]
32
+ }
33
+ },
34
+ {
35
+ "type": "upsertInteractionFlow",
36
+ "pageRef": "render",
37
+ "ref": "card-head",
38
+ "nodes": [
39
+ { "title": "On click", "nodeType": "trigger", "action": "onClick" },
40
+ {
41
+ "title": "Toggle body",
42
+ "nodeType": "callback",
43
+ "action": "toggleState",
44
+ "elementId": "card-body",
45
+ "params": { "category": "state", "key": "visibility" }
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ }
51
+ ```
52
+
53
+ Expand/collapse is **one step on one trigger** — never two `setState` branches under opposite `when` conditions,
54
+ which read the state as it was when the flow started and so are always one click behind. For several flows on one
55
+ element (an `onClick` and an `onMouseEnter`, or two independent clicks), call `upsertInteractionFlow` again with
56
+ the same `ref` and **omit `flowId`**; passing an existing `flowId` replaces that flow instead.
57
+
58
+ Two things routinely go wrong. Events **bubble**, so a clickable element inside another clickable element runs
59
+ BOTH flows — put the trigger on one of them (the render warns you and names the pair). And a `globalCallback`
60
+ (`addNotification`, `navigate`, app-level `setState`) is provided by a module, not by an element, so **omit
61
+ `elementId`** on those; a step with the wrong node type resolves against nothing and silently does nothing.
62
+
63
+ For data-driven widgets, an `apiContainer` fetches at runtime and `upsertBinding` wires the result into elements —
64
+ see "Data & interactivity" in `plitzi://render/guide`, which is also the full reference for everything above.
@@ -0,0 +1,29 @@
1
+ # The six things that decide whether it looks good
2
+
3
+ 1. **Width is free, height is scarce.** The widget renders in a side panel. A plain container stacks its children
4
+ vertically — that is the tall, half-empty default to avoid. Put peers (metrics, plans, options, image + text) in
5
+ a wrapping row (`display:flex` + `flex-wrap:wrap`, children `flex:1 1 0%` with a `min-width` as the wrap
6
+ threshold) or a grid with `grid-template-columns: repeat(auto-fit, minmax(160px, 1fr))`. Stack only what reads in
7
+ order: heading over paragraph, forms, steps, prose.
8
+ 2. **The host may be in dark mode.** Never hardcode a light palette. Take colours from the host variables with a
9
+ `light-dark()` fallback, and always set `color` wherever you set `background-color`.
10
+ 3. **You are not styling from zero.** The SDK's per-type CSS imposes **no minimum size**: a rail, a divider, a dot
11
+ or a narrow cell is exactly as small as you make it, and no `"min-width": "0"` escape hatch is needed. The flip
12
+ side is that an element with no content and no size takes no space at all — give a spacer its own `height`.
13
+ What does land on your elements is the BROWSER's own defaults: `heading` and `paragraph` keep their UA margins
14
+ (zero them, space with the parent's `gap`), `list` a 40px `padding-left`, `button` its native chrome. Borders
15
+ start at `0 solid`, so `border-color` alone paints nothing — give `border-width` too.
16
+ 4. **Draw with inline SVG, on a budget.** A logo, a sparkline, a badge or a decorative shape goes in a `blockHtml`
17
+ element whose `props.content` is an `<svg>` — keep a `viewBox` with `width`/`height` `100%` so the element's
18
+ class sizes it, and `fill`/`stroke` `currentColor` so it follows the theme. A handful of paths, drawn once and
19
+ reused. Never a `data:` URI, and never a full illustration or a photo-real scene: that costs more than the rest
20
+ of the widget, so use an `https` image, a flat colour or a CSS gradient instead. Markup only — `<script>` and
21
+ inline `on*` handlers are rejected.
22
+ 5. **Only use image URLs you have seen work.** Everything the widget loads from outside is fetched for it by the
23
+ render server, so redirects, hotlink rules and missing CORS headers are already handled and there is nothing to
24
+ configure — but nothing can guess a URL. Write a direct file URL, never a page about the picture, a search
25
+ result, or a pattern assembled from memory: one that 404s leaves a grey box in the middle of a finished layout.
26
+ With no URL you trust, draw that block instead (a gradient, a flat colour, an inline `<svg>`).
27
+ 6. **Write CSS plainly.** Kebab-case properties, shorthands welcome (`padding: 8px 16px`, `border: 1px solid red`,
28
+ `font: bold 16px/1.5 Arial`) — they are expanded and stored as longhands, so a breakpoint or state can override
29
+ one property on its own.