@jxsuite/studio 0.33.0 → 0.35.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 (106) hide show
  1. package/dist/iframe-entry.js +6238 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +30972 -17222
  4. package/dist/studio.js.map +328 -83
  5. package/package.json +9 -9
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +5 -1
  41. package/src/new-project/design-fields.ts +260 -0
  42. package/src/new-project/import-tab.ts +322 -0
  43. package/src/new-project/new-project-modal.ts +472 -89
  44. package/src/new-project/templates.ts +61 -0
  45. package/src/packages/ensure-deps.ts +6 -0
  46. package/src/packages/pull-package-sync.ts +339 -0
  47. package/src/page-params.ts +383 -0
  48. package/src/panels/ai-chat/attached-context.ts +49 -0
  49. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  50. package/src/panels/ai-chat/chat-view.ts +250 -0
  51. package/src/panels/ai-chat/composer.ts +315 -0
  52. package/src/panels/ai-chat/sessions-view.ts +98 -0
  53. package/src/panels/ai-panel.ts +189 -457
  54. package/src/panels/block-action-bar.ts +296 -138
  55. package/src/panels/canvas-dnd-bridge.ts +397 -0
  56. package/src/panels/component-preview.ts +56 -0
  57. package/src/panels/dnd.ts +41 -17
  58. package/src/panels/drag-ghost.ts +62 -0
  59. package/src/panels/editors.ts +1 -1
  60. package/src/panels/git-panel.ts +16 -1
  61. package/src/panels/overlays.ts +10 -125
  62. package/src/panels/properties-panel.ts +210 -0
  63. package/src/panels/right-panel.ts +21 -7
  64. package/src/panels/signals-panel.ts +136 -22
  65. package/src/panels/stylebook-doc.ts +373 -0
  66. package/src/panels/stylebook-panel.ts +46 -689
  67. package/src/panels/tab-bar.ts +159 -13
  68. package/src/panels/toolbar.ts +3 -2
  69. package/src/platforms/devserver.ts +45 -1
  70. package/src/services/agent-seed.ts +91 -0
  71. package/src/services/ai-models.ts +64 -0
  72. package/src/services/ai-session-store.ts +250 -0
  73. package/src/services/ai-settings.ts +5 -0
  74. package/src/services/automation.ts +140 -0
  75. package/src/services/document-assistant.ts +98 -38
  76. package/src/services/import-client.ts +134 -0
  77. package/src/services/monaco-setup.ts +12 -0
  78. package/src/services/render-critic.ts +9 -9
  79. package/src/services/settings-store.ts +93 -0
  80. package/src/settings/css-vars-editor.ts +2 -2
  81. package/src/store.ts +4 -62
  82. package/src/studio.ts +115 -40
  83. package/src/tabs/doc-op-apply.ts +89 -0
  84. package/src/tabs/patch-ops.ts +6 -2
  85. package/src/tabs/tab.ts +23 -4
  86. package/src/tabs/transact.ts +2 -74
  87. package/src/types.ts +98 -18
  88. package/src/ui/ai-credentials-form.ts +205 -0
  89. package/src/ui/jx-theme.ts +63 -0
  90. package/src/ui/media-picker.ts +6 -4
  91. package/src/ui/spectrum.ts +13 -0
  92. package/src/utils/canvas-media.ts +0 -137
  93. package/src/utils/edit-display.ts +23 -3
  94. package/src/utils/geometry.ts +43 -0
  95. package/src/utils/link-target.ts +93 -0
  96. package/src/utils/strip-events.ts +54 -0
  97. package/src/view.ts +0 -23
  98. package/src/workspace/workspace.ts +14 -1
  99. package/src/canvas/canvas-subtree-render.ts +0 -113
  100. package/src/editor/component-inline-edit.ts +0 -349
  101. package/src/editor/content-inline-edit.ts +0 -207
  102. package/src/editor/insertion-helper.ts +0 -308
  103. package/src/panels/canvas-dnd.ts +0 -329
  104. package/src/panels/panel-events.ts +0 -306
  105. package/src/panels/preview-render.ts +0 -132
  106. package/src/panels/pseudo-preview.ts +0 -75
@@ -8,7 +8,10 @@
8
8
  import { html, nothing } from "lit-html";
9
9
  import { classMap } from "lit-html/directives/class-map.js";
10
10
  import { ifDefined } from "lit-html/directives/if-defined.js";
11
+ import { live } from "lit-html/directives/live.js";
11
12
  import { styleMap } from "lit-html/directives/style-map.js";
13
+ import { isRef } from "@jxsuite/schema/guards";
14
+ import { dynamicRouteParams } from "../page-params";
12
15
  import { projectState } from "../state";
13
16
  import type { JsonValue } from "../types";
14
17
  import { activeTab } from "../workspace/workspace";
@@ -111,6 +114,14 @@ let expandedSignal: string | null = null;
111
114
  /** Track which functions have the advanced param editor open. */
112
115
  const advancedParamOpen = new Set();
113
116
 
117
+ /** Schema fields whose binding picker is in free-form Custom mode (cleared on commit). */
118
+ const bindingCustomOpen = new Set<string>();
119
+
120
+ /** Reset binding-control ephemeral UI state (test hook). */
121
+ export function resetBindingUiState() {
122
+ bindingCustomOpen.clear();
123
+ }
124
+
114
125
  /** Default templates for creating new signal definitions. */
115
126
  const DEF_TEMPLATES = {
116
127
  computed: { $compute: "", $deps: [] },
@@ -1294,6 +1305,18 @@ function renderInlineField(
1294
1305
  onChange: (val: unknown) => void,
1295
1306
  parentDef?: Record<string, unknown>,
1296
1307
  ) {
1308
+ if (isRef(value)) {
1309
+ return html`<sp-textfield
1310
+ size="s"
1311
+ label=${key}
1312
+ placeholder=${key}
1313
+ .value=${live(value.$ref)}
1314
+ @change=${(e: Event) => {
1315
+ const v = (e.target as HTMLInputElement).value.trim();
1316
+ onChange(v ? { $ref: v } : undefined);
1317
+ }}
1318
+ ></sp-textfield>`;
1319
+ }
1297
1320
  const enumValues = resolveSchemaEnum(schema.enum, parentDef);
1298
1321
 
1299
1322
  if (enumValues) {
@@ -1344,6 +1367,63 @@ function renderInlineField(
1344
1367
  ></sp-textfield>`;
1345
1368
  }
1346
1369
 
1370
+ /**
1371
+ * Render a binding picker for a `{ $ref }` config value — route params derived from the document
1372
+ * path plus a free-form custom ref, with a switch back to a static value.
1373
+ */
1374
+ function renderBindingControl(opts: {
1375
+ refVal: string;
1376
+ params: string[];
1377
+ fieldKey: string;
1378
+ commit: (next: { $ref: string } | undefined) => void;
1379
+ rerender: (() => void) | undefined;
1380
+ }) {
1381
+ const paramRefs = opts.params.map((p) => `#/$params/${p}`);
1382
+ const isCustom =
1383
+ bindingCustomOpen.has(opts.fieldKey) ||
1384
+ (opts.refVal !== "" && !paramRefs.includes(opts.refVal));
1385
+ return html`
1386
+ <div style="display:flex;flex-direction:column;gap:4px">
1387
+ <sp-picker
1388
+ size="s"
1389
+ .value=${live(isCustom ? "__custom__" : opts.refVal || "__static__")}
1390
+ @change=${(e: Event) => {
1391
+ const v = (e.target as HTMLInputElement).value;
1392
+ if (v === "__custom__") {
1393
+ bindingCustomOpen.add(opts.fieldKey);
1394
+ opts.rerender?.();
1395
+ return;
1396
+ }
1397
+ bindingCustomOpen.delete(opts.fieldKey);
1398
+ opts.commit(v === "__static__" ? undefined : { $ref: v });
1399
+ opts.rerender?.();
1400
+ }}
1401
+ >
1402
+ <sp-menu-item value="__static__">Static value</sp-menu-item>
1403
+ ${paramRefs.length > 0 ? html`<sp-menu-divider></sp-menu-divider>` : nothing}
1404
+ ${paramRefs.map((r) => html`<sp-menu-item value=${r}>${r.slice(2)}</sp-menu-item>`)}
1405
+ <sp-menu-divider></sp-menu-divider>
1406
+ <sp-menu-item value="__custom__">Custom…</sp-menu-item>
1407
+ </sp-picker>
1408
+ ${isCustom
1409
+ ? html`<sp-textfield
1410
+ size="s"
1411
+ placeholder="#/$params/…"
1412
+ .value=${live(opts.refVal)}
1413
+ @change=${(e: Event) => {
1414
+ const v = (e.target as HTMLInputElement).value.trim();
1415
+ if (!v) {
1416
+ bindingCustomOpen.delete(opts.fieldKey);
1417
+ }
1418
+ opts.commit(v ? { $ref: v } : undefined);
1419
+ opts.rerender?.();
1420
+ }}
1421
+ ></sp-textfield>`
1422
+ : nothing}
1423
+ </div>
1424
+ `;
1425
+ }
1426
+
1347
1427
  /** Render a debounced multiline JSON text field for array/object schema properties. */
1348
1428
  function renderJsonTextField(
1349
1429
  currentValue: unknown,
@@ -1382,7 +1462,7 @@ export function renderSchemaFieldsTemplate(
1382
1462
  schema: JsonSchema | null | undefined,
1383
1463
  def: SignalDef,
1384
1464
  name: string,
1385
- _S: SignalsPanelState,
1465
+ S: SignalsPanelState,
1386
1466
  ctx: SignalsPanelCtx | null = null,
1387
1467
  ) {
1388
1468
  if (!schema?.properties) {
@@ -1390,6 +1470,7 @@ export function renderSchemaFieldsTemplate(
1390
1470
  }
1391
1471
 
1392
1472
  const required = new Set(schema.required);
1473
+ const params = dynamicRouteParams(S.documentPath);
1393
1474
 
1394
1475
  const propertyFields = Object.entries(schema.properties)
1395
1476
  .filter(([prop]) => !STUDIO_RESERVED_KEYS.has(prop))
@@ -1399,7 +1480,21 @@ export function renderSchemaFieldsTemplate(
1399
1480
 
1400
1481
  let control;
1401
1482
  const enumValues = resolveSchemaEnum(ps.enum, def);
1402
- if (enumValues) {
1483
+ if (
1484
+ isRef(currentValue) &&
1485
+ ps.format !== "json-schema" &&
1486
+ ps.type !== "object" &&
1487
+ ps.type !== "array"
1488
+ ) {
1489
+ control = renderBindingControl({
1490
+ refVal: currentValue.$ref,
1491
+ params,
1492
+ fieldKey: `${name}.${prop}`,
1493
+ commit: (next) =>
1494
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { [prop]: next })),
1495
+ rerender: ctx ? () => ctx.renderLeftPanel() : undefined,
1496
+ });
1497
+ } else if (enumValues) {
1403
1498
  control = html`
1404
1499
  <sp-picker
1405
1500
  size="s"
@@ -1463,12 +1558,12 @@ export function renderSchemaFieldsTemplate(
1463
1558
  const hasValue =
1464
1559
  currentValue && typeof currentValue === "object" && Object.keys(currentValue).length > 0;
1465
1560
  const cv = currentValue as Record<string, unknown>;
1466
- const isRef = hasValue && cv.$ref;
1561
+ const isSchemaRef = hasValue && cv.$ref;
1467
1562
  /** @type {ReturnType<typeof setTimeout> | undefined} */
1468
1563
  let debounce: ReturnType<typeof setTimeout> | undefined;
1469
1564
  control = html`
1470
1565
  <div class="schema-param-editor">
1471
- ${hasValue && !isRef && cv.properties
1566
+ ${hasValue && !isSchemaRef && cv.properties
1472
1567
  ? html`
1473
1568
  <div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:4px">
1474
1569
  ${Object.entries(cv.properties as Record<string, Record<string, unknown>>).map(
@@ -1580,24 +1675,43 @@ export function renderSchemaFieldsTemplate(
1580
1675
  /** @type {ReturnType<typeof setTimeout> | undefined} */
1581
1676
  let debounce: ReturnType<typeof setTimeout> | undefined;
1582
1677
  const ph = ps.default !== undefined ? String(ps.default) : (ps.examples?.[0] ?? "");
1583
- control = html`<sp-textfield
1584
- size="s"
1585
- .value=${currentValue ?? ""}
1586
- placeholder=${ph || nothing}
1587
- title=${ps.description || nothing}
1588
- @input=${(e: Event) => {
1589
- clearTimeout(debounce);
1590
- debounce = setTimeout(
1591
- () =>
1592
- transactDoc(activeTab.value, (t) =>
1593
- mutateUpdateDef(t, name, {
1594
- [prop]: (e.target as HTMLInputElement).value || undefined,
1595
- }),
1596
- ),
1597
- 400,
1598
- );
1599
- }}
1600
- ></sp-textfield>`;
1678
+ control = html`<div style="display:flex;gap:4px;align-items:center">
1679
+ <sp-textfield
1680
+ size="s"
1681
+ style="flex:1"
1682
+ .value=${currentValue ?? ""}
1683
+ placeholder=${ph || nothing}
1684
+ title=${ps.description || nothing}
1685
+ @input=${(e: Event) => {
1686
+ clearTimeout(debounce);
1687
+ debounce = setTimeout(
1688
+ () =>
1689
+ transactDoc(activeTab.value, (t) =>
1690
+ mutateUpdateDef(t, name, {
1691
+ [prop]: (e.target as HTMLInputElement).value || undefined,
1692
+ }),
1693
+ ),
1694
+ 400,
1695
+ );
1696
+ }}
1697
+ ></sp-textfield>
1698
+ ${params.length > 0
1699
+ ? html`<sp-action-button
1700
+ quiet
1701
+ size="s"
1702
+ title="Bind to route param"
1703
+ @click=${() => {
1704
+ transactDoc(activeTab.value, (t) =>
1705
+ mutateUpdateDef(t, name, {
1706
+ [prop]: { $ref: `#/$params/${params[0]}` },
1707
+ }),
1708
+ );
1709
+ ctx?.renderLeftPanel();
1710
+ }}
1711
+ ><sp-icon-link slot="icon"></sp-icon-link
1712
+ ></sp-action-button>`
1713
+ : nothing}
1714
+ </div>`;
1601
1715
  }
1602
1716
 
1603
1717
  return renderFieldRow({
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Stylebook specimen-document generator — pure functions (no DOM) that turn the static element
3
+ * catalog (data/stylebook-meta.json) plus the project's component registry into a plain JxDocument
4
+ * the iframe canvas renders like any page. Each breakpoint panel renders the SAME generated doc in
5
+ * a width-sized iframe, so `@media` blocks evaluate for real — this replaces the legacy parent-side
6
+ * inline-style flatten (`buildStylebookElement`/`refreshStylebookStyles`).
7
+ *
8
+ * Two runtime facts shape {@link transposeStylebookStyle}:
9
+ *
10
+ * 1. The runtime's `applyStyle` emits `@media.selector` nesting but DROPS `selector.@media`
11
+ * (`emitNested` skips `@` keys) — the legacy flatten honored both orders, so tag rules' embedded
12
+ * `@name` blocks are HOISTED into the corresponding top-level `@name` block here.
13
+ * 2. Tag-keyed rules on the generated root would restyle the card chrome (`div` rules hit chrome
14
+ * divs), so every tag rule `T` is re-keyed to `` `& .element-card-preview ${T}` `` — applyStyle
15
+ * resolves `&` to the root's `[data-jx=uid]` scope, confining the cascade to specimens.
16
+ */
17
+
18
+ import { serializeJxPath } from "../canvas/path-mapping";
19
+ import type { StylebookEntry } from "./stylebook-panel";
20
+ import type { ComponentEntry } from "../files/components";
21
+ import type { JxPath } from "../state";
22
+ import type { JxMutableNode, JxStyle } from "@jxsuite/schema/types";
23
+
24
+ export interface StylebookDocResult {
25
+ /** The specimen document (plain JSON — safe to post over the bridge). */
26
+ doc: JxMutableNode;
27
+ /** SerializeJxPath(path) → tag or compound ("ul li") for hit decoding. Chrome paths absent. */
28
+ pathToTag: Map<string, string>;
29
+ /** Tag/compound → the FIRST matching card's document path (selection-overlay measurement). */
30
+ tagToCardPath: Map<string, JxPath>;
31
+ }
32
+
33
+ /** Selector-key classifier — a bare tag path like "p" or "table th" (mirrors style-panel's rule). */
34
+ function isTagPath(key: string): boolean {
35
+ return (
36
+ !key.startsWith(":") &&
37
+ !key.startsWith(".") &&
38
+ !key.startsWith("&") &&
39
+ !key.startsWith("[") &&
40
+ !key.startsWith("@") &&
41
+ !key.startsWith("--")
42
+ );
43
+ }
44
+
45
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
46
+ return v !== null && typeof v === "object" && !Array.isArray(v);
47
+ }
48
+
49
+ /** The specimen-scoping selector prefix (see module doc, runtime fact 2). */
50
+ const SPECIMEN_SCOPE = "& .element-card-preview";
51
+
52
+ /** Deep-merge `add` into `base` (objects merge, scalars overwrite; add wins). */
53
+ function mergeRules(
54
+ base: Record<string, unknown> | undefined,
55
+ add: Record<string, unknown>,
56
+ ): Record<string, unknown> {
57
+ const out: Record<string, unknown> = { ...base };
58
+ for (const [k, v] of Object.entries(add)) {
59
+ out[k] = isPlainObject(v) && isPlainObject(out[k]) ? mergeRules(out[k], v) : v;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** Drop nested `@` keys from a rule object (already-hoisted media can't nest deeper). */
65
+ function stripAtKeys(rule: Record<string, unknown>): Record<string, unknown> {
66
+ const out: Record<string, unknown> = {};
67
+ for (const [k, v] of Object.entries(rule)) {
68
+ if (isPlainObject(v) && k.startsWith("@")) {
69
+ continue;
70
+ }
71
+ out[k] = isPlainObject(v) ? stripAtKeys(v) : v;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * Transform the merged effective document style into the generated root's style block: flat scalars
78
+ * and `--` custom props stay at the top (inheritance into specimens), tag rules are re-keyed under
79
+ * {@link SPECIMEN_SCOPE}, and `selector.@media` nesting is hoisted into top-level `@name` blocks
80
+ * (both per the module doc). Shared by the doc generator and the live `styleUpdate` fast path so an
81
+ * edit round-trips through the exact same transform.
82
+ */
83
+ export function transposeStylebookStyle(effectiveStyle: JxStyle): JxStyle {
84
+ const style = effectiveStyle as Record<string, unknown>;
85
+ const out: Record<string, unknown> = {};
86
+ const mediaOut: Record<string, Record<string, unknown>> = {};
87
+
88
+ /** Re-key + hoist one tag rule subtree; returns the cleaned (media-free) rule. */
89
+ const processTagRule = (
90
+ rule: Record<string, unknown>,
91
+ tagPath: string,
92
+ ): Record<string, unknown> => {
93
+ const cleaned: Record<string, unknown> = {};
94
+ for (const [k, v] of Object.entries(rule)) {
95
+ if (!isPlainObject(v)) {
96
+ cleaned[k] = v;
97
+ continue;
98
+ }
99
+ if (k.startsWith("@")) {
100
+ if (k === "@--") {
101
+ continue; // Base canvas width marker, not a real query.
102
+ }
103
+ const bucket = (mediaOut[k] ??= {});
104
+ const sel = `${SPECIMEN_SCOPE} ${tagPath}`;
105
+ bucket[sel] = mergeRules(
106
+ bucket[sel] as Record<string, unknown> | undefined,
107
+ stripAtKeys(v),
108
+ );
109
+ continue;
110
+ }
111
+ if (isTagPath(k)) {
112
+ cleaned[k] = processTagRule(v, `${tagPath} ${k}`);
113
+ continue;
114
+ }
115
+ cleaned[k] = v; // Pseudo/class/& sub-rules ride along under the re-keyed parent.
116
+ }
117
+ return cleaned;
118
+ };
119
+
120
+ for (const [k, v] of Object.entries(style)) {
121
+ if (!isPlainObject(v)) {
122
+ out[k] = v; // Flat scalars + -- custom props.
123
+ continue;
124
+ }
125
+ if (k.startsWith("@")) {
126
+ if (k === "@--") {
127
+ continue;
128
+ }
129
+ // Media block: re-key tag rules inside; non-tag keys (scalars/pseudo) apply to the root.
130
+ const bucket = (mediaOut[k] ??= {});
131
+ for (const [mk, mv] of Object.entries(v)) {
132
+ if (isPlainObject(mv) && isTagPath(mk)) {
133
+ const sel = `${SPECIMEN_SCOPE} ${mk}`;
134
+ bucket[sel] = mergeRules(bucket[sel] as Record<string, unknown> | undefined, mv);
135
+ } else {
136
+ bucket[mk] = mv;
137
+ }
138
+ }
139
+ continue;
140
+ }
141
+ if (isTagPath(k)) {
142
+ out[`${SPECIMEN_SCOPE} ${k}`] = processTagRule(v, k);
143
+ continue;
144
+ }
145
+ out[k] = v;
146
+ }
147
+
148
+ for (const [mk, mv] of Object.entries(mediaOut)) {
149
+ out[mk] = isPlainObject(out[mk]) ? mergeRules(out[mk] as Record<string, unknown>, mv) : mv;
150
+ }
151
+ return out as JxStyle;
152
+ }
153
+
154
+ /** Resolve a nested tag path ("table th") in a style object; null when absent. */
155
+ function resolveNestedStyle(
156
+ style: Record<string, unknown>,
157
+ tagPath: string,
158
+ ): Record<string, unknown> | null {
159
+ const parts = tagPath.split(" ");
160
+ let obj: unknown = style;
161
+ for (const part of parts) {
162
+ if (!isPlainObject(obj)) {
163
+ return null;
164
+ }
165
+ obj = obj[part];
166
+ }
167
+ return isPlainObject(obj) ? obj : null;
168
+ }
169
+
170
+ /** Whether the effective style customizes `tagPath` (directly or under any `@media` block). */
171
+ export function hasTagStyle(rootStyle: JxStyle, tagPath: string): boolean {
172
+ const style = rootStyle as Record<string, unknown>;
173
+ const direct = resolveNestedStyle(style, tagPath);
174
+ if (direct && Object.keys(direct).length > 0) {
175
+ return true;
176
+ }
177
+ for (const [key, val] of Object.entries(style)) {
178
+ if (!key.startsWith("@") || !isPlainObject(val)) {
179
+ continue;
180
+ }
181
+ const nested = resolveNestedStyle(val, tagPath);
182
+ if (nested && Object.keys(nested).length > 0) {
183
+ return true;
184
+ }
185
+ }
186
+ return false;
187
+ }
188
+
189
+ /** Options for {@link buildStylebookDoc}. */
190
+ export interface BuildStylebookDocOpts {
191
+ meta: { $sections: { label: string; elements: StylebookEntry[] }[] };
192
+ components: ComponentEntry[];
193
+ /** Merged document+project style (getEffectiveStyle output) — transposed here. */
194
+ effectiveStyle: JxStyle;
195
+ /** Merged $media map (getEffectiveMedia output) — becomes the doc's $media. */
196
+ effectiveMedia: Record<string, string>;
197
+ filter: string;
198
+ customizedOnly: boolean;
199
+ projectRoot: string | null;
200
+ }
201
+
202
+ /**
203
+ * Build the specimen document + path↔tag maps. Deterministic: paths are recorded as the tree is
204
+ * assembled, so the parent can decode `hit` paths and measure selection cards without reading the
205
+ * iframe DOM.
206
+ */
207
+ export function buildStylebookDoc(opts: BuildStylebookDocOpts): StylebookDocResult {
208
+ const pathToTag = new Map<string, string>();
209
+ const tagToCardPath = new Map<string, JxPath>();
210
+ const filter = opts.filter.toLowerCase();
211
+
212
+ /** Register a specimen subtree's paths: root → tag; descendants → compound (legacy semantics). */
213
+ const registerSpecimenPaths = (entry: StylebookEntry, path: JxPath, rootTag: string): void => {
214
+ const compound = entry.tag === rootTag ? rootTag : `${rootTag} ${entry.tag}`;
215
+ pathToTag.set(serializeJxPath(path), compound);
216
+ const kids = entry.children ?? [];
217
+ for (const [i, child] of kids.entries()) {
218
+ registerSpecimenPaths(child, [...path, "children", i], rootTag);
219
+ }
220
+ };
221
+
222
+ const specimenNode = (entry: StylebookEntry): JxMutableNode => {
223
+ const attributes: Record<string, string> = { ...entry.attributes };
224
+ if (entry.style) {
225
+ attributes.style = entry.style; // Inline CSS text — legacy `el.style.cssText` parity.
226
+ }
227
+ return {
228
+ tagName: entry.tag,
229
+ ...(entry.text ? { textContent: entry.text } : {}),
230
+ ...(Object.keys(attributes).length > 0 ? { attributes } : {}),
231
+ ...(entry.children ? { children: entry.children.map(specimenNode) } : {}),
232
+ } as JxMutableNode;
233
+ };
234
+
235
+ /** A card wrapping one specimen; registers card/preview/specimen paths under `tag`. */
236
+ const cardNode = (
237
+ tag: string,
238
+ label: string,
239
+ specimen: JxMutableNode,
240
+ cardPath: JxPath,
241
+ entryForPaths: StylebookEntry | null,
242
+ ): JxMutableNode => {
243
+ pathToTag.set(serializeJxPath(cardPath), tag);
244
+ pathToTag.set(serializeJxPath([...cardPath, "children", 0]), tag);
245
+ const specimenPath = [...cardPath, "children", 0, "children", 0];
246
+ if (entryForPaths) {
247
+ registerSpecimenPaths(entryForPaths, specimenPath, tag);
248
+ } else {
249
+ pathToTag.set(serializeJxPath(specimenPath), tag);
250
+ }
251
+ if (!tagToCardPath.has(tag)) {
252
+ tagToCardPath.set(tag, cardPath);
253
+ }
254
+ return {
255
+ attributes: { class: "element-card" },
256
+ children: [
257
+ { attributes: { class: "element-card-preview" }, children: [specimen], tagName: "div" },
258
+ { attributes: { class: "element-card-label" }, tagName: "div", textContent: label },
259
+ ],
260
+ tagName: "div",
261
+ } as JxMutableNode;
262
+ };
263
+
264
+ const sections: JxMutableNode[] = [];
265
+ const sectionNode = (label: string, cards: JxMutableNode[]): JxMutableNode =>
266
+ ({
267
+ attributes: { class: "sb-section" },
268
+ children: [
269
+ { attributes: { class: "sb-label" }, tagName: "div", textContent: label },
270
+ { attributes: { class: "sb-body" }, children: cards, tagName: "div" },
271
+ ],
272
+ tagName: "div",
273
+ }) as JxMutableNode;
274
+
275
+ // Paths below mirror the assembled tree: root.children[si] = section; section.children[1] =
276
+ // Sb-body; body.children[ci] = card. Sections are appended only when non-empty, so indices are
277
+ // Computed from the OUTPUT arrays, not the input catalog.
278
+ const sectionBodyPath = (): JxPath => ["children", sections.length, "children", 1];
279
+
280
+ for (const section of opts.meta.$sections) {
281
+ let entries = section.elements;
282
+ if (filter) {
283
+ entries = entries.filter(
284
+ (e) => e.tag.includes(filter) || section.label.toLowerCase().includes(filter),
285
+ );
286
+ }
287
+ if (opts.customizedOnly) {
288
+ entries = entries.filter((e) => hasTagStyle(opts.effectiveStyle, e.tag));
289
+ }
290
+ if (entries.length === 0) {
291
+ continue;
292
+ }
293
+ const bodyPath = sectionBodyPath();
294
+ const cards = entries.map((entry, ci) =>
295
+ cardNode(
296
+ entry.tag,
297
+ `<${entry.tag}>`,
298
+ specimenNode(entry),
299
+ [...bodyPath, "children", ci],
300
+ entry,
301
+ ),
302
+ );
303
+ sections.push(sectionNode(section.label, cards));
304
+ }
305
+
306
+ // Custom components from the registry — live custom elements registered via $elements.
307
+ const $elements: (string | { $ref: string })[] = [];
308
+ {
309
+ let comps = opts.components;
310
+ if (filter) {
311
+ comps = comps.filter((c) => c.tagName.toLowerCase().includes(filter));
312
+ }
313
+ if (opts.customizedOnly) {
314
+ comps = comps.filter((c) => hasTagStyle(opts.effectiveStyle, c.tagName));
315
+ }
316
+ if (comps.length > 0) {
317
+ const bodyPath = sectionBodyPath();
318
+ const cards = comps.map((comp, ci) => {
319
+ const cardPath = [...bodyPath, "children", ci];
320
+ const renderable = comp.source !== "npm" && comp.path?.endsWith(".json");
321
+ if (renderable) {
322
+ // Root-relative $ref: registerElements resolves it against docBase (the canvas origin).
323
+ // Collapse leading slashes — an absolute projectRoot ("/home/…") would otherwise produce
324
+ // A protocol-relative "//home/…" that URL-resolves to a foreign host.
325
+ $elements.push({
326
+ $ref: `/${opts.projectRoot ? `${opts.projectRoot}/` : ""}${comp.path}`.replace(
327
+ /^\/+/,
328
+ "/",
329
+ ),
330
+ });
331
+ const attributes: Record<string, string> = {};
332
+ for (const p of comp.props ?? []) {
333
+ if (p.default !== undefined && p.default !== "false" && p.default !== "''") {
334
+ attributes[p.name] = String(p.default).replaceAll(/^'|'$/g, "");
335
+ }
336
+ }
337
+ const specimen = {
338
+ tagName: comp.tagName,
339
+ ...(Object.keys(attributes).length > 0 ? { attributes } : {}),
340
+ } as JxMutableNode;
341
+ return cardNode(comp.tagName, `<${comp.tagName}>`, specimen, cardPath, null);
342
+ }
343
+ // Npm/format-class components can't be $ref-registered — placeholder box (legacy parity).
344
+ const fallback = {
345
+ attributes: { class: "sb-fallback" },
346
+ tagName: "div",
347
+ textContent: `<${comp.tagName}>`,
348
+ } as JxMutableNode;
349
+ return cardNode(comp.tagName, `<${comp.tagName}>`, fallback, cardPath, null);
350
+ });
351
+ sections.push(sectionNode("Components", cards));
352
+ }
353
+ }
354
+
355
+ if (sections.length === 0) {
356
+ sections.push({
357
+ attributes: { class: "sb-empty" },
358
+ tagName: "div",
359
+ textContent: opts.customizedOnly ? "No customized elements" : "No matching elements",
360
+ } as JxMutableNode);
361
+ }
362
+
363
+ const doc = {
364
+ ...($elements.length > 0 ? { $elements } : {}),
365
+ $media: opts.effectiveMedia,
366
+ attributes: { class: "sb-root" },
367
+ children: sections,
368
+ style: transposeStylebookStyle(opts.effectiveStyle),
369
+ tagName: "div",
370
+ } as JxMutableNode;
371
+
372
+ return { doc, pathToTag, tagToCardPath };
373
+ }