@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
@@ -0,0 +1,62 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Cross-frame drag GHOST (Phase 4c, commit 5). A single parent-realm `position:fixed` layer that
4
+ * follows the raw pointer 1:1 during a canvas drag. It lives OUTSIDE the scaled `panzoom-wrap`
5
+ * (D-3) — a sibling of `#canvas-wrap` on `<body>` — so it tracks `clientX`/`clientY` directly and
6
+ * is NEVER subject to the canvas zoom transform (drawing it in the scaled overlay would shrink/grow
7
+ * it with zoom). pragmatic's native drag image is suppressed per source via
8
+ * `disableNativeDragPreview`, so this is the only drag affordance the user sees.
9
+ *
10
+ * One ghost is reused across drags + panels: {@link setDragGhost} shows it with a label and moves
11
+ * it to a cursor; {@link clearDragGhost} hides it. The element is created lazily on first show so
12
+ * the module has no import-time DOM side effect (and stays inert in tests until used).
13
+ */
14
+
15
+ let ghostEl: HTMLElement | null = null;
16
+
17
+ /** Lazily create (once) the fixed ghost element appended to `<body>`. */
18
+ function ensureGhost(doc: Document): HTMLElement {
19
+ if (ghostEl?.isConnected) {
20
+ return ghostEl;
21
+ }
22
+ const el = doc.createElement("div");
23
+ el.className = "jx-drag-ghost";
24
+ // Fixed + non-interactive + above the canvas; offset slightly from the cursor so it doesn't sit
25
+ // Under the pointer and block hit-testing visuals. Positioned via left/top on each move.
26
+ el.style.cssText =
27
+ "position:fixed;left:0;top:0;z-index:9999;pointer-events:none;display:none;" +
28
+ "padding:2px 8px;border-radius:4px;font-size:12px;line-height:1.4;white-space:nowrap;" +
29
+ "background:var(--accent,#3b82f6);color:var(--accent-fg,#fff);box-shadow:0 2px 8px rgba(0,0,0,0.25);" +
30
+ "transform:translate(8px,8px)";
31
+ doc.body.append(el);
32
+ ghostEl = el;
33
+ return el;
34
+ }
35
+
36
+ /**
37
+ * Show the ghost with `label`, positioned at the raw pointer (`x`,`y` in parent-viewport px). Safe
38
+ * to call every pointermove; reuses the one element.
39
+ */
40
+ export function setDragGhost(label: string, x: number, y: number, doc: Document = document): void {
41
+ const el = ensureGhost(doc);
42
+ el.textContent = label;
43
+ el.style.left = `${x}px`;
44
+ el.style.top = `${y}px`;
45
+ el.style.display = "block";
46
+ }
47
+
48
+ /** Move the ghost to a new pointer position without changing its label (no-op when hidden). */
49
+ export function moveDragGhost(x: number, y: number): void {
50
+ if (!ghostEl || ghostEl.style.display === "none") {
51
+ return;
52
+ }
53
+ ghostEl.style.left = `${x}px`;
54
+ ghostEl.style.top = `${y}px`;
55
+ }
56
+
57
+ /** Hide the ghost (between drags). The element is retained for reuse. */
58
+ export function clearDragGhost(): void {
59
+ if (ghostEl) {
60
+ ghostEl.style.display = "none";
61
+ }
62
+ }
@@ -102,7 +102,7 @@ export function renderFunctionEditor() {
102
102
 
103
103
  view.functionEditor = monaco.editor.create(editorContainer as unknown as HTMLElement, {
104
104
  automaticLayout: true,
105
- fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
105
+ fontFamily: "'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace",
106
106
  fontSize: 12,
107
107
  language: "javascript",
108
108
  lineNumbers: "on",
@@ -23,6 +23,7 @@ import { view } from "../view";
23
23
  import { showConfirmDialog, showDialog } from "../ui/layers";
24
24
  import { statusMessage } from "./statusbar";
25
25
  import { publishToGithub } from "../github/github-publish";
26
+ import { pullWithPackageSync } from "../packages/pull-package-sync";
26
27
 
27
28
  interface GitLogEntry {
28
29
  hash: string;
@@ -141,6 +142,20 @@ async function gitAction(action: string, body?: unknown) {
141
142
  }
142
143
  }
143
144
 
145
+ /** Pull via the package-aware orchestrator; same loading/error contract as gitAction. */
146
+ async function doPull() {
147
+ updateUi("gitLoading", true);
148
+ updateUi("gitError", null);
149
+ try {
150
+ await pullWithPackageSync();
151
+ await refreshGitStatus();
152
+ } catch (error) {
153
+ updateUi("gitError", errorMessage(error));
154
+ updateUi("gitLoading", false);
155
+ renderOnly("leftPanel");
156
+ }
157
+ }
158
+
144
159
  let _pollTimer = null as ReturnType<typeof setInterval> | null;
145
160
  let _lastUpdated = null as Date | null;
146
161
  let _gitSubTab = "changes";
@@ -315,7 +330,7 @@ export function renderGitPanel(
315
330
  </sp-action-button>
316
331
  <sp-action-button
317
332
  title="Pull${status?.behind ? ` (${status.behind} behind)` : ""}"
318
- @click=${() => gitAction("gitPull")}
333
+ @click=${() => void doPull()}
319
334
  ?disabled=${loading}
320
335
  >
321
336
  <sp-icon-arrow-down slot="icon" size="xs"></sp-icon-arrow-down>
@@ -1,29 +1,15 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Overlays panel — renders hover/selection overlay boxes on canvas panels. Delegates block action
4
- * bar rendering to studio.js via ctx callback.
3
+ * Overlays panel — the iframe canvas owns hit-testing and draws its own hover/selection boxes (from
4
+ * posted rects, inside each host's overlay layer), so this panel only keeps panel-header
5
+ * highlighting in sync and delegates the block-action-bar render on tracked session changes.
5
6
  */
6
7
 
7
- import { html, render as litRender, nothing } from "lit-html";
8
- import { styleMap } from "lit-html/directives/style-map.js";
9
- import { canvasPanels, pathsEqual } from "../store";
10
8
  import { effect, effectScope } from "../reactivity";
11
9
  import { activeTab } from "../workspace/workspace";
12
- import { view } from "../view";
13
- import { effectiveZoom, findCanvasElement, getActivePanel } from "../canvas/canvas-helpers";
14
- import { layoutElements } from "../canvas/canvas-live-render";
10
+ import { updateActivePanelHeaders } from "../canvas/canvas-utils";
15
11
  import type { EffectScope } from "@vue/reactivity";
16
12
 
17
- interface OverlayBox {
18
- cls: string;
19
- top: string;
20
- left: string;
21
- width: string;
22
- height: string;
23
- border?: string;
24
- isLayout?: boolean;
25
- }
26
-
27
13
  interface OverlaysCtx {
28
14
  getCanvasMode: () => string;
29
15
  isEditing: () => boolean;
@@ -50,10 +36,12 @@ export function mount(ctx: OverlaysCtx) {
50
36
  if (!tab) {
51
37
  return;
52
38
  }
53
- // Track selection, hover, and mode
39
+ // Track selection, hover, mode, and the active panel (a hit in another breakpoint panel — or
40
+ // A header click — re-anchors the block action bar even when the selection path is unchanged).
54
41
  void tab.session.selection;
55
42
  void tab.session.hover;
56
43
  void tab.doc.mode;
44
+ void tab.session.ui.activeMedia;
57
45
  render();
58
46
  });
59
47
  });
@@ -84,113 +72,10 @@ function _flush() {
84
72
  if (!tab) {
85
73
  return;
86
74
  }
87
- const { selection, hover } = tab.session;
88
- const { stylebookTab } = tab.session.ui;
89
- const canvasMode = _ctx.getCanvasMode();
90
-
91
- if (canvasMode !== "design" && canvasMode !== "edit" && canvasMode !== "stylebook") {
92
- for (const p of canvasPanels) {
93
- litRender(nothing, p.overlay);
94
- p.overlayClk.style.pointerEvents = "none";
95
- }
96
- if (view.selDragCleanup) {
97
- view.selDragCleanup();
98
- view.selDragCleanup = null;
99
- }
100
- return;
101
- }
102
-
103
- if (canvasMode === "stylebook") {
104
- const enable = stylebookTab === "elements";
105
- for (const p of canvasPanels) {
106
- p.overlayClk.style.pointerEvents = enable ? "" : "none";
107
- }
108
- return;
109
- }
110
-
111
- for (const p of canvasPanels) {
112
- p.overlayClk.style.pointerEvents = view.componentInlineEdit || _ctx.isEditing() ? "none" : "";
113
- }
114
75
 
115
- if (view.selDragCleanup) {
116
- view.selDragCleanup();
117
- view.selDragCleanup = null;
118
- }
119
-
120
- for (const p of canvasPanels) {
121
- const boxes: OverlayBox[] = [];
122
-
123
- // Batch layout reads: read viewport geometry once per panel
124
- if (!p.viewport) {
125
- continue;
126
- }
127
- const vpRect = p.viewport.getBoundingClientRect();
128
- const { scrollTop } = p.viewport;
129
- const { scrollLeft } = p.viewport;
130
- const scale = effectiveZoom();
131
-
132
- if (hover && !pathsEqual(hover, selection)) {
133
- const el = findCanvasElement(hover, p.canvas);
134
- if (el) {
135
- const elRect = el.getBoundingClientRect();
136
- const desc: OverlayBox = {
137
- cls: "overlay-box overlay-hover",
138
- height: `${elRect.height / scale}px`,
139
- left: `${(elRect.left - vpRect.left + scrollLeft) / scale}px`,
140
- top: `${(elRect.top - vpRect.top + scrollTop) / scale}px`,
141
- width: `${elRect.width / scale}px`,
142
- };
143
- if (layoutElements.has(el)) {
144
- desc.isLayout = true;
145
- }
146
- boxes.push(desc);
147
- }
148
- }
149
-
150
- if (selection && p === getActivePanel()) {
151
- const el = findCanvasElement(selection, p.canvas);
152
- if (el) {
153
- const elRect = el.getBoundingClientRect();
154
- const desc: OverlayBox = {
155
- cls: "overlay-box overlay-selection",
156
- height: `${elRect.height / scale}px`,
157
- left: `${(elRect.left - vpRect.left + scrollLeft) / scale}px`,
158
- top: `${(elRect.top - vpRect.top + scrollTop) / scale}px`,
159
- width: `${elRect.width / scale}px`,
160
- };
161
- if (view.componentInlineEdit || _ctx.isEditing()) {
162
- desc.border = "none";
163
- }
164
- if (layoutElements.has(el)) {
165
- desc.isLayout = true;
166
- }
167
- boxes.push(desc);
168
- }
169
- }
170
-
171
- litRender(
172
- html`
173
- ${p.dropLine}
174
- ${boxes.map(
175
- (b) => html`
176
- <div
177
- class="${b.cls}${b.isLayout ? " overlay-layout" : ""}"
178
- style=${styleMap({
179
- border: b.border,
180
- height: b.height,
181
- left: b.left,
182
- top: b.top,
183
- width: b.width,
184
- })}
185
- >
186
- ${b.isLayout ? html`<span class="overlay-layout-badge">Layout</span>` : nothing}
187
- </div>
188
- `,
189
- )}
190
- `,
191
- p.overlay,
192
- );
193
- }
76
+ // Header highlighting follows hit-driven activation immediately (it otherwise only refreshes on
77
+ // Full canvas renders).
78
+ updateActivePanelHeaders();
194
79
 
195
80
  _ctx.renderBlockActionBar();
196
81
  }
@@ -29,6 +29,8 @@ import {
29
29
  inferInputType,
30
30
  parseCemType,
31
31
  } from "../utils/studio-utils";
32
+ import { classifyHref, composeHref } from "../utils/link-target";
33
+ import type { LinkKind } from "../utils/link-target";
32
34
  import { collectCssParts, isCustomElementDoc } from "./signals-panel";
33
35
  import { mediaDisplayName } from "./shared";
34
36
  import { getCssInitialMap } from "./style-utils";
@@ -800,6 +802,202 @@ function isPageDocument(documentPath: string | undefined | null) {
800
802
  return documentPath.startsWith("pages/") || documentPath.startsWith("./pages/");
801
803
  }
802
804
 
805
+ // ─── Page-route enumeration (for the Link-target Internal picker) ─────────────
806
+
807
+ /** @type {string[] | null} — cached list of internal routes derived from the pages/ tree. */
808
+ let pageRouteEntries: string[] | null = null;
809
+
810
+ /**
811
+ * Derive a site route from a page file path relative to `pages/`, following the file-based routing
812
+ * convention: `index.json` → the directory route, `[slug].json` → `:slug`, all others drop their
813
+ * extension. Directory routes get a trailing slash (`/about/`); the root is `/`.
814
+ *
815
+ * @param {string} relPath — path relative to `pages/`, forward-slashed (e.g. "blog/[slug].json").
816
+ * @returns {string}
817
+ */
818
+ function routeForPagePath(relPath: string): string {
819
+ const withoutExt = relPath.replace(/\.[^./]+$/, "");
820
+ const segments = withoutExt
821
+ .split("/")
822
+ .map((seg) => (seg.startsWith("[") ? `:${seg.slice(1, -1)}` : seg));
823
+ const isIndex = segments.at(-1) === "index";
824
+ if (isIndex) {
825
+ segments.pop();
826
+ }
827
+ const body = segments.join("/");
828
+ if (!body) {
829
+ return "/";
830
+ }
831
+ // Dynamic routes keep no trailing slash; static routes are directory-style (trailing slash).
832
+ return isIndex || body.includes(":") ? `/${body}${isIndex ? "/" : ""}` : `/${body}/`;
833
+ }
834
+
835
+ /** Recursively walk the pages/ tree and populate {@link pageRouteEntries} with derived routes. */
836
+ async function loadPageRouteEntries() {
837
+ const platform = getPlatform();
838
+ const routes: string[] = [];
839
+ const docExts = new Set([".json", ".md", ".html"]);
840
+ async function walk(dir: string, rel: string) {
841
+ let listing: DirEntry[];
842
+ try {
843
+ listing = await platform.listDirectory(dir);
844
+ } catch {
845
+ return;
846
+ }
847
+ for (const entry of listing) {
848
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
849
+ if (entry.type === "directory") {
850
+ await walk(entry.path ?? `${dir}/${entry.name}`, childRel);
851
+ } else if (docExts.has(entry.name.slice(entry.name.lastIndexOf(".")))) {
852
+ routes.push(routeForPagePath(childRel));
853
+ }
854
+ }
855
+ }
856
+ await walk("pages", "");
857
+ pageRouteEntries = [...new Set(routes)].toSorted((a, b) => a.localeCompare(b));
858
+ renderOnly("rightPanel");
859
+ }
860
+
861
+ export function invalidatePageRouteCache() {
862
+ pageRouteEntries = null;
863
+ }
864
+
865
+ /**
866
+ * Composite Link-target control for an anchor's `href` — a kind selector (Internal / External /
867
+ * Anchor / Email / Phone) plus the matching input, backed by classifyHref/composeHref so edits
868
+ * round-trip. Internal targets render an sp-picker of page routes enumerated from the pages/ tree.
869
+ *
870
+ * @param {JxMutableNode} node
871
+ * @param {JxPath} path
872
+ */
873
+ function renderLinkTargetField(node: JxMutableNode, path: JxPath) {
874
+ const raw = typeof node.attributes?.href === "string" ? node.attributes.href : "";
875
+ const { kind, value } = classifyHref(raw);
876
+
877
+ const commit = (nextKind: LinkKind, nextValue: string) => {
878
+ const composed = composeHref(nextKind, nextValue);
879
+ transactDoc(activeTab.value!, (t) =>
880
+ mutateUpdateAttribute(t, path, "href", composed || undefined),
881
+ );
882
+ };
883
+
884
+ const kindOptions: { value: LinkKind; label: string }[] = [
885
+ { label: "Internal Page", value: "internal" },
886
+ { label: "External URL", value: "external" },
887
+ { label: "Anchor", value: "anchor" },
888
+ { label: "Email", value: "mailto" },
889
+ { label: "Phone", value: "tel" },
890
+ ];
891
+
892
+ const kindSelector = html`
893
+ <sp-picker
894
+ class="link-target-kind"
895
+ size="s"
896
+ value=${kind}
897
+ @change=${(e: Event) => {
898
+ const nextKind = (e.target as HTMLInputElement).value as LinkKind;
899
+ // Switching kind reinterprets the current value under the new kind.
900
+ commit(nextKind, value);
901
+ }}
902
+ >
903
+ ${kindOptions.map((o) => html`<sp-menu-item value=${o.value}>${o.label}</sp-menu-item>`)}
904
+ </sp-picker>
905
+ `;
906
+
907
+ let valueInput;
908
+ if (kind === "internal") {
909
+ if (pageRouteEntries === null) {
910
+ void loadPageRouteEntries();
911
+ }
912
+ const routes = pageRouteEntries ?? [];
913
+ const knownValue = value !== "" && !routes.includes(value);
914
+ valueInput = html`
915
+ <sp-picker
916
+ class="link-target-value"
917
+ size="s"
918
+ value=${value}
919
+ @change=${(e: Event) => commit("internal", (e.target as HTMLInputElement).value)}
920
+ >
921
+ ${knownValue ? html`<sp-menu-item value=${value}>${value}</sp-menu-item>` : nothing}
922
+ ${routes.map((r) => html`<sp-menu-item value=${r}>${r}</sp-menu-item>`)}
923
+ </sp-picker>
924
+ `;
925
+ } else {
926
+ const placeholder =
927
+ kind === "mailto"
928
+ ? "name@example.com"
929
+ : kind === "tel"
930
+ ? "+15551234567"
931
+ : kind === "anchor"
932
+ ? "section-id"
933
+ : "https://example.com";
934
+ valueInput = html`
935
+ <sp-textfield
936
+ class="link-target-value"
937
+ size="s"
938
+ placeholder=${placeholder}
939
+ .value=${live(value)}
940
+ @input=${debouncedStyleCommit("link:href", 400, (e: Event) =>
941
+ commit(kind, (e.target as HTMLInputElement).value),
942
+ )}
943
+ ></sp-textfield>
944
+ `;
945
+ }
946
+
947
+ return renderFieldRow({
948
+ hasValue: raw !== "",
949
+ label: "Link",
950
+ onClear: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "href")),
951
+ prop: "href",
952
+ widget: html`<div class="link-target-field">${kindSelector}${valueInput}</div>`,
953
+ });
954
+ }
955
+
956
+ /**
957
+ * Real enum picker (sp-picker) for the anchor `target` attribute, replacing the generic
958
+ * jx-value-selector so the four browsing-context keywords are offered as a dropdown.
959
+ *
960
+ * @param {JxMutableNode} node
961
+ * @param {JxPath} path
962
+ * @param {HtmlMetaEntry} entry
963
+ */
964
+ function renderTargetField(node: JxMutableNode, path: JxPath, entry: HtmlMetaEntry) {
965
+ const options = Array.isArray(entry.enum) ? (entry.enum as string[]) : [];
966
+ const current = typeof node.attributes?.target === "string" ? node.attributes.target : "";
967
+ return renderFieldRow({
968
+ hasValue: current !== "",
969
+ label: attrLabel(entry, "target"),
970
+ onClear: () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "target")),
971
+ prop: "target",
972
+ widget: html`
973
+ <sp-picker
974
+ class="link-target-window"
975
+ size="s"
976
+ value=${current}
977
+ @change=${(e: Event) =>
978
+ transactDoc(activeTab.value!, (t) =>
979
+ mutateUpdateAttribute(
980
+ t,
981
+ path,
982
+ "target",
983
+ (e.target as HTMLInputElement).value || undefined,
984
+ ),
985
+ )}
986
+ >
987
+ ${options.map((o) => html`<sp-menu-item value=${o}>${o}</sp-menu-item>`)}
988
+ </sp-picker>
989
+ `,
990
+ });
991
+ }
992
+
993
+ /**
994
+ * True when an attribute value is a binding (a `$ref` object or a template string containing
995
+ * `${…}`), so the Link-target special-case must fall back to the raw widget to keep it editable.
996
+ */
997
+ function isBoundAttrValue(value: unknown): boolean {
998
+ return isRef(value) || (typeof value === "string" && value.includes("${"));
999
+ }
1000
+
803
1001
  function renderPageSection(node: JxMutableNode) {
804
1002
  const tab = activeTab.value;
805
1003
  if (!isPageDocument(tab!.documentPath)) {
@@ -968,6 +1166,18 @@ export function renderPropertiesPanelTemplate(ctx: {
968
1166
  const type = inferInputType(entry);
969
1167
  const hasVal = value !== undefined && value !== "";
970
1168
 
1169
+ // Enhanced Link handling: only for anchors (a/area) with a plain (non-binding) value. Bindings
1170
+ // ($ref objects or ${…} template strings) fall through to the raw widget to stay editable.
1171
+ const isAnchor = tagName === "a" || tagName === "area";
1172
+ if (isAnchor && !isBoundAttrValue(value)) {
1173
+ if (attr === "href") {
1174
+ return renderLinkTargetField(node, path);
1175
+ }
1176
+ if (attr === "target") {
1177
+ return renderTargetField(node, path, entry);
1178
+ }
1179
+ }
1180
+
971
1181
  if (entry.type === "boolean") {
972
1182
  return renderFieldRow({
973
1183
  hasValue: hasVal,
@@ -10,7 +10,8 @@ import { rightPanel, updateUi } from "../store";
10
10
  import { effect, effectScope } from "../reactivity";
11
11
  import { createPanelScheduler } from "./panel-scheduler";
12
12
  import type { PanelScheduler } from "./panel-scheduler";
13
- import { activeTab } from "../workspace/workspace";
13
+ import { activeTab, workspace } from "../workspace/workspace";
14
+ import { consumePendingAgentPrompt, hasPendingAgentPrompt } from "../services/agent-seed";
14
15
  import { tabIcon } from "./activity-bar";
15
16
  import { eventsSidebarTemplate } from "./events-panel";
16
17
  import { isCustomElementDoc } from "./signals-panel";
@@ -22,16 +23,15 @@ import { renderPropertiesPanelTemplate } from "./properties-panel";
22
23
  import type { EffectScope } from "@vue/reactivity";
23
24
  import {
24
25
  renderAiPanelTemplate,
26
+ bindAiPanelHost,
25
27
  mountAiPanel,
26
- mountQuikChat,
27
- registerRightPanelRender,
28
+ seedAssistantPrompt,
28
29
  } from "./ai-panel";
29
30
 
30
31
  interface RightPanelCtx {
31
32
  navigateToComponent: (path: string) => void;
32
33
  getCanvasMode: () => string;
33
34
  renderCanvas: () => void;
34
- updateForcedPseudoPreview: () => void;
35
35
  }
36
36
 
37
37
  let _ctx: RightPanelCtx | null = null;
@@ -48,7 +48,6 @@ let _scheduler: PanelScheduler | null = null;
48
48
  export function mount(ctx: RightPanelCtx) {
49
49
  _ctx = ctx;
50
50
  mountAiPanel();
51
- registerRightPanelRender(render);
52
51
  _scheduler = createPanelScheduler({
53
52
  blockWhile: isColorPopoverOpen,
54
53
  render: _doRender,
@@ -117,6 +116,9 @@ function _ensureContainers() {
117
116
  _assistantContainer = document.createElement("div");
118
117
  _assistantContainer.className = "panel-body";
119
118
  _assistantContainer.style.cssText = "display:flex;flex-direction:column;overflow:hidden";
119
+ // The AI panel owns a focus-guard-free rAF render loop into this container so
120
+ // Streaming repaints while the composer is focused (see ai-panel.ts).
121
+ bindAiPanelHost(_assistantContainer);
120
122
  }
121
123
 
122
124
  function _doRender() {
@@ -136,6 +138,12 @@ function _doRender() {
136
138
  selection: aTab.session.selection,
137
139
  ui: aTab.session.ui,
138
140
  };
141
+ // A pending agent prompt (stored by the New Project flow, possibly from another window)
142
+ // Forces the Assistant tab open — same updateUi mechanism the automation hook uses.
143
+ const root = workspace.projectRoot;
144
+ if (root && S.ui.rightTab !== "assistant" && hasPendingAgentPrompt(root)) {
145
+ updateUi("rightTab", "assistant");
146
+ }
139
147
  const tab = S.ui.rightTab;
140
148
 
141
149
  // Render tabs header
@@ -218,10 +226,16 @@ function _doRender() {
218
226
  }
219
227
  } else if (tab === "assistant") {
220
228
  litRender(renderAiPanelTemplate(), _assistantContainer!);
229
+ if (root) {
230
+ // Consume-on-read keeps repeated renders idempotent; seed after the panel template
231
+ // Has rendered so the assistant machinery is in place.
232
+ const prompt = consumePendingAgentPrompt(root);
233
+ if (prompt) {
234
+ requestAnimationFrame(() => void seedAssistantPrompt(prompt));
235
+ }
236
+ }
221
237
  }
222
238
  } catch (error) {
223
239
  console.error("right-panel render error:", error);
224
240
  }
225
- requestAnimationFrame(() => mountQuikChat());
226
- _ctx.updateForcedPseudoPreview();
227
241
  }