sbuilder-mcp 0.76.4 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ import { BASE_ONLY_CONFIG, BOUND_SPECIALS, CONFIG_VALUES, ELEMENT_VALUES, ELEMENTS, HOVER_HOMES, SATELLITE_RULES, TRAIT_WRITES, WRITE_PRECONDITIONS, } from '../../catalog/elements.generated.js';
2
+ import { FIELD_SKIN_BY_NODE } from '../../catalog/fieldskin.generated.js';
3
+ import { FORM_RULE_VOCAB } from '../../catalog/formrules.generated.js';
4
+ import { animationNote, deadKeyNote, preconditionNotes, unknownValueNote, unknownWriteNote, unsupportedSettingNote, } from './vocabulary.js';
5
+ /**
6
+ * Keys any node may carry, read by shared helpers rather than one element:
7
+ * visibility and reveal (`display` trait), the entrance animation, the pinned
8
+ * state's trigger, the style preset, the parent-hover host, custom class/CSS,
9
+ * the projected link, and the two STORED references (the composed stamps are
10
+ * refused by their own guards, so they are not listed).
11
+ */
12
+ const UNIVERSAL = {
13
+ config: ['hidden', 'revealOnHover', 'animation', 'stuckAfter'],
14
+ specials: ['stylePreset', 'hoverHostDepth', 'className', 'customCss', 'href', 'target', 'globalRef', 'appBlockRef', 'appBlockValues'],
15
+ };
16
+ /**
17
+ * Inspector rows that are a WIDGET rather than a trait, so `TRAIT_WRITES` does
18
+ * not describe what they write — the key sits in the widget's props
19
+ * (`editor/src/trait/widgets.ts`: `specialKey: 'icon'` on `button_icon`) or in
20
+ * the component it mounts (`text_global_style` → `StylePresetRow`, which
21
+ * stores the picked site text style as `config.textGlobalStyle`, the key
22
+ * `schema/src/theme.ts` names for every text-bearing element). Keyed by the
23
+ * GENERATED control name, so a key is known only where the element offers the row.
24
+ *
25
+ * ponytail: hand-read, and only the rows the platform's own seeds exercise
26
+ * (test/seed-sweep.test.ts). Upgrade: have codegen read every widget's
27
+ * `specialKey`/`configKey` into the catalog — blocked on a catalog regenerate.
28
+ */
29
+ const WIDGET_WRITES = {
30
+ button_icon: ['specials', 'icon'],
31
+ breadcrumb_icon: ['specials', 'icon'],
32
+ divider_icon: ['specials', 'iconName'],
33
+ text_global_style: ['config', 'textGlobalStyle'],
34
+ };
35
+ const known = new Map();
36
+ function keysOf(type) {
37
+ const el = ELEMENTS[type];
38
+ if (!el)
39
+ return null;
40
+ const hit = known.get(type);
41
+ if (hit)
42
+ return hit;
43
+ const out = { config: new Set(UNIVERSAL.config), specials: new Set(UNIVERSAL.specials) };
44
+ const add = (ns, keys) => {
45
+ if (ns !== 'config' && ns !== 'specials')
46
+ return;
47
+ for (const k of keys ?? [])
48
+ out[ns].add(k);
49
+ };
50
+ const d = el.defaults;
51
+ add('config', Object.keys(d.config ?? {}));
52
+ add('specials', Object.keys(d.specials ?? {}));
53
+ for (const layer of [d.responsive, d.states]) {
54
+ for (const slot of Object.values(layer ?? {})) {
55
+ for (const ns of ['config', 'specials'])
56
+ add(ns, Object.keys((slot?.[ns] ?? {})));
57
+ }
58
+ }
59
+ for (const c of el.controls)
60
+ for (const w of TRAIT_WRITES[c]?.writes ?? [])
61
+ add(w.target, [w.writeKey]);
62
+ for (const c of el.controls)
63
+ if (WIDGET_WRITES[c])
64
+ add(WIDGET_WRITES[c][0], [WIDGET_WRITES[c][1]]);
65
+ // A control the trait registry does not describe is often an inspector row
66
+ // named after the key it writes (`{ key: 'quickviewId', visible: false }` on
67
+ // list-dataset), so its NAME counts as a key in either namespace.
68
+ add('config', el.controls);
69
+ add('specials', el.controls);
70
+ for (const v of [...Object.values(ELEMENT_VALUES[type] ?? {}), ...Object.values(ELEMENT_VALUES['*'] ?? {})]) {
71
+ add(v.target, [v.writeKey]);
72
+ }
73
+ add('config', Object.keys(CONFIG_VALUES));
74
+ add('config', BASE_ONLY_CONFIG);
75
+ add('config', (SATELLITE_RULES[type] ?? []).map((r) => r.configKey));
76
+ add('config', FIELD_SKIN_BY_NODE[type]);
77
+ if (HOVER_HOMES[type]?.home === 'legacy')
78
+ add('config', ['stateHover']);
79
+ add('specials', BOUND_SPECIALS[type]);
80
+ // The binding fields the element is BORN with name specials keys too
81
+ // (`boundProductId` is one no html.go reads, and it is still correct).
82
+ for (const b of bindingFields(type))
83
+ add('specials', [b]);
84
+ for (const p of WRITE_PRECONDITIONS) {
85
+ if (p.types.includes(type))
86
+ add('specials', [p.key, ...p.requires.map((r) => r.key)]);
87
+ }
88
+ known.set(type, out);
89
+ return out;
90
+ }
91
+ /** Every `specials.<key>` this element's generated bindings write, key only. */
92
+ export function bindingFields(type) {
93
+ const el = ELEMENTS[type];
94
+ const rows = [...(el?.defaults.bindings ?? []), ...Object.values(el?.bindingsFor ?? {}).flat()];
95
+ return [...new Set(rows.map((b) => b.field ?? '').filter((f) => f.startsWith('specials.')).map((f) => f.slice(9)))];
96
+ }
97
+ /**
98
+ * The `specials.<key>` fields `sb_bind` accepts on this element, and the list
99
+ * `sb_traits_for` reports as `bindable` — one function so the two cannot drift.
100
+ * What the renderer paints (`BOUND_SPECIALS`) plus the element's own generated
101
+ * binding fields (`boundProductId` is read by the runtime, not html.go). Empty
102
+ * when the catalog knows no bound special for the type: `sb_bind` then does not
103
+ * restrict the field.
104
+ */
105
+ export function bindableFields(type) {
106
+ const painted = BOUND_SPECIALS[type] ?? [];
107
+ return painted.length ? [...new Set([...painted, ...bindingFields(type)])] : [];
108
+ }
109
+ /**
110
+ * Check one write. `specials` is the node's specials AS THEY WILL BE after the
111
+ * write, for the precondition question (a pair is legal or not together).
112
+ */
113
+ export function writeCheck(type, namespace, keys, opts = {}) {
114
+ const out = [];
115
+ const push = (once, code, key, problem, fix) => out.push({ once, note: { code, ...(opts.id ? { id: opts.id } : {}), key: `${namespace}.${key}`, problem, fix } });
116
+ if (namespace !== 'config' && namespace !== 'specials')
117
+ return out;
118
+ const ks = keysOf(type);
119
+ for (const [k, v] of Object.entries(keys)) {
120
+ const dead = deadKeyNote(namespace, k);
121
+ if (dead) {
122
+ push(`dead-key:${namespace}.${k}`, 'dead_key', k, dead, 'Drop the key — no value for it does anything.');
123
+ continue;
124
+ }
125
+ if (ks && !ks[namespace].has(k)) {
126
+ const other = namespace === 'config' ? 'specials' : 'config';
127
+ push(`unknown-key:${type}.${namespace}.${k}`, 'unknown_key', k, `${namespace}.${k} is not a key ${type} is known to read — not in its seeded defaults, its ` +
128
+ 'declared controls or any generated vocabulary. It is stored as written; if nothing reads ' +
129
+ 'it, the write changes nothing on the page.', ks[other].has(k)
130
+ ? `${type} reads ${other}.${k} — write it in namespace "${other}".`
131
+ : `Check the spelling against sb_traits_for "${type}"; a CSS property belongs in namespace ` +
132
+ '"style". Ignore this if the key is newer than this catalog.');
133
+ continue;
134
+ }
135
+ const vn = type ? unknownWriteNote(type, namespace, k, v) : null;
136
+ if (vn) {
137
+ push(`value:${type}.${namespace}.${k}=${JSON.stringify(v)}`, 'unknown_value', k, vn, 'Write one of the listed values.');
138
+ }
139
+ if (namespace === 'config') {
140
+ const cn = k === 'animation' ? animationNote(v) : unknownValueNote(k, v);
141
+ if (cn) {
142
+ push(`config-value:${k}=${JSON.stringify(v)}`, k === 'animation' ? 'animation' : 'unknown_value', k, cn, k === 'animation'
143
+ ? 'Write an object: { active: true, type: "fade_in", … } — sb_traits_for lists the vocabulary.'
144
+ : 'Write one of the listed values.');
145
+ }
146
+ }
147
+ if (namespace === 'specials') {
148
+ const un = unsupportedSettingNote(type, k, v);
149
+ if (un)
150
+ push(`unsupported:${type}.${k}=${JSON.stringify(v)}`, 'unsupported_setting', k, un, `Unset specials.${k}; ${type} never reads it.`);
151
+ }
152
+ }
153
+ if (namespace === 'specials' && type === 'form' && 'formRules' in keys && opts.doc) {
154
+ for (const r of formRuleChecks(opts.doc, keys.formRules)) {
155
+ push(`form-rule:${r.problem}`, 'form_rule', 'formRules', r.problem, r.fix);
156
+ }
157
+ }
158
+ if (namespace === 'specials') {
159
+ for (const n of preconditionNotes(type, opts.specials ?? keys)) {
160
+ const key = n.slice('specials.'.length, n.indexOf(' ='));
161
+ push(`precondition:${type}:${n.slice(0, 60)}`, 'precondition', key, n, 'Set the neighbouring keys named above, or unset this one.');
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+ /**
167
+ * `specials.formRules` on a form root, judged against the open form document.
168
+ *
169
+ * EVERY READER DROPS A BAD RULE IN SILENCE — `readFormRules` (schema) and
170
+ * `ReadFormRules` (server/internal/forms/rules.go) skip an entry with no id or
171
+ * an unknown join/op/action, one at a time — so the save succeeds and the field
172
+ * it governs never changes. A rule is keyed by each field's POSTED NAME,
173
+ * `specials.name` trimmed, falling back to the node id only when there is none
174
+ * (the editor's `ruleNameOf`, DeriveSchema's rule) — so a named field's node id
175
+ * is NOT a key. Warnings only: the platform stores the document either way.
176
+ */
177
+ export function formRuleChecks(doc, raw) {
178
+ const out = [];
179
+ const add = (problem, fix) => out.push({ problem, fix });
180
+ let list = raw;
181
+ if (typeof raw === 'string') {
182
+ if (!raw.trim())
183
+ return out;
184
+ try {
185
+ list = JSON.parse(raw);
186
+ }
187
+ catch {
188
+ add('specials.formRules is not valid JSON, so the platform reads NO rules at all.', 'Write JSON.stringify(rules) of an array of rules.');
189
+ return out;
190
+ }
191
+ }
192
+ if (!Array.isArray(list)) {
193
+ add('specials.formRules must be a JSON array of rules; anything else is read as no rules.', 'Wrap the rule in [ … ].');
194
+ return out;
195
+ }
196
+ // ponytail: names from every node, not only answering fields — a rule naming
197
+ // a title is missed rather than a real field being flagged.
198
+ const names = new Set(Object.values(doc.nodes)
199
+ .filter((n) => n.id !== doc.root_node_id)
200
+ .map((n) => (typeof n.specials?.name === 'string' && n.specials.name.trim()) || n.id));
201
+ const v = FORM_RULE_VOCAB;
202
+ const dropped = 'and every reader drops the whole rule';
203
+ list.forEach((r, i) => {
204
+ const at = `rule ${i + 1}${r && typeof r.id === 'string' && r.id ? ` (${r.id})` : ''}`;
205
+ if (!r || typeof r !== 'object')
206
+ return add(`${at} is not an object, ${dropped}.`, 'Remove it.');
207
+ if (typeof r.id !== 'string' || !r.id.trim())
208
+ add(`${at} has no id, ${dropped}.`, 'Give it a unique string id, e.g. "rule_1".');
209
+ if (!v.joins.includes(r.join))
210
+ add(`${at} has join ${JSON.stringify(r.join)}, ${dropped}.`, `Use one of: ${v.joins.join(', ')}.`);
211
+ const conds = Array.isArray(r.conditions) ? r.conditions : null;
212
+ const targets = Array.isArray(r.targets) ? r.targets : null;
213
+ if (!conds || !targets)
214
+ return add(`${at} needs conditions[] and targets[] arrays, ${dropped}.`, 'Add both arrays.');
215
+ if (conds.length === 0)
216
+ add(`${at} has no conditions, so it never fires.`, 'Add at least one { field, op, value }.');
217
+ const fieldNote = (f, role) => {
218
+ if (typeof f !== 'string' || !names.has(f)) {
219
+ add(`${at} ${role} ${JSON.stringify(f)}, which names no field in this form — a rule is keyed by a field's specials.name (its node id only when it has none).`, `Use one of: ${[...names].join(', ')}.`);
220
+ }
221
+ };
222
+ for (const c of conds) {
223
+ if (!v.ops.includes(c?.op))
224
+ add(`${at} has op ${JSON.stringify(c?.op)}, ${dropped}.`, `Use one of: ${v.ops.join(', ')}.`);
225
+ if (typeof c?.value !== 'string')
226
+ add(`${at} has a non-string condition value, ${dropped}.`, 'Write value as a string ("" for filled/empty).');
227
+ fieldNote(c?.field, 'conditions on');
228
+ }
229
+ const sources = new Set(conds.map((c) => c?.field));
230
+ for (const t of targets) {
231
+ if (!v.actions.includes(t?.action))
232
+ add(`${at} has action ${JSON.stringify(t?.action)}, ${dropped}.`, `Use one of: ${v.actions.join(', ')}.`);
233
+ fieldNote(t?.field, 'targets');
234
+ if (sources.has(t?.field)) {
235
+ add(`${at} targets ${JSON.stringify(t?.field)}, which it also conditions on — hiding a field blanks its answer, so the rule flips itself.`, 'Target a different field.');
236
+ }
237
+ }
238
+ });
239
+ return out;
240
+ }
241
+ /** The same, for every node of a nested `sb_add` spec, as each will be born. */
242
+ export function specCheck(spec) {
243
+ const out = [];
244
+ const walk = (s) => {
245
+ const born = ELEMENTS[s.type]?.defaults.specials ?? {};
246
+ if (s.config)
247
+ out.push(...writeCheck(s.type, 'config', s.config));
248
+ if (s.specials)
249
+ out.push(...writeCheck(s.type, 'specials', s.specials, { specials: { ...born, ...s.specials } }));
250
+ for (const c of s.children ?? [])
251
+ walk(c);
252
+ };
253
+ walk(spec);
254
+ return out;
255
+ }
256
+ /** The notes `say` has not said yet, deduped within the call too. */
257
+ export function sayChecks(checked, say) {
258
+ const seen = new Set();
259
+ return checked
260
+ .filter((c) => !seen.has(c.once) && (seen.add(c.once), say(c.once, c.note.problem) !== undefined))
261
+ .map((c) => c.note);
262
+ }
@@ -15,6 +15,14 @@ export class Notices {
15
15
  this.said.add(key);
16
16
  return body;
17
17
  }
18
+ /**
19
+ * What `once` WOULD return, without spending it. A dry run uses this: it
20
+ * must show the same warnings as the real call, and the real call must
21
+ * still have them to show.
22
+ */
23
+ peek(key, body) {
24
+ return this.said.has(key) ? undefined : body;
25
+ }
18
26
  reset() {
19
27
  this.said.clear();
20
28
  }
package/dist/server.js CHANGED
@@ -22,13 +22,13 @@ import { registerThemeTools } from './tools/theme.js';
22
22
  */
23
23
  const INSTRUCTIONS = `Store Builder. Call sb_connect first.
24
24
 
25
- API: sb_api_find query → one line per operation (${SWAGGER_SOURCE.operations} reachable); sb_api_find id → its call sheet; sb_api_call runs it. /api/v1 paths take the API key, every other path the session; the platform refuses each on the other's surface. If the sheet says the body is undescribed, read the matching GET and send back a modified copy.
25
+ API: sb_api_find query → one line per operation (${SWAGGER_SOURCE.operations} reachable); sb_api_find id → its call sheet; sb_api_call runs it. /api/v1 paths take the API key, every other path the session; each is refused on the other's surface. Undescribed body: send back a modified copy of the matching GET.
26
26
 
27
- Design: sb_page_open → sb_catalog_search (${ELEMENT_SOURCE.count} elements) → sb_traits_for the one you chose → sb_add with a NESTED spec (one call per section) → sb_set → sb_look → sb_review.
27
+ Design, native first: sb_page_open → sb_catalog_search (${ELEMENT_SOURCE.count} elements) → sb_traits_for → sb_add one NESTED native spec per section → sb_bind/sb_event → sb_look + sb_review. custom-code only for a gap you can name that no element covers.
28
28
  - Writes and API calls default to dry_run:true; pass dry_run:false to act.
29
- - sb_set writes per breakpoint by default; base is the cascade's fallback layer, fine for values that should not vary.
30
- - Outline flags: global = shared master, an edit lands on every page; overlay = not this page; app = an app block, not editable inside.
31
- - sb_live_join shows edits live in an open editor (needs SB_EMAIL/SB_PASSWORD). sb_bind puts real store data in the page.`;
29
+ - sb_set writes per breakpoint by default; base is the cascade's fallback layer, for values that should not vary.
30
+ - Outline flags: global = shared master, an edit lands on every page; overlay = not this page; app = app block, not editable inside.
31
+ - sb_live_join shows edits live in an open editor (needs SB_EMAIL/SB_PASSWORD).`;
32
32
  /**
33
33
  * The published version, read from package.json at runtime so serverInfo never
34
34
  * drifts from what npm shipped. package.json sits one level above both dist/ and
@@ -73,6 +73,6 @@ export function createServer(ctx = buildContext()) {
73
73
  registerStoreTools(server, ctx, pageSession);
74
74
  registerThemeTools(server, ctx);
75
75
  registerImportTools(server, ctx, pageSession);
76
- registerUndoTools(server, ctx);
76
+ registerUndoTools(server, ctx, pageSession);
77
77
  return server;
78
78
  }
@@ -519,7 +519,7 @@ export function registerImportTools(server, ctx, session) {
519
519
  // per page would give the first page element defaults and every later page
520
520
  // the defaults of the blank page before it — rule 0 failing on every page
521
521
  // at once. The open page if there is one, the site's home page otherwise.
522
- let tokenDoc = session.peek();
522
+ let tokenDoc = session.page() ? session.peek() : null;
523
523
  if (!tokenDoc && home && typeof home.id === 'string') {
524
524
  try {
525
525
  tokenDoc = PageDoc.from((await loadSource(ctx, siteId, home.id)).document);
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { text, images } from '../mcp/response.js';
4
4
  import { BINDING_SOURCES, ELEMENTS } from '../catalog/elements.generated.js';
5
+ import { bindableFields } from '../domains/site/writecheck.js';
5
6
  import { previewUrl } from '../vision/preview.js';
6
7
  import { uploadMedia } from '../transport/media.js';
7
8
  import { request } from '../transport/http.js';
@@ -26,7 +27,7 @@ import { RealtimeSocket } from '../transport/socket.js';
26
27
  import { LiveSession } from '../live/session.js';
27
28
  import { refuseAppBlockInterior } from '../domains/site/builder.js';
28
29
  import { soft } from '../domains/site/guard.js';
29
- import { hrefPatches, hasPurchaseBinding, PRODUCT_ACTION_BINDING_ID, } from '../domains/site/navhref.js';
30
+ import { hrefPatches, hasPurchaseBinding, liveEventTable, PAYLOAD_NEEDS, PRODUCT_ACTION_BINDING_ID, } from '../domains/site/navhref.js';
30
31
  import { offscreenNodes, offscreenRootOf } from '../domains/site/offscreen.js';
31
32
  import { siteToken } from './credentialpick.js';
32
33
  import { searchStock, SearchUnavailable, NO_SEARCH_NEXT } from '../transport/stock.js';
@@ -70,6 +71,24 @@ export function bindNode(doc, id, source, field, action, guard) {
70
71
  throw new Error(`sbuilder: a binding field must be "specials.<key>", not "${field}". The renderer ignores ` +
71
72
  'every other namespace, so the binding would be stored and never applied.');
72
73
  }
74
+ // A FIELD THE ELEMENT NEVER PAINTS. `BOUND_SPECIALS` is read off each
75
+ // element's html.go, so a `specials.boundImage` binding on a text-dataset is
76
+ // stored, saved, published and rendered as placeholder text forever. Soft:
77
+ // the list is the catalog's copy of the renderer. The element's own generated
78
+ // binding fields count too — `boundProductId` is read by the runtime, not
79
+ // html.go, and every default product binding carries it.
80
+ const type = node.data?.type ?? '';
81
+ const key = field.slice(dot + 1);
82
+ const allowed = bindableFields(type);
83
+ if (action === undefined && allowed.length) {
84
+ if (!allowed.includes(key)) {
85
+ soft(guard, () => {
86
+ throw new Error(`sbuilder: a ${type} renders a binding only into ${allowed.map((k) => `specials.${k}`).join(', ')} ` +
87
+ `— "${field}" would be stored and never painted. Allowed fields: ${allowed.join(', ')}. ` +
88
+ 'Bind one of those, or swap to an element that shows this value (sb_catalog_search).');
89
+ });
90
+ }
91
+ }
73
92
  // A PURCHASE BINDING, which is what makes a button add to the cart.
74
93
  //
75
94
  // It is not an ordinary binding and cannot be written as one: the renderer
@@ -109,23 +128,6 @@ export function bindNode(doc, id, source, field, action, guard) {
109
128
  },
110
129
  ];
111
130
  }
112
- /**
113
- * The click-action allow-list that is LIVE for this node.
114
- *
115
- * `activeEvents` in the platform, whose whole rule is one line in
116
- * `ActionTrait.vue`: `return action ? def.binding_events : def.events`. A
117
- * purchase control is a different kind of control — an unbound button navigates,
118
- * a bound one hands off to the cart or the checkout — and the two sets are
119
- * mutually exclusive, because "add this product, then go to an arbitrary URL" is
120
- * not a thing the cart runtime can express.
121
- */
122
- function liveEventTable(type, node) {
123
- const meta = ELEMENTS[type];
124
- if (!meta?.events)
125
- return undefined;
126
- const bound = (node.bindings ?? []).some((b) => b?.id === PRODUCT_ACTION_BINDING_ID);
127
- return (bound && meta.bindingEvents) || meta.events;
128
- }
129
131
  /**
130
132
  * Put a click action on a node, or take one off.
131
133
  *
@@ -172,7 +174,7 @@ export function setEvent(doc, id, trigger, action, payload, guard) {
172
174
  ...project(next),
173
175
  ];
174
176
  }
175
- const table = liveEventTable(node.data.type, node);
177
+ const table = liveEventTable(doc.doc.nodes, id);
176
178
  if (!table) {
177
179
  throw new Error(`sbuilder: a ${node.data.type} declares no click actions, so an event on it would be ` +
178
180
  'stored and never fired. Elements that do: ' +
@@ -192,6 +194,21 @@ export function setEvent(doc, id, trigger, action, payload, guard) {
192
194
  : '') +
193
195
  `Allowed: ${allowed.join(', ')}.`);
194
196
  }
197
+ // A NAVIGATION WITH NOWHERE TO GO. The renderer turns a sole go_to_url /
198
+ // open_page click into `<a href>` from `payload.url` ALONE (navhref.ts) — an
199
+ // `open_page` with only a page id projects nothing, since neither renderer
200
+ // can resolve one — and a `popup` with no `payload.id` publishes a call with
201
+ // no target that opens nothing. All three store, save and publish clean.
202
+ const need = PAYLOAD_NEEDS[action];
203
+ if (need) {
204
+ const v = (payload ?? {})[need.key];
205
+ if (typeof v !== 'string' || v === '') {
206
+ soft(guard, () => {
207
+ throw new Error(`sbuilder: "${action}" needs payload.${need.key} — without it the click ${need.why}. ` +
208
+ `Expected payload: ${need.shape}.`);
209
+ });
210
+ }
211
+ }
195
212
  const value = { id: `ev_${action}`, name: trigger, action, payload: payload ?? {} };
196
213
  if (at >= 0) {
197
214
  const next = events.map((e, i) => (i === at ? value : e));
@@ -258,7 +275,7 @@ export function joinRoom(ctx, session, siteId) {
258
275
  const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(siteId)}`, tokenFn);
259
276
  const live = new LiveSession(socket, {
260
277
  onRemote: (patches) => session.applyRemote(patches),
261
- onDesync: (reason) => session.markStale(reason),
278
+ onDesync: (reason) => session.roomDesync(reason),
262
279
  onSource: (pageId, rev, peerId) => session.sourceSaved(pageId, rev, peerId),
263
280
  onMaster: (kind, id, op, rev) => session.masterSaved(kind, id, op, rev),
264
281
  });
@@ -661,8 +678,10 @@ export function registerLiveTools(server, ctx, session) {
661
678
  const guard = { force, forced: [] };
662
679
  const patches = setEvent(d, id, trigger ?? 'click', action, payload, guard);
663
680
  const forced = guard.forced.length ? { forced: guard.forced } : {};
664
- if (dry_run !== false)
665
- return text({ dry_run: true, patches, ...forced });
681
+ if (dry_run !== false) {
682
+ const refuse = session.wouldRefuse(patches);
683
+ return text({ dry_run: true, patches, ...(refuse ? { would_refuse: refuse } : {}), ...forced });
684
+ }
666
685
  await session.applyAndSave(patches);
667
686
  return text({ node: id, trigger: trigger ?? 'click', action, rev: d.rev, ...forced });
668
687
  });
@@ -692,8 +711,10 @@ export function registerLiveTools(server, ctx, session) {
692
711
  const guard = { force, forced: [] };
693
712
  const patches = bindNode(d, id, source, field, action, guard);
694
713
  const forced = guard.forced.length ? { forced: guard.forced } : {};
695
- if (dry_run !== false)
696
- return text({ dry_run: true, patches, ...forced });
714
+ if (dry_run !== false) {
715
+ const refuse = session.wouldRefuse(patches);
716
+ return text({ dry_run: true, patches, ...(refuse ? { would_refuse: refuse } : {}), ...forced });
717
+ }
697
718
  await session.applyAndSave(patches);
698
719
  return text({ bound: id, source, field, ...(action ? { action } : {}), rev: d.rev, ...forced });
699
720
  });
@@ -323,7 +323,7 @@ export async function ensureCartDrawer(ctx, session, siteId, dryRun) {
323
323
  note: 'Nothing was sent. Re-call with dry_run:false to create it; it shows on every page.',
324
324
  };
325
325
  }
326
- const open = session.peek() ? session.location() : null;
326
+ const open = session.page();
327
327
  const onPage = open?.siteId === siteId;
328
328
  if (onPage)
329
329
  await session.applyAndSave([]);
@@ -365,7 +365,7 @@ export async function relocalizeCartDrawer(ctx, session, siteId, dryRun) {
365
365
  const raw = settings.settings?.locale;
366
366
  // No locale means no language to move the WORDS to; the thumbnail needs none.
367
367
  const language = typeof raw === 'string' && raw ? cartSeedLocale(raw) : null;
368
- const open = session.peek() ? session.location() : null;
368
+ const open = session.page();
369
369
  const composed = open?.siteId === siteId
370
370
  ? Object.values(session.current().doc.nodes).find((n) => n.specials?.overlayId === cart.id)
371
371
  : undefined;