@jxsuite/studio 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/iframe-entry.js +760 -65
  2. package/dist/iframe-entry.js.map +16 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +71573 -50074
  5. package/dist/studio.js.map +123 -88
  6. package/package.json +11 -9
  7. package/src/canvas/canvas-live-render.ts +21 -2
  8. package/src/canvas/canvas-render.ts +35 -16
  9. package/src/canvas/canvas-utils.ts +118 -72
  10. package/src/canvas/iframe-entry.ts +37 -1
  11. package/src/canvas/iframe-eval.ts +155 -0
  12. package/src/canvas/iframe-host.ts +193 -6
  13. package/src/canvas/iframe-inline-edit.ts +107 -1
  14. package/src/canvas/iframe-protocol.ts +56 -3
  15. package/src/canvas/iframe-render.ts +53 -14
  16. package/src/canvas/site-style-css.ts +121 -0
  17. package/src/collab/collab-session.ts +23 -8
  18. package/src/collab/collab-state.ts +6 -3
  19. package/src/component-props.ts +104 -0
  20. package/src/editor/inline-edit-apply.ts +36 -1
  21. package/src/editor/inline-edit.ts +58 -1
  22. package/src/editor/shortcuts.ts +41 -12
  23. package/src/files/file-ops.ts +14 -0
  24. package/src/files/files.ts +53 -1
  25. package/src/grid/cell-editors.ts +288 -0
  26. package/src/grid/cell-popovers.ts +108 -0
  27. package/src/grid/csv-codec.ts +122 -0
  28. package/src/grid/edit-buffer.ts +490 -0
  29. package/src/grid/grid-controller.ts +417 -0
  30. package/src/grid/grid-layout.ts +83 -0
  31. package/src/grid/grid-open.ts +167 -0
  32. package/src/grid/grid-panel.ts +302 -0
  33. package/src/grid/grid-source.ts +210 -0
  34. package/src/grid/grid-view.ts +367 -0
  35. package/src/grid/schema-columns.ts +261 -0
  36. package/src/grid/sources/connector-source.ts +217 -0
  37. package/src/grid/sources/content-source.ts +542 -0
  38. package/src/grid/sources/csv-file-source.ts +247 -0
  39. package/src/grid/tabulator-tables.d.ts +120 -0
  40. package/src/panels/block-action-bar.ts +8 -3
  41. package/src/panels/chat-panel.ts +98 -0
  42. package/src/panels/data-grid.ts +9 -387
  43. package/src/panels/editors.ts +43 -17
  44. package/src/panels/events-panel.ts +137 -44
  45. package/src/panels/formula-workspace.ts +379 -0
  46. package/src/panels/frontmatter-fields.ts +231 -0
  47. package/src/panels/frontmatter-panel.ts +132 -0
  48. package/src/panels/git-panel.ts +1 -1
  49. package/src/panels/head-panel.ts +11 -175
  50. package/src/panels/properties-panel.ts +154 -144
  51. package/src/panels/right-panel.ts +9 -47
  52. package/src/panels/signals-panel.ts +115 -23
  53. package/src/panels/statement-editor.ts +710 -0
  54. package/src/panels/style-panel.ts +76 -22
  55. package/src/panels/stylebook-panel.ts +0 -2
  56. package/src/panels/tab-bar.ts +214 -10
  57. package/src/panels/tab-strip.ts +62 -6
  58. package/src/panels/toolbar.ts +37 -3
  59. package/src/services/ai-project-tools.ts +541 -0
  60. package/src/services/ai-session-store.ts +28 -0
  61. package/src/services/ai-system-prompt.ts +194 -24
  62. package/src/services/ai-tools.ts +88 -21
  63. package/src/services/automation.ts +16 -0
  64. package/src/services/document-assistant.ts +81 -11
  65. package/src/services/gated-registry.ts +72 -0
  66. package/src/services/live-preview.ts +0 -0
  67. package/src/services/preview-eval.ts +68 -0
  68. package/src/services/project-adoption.ts +31 -0
  69. package/src/settings/css-vars-editor.ts +122 -4
  70. package/src/site-context.ts +2 -0
  71. package/src/store.ts +4 -0
  72. package/src/studio.ts +90 -52
  73. package/src/tabs/patch-ops.ts +25 -0
  74. package/src/tabs/tab.ts +46 -1
  75. package/src/tabs/transact.ts +60 -13
  76. package/src/types.ts +12 -0
  77. package/src/ui/dynamic-slot.ts +272 -0
  78. package/src/ui/expression-editor.ts +423 -125
  79. package/src/ui/field-row.ts +5 -0
  80. package/src/ui/formula-catalog.ts +557 -0
  81. package/src/ui/formula-chips.ts +216 -0
  82. package/src/ui/formula-palette.ts +211 -0
  83. package/src/ui/layers.ts +40 -0
  84. package/src/ui/media-picker.ts +1 -1
  85. package/src/ui/panel-resize.ts +15 -1
  86. package/src/utils/canvas-media.ts +23 -0
  87. package/src/utils/preview-format.ts +26 -0
  88. package/src/view.ts +4 -4
  89. package/src/workspace/workspace.ts +14 -0
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { JxDocOp } from "../tabs/patch-ops";
13
13
  import type { JxContentResult, SlashCommand } from "../editor/inline-edit";
14
- import type { JxMutableNode } from "@jxsuite/schema/types";
14
+ import type { JxExpressionNode, JxMutableNode } from "@jxsuite/schema/types";
15
15
 
16
16
  /**
17
17
  * The wire form of a value-carrying document op (the `forward` half of a recorded
@@ -61,8 +61,21 @@ export type ParentToIframe =
61
61
  docBase: string;
62
62
  mapperCtx: WireMapperCtx;
63
63
  siteStyle: Record<string, unknown> | null;
64
+ // Forced color-scheme preview (spec §9.5): "light"/"dark" sets data-color-scheme on the
65
+ // Iframe root, null clears it (auto — follow the OS).
66
+ colorScheme: "light" | "dark" | null;
64
67
  gen: number;
65
68
  }
69
+ // Flip the forced color-scheme preview on the iframe root without re-rendering — a document-level
70
+ // Idempotent attribute write, deliberately gen-less (like endEdit).
71
+ | { kind: "setColorScheme"; scheme: "light" | "dark" | null }
72
+ // Replace the injected site-style sheet in place (live design-token editing) — idempotent and
73
+ // Render-free; the superseding render carries the same style via its own siteStyle.
74
+ | {
75
+ kind: "siteStyleUpdate";
76
+ siteStyle: Record<string, unknown> | null;
77
+ media: Record<string, string>;
78
+ }
66
79
  // Ask the iframe to measure the given document paths and post their current rects back. Used to
67
80
  // Draw the selection overlay regardless of where the selection change originated (canvas click,
68
81
  // Layers panel, keyboard) — the parent can't measure iframe nodes itself (cross-origin bridge).
@@ -110,6 +123,21 @@ export type ParentToIframe =
110
123
  | { command: "link"; href: string | null } // Null/"" = remove
111
124
  | { command: "insertData"; token: string };
112
125
  }
126
+ // ─── Live expression preview (M6) ───────────────────────────────────────────
127
+ // Evaluate expression nodes against the iframe's LIVE resolved scope (real repeater items, real
128
+ // Window#/ globals) and post per-node display values back. `contextPath` is the document path of
129
+ // The node whose scope context the expressions bind to (null = the root scope); when it sits
130
+ // Inside a repeater template, the iframe binds the first rendered item's $map context. `reqId`
131
+ // Correlates the reply (measure/geometry precedent); `gen` is the render the request targets —
132
+ // The iframe refuses to evaluate against a different render, and the parent drops replies whose
133
+ // Gen no longer matches its last-rendered one.
134
+ | {
135
+ kind: "evalExpr";
136
+ exprs: { id: string; node: JxExpressionNode }[];
137
+ contextPath: (string | number)[] | null;
138
+ reqId: number;
139
+ gen: number;
140
+ }
113
141
  // ─── Cross-frame DnD (Phase 4c spike) ──────────────────────────────────────
114
142
  // Begin a drag session in the iframe: `src` is the realm-agnostic source kind, `dragSeq` is the
115
143
  // Per-session id (parent drops any reply with a different seq), `gen` is the render the session
@@ -125,6 +153,19 @@ export type ParentToIframe =
125
153
  // The drag was cancelled (Escape/abort from the parent realm) — same teardown as dragEnd.
126
154
  | { kind: "dragCancel"; dragSeq: number };
127
155
 
156
+ /**
157
+ * One expression's live evaluation outcome (M6). `values` are path-key → display-string pairs — the
158
+ * trace hook's per-node values, already formatted INSIDE the iframe (same truncation rules as the
159
+ * parent's snapshot preview) so only postMessage-safe strings cross the boundary. `error` carries
160
+ * the thrown message when this expression failed (its `values` then hold whatever nodes reported
161
+ * before the throw).
162
+ */
163
+ export interface EvalExprResult {
164
+ id: string;
165
+ values: [string, string][];
166
+ error?: string;
167
+ }
168
+
128
169
  /** A node's bounding box, in the iframe's own viewport coordinates. */
129
170
  export interface SerializableRect {
130
171
  x: number;
@@ -229,6 +270,10 @@ export type IframeToParent =
229
270
  // Response to `measure`: the rects of whichever requested paths resolved to a node (missing paths
230
271
  // Are simply omitted). `reqId` echoes the request so the parent can drop stale responses.
231
272
  | { kind: "geometry"; reqId: number; hits: NodeHit[] }
273
+ // Response to `evalExpr`: one result per requested expression (empty when the request's gen no
274
+ // Longer matches the live render — the iframe never evaluates against the wrong scope). `reqId`/
275
+ // `gen` echo the request so the parent can drop stale replies (measure/geometry precedent).
276
+ | { kind: "evalResult"; reqId: number; gen: number; results: EvalExprResult[] }
232
277
  // A patch applied cleanly (echoes gen so the host can re-measure the selection overlay).
233
278
  | { kind: "patchComplete"; gen: number }
234
279
  // A patch could not be applied surgically — the parent escalates to a full render.
@@ -248,8 +293,9 @@ export type IframeToParent =
248
293
  metaKey: boolean;
249
294
  shiftKey: boolean;
250
295
  }
251
- // Inline editing started in the iframe (the parent shows the format toolbar from here).
252
- | { kind: "editStart"; path: (string | number)[] }
296
+ // Inline editing started in the iframe (the parent shows the format toolbar from here). `prop`
297
+ // Names the component-instance prop when the session edits prop-bound text (plain sessions).
298
+ | { kind: "editStart"; path: (string | number)[]; prop?: string }
253
299
  // Committed inline-edit content (rich `children` else `textContent`) for the parent to persist.
254
300
  | {
255
301
  kind: "editCommit";
@@ -257,6 +303,13 @@ export type IframeToParent =
257
303
  children: (JxMutableNode | string)[] | null;
258
304
  textContent: string | null;
259
305
  }
306
+ // Committed prop-bound text: persist `value` into `$props[prop]` of the instance at `path`.
307
+ | {
308
+ kind: "editCommitProp";
309
+ path: (string | number)[];
310
+ prop: string;
311
+ value: string;
312
+ }
260
313
  // Enter split a paragraph: keep `before` in the node, insert a new one with `after`.
261
314
  | {
262
315
  kind: "editSplit";
@@ -20,9 +20,11 @@ import {
20
20
  setCanvasViewportTranspose,
21
21
  setRootMedia,
22
22
  setSkipServerFunctions,
23
+ setStampPropBindings,
23
24
  transposeCanvasUnits,
24
25
  } from "@jxsuite/runtime";
25
26
  import { classifyRenderNode, serializeJxPath } from "./path-mapping";
27
+ import { SITE_STYLE_ID, buildSiteStyleCSS } from "./site-style-css";
26
28
  import type { CanvasMode } from "./iframe-protocol";
27
29
  import type { JxDocument } from "@jxsuite/schema/types";
28
30
  import type { PathMapCtx } from "./path-mapping";
@@ -150,6 +152,18 @@ export const EDIT_PLACEHOLDER_CSS = `
150
152
  font-size: 13px;
151
153
  pointer-events: none;
152
154
  }
155
+ [data-jx-bound-prop]:hover {
156
+ cursor: text;
157
+ outline: 1px dashed color-mix(in srgb, #808080 40%, transparent);
158
+ outline-offset: 1px;
159
+ }
160
+ [data-jx-bound-prop]:empty:not([contenteditable="plaintext-only"]):not([contenteditable="true"])::after {
161
+ content: "Empty \\2014 double-click to edit";
162
+ color: color-mix(in srgb, #808080 40%, transparent);
163
+ font-style: italic;
164
+ font-size: 13px;
165
+ pointer-events: none;
166
+ }
153
167
  `;
154
168
 
155
169
  /**
@@ -281,22 +295,42 @@ export function syncStylebookCss(doc: Document, mode: CanvasMode): void {
281
295
  doc.head.append(style);
282
296
  }
283
297
 
284
- /** Apply the project's site style: custom properties on :root, plain properties on <body>. */
285
- export function applySiteStyle(siteStyle: Record<string, unknown> | null | undefined): void {
298
+ /**
299
+ * Apply the project's site style as a real stylesheet (replace-in-place): custom properties on
300
+ * `:root`, plain properties on `body`, conditional blocks dual-emitted per the forced-scheme
301
+ * contract. A stylesheet — not inline root properties — so `:root[data-color-scheme]` override
302
+ * selectors can win (spec §9.5), and so removed tokens can't linger on reused iframes.
303
+ */
304
+ export function applySiteStyle(
305
+ siteStyle: Record<string, unknown> | null | undefined,
306
+ mediaQueries: Record<string, string> = {},
307
+ ): void {
308
+ const existing = document.head.querySelector(`#${SITE_STYLE_ID}`);
286
309
  if (!siteStyle || typeof siteStyle !== "object") {
310
+ existing?.remove();
287
311
  return;
288
312
  }
289
- const rootStyle = document.documentElement.style;
290
- const bodyStyle = document.body.style as unknown as Record<string, string>;
291
- for (const [key, value] of Object.entries(siteStyle)) {
292
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
293
- continue;
294
- }
295
- if (key.startsWith("--")) {
296
- rootStyle.setProperty(key, transposeCanvasUnits(String(value)));
297
- } else {
298
- bodyStyle[key] = transposeCanvasUnits(String(value));
299
- }
313
+ const css = buildSiteStyleCSS(siteStyle, mediaQueries, transposeCanvasUnits);
314
+ if (existing) {
315
+ existing.textContent = css;
316
+ return;
317
+ }
318
+ const tag = document.createElement("style");
319
+ tag.id = SITE_STYLE_ID;
320
+ tag.textContent = css;
321
+ document.head.append(tag);
322
+ }
323
+
324
+ /**
325
+ * Force or clear the color-scheme preview on the iframe's root element (the platform's
326
+ * data-color-scheme contract, spec §9.5). Survives re-renders and patches — renders only replace
327
+ * the container's children, never the root element.
328
+ */
329
+ export function applyPreviewColorScheme(doc: Document, scheme: "light" | "dark" | null): void {
330
+ if (scheme) {
331
+ doc.documentElement.dataset.colorScheme = scheme;
332
+ } else {
333
+ delete doc.documentElement.dataset.colorScheme;
300
334
  }
301
335
  }
302
336
 
@@ -425,7 +459,12 @@ export async function renderResolvedDocument(opts: {
425
459
  // De-link `<a href>` in design/edit so clicks select the anchor instead of navigating the iframe;
426
460
  // Preview keeps real links live (mirrors the server-function gate above).
427
461
  setCanvasDelinkAnchors(opts.mode !== "preview");
428
- applySiteStyle(opts.siteStyle);
462
+ // Stamp `data-jx-bound-prop` on component-internal invertible text bindings in design/edit only —
463
+ // The inline prop-edit affordance. Set every render so a preview/stylebook render in the same
464
+ // Iframe clears it (page-level templates are inert in design/edit via prepareForEditMode, so only
465
+ // Component internals get stamped).
466
+ setStampPropBindings(opts.mode === "design" || opts.mode === "edit");
467
+ applySiteStyle(opts.siteStyle, (opts.doc as { $media?: Record<string, string> }).$media ?? {});
429
468
  injectHead(opts.doc);
430
469
  syncEditModeCss(opts.container.ownerDocument, opts.mode);
431
470
  syncStylebookCss(opts.container.ownerDocument, opts.mode);
@@ -0,0 +1,121 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Site-style stylesheet builder — pure functions extracted for testability.
4
+ *
5
+ * The canvas iframe injects the project's `style` as a real stylesheet instead of inline properties
6
+ * on the root element: inline custom properties would defeat the forced-scheme override selectors
7
+ * (`:root[data-color-scheme]`, spec §9.5), and object-valued `@--name` blocks used to be dropped
8
+ * entirely. Scheme-query blocks dual-emit through the runtime's schemeSelectors so the canvas
9
+ * honors both the OS preference and the preview toggle.
10
+ */
11
+
12
+ import { pureSchemeOf, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
13
+
14
+ /** Id of the injected site-style tag (replace-in-place, never accumulate). */
15
+ export const SITE_STYLE_ID = "jx-site-style";
16
+
17
+ /** Serialize scalar declarations, kebab-casing property names and transposing values. */
18
+ function toDecls(props: Record<string, unknown>, transpose: (value: string) => string): string {
19
+ return Object.entries(props)
20
+ .map(([key, value]) => `${camelToKebab(key)}: ${transpose(String(value))}`)
21
+ .join("; ");
22
+ }
23
+
24
+ /**
25
+ * Build the site-style sheet text: custom properties on `:root`, plain properties on `body`,
26
+ * conditional `@`-blocks resolved against `mediaQueries` (scheme queries dual-emitted per the
27
+ * forced-scheme contract), and `color-scheme: light dark` declared when a scheme query exists.
28
+ *
29
+ * @param {Record<string, unknown>} siteStyle
30
+ * @param {Record<string, string>} mediaQueries
31
+ * @param {(value: string) => string} transpose - Unit transposer (canvas vh→cqh etc.)
32
+ * @returns {string}
33
+ */
34
+ export function buildSiteStyleCSS(
35
+ siteStyle: Record<string, unknown>,
36
+ mediaQueries: Record<string, string>,
37
+ transpose: (value: string) => string,
38
+ ): string {
39
+ const rules: string[] = [];
40
+ const rootProps: Record<string, unknown> = {};
41
+ const bodyProps: Record<string, unknown> = {};
42
+ const condBlocks: [string, Record<string, unknown>][] = [];
43
+
44
+ for (const [key, value] of Object.entries(siteStyle)) {
45
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
46
+ if (key.startsWith("@")) {
47
+ condBlocks.push([key, value as Record<string, unknown>]);
48
+ }
49
+ // Non-@ objects (nested element selectors) are page-content styling — the resolved doc's
50
+ // Own style pass covers those; the site sheet handles tokens + conditional overrides.
51
+ continue;
52
+ }
53
+ if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@")) {
54
+ continue;
55
+ }
56
+ if (key.startsWith("--")) {
57
+ rootProps[key] = value;
58
+ } else {
59
+ bodyProps[key] = value;
60
+ }
61
+ }
62
+
63
+ const rootCSS = toDecls(rootProps, transpose);
64
+ if (rootCSS) {
65
+ rules.push(`:root { ${rootCSS} }`);
66
+ }
67
+ const bodyCSS = toDecls(bodyProps, transpose);
68
+ if (bodyCSS) {
69
+ rules.push(`body { ${bodyCSS} }`);
70
+ }
71
+
72
+ for (const [atKey, block] of condBlocks) {
73
+ const query = atKey.startsWith("@--")
74
+ ? (mediaQueries[atKey.slice(1)] ?? atKey.slice(1))
75
+ : atKey.startsWith("@(")
76
+ ? atKey.slice(1)
77
+ : null;
78
+ if (query === null) {
79
+ continue;
80
+ }
81
+ const scheme = pureSchemeOf(query);
82
+ const emit = (selector: string, props: string) => {
83
+ if (!props) {
84
+ return;
85
+ }
86
+ if (scheme) {
87
+ const { auto, forced } = schemeSelectors(selector, scheme);
88
+ rules.push(`@media ${query} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
89
+ } else {
90
+ rules.push(`@media ${query} { ${selector} { ${props} } }`);
91
+ }
92
+ };
93
+ const condRoot: Record<string, unknown> = {};
94
+ const condBody: Record<string, unknown> = {};
95
+ const condSubs: [string, Record<string, unknown>][] = [];
96
+ for (const [k, v] of Object.entries(block)) {
97
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
98
+ if (!k.startsWith("@")) {
99
+ condSubs.push([k, v as Record<string, unknown>]);
100
+ }
101
+ continue;
102
+ }
103
+ if (k.startsWith("--")) {
104
+ condRoot[k] = v;
105
+ } else {
106
+ condBody[k] = v;
107
+ }
108
+ }
109
+ emit(":root", toDecls(condRoot, transpose));
110
+ emit("body", toDecls(condBody, transpose));
111
+ for (const [sel, sub] of condSubs) {
112
+ emit(sel, toDecls(sub, transpose));
113
+ }
114
+ }
115
+
116
+ if (Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null)) {
117
+ rules.push(":root { color-scheme: light dark }");
118
+ }
119
+
120
+ return rules.join("\n");
121
+ }
@@ -7,8 +7,14 @@
7
7
  * before-image). Inbound: remote Y transactions convert back into JxDocOps and replay through
8
8
  * `applyExternalDocOps`, riding the surgical canvas patcher; unconvertible shapes hard-reconcile
9
9
  * the tab from the Y tree. Undo becomes a local-origin-scoped Y.UndoManager registered as the tab's
10
- * history delegate. While attached, the shared session persists automatically: `dirty` stays false
11
- * and Cmd+S becomes a serialize→flush.
10
+ * history delegate.
11
+ *
12
+ * Saving is EXPLICIT even while attached: syncing edits to peers (the Y.Doc) is automatic, but
13
+ * folding the shared source to disk is not. Local edits mark `tab.doc.dirty` immediately for
14
+ * instant Save-button feedback; the room-level, server-authoritative unsaved state then arrives via
15
+ * `handle.onDirty` (any peer's edit dirties the room for everyone; a save clears it for everyone).
16
+ * Cmd+S / Save runs a serialize→mirror→flush, and the provider broadcasts the room clean once the
17
+ * write lands.
12
18
  *
13
19
  * All yjs code sits behind a dynamic import: the unsplit bundle inlines the bytes, but module
14
20
  * evaluation defers until a collab session actually attaches.
@@ -177,17 +183,18 @@ function onTransact(tab: Tab, record: TransactionRecord, origin: TransactOrigin)
177
183
  }
178
184
  session.lastSeenRef = toRaw(tab.doc.document) as object;
179
185
  if (origin === "remote") {
180
- // The reconciler keeps source text fresh regardless of WHO edited the structure.
186
+ // The reconciler keeps source text fresh regardless of WHO edited the structure. The tab's
187
+ // Dirty state is driven by the server's room-level `doc-dirty` broadcast (see onDirty in
188
+ // CreateSession), not forced here.
181
189
  scheduleMirror(session);
182
- tab.doc.dirty = false;
183
190
  return;
184
191
  }
185
192
  if (session.canWrite) {
186
193
  publishRecord(session, record);
187
194
  scheduleMirror(session);
188
195
  }
189
- // The shared session persists automatically; the dirty dot means "unsaved" and stays off.
190
- tab.doc.dirty = false;
196
+ // Local edits leave the tab dirty (transactDoc already set it) until an explicit save flushes and
197
+ // The provider broadcasts the room clean.
191
198
  }
192
199
 
193
200
  function onBatchEnd(tab: Tab): void {
@@ -673,7 +680,15 @@ function createSession(
673
680
  });
674
681
  }
675
682
  session.lastSeenRef = toRaw(tab.doc.document) as object;
676
- tab.doc.dirty = false;
683
+
684
+ // The server owns the room-level unsaved state: `onDirty` fires synchronously with the current
685
+ // Value on subscribe (delivered during the open handshake), so attaching to a clean room clears
686
+ // Any dirty the initial reconcile set, and attaching to an already-dirty room shows dirty.
687
+ session.disposers.push(
688
+ handle.onDirty((dirty) => {
689
+ tab.doc.dirty = dirty;
690
+ }),
691
+ );
677
692
 
678
693
  // Presence identity + write capability (drives reconciler election).
679
694
  handle.awareness.setLocalState({
@@ -864,7 +879,7 @@ function createSession(
864
879
  session.lastSeenRef = nowRef;
865
880
  publishDiff(session);
866
881
  scheduleMirror(session);
867
- tab.doc.dirty = false;
882
+ // Dirty stays as the edit left it; the server's doc-dirty broadcast is authoritative.
868
883
  });
869
884
  });
870
885
  });
@@ -4,7 +4,7 @@
4
4
  * fs-sync/reload guards without importing the session machinery.
5
5
  */
6
6
 
7
- import { reactive } from "../reactivity";
7
+ import { reactive, toRaw } from "../reactivity";
8
8
  import type { Tab } from "../tabs/tab";
9
9
  import type { CollabAwarenessState } from "@jxsuite/collab/awareness-types";
10
10
 
@@ -33,7 +33,10 @@ const states = new WeakMap<Tab, TabCollabState>();
33
33
  const attachedPaths = new Map<string, number>();
34
34
 
35
35
  export function collabState(tab: Tab): TabCollabState {
36
- let state = states.get(tab);
36
+ // Key on the raw tab: writers (the collab session, via ensureCollab's raw tab) and readers
37
+ // (toolbar/tab-strip, via reactive `workspace.tabs.get` proxies) must resolve the same entry.
38
+ const key = toRaw(tab as unknown as object) as Tab;
39
+ let state = states.get(key);
37
40
  if (!state) {
38
41
  state = reactive({
39
42
  active: false,
@@ -42,7 +45,7 @@ export function collabState(tab: Tab): TabCollabState {
42
45
  sourceCanonical: false,
43
46
  status: "detached",
44
47
  }) as TabCollabState;
45
- states.set(tab, state);
48
+ states.set(key, state);
46
49
  }
47
50
  return state;
48
51
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Component test props (M6) — the previewParams mirror for component docs. A non-instantiated
3
+ * component definition renders on the canvas with its state defaults; "test values" let the author
4
+ * pick per-prop values that the canvas render seeds into the definition's state, so templates,
5
+ * dataScope snapshots, and live/snapshot expression previews all see real data.
6
+ *
7
+ * Pure and DOM-free: {@link componentPropEntries} derives the editable prop rows from the doc's
8
+ * state (the same plain-data entries the CEM export publishes as fields), and
9
+ * {@link substitutePreviewProps} rebuilds a render doc with chosen values seeded — never mutating
10
+ * the source document (the substitutePreviewParams contract).
11
+ */
12
+
13
+ import type { JsonValue } from "./types";
14
+ import type { JxMutableNode } from "@jxsuite/schema/types";
15
+
16
+ /** One editable prop row: the state key and its current default value (undefined = none). */
17
+ export interface ComponentPropEntry {
18
+ name: string;
19
+ value: JsonValue | undefined;
20
+ }
21
+
22
+ /** Whether the doc is a component definition (a custom-element root) rather than a page. */
23
+ export function isComponentDoc(doc: JxMutableNode | null | undefined): boolean {
24
+ return typeof doc?.tagName === "string" && doc.tagName.includes("-");
25
+ }
26
+
27
+ /**
28
+ * Classify a state entry as a prop (plain data the CEM export publishes as a field): naked
29
+ * literals/arrays/objects and expanded/typed signal defs — never expressions, computeds, functions,
30
+ * templates, or $prototype data sources (overriding those would clobber behavior, the boundPropKey
31
+ * rule).
32
+ */
33
+ function propShape(def: unknown): "literal" | "signal" | null {
34
+ if (def === null || typeof def === "number" || typeof def === "boolean") {
35
+ return "literal";
36
+ }
37
+ if (typeof def === "string") {
38
+ return def.includes("${") ? null : "literal"; // Template strings render computed values.
39
+ }
40
+ if (Array.isArray(def)) {
41
+ return "literal";
42
+ }
43
+ if (typeof def === "object") {
44
+ const d = def as Record<string, unknown>;
45
+ if (d.$expression || d.$compute || d.$prototype || d.$handler) {
46
+ return null;
47
+ }
48
+ // An expanded signal ({default}) or a typed prop def ({type}/schema keywords) seeds through
49
+ // Its `default`; any other plain object is a Shape-1 naked value.
50
+ return "default" in d || "type" in d ? "signal" : "literal";
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * The component's editable prop entries, derived from the doc's state — non-private plain-data
57
+ * entries with their current default values (the CEM-export "field" subset).
58
+ */
59
+ export function componentPropEntries(doc: JxMutableNode | null | undefined): ComponentPropEntry[] {
60
+ const state = (doc?.state ?? {}) as Record<string, unknown>;
61
+ const out: ComponentPropEntry[] = [];
62
+ for (const [name, def] of Object.entries(state)) {
63
+ if (name.startsWith("#")) {
64
+ continue; // Private entries are not props (the CEM-export rule).
65
+ }
66
+ const shape = propShape(def);
67
+ if (!shape) {
68
+ continue;
69
+ }
70
+ const value =
71
+ shape === "signal"
72
+ ? ((def as { default?: JsonValue }).default as JsonValue | undefined)
73
+ : (def as JsonValue);
74
+ out.push({ name, value });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * Rebuild `renderDoc` with the chosen test-prop values seeded into its state: a literal entry is
81
+ * replaced by the value; a signal/typed entry keeps its metadata and gets the value as `default`
82
+ * (exactly what buildScope reads). Pure — returns a shallow rebuild; the render doc shares node
83
+ * references with the tab's source document, so in-place mutation would corrupt the edited doc.
84
+ */
85
+ export function substitutePreviewProps(
86
+ renderDoc: JxMutableNode,
87
+ props: Record<string, JsonValue>,
88
+ ): JxMutableNode {
89
+ const state = { ...((renderDoc.state ?? {}) as Record<string, unknown>) };
90
+ let seeded = false;
91
+ for (const [name, value] of Object.entries(props)) {
92
+ const shape = propShape(state[name]);
93
+ if (!shape) {
94
+ continue; // Unknown/behavioral entries are never overridden by a stale test value.
95
+ }
96
+ state[name] =
97
+ shape === "signal" ? { ...(state[name] as Record<string, unknown>), default: value } : value;
98
+ seeded = true;
99
+ }
100
+ if (!seeded) {
101
+ return renderDoc;
102
+ }
103
+ return { ...renderDoc, state } as JxMutableNode;
104
+ }
@@ -9,7 +9,12 @@
9
9
 
10
10
  import { childIndex, getNodeAtPath, parentElementPath } from "../store";
11
11
  import { isTabActive } from "../workspace/workspace";
12
- import { mutateInsertNode, mutateUpdateProperty, transactDoc } from "../tabs/transact";
12
+ import {
13
+ mutateInsertNode,
14
+ mutateUpdateProp,
15
+ mutateUpdateProperty,
16
+ transactDoc,
17
+ } from "../tabs/transact";
13
18
  import { defaultDef } from "../panels/shared";
14
19
 
15
20
  import type { JxContentResult, SlashCommand } from "./inline-edit";
@@ -83,6 +88,36 @@ export function applyInlineCommit(
83
88
  }
84
89
  }
85
90
 
91
+ /**
92
+ * Commit a prop-bound inline edit: persist `value` into `$props[prop]` of the component instance at
93
+ * `path`. The unchanged-value no-op is LOAD-BEARING, not cosmetic: Escape-cancel and
94
+ * patch-disturbed sessions post unchanged values, and without the equality check a commit→patch→
95
+ * disturb→re-commit cycle loops (and pollutes undo history). An empty value deletes the prop
96
+ * (mutateUpdateProp), reverting the instance to the definition default — same as clearing the
97
+ * sidebar field.
98
+ */
99
+ export function applyInlinePropCommit(
100
+ tab: Tab | null,
101
+ path: JxPath,
102
+ prop: string,
103
+ value: string,
104
+ ): void {
105
+ if (!tab) {
106
+ return;
107
+ }
108
+ const node = getNodeAtPath(tab.doc.document, path);
109
+ if (!node) {
110
+ return;
111
+ }
112
+ const current = (node.$props as Record<string, unknown> | undefined)?.[prop];
113
+ if (`${current ?? ""}` === value) {
114
+ return;
115
+ }
116
+ transactDoc(tab, (t) => {
117
+ mutateUpdateProp(t, path, prop, value);
118
+ });
119
+ }
120
+
86
121
  /**
87
122
  * Apply a paragraph split to `tab`'s document: keep `before` in the node, insert a new `<p>` with
88
123
  * `after`. Returns its path (unchanged when `tab` is gone — the caller may still use it for