@jxsuite/studio 0.33.0 → 0.34.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 (85) hide show
  1. package/dist/iframe-entry.js +6230 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +10754 -11060
  4. package/dist/studio.js.map +83 -72
  5. package/package.json +7 -7
  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 +2 -1
  41. package/src/page-params.ts +383 -0
  42. package/src/panels/ai-panel.ts +5 -7
  43. package/src/panels/block-action-bar.ts +296 -138
  44. package/src/panels/canvas-dnd-bridge.ts +397 -0
  45. package/src/panels/component-preview.ts +56 -0
  46. package/src/panels/dnd.ts +41 -17
  47. package/src/panels/drag-ghost.ts +62 -0
  48. package/src/panels/editors.ts +1 -1
  49. package/src/panels/overlays.ts +10 -125
  50. package/src/panels/properties-panel.ts +210 -0
  51. package/src/panels/right-panel.ts +0 -2
  52. package/src/panels/signals-panel.ts +136 -22
  53. package/src/panels/stylebook-doc.ts +373 -0
  54. package/src/panels/stylebook-panel.ts +46 -689
  55. package/src/panels/tab-bar.ts +159 -13
  56. package/src/panels/toolbar.ts +3 -2
  57. package/src/platforms/devserver.ts +15 -0
  58. package/src/services/monaco-setup.ts +12 -0
  59. package/src/services/render-critic.ts +9 -9
  60. package/src/settings/css-vars-editor.ts +2 -2
  61. package/src/store.ts +4 -62
  62. package/src/studio.ts +90 -40
  63. package/src/tabs/doc-op-apply.ts +89 -0
  64. package/src/tabs/patch-ops.ts +6 -2
  65. package/src/tabs/tab.ts +23 -4
  66. package/src/tabs/transact.ts +2 -74
  67. package/src/types.ts +14 -18
  68. package/src/ui/jx-theme.ts +63 -0
  69. package/src/ui/media-picker.ts +6 -4
  70. package/src/ui/spectrum.ts +5 -0
  71. package/src/utils/canvas-media.ts +0 -137
  72. package/src/utils/edit-display.ts +23 -3
  73. package/src/utils/geometry.ts +43 -0
  74. package/src/utils/link-target.ts +93 -0
  75. package/src/utils/strip-events.ts +54 -0
  76. package/src/view.ts +0 -23
  77. package/src/workspace/workspace.ts +14 -1
  78. package/src/canvas/canvas-subtree-render.ts +0 -113
  79. package/src/editor/component-inline-edit.ts +0 -349
  80. package/src/editor/content-inline-edit.ts +0 -207
  81. package/src/editor/insertion-helper.ts +0 -308
  82. package/src/panels/canvas-dnd.ts +0 -329
  83. package/src/panels/panel-events.ts +0 -306
  84. package/src/panels/preview-render.ts +0 -132
  85. package/src/panels/pseudo-preview.ts +0 -75
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Pure document-op applier — folds a value-carrying {@link JxDocOp} into a bare (non-reactive)
3
+ * document tree. Shared by two consumers that MUST stay byte-identical or undo/redo would drift
4
+ * from live editing: history replay in the parent ({@link file://./transact.ts}) and the iframe
5
+ * canvas's non-reactive shadow doc (the patch source-of-truth across the cross-origin bridge).
6
+ *
7
+ * Dependency-light on purpose: it pulls only the pure path helper from `../state` and clones values
8
+ * with a local JSON round-trip, so the slim canvas-iframe bundle can import it without dragging in
9
+ * the editor's reactive store / format host.
10
+ */
11
+
12
+ import { getNodeAtPath } from "../state";
13
+ import type { JxDocOp } from "./patch-ops";
14
+ import type { JxMutableNode } from "@jxsuite/schema/types";
15
+
16
+ /** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
17
+ function jsonClone<T>(value: T): T {
18
+ // oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
19
+ return JSON.parse(JSON.stringify(value)) as T;
20
+ }
21
+
22
+ /** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
23
+ export function cloneValue<T>(v: T): T {
24
+ return v === undefined || v === null ? v : (jsonClone(v as object) as T);
25
+ }
26
+
27
+ /** The node's children array, lazily created; throws if `node` is itself a children array or mapped. */
28
+ export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
29
+ // Defense-in-depth: a path that resolves to a children array (rather than a node) would
30
+ // Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
31
+ // Nothing renders. Callers must pass a node; fail loudly if they don't.
32
+ if (Array.isArray(node)) {
33
+ throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
34
+ }
35
+ if (!node.children) {
36
+ node.children = [];
37
+ }
38
+ if (!Array.isArray(node.children)) {
39
+ throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
40
+ }
41
+ return node.children;
42
+ }
43
+
44
+ /**
45
+ * Apply a replayable doc op to a bare document tree. Values/nodes are cloned in, so the tree never
46
+ * aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
47
+ */
48
+ export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
49
+ switch (op.op) {
50
+ case "set-key": {
51
+ const node = getNodeAtPath(doc, op.path);
52
+ if (!node) {
53
+ throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
54
+ }
55
+ const target = node as Record<string, unknown>;
56
+ if (op.value === undefined) {
57
+ delete target[op.key];
58
+ } else {
59
+ target[op.key] = cloneValue(op.value);
60
+ }
61
+ return;
62
+ }
63
+ case "insert-child": {
64
+ const parent = getNodeAtPath(doc, op.parentPath);
65
+ childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
66
+ return;
67
+ }
68
+ case "remove-child": {
69
+ const parent = getNodeAtPath(doc, op.parentPath);
70
+ childArray(parent).splice(op.index, 1);
71
+ return;
72
+ }
73
+ case "set-child": {
74
+ const parent = getNodeAtPath(doc, op.parentPath);
75
+ childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
76
+ return;
77
+ }
78
+ case "move-child": {
79
+ const fromParent = getNodeAtPath(doc, op.fromParentPath);
80
+ const toParent = getNodeAtPath(doc, op.toParentPath);
81
+ const [node] = childArray(fromParent).splice(op.fromIndex, 1);
82
+ childArray(toParent).splice(op.toIndex, 0, node!);
83
+ return;
84
+ }
85
+ default: {
86
+ throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
87
+ }
88
+ }
89
+ }
@@ -78,8 +78,12 @@ export interface PatchConsumer {
78
78
  classify: (tab: Tab, ops: JxPatchOp[]) => { patchable: boolean; reason: string };
79
79
  /** Mark a document root reference as surgically consumed (checked by the canvas doc-effect). */
80
80
  markConsumed: (docRef: object) => void;
81
- /** Apply the batch to all ready canvas panels. Throws on any failure (caller escalates). */
82
- apply: (tab: Tab, ops: JxPatchOp[]) => void;
81
+ /**
82
+ * Apply the batch to all ready canvas panels. Throws on any failure (caller escalates). `record`
83
+ * carries the value-carrying `docOps` the iframe consumer posts across the frame boundary; the
84
+ * legacy (parent-DOM) consumer ignores it and reads the post-mutation reactive doc instead.
85
+ */
86
+ apply: (tab: Tab, ops: JxPatchOp[], record?: TransactionRecord) => void;
83
87
  /** Schedule a full canvas render as the fallback path, recording the reason. */
84
88
  escalate: (reason: string) => void;
85
89
  }
package/src/tabs/tab.ts CHANGED
@@ -16,6 +16,12 @@ import type { JxDocOp } from "./patch-ops";
16
16
  export interface TabUi {
17
17
  rightTab: string;
18
18
  canvasMode: string;
19
+ /** Preview toggle — composes with an edit/design canvasMode; the effective mode becomes "preview". */
20
+ preview: boolean;
21
+ /** Show elements inherited from the page's layout (pages with an effective layout only). */
22
+ showLayout: boolean;
23
+ /** Chosen literal values for dynamic route params (e.g. { sku: "mini-trencher" }). */
24
+ previewParams: Record<string, string>;
19
25
  zoom: number;
20
26
  activeMedia: string | null;
21
27
  activeSelector: string | null;
@@ -79,7 +85,10 @@ export interface Tab {
79
85
  ui: TabUi;
80
86
  canvas: {
81
87
  status: string;
82
- scope: { stop: () => void; [k: string]: unknown } | null;
88
+ // A serializable snapshot of the iframe's resolved `$defs` (data-source values), posted over
89
+ // The bridge as a `dataScope` message and read by the data-explorer panel. Plain data now —
90
+ // The old live `EffectScope` (with `.stop()`) moved into the iframe realm with buildScope.
91
+ scope: Record<string, unknown> | null;
83
92
  error: string | null;
84
93
  pendingInlineEdit: InlineEditDef | null;
85
94
  };
@@ -92,9 +101,10 @@ export interface Tab {
92
101
 
93
102
  /**
94
103
  * @param {string} canvasMode — initial canvas mode (the tab's first allowed mode)
104
+ * @param {boolean} preview — initial preview-toggle state
95
105
  * @returns {TabUi}
96
106
  */
97
- function createDefaultUi(canvasMode: string) {
107
+ function createDefaultUi(canvasMode: string, preview = false) {
98
108
  return {
99
109
  activeMedia: null,
100
110
  activeSelector: null,
@@ -109,8 +119,11 @@ function createDefaultUi(canvasMode: string) {
109
119
  gitStatus: null,
110
120
  inspectorSections: {},
111
121
  pendingInlineEdit: null,
122
+ preview,
123
+ previewParams: {},
112
124
  rightTab: "properties",
113
125
  settingsTab: "stylebook",
126
+ showLayout: true,
114
127
  styleFilter: "",
115
128
  styleFilterActive: false,
116
129
  styleSections: {},
@@ -165,7 +178,10 @@ export function createTab({
165
178
  const resolvedModes = capabilities?.modes ?? inferModes(documentPath, sourceFormat);
166
179
  // A tab opens in its first allowed mode — never one the toolbar would disable.
167
180
  // Formats author mode order so the default comes first (edit, stylebook, etc.).
168
- const initialCanvasMode = resolvedModes[0] ?? "edit";
181
+ // "preview" is a per-tab toggle rather than a base mode: a preview-first format opens
182
+ // In its first non-preview mode with the toggle already on.
183
+ const initialCanvasMode = resolvedModes.find((m) => m !== "preview") ?? "edit";
184
+ const initialPreview = resolvedModes[0] === "preview";
169
185
 
170
186
  const tab = scope.run(() => ({
171
187
  capabilities: { modes: resolvedModes },
@@ -196,7 +212,7 @@ export function createTab({
196
212
  documentStack: [],
197
213
  hover: null,
198
214
  selection: null,
199
- ui: createDefaultUi(initialCanvasMode),
215
+ ui: createDefaultUi(initialCanvasMode, initialPreview),
200
216
  }),
201
217
  })) as unknown as Tab;
202
218
 
@@ -204,6 +220,9 @@ export function createTab({
204
220
  }
205
221
 
206
222
  /**
223
+ * Allowed modes for a document. "preview" in a format's mode list means the preview toggle is
224
+ * Available for the tab (it is not a base canvas mode the toolbar switches to).
225
+ *
207
226
  * @param {string | null | undefined} documentPath
208
227
  * @param {string | null} sourceFormat
209
228
  * @returns {string[]}
@@ -2,6 +2,7 @@
2
2
  import { toRaw } from "../reactivity";
3
3
  import { jsonClone } from "../utils/studio-utils";
4
4
  import { childIndex, getNodeAtPath, isAncestor, parentElementPath, pathsEqual } from "../state";
5
+ import { applyDocOpToDoc, childArray, cloneValue } from "./doc-op-apply";
5
6
  import {
6
7
  beginRecording,
7
8
  endRecording,
@@ -33,11 +34,6 @@ function patchHistoryEnabled() {
33
34
  }
34
35
  }
35
36
 
36
- /** Deep-clone a recorded value (undefined passes through; reactive proxies are read through). */
37
- function cloneValue<T>(v: T): T {
38
- return v === undefined || v === null ? v : (jsonClone(v as object) as T);
39
- }
40
-
41
37
  /** Forward/inverse pair for a single-key change on the node at path. */
42
38
  function setKeyPair(path: JxPath, key: string, before: unknown, after: unknown): JxDocOpPair {
43
39
  return {
@@ -81,26 +77,6 @@ export type JxNodeValue =
81
77
  | JxEventBinding
82
78
  | undefined;
83
79
 
84
- /**
85
- * The editable children array of a node, created when absent. Mapped-array children (`$prototype:
86
- * "Array"`) cannot be index-mutated — fail loudly instead of corrupting.
87
- */
88
- function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
89
- // Defense-in-depth: a path that resolves to a children array (rather than a node) would
90
- // Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
91
- // Nothing renders. Callers must pass a node; fail loudly if they don't.
92
- if (Array.isArray(node)) {
93
- throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
94
- }
95
- if (!node.children) {
96
- node.children = [];
97
- }
98
- if (!Array.isArray(node.children)) {
99
- throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
100
- }
101
- return node.children;
102
- }
103
-
104
80
  // ─── Transactional layer ─────────────────────────────────────────────────────
105
81
 
106
82
  /**
@@ -152,7 +128,7 @@ export function transactDoc(
152
128
 
153
129
  if (verdict.patchable) {
154
130
  try {
155
- consumer!.apply(tab, record.ops);
131
+ consumer!.apply(tab, record.ops, record);
156
132
  } catch (error) {
157
133
  consumer!.escalate(
158
134
  `patch-apply-failed: ${error instanceof Error ? error.message : String(error)}`,
@@ -254,54 +230,6 @@ export function transact(
254
230
  /** Document-level keys whose changes require a full scope/panel rebuild on the canvas. */
255
231
  const DOC_META_KEYS = new Set(["state", "$media", "$head", "$elements", "imports", "$layout"]);
256
232
 
257
- /**
258
- * Apply a replayable doc op to a bare document tree (history replay — no canvas recording).
259
- *
260
- * @param {JxMutableNode} doc
261
- * @param {JxDocOp} op
262
- */
263
- function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp) {
264
- switch (op.op) {
265
- case "set-key": {
266
- const node = getNodeAtPath(doc, op.path);
267
- if (!node) {
268
- throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
269
- }
270
- if (op.value === undefined) {
271
- delete node[op.key];
272
- } else {
273
- node[op.key] = cloneValue(op.value) as JxNodeValue;
274
- }
275
- return;
276
- }
277
- case "insert-child": {
278
- const parent = getNodeAtPath(doc, op.parentPath);
279
- childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
280
- return;
281
- }
282
- case "remove-child": {
283
- const parent = getNodeAtPath(doc, op.parentPath);
284
- childArray(parent).splice(op.index, 1);
285
- return;
286
- }
287
- case "set-child": {
288
- const parent = getNodeAtPath(doc, op.parentPath);
289
- childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
290
- return;
291
- }
292
- case "move-child": {
293
- const fromParent = getNodeAtPath(doc, op.fromParentPath);
294
- const toParent = getNodeAtPath(doc, op.toParentPath);
295
- const [node] = childArray(fromParent).splice(op.fromIndex, 1);
296
- childArray(toParent).splice(op.toIndex, 0, node!);
297
- return;
298
- }
299
- default: {
300
- throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
301
- }
302
- }
303
- }
304
-
305
233
  /**
306
234
  * Apply a doc op to the live document AND record the matching canvas patch op, so undo/redo
307
235
  * transactions patch the canvas surgically like any other edit.
package/src/types.ts CHANGED
@@ -126,6 +126,13 @@ export interface RenameResult {
126
126
  export interface StudioPlatform {
127
127
  id: string;
128
128
  projectRoot: string;
129
+ /**
130
+ * URL of the canvas iframe document. Optional: when set (chromium serves it from the project
131
+ * server under the studio namespace), the iframe host uses it; otherwise the host falls back to
132
+ * the default dev-server path. Not keyed on `id` — chromium and electrobun both report `id:
133
+ * "desktop"`, but only chromium sets this.
134
+ */
135
+ canvasUrl?: string;
129
136
  activate: (root?: string) => Promise<void>;
130
137
  openProject: () => Promise<{
131
138
  config: ProjectConfig;
@@ -190,6 +197,13 @@ export interface StudioPlatform {
190
197
  /** Invoke a format capability (parse/serialize) — { format, action, source?, doc?, options? }. */
191
198
  formatAction?: (payload: Record<string, unknown>) => Promise<unknown>;
192
199
  fetchPluginSchema: (src: string, prototype?: string, base?: string) => Promise<unknown>;
200
+ /**
201
+ * Resolve a class-prototype config through the backend's `/__jx_resolve__` pipeline (with the
202
+ * project's content types loaded) and return the parsed result. Used by the tab-bar's dynamic
203
+ * route-param picker to enumerate ContentCollection entries. Optional: platforms without a
204
+ * resolve backend omit it and the picker falls back to a plain fetch.
205
+ */
206
+ resolveClass?: (body: Record<string, unknown>) => Promise<unknown>;
193
207
  gitStatus: () => Promise<GitStatusResult>;
194
208
  gitBranches: () => Promise<GitBranchesResult>;
195
209
  gitLog: (limit?: number) => Promise<GitLogEntry[]>;
@@ -252,18 +266,11 @@ export interface CanvasPanel {
252
266
  mediaName: string;
253
267
  element: HTMLElement;
254
268
  canvas: HTMLElement;
255
- overlay: HTMLElement;
256
- overlayClk: HTMLElement;
257
269
  viewport: HTMLElement;
258
270
  scrollContainer: HTMLElement;
259
- dropLine: HTMLElement;
260
271
  _width: number | null;
261
272
  /** True when the panel's DOM reflects the current document via a successful live render. */
262
273
  ready: boolean;
263
- /** Breakpoints active for this panel's width (persisted for surgical patch re-application). */
264
- activeBreakpoints: Set<string> | null;
265
- /** Render context captured from the last successful live render (null until then). */
266
- liveCtx: PanelLiveCtx | null;
267
274
  /**
268
275
  * Effect scope owning the reactive effects created while rendering this panel's content
269
276
  * (including child scopes from surgical subtree renders). Stopped when panels are rebuilt.
@@ -271,17 +278,6 @@ export interface CanvasPanel {
271
278
  renderScope: { stop: () => void; run: <T>(fn: () => T) => T | undefined } | null;
272
279
  }
273
280
 
274
- /** Per-panel context persisted by a successful live render so patches can re-render subtrees. */
275
- export interface PanelLiveCtx {
276
- scope: Record<string, unknown>;
277
- canvasMode: string;
278
- layoutWrapped: boolean;
279
- pageContentPrefix: (string | number)[] | null;
280
- pageContentOffset: number;
281
- arrayPaths: Set<string>;
282
- pathMapper: (created: Node, path: (string | number)[], def: unknown) => void;
283
- }
284
-
285
281
  export interface DocumentStackEntry {
286
282
  document: JxMutableNode;
287
283
  documentPath: string | null;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Jx brand theme fragment.
3
+ *
4
+ * Registered as the Spectrum 'app' fragment (see spectrum.ts), so it is adopted into every
5
+ * <sp-theme> shadow root _after_ the system/color/scale fragments and wins the :host cascade. It
6
+ * rebrands the stock Spectrum dark theme by overriding only the palette `-rgb` triplets: every
7
+ * derived token in the published theme CSS (accent, focus ring, background layers, alpha-composed
8
+ * tints) resolves through `rgba(var(--spectrum-*-rgb))`, so the whole theme follows coherently.
9
+ *
10
+ * Canonical brand values: sites/jxsuite.com/project.json (accent #3b82f6, accent-hover #60a5fa,
11
+ * neutral surfaces #0a0a0a / #111111 / #1a1a1a / #222222). Intermediate stops interpolate along the
12
+ * Tailwind blue/zinc ramps the brand palette derives from.
13
+ *
14
+ * Dark-theme stop order: gray 50 (darkest) -> 900 (lightest); blue 100 (darkest) -> 1400
15
+ * (lightest). Semantic anchors under this ramp:
16
+ *
17
+ * - Accent button fill = accent-500 (white text ~5.2:1)
18
+ * - Focus ring = blue-800 (brand accent-hover)
19
+ * - Studio --accent = accent-700 (exact brand blue)
20
+ * - Accent content/visual = accent-900 (soft on-dark tint)
21
+ */
22
+
23
+ import { css } from "@spectrum-web-components/base";
24
+
25
+ export const jxTheme = css`
26
+ :host {
27
+ /* Neutral (gray) ramp — Jx near-black surfaces and zinc text tones */
28
+ --spectrum-gray-50-rgb: 10, 10, 10; /* #0a0a0a bg-primary / base */
29
+ --spectrum-gray-75-rgb: 17, 17, 17; /* #111111 bg-secondary / layer-1 */
30
+ --spectrum-gray-100-rgb: 26, 26, 26; /* #1a1a1a surface / layer-2 */
31
+ --spectrum-gray-200-rgb: 34, 34, 34; /* #222222 border / surface-hover */
32
+ --spectrum-gray-300-rgb: 63, 63, 70; /* #3f3f46 strong border */
33
+ --spectrum-gray-400-rgb: 82, 82, 91; /* #52525b component border */
34
+ --spectrum-gray-500-rgb: 113, 113, 122; /* #71717a text-muted */
35
+ --spectrum-gray-600-rgb: 161, 161, 170; /* #a1a1aa text-secondary */
36
+ --spectrum-gray-700-rgb: 212, 212, 216; /* #d4d4d8 subtle text */
37
+ --spectrum-gray-800-rgb: 228, 228, 231; /* #e4e4e7 primary text */
38
+ --spectrum-gray-900-rgb: 250, 250, 250; /* #fafafa ink / headings */
39
+
40
+ /* Blue ramp — drives accent, informative, and the focus indicator */
41
+ --spectrum-blue-100-rgb: 23, 37, 84; /* #172554 */
42
+ --spectrum-blue-200-rgb: 30, 58, 138; /* #1e3a8a */
43
+ --spectrum-blue-300-rgb: 30, 64, 175; /* #1e40af */
44
+ --spectrum-blue-400-rgb: 29, 78, 216; /* #1d4ed8 */
45
+ --spectrum-blue-500-rgb: 37, 99, 235; /* #2563eb accent button fill */
46
+ --spectrum-blue-600-rgb: 48, 112, 241; /* #3070f1 */
47
+ --spectrum-blue-700-rgb: 59, 130, 246; /* #3b82f6 brand accent */
48
+ --spectrum-blue-800-rgb: 96, 165, 250; /* #60a5fa accent-hover / focus */
49
+ --spectrum-blue-900-rgb: 147, 197, 253; /* #93c5fd soft on-dark accent */
50
+ --spectrum-blue-1000-rgb: 191, 219, 254; /* #bfdbfe */
51
+ --spectrum-blue-1100-rgb: 219, 234, 254; /* #dbeafe */
52
+ --spectrum-blue-1200-rgb: 239, 246, 255; /* #eff6ff */
53
+ --spectrum-blue-1300-rgb: 248, 250, 252; /* #f8fafc */
54
+ --spectrum-blue-1400-rgb: 255, 255, 255; /* #ffffff */
55
+
56
+ /* Brand font stacks. Sans drops adobe-clean so Adobe Clean never renders
57
+ on machines that have it installed; mono leads with the vendored
58
+ JetBrains Mono (see index.html @font-face). */
59
+ --spectrum-sans-font-family-stack: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
60
+ --spectrum-code-font-family-stack:
61
+ "JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
62
+ }
63
+ `;
@@ -12,6 +12,8 @@ import { ref } from "lit-html/directives/ref.js";
12
12
  import { getPlatform } from "../platform";
13
13
  import { debouncedStyleCommit, renderOnly } from "../store";
14
14
  import { getLayerSlot } from "./layers";
15
+ import { rectOf } from "../utils/geometry";
16
+ import { loopbackAssetSrc } from "../canvas/canvas-origin";
15
17
 
16
18
  // ─── Media file cache ────────────────────────────────────────────────────────
17
19
 
@@ -142,7 +144,7 @@ function onPopoverOutsideClick(e: MouseEvent) {
142
144
 
143
145
  function renderMediaPickerPopover() {
144
146
  const host = getLayerSlot("popover", "media-picker");
145
- const rect = _popoverAnchorEl?.getBoundingClientRect();
147
+ const rect = _popoverAnchorEl ? rectOf(_popoverAnchorEl) : undefined;
146
148
  if (!rect) {
147
149
  return;
148
150
  }
@@ -210,7 +212,7 @@ function renderMediaPickerPopover() {
210
212
  ${m.isImage
211
213
  ? html`<img
212
214
  slot="icon"
213
- src=${m.path}
215
+ src=${loopbackAssetSrc(m.path)}
214
216
  alt=""
215
217
  style="width:24px;height:24px;object-fit:cover;border-radius:var(--spectrum-corner-radius-75, 2px)"
216
218
  />`
@@ -232,7 +234,7 @@ function renderMediaPickerPopover() {
232
234
  // Fine-tune position after render using actual measured dimensions
233
235
  requestAnimationFrame(() => {
234
236
  if (_popoverEl) {
235
- const popoverRect = _popoverEl.getBoundingClientRect();
237
+ const popoverRect = rectOf(_popoverEl);
236
238
  let adjLeft = popoverRect.left;
237
239
  let adjTop = popoverRect.top;
238
240
  let needsAdjust = false;
@@ -304,7 +306,7 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
304
306
  return html`
305
307
  <div class="media-picker">
306
308
  ${isImage && currentValue
307
- ? html`<img class="media-picker-thumb" src=${currentValue} alt="" />`
309
+ ? html`<img class="media-picker-thumb" src=${loopbackAssetSrc(currentValue)} alt="" />`
308
310
  : nothing}
309
311
  <sp-textfield
310
312
  size="s"
@@ -11,6 +11,7 @@ import { Theme } from "@spectrum-web-components/theme/src/Theme.js";
11
11
  import themeSpectrumCSS from "@spectrum-web-components/theme/src/theme.css.js";
12
12
  import themeDarkCSS from "@spectrum-web-components/theme/src/theme-dark.css.js";
13
13
  import scaleMediumCSS from "@spectrum-web-components/theme/src/scale-medium.css.js";
14
+ import { jxTheme } from "./jx-theme";
14
15
  import { Tabs } from "@spectrum-web-components/tabs/src/Tabs.js";
15
16
  import { Tab } from "@spectrum-web-components/tabs/src/Tab.js";
16
17
  import { TabPanel } from "@spectrum-web-components/tabs/src/TabPanel.js";
@@ -296,5 +297,9 @@ for (const [tag, ctor] of components as [string, CustomElementConstructor][]) {
296
297
  Theme.registerThemeFragment("spectrum", "system", themeSpectrumCSS);
297
298
  Theme.registerThemeFragment("dark", "color", themeDarkCSS);
298
299
  Theme.registerThemeFragment("medium", "scale", scaleMediumCSS);
300
+ /* Jx brand overrides. The 'app' kind must be registered under the literal
301
+ name "app" and, registered last, is adopted after the fragments above so
302
+ its :host declarations win the cascade (see src/ui/jx-theme.ts). */
303
+ Theme.registerThemeFragment("app", "app", jxTheme);
299
304
 
300
305
  export { components };
@@ -2,8 +2,6 @@
2
2
  /// <reference lib="dom.iterable" />
3
3
  /** Canvas media/breakpoint utilities — pure functions extracted for testability. */
4
4
 
5
- import type { JxStyle } from "@jxsuite/schema/types";
6
-
7
5
  /**
8
6
  * Classify $media entries into size breakpoints (get a canvas each) and feature queries (rendered
9
7
  * as toolbar toggles).
@@ -63,138 +61,3 @@ export function activeBreakpointsForWidth(
63
61
  }
64
62
  return active;
65
63
  }
66
-
67
- /**
68
- * Apply styles to a canvas element, including active media overrides. Base (flat) styles applied
69
- * first, then matching media overrides in source order.
70
- *
71
- * @param {HTMLElement} el
72
- * @param {JxStyle | null | undefined} styleDef
73
- * @param {Set<string>} activeBreakpoints
74
- * @param {Record<string, boolean>} featureToggles
75
- */
76
- export function applyCanvasStyle(
77
- el: HTMLElement,
78
- styleDef: JxStyle | null | undefined,
79
- activeBreakpoints: Set<string>,
80
- featureToggles: Record<string, boolean>,
81
- ) {
82
- if (!styleDef || typeof styleDef !== "object") {
83
- return;
84
- }
85
- for (const [prop, val] of Object.entries(styleDef)) {
86
- if (typeof val === "string" || typeof val === "number") {
87
- try {
88
- if (prop.startsWith("--")) {
89
- el.style.setProperty(prop, String(val));
90
- } else {
91
- (el.style as unknown as Record<string, unknown>)[prop] = val;
92
- }
93
- } catch {}
94
- }
95
- }
96
- for (const [key, val] of Object.entries(styleDef)) {
97
- if (!key.startsWith("@") || typeof val !== "object" || val === null) {
98
- continue;
99
- }
100
- const mediaName = key.slice(1);
101
- if (mediaName === "--") {
102
- continue;
103
- }
104
- if (activeBreakpoints.has(mediaName) || featureToggles[mediaName]) {
105
- for (const [prop, v] of Object.entries(/** @type {Record<string, unknown>} */ val)) {
106
- if (typeof v === "string" || typeof v === "number") {
107
- try {
108
- if (prop.startsWith("--")) {
109
- el.style.setProperty(prop, String(v));
110
- } else {
111
- (el.style as unknown as Record<string, unknown>)[prop] = v;
112
- }
113
- } catch {}
114
- }
115
- }
116
- }
117
- }
118
- }
119
-
120
- /**
121
- * Scan stylesheets for @media rules matching active breakpoints, collecting the CSS declarations
122
- * that should be applied as inline overrides per data-jx element.
123
- *
124
- * Returns a Map of data-jx uid → Map of CSS property → value.
125
- *
126
- * @param {Iterable<CSSStyleSheet>} styleSheets
127
- * @param {Set<string>} activeBreakpoints
128
- * @returns {Map<string, Map<string, string>>}
129
- */
130
- export function collectMediaOverrides(
131
- styleSheets: Iterable<CSSStyleSheet>,
132
- activeBreakpoints: Set<string>,
133
- ) {
134
- const overrides = new Map<string, Map<string, string>>();
135
- if (activeBreakpoints.size === 0) {
136
- return overrides;
137
- }
138
-
139
- for (const sheet of styleSheets) {
140
- /** @type {CSSRuleList | null} */
141
- let rules;
142
- try {
143
- rules = sheet.cssRules;
144
- } catch {
145
- continue;
146
- }
147
- if (!rules) {
148
- continue;
149
- }
150
- for (const rule of rules) {
151
- if (!(rule instanceof CSSMediaRule)) {
152
- continue;
153
- }
154
- if (!activeBreakpoints.has(rule.conditionText)) {
155
- continue;
156
- }
157
- for (const mediaRule of rule.cssRules) {
158
- if (!(mediaRule instanceof CSSStyleRule)) {
159
- continue;
160
- }
161
- const selector = mediaRule.selectorText;
162
- const jxMatch = selector.match(/\[data-jx="([^"]+)"\]/);
163
- if (!jxMatch) {
164
- continue;
165
- }
166
- const [, uid] = jxMatch;
167
- if (!overrides.has(uid!)) {
168
- overrides.set(uid!, new Map());
169
- }
170
- const props = overrides.get(uid!) as Map<string, string>;
171
- for (const prop of mediaRule.style) {
172
- props.set(prop, mediaRule.style.getPropertyValue(prop));
173
- }
174
- }
175
- }
176
- }
177
- return overrides;
178
- }
179
-
180
- /**
181
- * Apply collected media overrides to elements within a canvas.
182
- *
183
- * @param {Element} canvasEl
184
- * @param {Map<string, Map<string, string>>} overrides
185
- */
186
- export function applyOverridesToCanvas(
187
- canvasEl: Element,
188
- overrides: Map<string, Map<string, string>>,
189
- ) {
190
- for (const [uid, props] of overrides) {
191
- const els = canvasEl.querySelectorAll(`[data-jx="${uid}"]`);
192
- for (const el of els) {
193
- for (const [prop, val] of props) {
194
- try {
195
- (el as HTMLElement).style.setProperty(prop, val);
196
- } catch {}
197
- }
198
- }
199
- }
200
- }