@backmatter/studio 0.0.15 → 0.0.17

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 (80) hide show
  1. package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-D7vllRQE.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-DH1LD-2Q.js} +30013 -27965
  3. package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-Dsaeud2x.js} +55 -54
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-DHJvh5Uy.js → index-CGMqw2NH.js} +1 -1
  6. package/dist/dashboard.js +2 -2
  7. package/dist/define.js +1 -1
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.css +49 -1
  10. package/dist/site-widget.js +8341 -8058
  11. package/dist/studio.css +279 -13
  12. package/package.json +2 -2
  13. package/src/WorkspaceRoutes.tsx +5 -2
  14. package/src/api.ts +82 -7
  15. package/src/chat/ChangeCard.tsx +8 -2
  16. package/src/chat/ChatMessage.tsx +171 -102
  17. package/src/chat/ChatPanel.tsx +54 -34
  18. package/src/chat/ChatProvider.tsx +122 -20
  19. package/src/chat/Composer.tsx +51 -8
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +67 -3
  23. package/src/chat/chat-helpers.ts +12 -0
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +12 -0
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/Toast.tsx +115 -0
  29. package/src/chrome/UserMenu.tsx +34 -3
  30. package/src/chrome/WorkspaceStatusBar.tsx +221 -30
  31. package/src/chrome/commit-error.ts +13 -0
  32. package/src/chrome/content-api-url.ts +0 -78
  33. package/src/chrome/content-tree-visibility.ts +13 -3
  34. package/src/chrome/explorer-icons.tsx +5 -0
  35. package/src/chrome/pull-failure.ts +61 -0
  36. package/src/code-session-entry.ts +9 -0
  37. package/src/components/WorkspaceSwitcher.tsx +2 -2
  38. package/src/element-origin.ts +149 -0
  39. package/src/fields/FieldControl.tsx +748 -0
  40. package/src/fields/choose-field-input.ts +64 -0
  41. package/src/fields/load-relation-documents.ts +35 -0
  42. package/src/fields/omit-field.ts +12 -0
  43. package/src/fields/relation-targets.ts +44 -0
  44. package/src/fields/resolve-object-fields.ts +21 -0
  45. package/src/host-pointer-bootstrap.ts +11 -0
  46. package/src/host-pointer.ts +147 -0
  47. package/src/install-preview-pointer.ts +19 -3
  48. package/src/pages/BlockSettingsDialog.tsx +19 -115
  49. package/src/pages/BlocksList.tsx +43 -35
  50. package/src/pages/BodyProse.tsx +16 -1
  51. package/src/pages/CodeSessionFiles.tsx +221 -0
  52. package/src/pages/CodeSessionPage.tsx +1361 -0
  53. package/src/pages/DocEditorPage.tsx +120 -107
  54. package/src/pages/PreviewSize.tsx +181 -0
  55. package/src/pages/RenderedBody.tsx +17 -2
  56. package/src/pages/SettingsGeneralPage.tsx +67 -2
  57. package/src/pages/WorkspaceHomePage.tsx +313 -0
  58. package/src/pages/WorkspaceLayout.tsx +10 -1
  59. package/src/pages/branch-notice.ts +16 -0
  60. package/src/pages/code-session-boot.ts +43 -0
  61. package/src/pages/code-session-context.tsx +53 -0
  62. package/src/pages/code-session-files.ts +42 -0
  63. package/src/pages/code-session-frame.ts +52 -0
  64. package/src/pages/code-session-routes.ts +42 -0
  65. package/src/pages/home-rows.ts +140 -0
  66. package/src/pages/preview-size.ts +33 -0
  67. package/src/pages/session-preview.ts +109 -0
  68. package/src/pages/use-session-preview.ts +70 -0
  69. package/src/replica/store.ts +11 -0
  70. package/src/replica/types.ts +6 -0
  71. package/src/shell-resets.css +15 -0
  72. package/src/site-pointer.ts +24 -5
  73. package/src/studio-config.ts +1 -1
  74. package/src/ui/Input.tsx +178 -20
  75. package/src/ui/ListRow.tsx +12 -3
  76. package/src/ui/Page.tsx +12 -1
  77. package/src/ui/popup.ts +11 -2
  78. package/src/workspace-path.ts +35 -3
  79. package/src/chrome/ContentApiPopover.tsx +0 -215
  80. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -0,0 +1,140 @@
1
+ // packages/studio/src/pages/home-rows.ts
2
+ import type { ChangeStatus, RecentCommit } from "../api";
3
+ import { routeFor } from "@backmatter/core";
4
+ import { docTreeLabel } from "../chrome/content-repo-path";
5
+ import type { ContentTypeNav } from "../chrome/content-type-groups";
6
+ import { contentDocPath, settingsPath } from "../workspace-path";
7
+
8
+ const STATUS_WORD: Record<ChangeStatus, string> = {
9
+ modified: "modified",
10
+ added: "new",
11
+ conflict: "conflict",
12
+ deleted: "deleted",
13
+ };
14
+
15
+ export type HomeChange = {
16
+ type: string;
17
+ slug: string;
18
+ status: ChangeStatus;
19
+ updatedAt?: string;
20
+ };
21
+
22
+ export type HomeModel = Pick<ContentTypeNav, "type" | "path"> & {
23
+ route?: string | false;
24
+ indexSlug?: string;
25
+ };
26
+
27
+ export type HomeRow = {
28
+ key: string;
29
+ label: string;
30
+ /** Public path when the model is routable. Null for data with no route. */
31
+ route: string | null;
32
+ status: string;
33
+ href: string | null;
34
+ updatedAt: string;
35
+ };
36
+
37
+ /** Public path for a changed document. Data models with `route: false` have none. */
38
+ export function changedRoute(
39
+ change: Pick<HomeChange, "type" | "slug">,
40
+ model: Pick<HomeModel, "type" | "route" | "indexSlug"> | undefined,
41
+ ): string | null {
42
+ if (model?.route === false) return null;
43
+ const path = routeFor(
44
+ { type: change.type, route: model?.route, indexSlug: model?.indexSlug },
45
+ change.slug,
46
+ );
47
+ return path || null;
48
+ }
49
+
50
+ /** Working-tree rows for the workspace home, newest edit first. */
51
+ export function homeRows(
52
+ changes: HomeChange[],
53
+ models: HomeModel[],
54
+ workspaceSlug: string,
55
+ ): HomeRow[] {
56
+ const rows = changes.map((change) => {
57
+ const model = models.find((item) => item.type === change.type);
58
+ const label = model ? docTreeLabel(model, change) : change.slug;
59
+ return {
60
+ key: `${change.type}/${change.slug}`,
61
+ label,
62
+ route: changedRoute(change, model),
63
+ status: STATUS_WORD[change.status],
64
+ href:
65
+ change.status === "deleted"
66
+ ? null
67
+ : contentDocPath(workspaceSlug, change.type, change.slug),
68
+ updatedAt: change.updatedAt ?? "",
69
+ };
70
+ });
71
+ rows.sort((a, b) => {
72
+ if (a.updatedAt !== b.updatedAt) return a.updatedAt < b.updatedAt ? 1 : -1;
73
+ return a.label.localeCompare(b.label);
74
+ });
75
+ return rows;
76
+ }
77
+
78
+ const RECENT_LIMIT = 4;
79
+
80
+ export type HomeSummary = {
81
+ name: string;
82
+ branch: string;
83
+ defaultBranch: string;
84
+ uncommitted: number;
85
+ conflicts: number;
86
+ recent: HomeRow[];
87
+ modelCount: number;
88
+ repository: string | null;
89
+ siteUrl: string | null;
90
+ schemaHref: string;
91
+ githubHref: string;
92
+ generalHref: string;
93
+ /** Null until git status has loaded. Empty means the branch has no commits. */
94
+ commits: RecentCommit[] | null;
95
+ };
96
+
97
+ /** How long ago a commit landed, stable enough to test. */
98
+ export function commitAge(iso: string, now: number): string {
99
+ const then = Date.parse(iso);
100
+ if (!Number.isFinite(then)) return "";
101
+ const minutes = Math.max(0, Math.round((now - then) / 60_000));
102
+ if (minutes < 1) return "just now";
103
+ if (minutes < 60) return `${minutes}m ago`;
104
+ const hours = Math.round(minutes / 60);
105
+ if (hours < 24) return `${hours}h ago`;
106
+ const days = Math.round(hours / 24);
107
+ if (days < 14) return `${days}d ago`;
108
+ return iso.slice(0, 10);
109
+ }
110
+
111
+ /** The few facts the workspace home can show without inventing activity. */
112
+ export function homeSummary(input: {
113
+ name: string;
114
+ slug: string;
115
+ branch: string;
116
+ defaultBranch: string;
117
+ repository: string | null;
118
+ siteUrl: string | null;
119
+ modelCount: number;
120
+ changes: HomeChange[];
121
+ models: HomeModel[];
122
+ commits?: RecentCommit[] | null;
123
+ }): HomeSummary {
124
+ const rows = homeRows(input.changes, input.models, input.slug);
125
+ return {
126
+ name: input.name,
127
+ branch: input.branch,
128
+ defaultBranch: input.defaultBranch,
129
+ uncommitted: rows.length,
130
+ conflicts: input.changes.filter((change) => change.status === "conflict").length,
131
+ recent: rows.slice(0, RECENT_LIMIT),
132
+ modelCount: input.modelCount,
133
+ repository: input.repository,
134
+ siteUrl: input.siteUrl,
135
+ schemaHref: settingsPath(input.slug, "schema"),
136
+ githubHref: settingsPath(input.slug, "github"),
137
+ generalHref: settingsPath(input.slug, "general"),
138
+ commits: input.commits ?? null,
139
+ };
140
+ }
@@ -0,0 +1,33 @@
1
+ // packages/studio/src/pages/preview-size.ts
2
+ export type PreviewSize = "desktop" | "tablet" | "phone";
3
+
4
+ export const PREVIEW_SIZE_KEY = "backmatter-preview-size";
5
+
6
+ export type PreviewSizeStore = {
7
+ getItem(key: string): string | null;
8
+ setItem(key: string, value: string): void;
9
+ };
10
+
11
+ export function previewFrameWidth(size: PreviewSize): "390px" | "768px" | null {
12
+ if (size === "phone") return "390px";
13
+ if (size === "tablet") return "768px";
14
+ return null;
15
+ }
16
+
17
+ export function readPreviewSize(store?: PreviewSizeStore): PreviewSize {
18
+ try {
19
+ const raw = (store ?? localStorage).getItem(PREVIEW_SIZE_KEY);
20
+ if (raw === "desktop" || raw === "tablet" || raw === "phone") return raw;
21
+ } catch {
22
+ // Private mode, or no browser storage.
23
+ }
24
+ return "desktop";
25
+ }
26
+
27
+ export function persistPreviewSize(size: PreviewSize, store?: PreviewSizeStore) {
28
+ try {
29
+ (store ?? localStorage).setItem(PREVIEW_SIZE_KEY, size);
30
+ } catch {
31
+ // Ignore quota and private mode.
32
+ }
33
+ }
@@ -0,0 +1,109 @@
1
+ // packages/studio/src/pages/session-preview.ts
2
+ // Studio side of pointing at the running host. Same preview channel as the
3
+ // document editor. The session never pushes a document into the page.
4
+ import { matchRoute, type RouteModel } from "@backmatter/core";
5
+ import {
6
+ createPreviewEditor,
7
+ type PreviewChannelRef,
8
+ type PreviewEditorSession,
9
+ type PreviewTransport,
10
+ } from "@backmatter/core/preview";
11
+ import type { ChatPointer } from "../chat/chat-context";
12
+
13
+ /** The document a hello or grab refers to. A route ref is resolved against the models. */
14
+ export function sessionDocument(
15
+ ref: PreviewChannelRef,
16
+ models: readonly RouteModel[],
17
+ ): { type: string; slug: string } | null {
18
+ if (ref.type === "route") {
19
+ const path = ref.slug.split(/[?#]/, 1)[0] || "/";
20
+ const hit = matchRoute(models, path);
21
+ return hit ? { type: hit.type, slug: hit.slug } : null;
22
+ }
23
+ if (!ref.type || !ref.slug) return null;
24
+ return { type: ref.type, slug: ref.slug };
25
+ }
26
+
27
+ export type SessionPreviewBinding = {
28
+ arm(on: boolean): void;
29
+ highlight(blockId: string | null): void;
30
+ /** Ask a page that already loaded to send hello again. */
31
+ nudge(): void;
32
+ close(): void;
33
+ };
34
+
35
+ export function bindSessionPreview(options: {
36
+ transport: PreviewTransport;
37
+ onCanPoint(on: boolean): void;
38
+ onPointer(pointer: ChatPointer): void;
39
+ onDisarm(): void;
40
+ /** The document a hello or grab refers to. A route ref is resolved by the caller. */
41
+ documentAt(ref: PreviewChannelRef): { type: string; slug: string } | null;
42
+ }): SessionPreviewBinding {
43
+ let ref: PreviewChannelRef = { type: "_", slug: "_" };
44
+ let known = false;
45
+ let wantPoint = false;
46
+ let editor: PreviewEditorSession | null = null;
47
+
48
+ function remember(next: PreviewChannelRef) {
49
+ ref = {
50
+ type: next.type,
51
+ slug: next.slug,
52
+ ...(next.branch ? { branch: next.branch } : {}),
53
+ ...(next.tree ? { tree: next.tree } : {}),
54
+ };
55
+ known = true;
56
+ }
57
+
58
+ editor = createPreviewEditor({
59
+ transport: options.transport,
60
+ ref: () => ref,
61
+ onHello(next, capabilities) {
62
+ remember(next);
63
+ options.onCanPoint(capabilities?.includes("point") ?? false);
64
+ editor?.point(wantPoint);
65
+ },
66
+ onGrab(grab) {
67
+ const doc = options.documentAt(grab.ref);
68
+ if (!doc) return;
69
+ options.onPointer({
70
+ type: doc.type,
71
+ slug: doc.slug,
72
+ blockId: grab.blockId,
73
+ blockType: grab.blockType,
74
+ blockIndex: grab.blockIndex,
75
+ quote: grab.quote,
76
+ ...(grab.component ? { component: grab.component } : {}),
77
+ ...(grab.file ? { file: grab.file } : {}),
78
+ ...(grab.line ? { line: grab.line } : {}),
79
+ ...(grab.column ? { column: grab.column } : {}),
80
+ ...(grab.stack ? { stack: grab.stack } : {}),
81
+ });
82
+ },
83
+ onPoint(on) {
84
+ if (!on) {
85
+ wantPoint = false;
86
+ options.onDisarm();
87
+ }
88
+ },
89
+ });
90
+
91
+ options.onCanPoint(false);
92
+
93
+ return {
94
+ arm(on) {
95
+ wantPoint = on;
96
+ if (known) editor?.point(on);
97
+ },
98
+ highlight(blockId) {
99
+ if (known) editor?.highlight(blockId);
100
+ },
101
+ nudge() {
102
+ if (!known) editor?.point(false);
103
+ },
104
+ close() {
105
+ editor?.close();
106
+ editor = null;
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,70 @@
1
+ // packages/studio/src/pages/use-session-preview.ts
2
+ import { useEffect, useRef } from "react";
3
+ import type { RouteModel } from "@backmatter/core";
4
+ import { POINTER_ARM_EVENT } from "../chat/pointer-arm";
5
+ import { useChatUIOptional } from "../chat/ChatProvider";
6
+ import { registerPreviewHighlight } from "../preview-highlight";
7
+ import { previewFrameTransport } from "./preview-bridge";
8
+ import { bindSessionPreview, sessionDocument } from "./session-preview";
9
+
10
+ /** Point at a block in the code session's host, the same way the document editor does. */
11
+ export function useSessionPreview(input: {
12
+ frame: HTMLIFrameElement | null;
13
+ origin: string | null;
14
+ models: readonly RouteModel[];
15
+ enabled: boolean;
16
+ }): void {
17
+ const ui = useChatUIOptional();
18
+ const uiRef = useRef(ui);
19
+ uiRef.current = ui;
20
+ const modelsRef = useRef(input.models);
21
+ modelsRef.current = input.models;
22
+ const armRef = useRef<(on: boolean) => void>(() => {});
23
+ const highlightRef = useRef<(blockId: string | null) => void>(() => {});
24
+
25
+ useEffect(() => {
26
+ if (!input.enabled) return;
27
+ const stop = registerPreviewHighlight((blockId) => highlightRef.current(blockId));
28
+ function onArm(event: Event) {
29
+ const on = (event as CustomEvent<{ on?: boolean }>).detail?.on === true;
30
+ armRef.current(on);
31
+ }
32
+ window.addEventListener(POINTER_ARM_EVENT, onArm);
33
+ return () => {
34
+ window.removeEventListener(POINTER_ARM_EVENT, onArm);
35
+ stop();
36
+ uiRef.current?.setPointerHighlight("local");
37
+ uiRef.current?.setPreviewPointer(false);
38
+ };
39
+ }, [input.enabled]);
40
+
41
+ useEffect(() => {
42
+ const chat = uiRef.current;
43
+ const frame = input.frame;
44
+ const origin = input.origin;
45
+ if (!input.enabled || !chat?.codeSessionId || !frame || !origin) return;
46
+ const transport = previewFrameTransport({ frame, siteOrigin: origin });
47
+ if (!transport) return;
48
+ chat.setPointerHighlight("channel");
49
+ const binding = bindSessionPreview({
50
+ transport,
51
+ onCanPoint: (on) => uiRef.current?.setPreviewPointer(on),
52
+ onPointer: (pointer) => uiRef.current?.addPointer(pointer),
53
+ onDisarm: () => {
54
+ window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on: false } }));
55
+ },
56
+ documentAt: (ref) => sessionDocument(ref, modelsRef.current),
57
+ });
58
+ armRef.current = (on) => binding.arm(on);
59
+ highlightRef.current = (blockId) => binding.highlight(blockId);
60
+ binding.nudge();
61
+ return () => {
62
+ armRef.current = () => {};
63
+ highlightRef.current = () => {};
64
+ binding.close();
65
+ transport.close();
66
+ window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on: false } }));
67
+ uiRef.current?.setPreviewPointer(false);
68
+ };
69
+ }, [input.enabled, input.frame, input.origin]);
70
+ }
@@ -165,6 +165,8 @@ export function createReplicaStore(
165
165
  cached.head ??
166
166
  (slot?.headSha ? { sha: slot.headSha, updatedAt: "" } : null),
167
167
  changes: changesFromDocs(slot?.docs ?? []),
168
+ fetchedChanges: entry.git?.fetched,
169
+ recentCommits: entry.git?.recentCommits,
168
170
  };
169
171
  },
170
172
  };
@@ -241,6 +243,7 @@ export function createReplicaStore(
241
243
  const existing = checkouts[snapshot.workspace.id];
242
244
  const defaultBranch =
243
245
  snapshot.workspace.githubDefaultBranch ?? snapshot.checkout.branch;
246
+ const sameBranch = existing?.currentBranch === snapshot.checkout.branch;
244
247
  checkouts = {
245
248
  ...checkouts,
246
249
  [snapshot.workspace.id]: {
@@ -257,6 +260,12 @@ export function createReplicaStore(
257
260
  : "",
258
261
  }
259
262
  : null,
263
+ ...(sameBranch && existing?.git?.fetched
264
+ ? { fetched: existing.git.fetched }
265
+ : {}),
266
+ ...(sameBranch && existing?.git?.recentCommits
267
+ ? { recentCommits: existing.git.recentCommits }
268
+ : {}),
260
269
  },
261
270
  branches: {
262
271
  ...(existing?.branches ?? {}),
@@ -400,6 +409,8 @@ export function createReplicaStore(
400
409
  defaultBranch: git.defaultBranch,
401
410
  branches: git.branches,
402
411
  head: git.head,
412
+ fetched: git.changes,
413
+ recentCommits: git.recentCommits,
403
414
  },
404
415
  },
405
416
  };
@@ -2,7 +2,9 @@
2
2
  import type {
3
3
  DocListItem,
4
4
  DocStatus,
5
+ GitChange,
5
6
  GitState,
7
+ RecentCommit,
6
8
  SchemaPayload,
7
9
  WorkspaceDetail,
8
10
  WorkspaceSummary,
@@ -61,6 +63,10 @@ export type ReplicaGitCache = {
61
63
  defaultBranch: string;
62
64
  branches: Array<{ name: string }>;
63
65
  head: { sha: string; updatedAt: string } | null;
66
+ /** Git status changes for the current branch, once fetched. */
67
+ fetched?: GitChange[];
68
+ /** Latest commits on this branch, once git status has been fetched. */
69
+ recentCommits?: RecentCommit[];
64
70
  };
65
71
 
66
72
  export type ReplicaCheckout = {
@@ -150,10 +150,20 @@ html[data-theme="dark"]:has(.backmatter-studio)
150
150
  outline-color: #fafafa;
151
151
  }
152
152
 
153
+ [data-no-ring]:focus,
153
154
  [data-no-ring]:focus-visible {
154
155
  outline: none;
156
+ outline-offset: 0;
157
+ box-shadow: none;
155
158
  }
156
159
 
160
+ :is(
161
+ .backmatter-studio,
162
+ [data-backmatter-panel],
163
+ [data-base-ui-portal],
164
+ [data-selection-bar]
165
+ )
166
+ [data-no-ring]:focus,
157
167
  :is(
158
168
  .backmatter-studio,
159
169
  [data-backmatter-panel],
@@ -161,8 +171,13 @@ html[data-theme="dark"]:has(.backmatter-studio)
161
171
  [data-selection-bar]
162
172
  )
163
173
  [data-no-ring]:focus-visible,
174
+ html[data-theme="dark"]:has(.backmatter-studio)
175
+ :is(.backmatter-studio, [data-base-ui-portal], [data-selection-bar])
176
+ [data-no-ring]:focus,
164
177
  html[data-theme="dark"]:has(.backmatter-studio)
165
178
  :is(.backmatter-studio, [data-base-ui-portal], [data-selection-bar])
166
179
  [data-no-ring]:focus-visible {
167
180
  outline: none;
181
+ outline-offset: 0;
182
+ box-shadow: none;
168
183
  }
@@ -1,6 +1,8 @@
1
1
  // packages/studio/src/site-pointer.ts
2
2
  // Hit-test a click on the host page and draw the armed pointer outline.
3
3
 
4
+ import { readElementOrigin, type ElementOrigin } from "./element-origin";
5
+
4
6
  export const POINTER_QUOTE_MAX = 240;
5
7
 
6
8
  export type PointerGrab = {
@@ -8,6 +10,14 @@ export type PointerGrab = {
8
10
  blockType: string | null;
9
11
  blockIndex: number | null;
10
12
  quote: string;
13
+ } & ElementOrigin;
14
+
15
+ const EMPTY_ORIGIN: ElementOrigin = {
16
+ component: null,
17
+ file: null,
18
+ line: null,
19
+ column: null,
20
+ stack: [],
11
21
  };
12
22
 
13
23
  function quoteOf(target: Element): string {
@@ -29,9 +39,10 @@ export function grabAt(target: Element | null, bar: Element | null): PointerGrab
29
39
 
30
40
  const marker = target.closest("[data-backmatter-block]");
31
41
  const quote = quoteOf(target);
42
+ const origin = readElementOrigin(target) ?? EMPTY_ORIGIN;
32
43
  if (!marker) {
33
- if (!quote) return null;
34
- return { blockId: null, blockType: null, blockIndex: null, quote };
44
+ if (!quote && !origin.component && !origin.file) return null;
45
+ return { blockId: null, blockType: null, blockIndex: null, quote, ...origin };
35
46
  }
36
47
  const indexRaw = marker.getAttribute("data-backmatter-index");
37
48
  const blockIndex = indexRaw !== null && /^\d+$/.test(indexRaw) ? Number(indexRaw) : null;
@@ -40,6 +51,7 @@ export function grabAt(target: Element | null, bar: Element | null): PointerGrab
40
51
  blockType: presentAttr(marker, "data-backmatter-type"),
41
52
  blockIndex,
42
53
  quote,
54
+ ...origin,
43
55
  };
44
56
  }
45
57
 
@@ -104,10 +116,13 @@ export function installSitePointer(
104
116
  }
105
117
 
106
118
  function onClick(event: MouseEvent) {
107
- if (!armed || ignored(event.target)) return;
119
+ if (!armed) return;
120
+ const raw = event.target instanceof Element ? event.target : null;
121
+ // The outline sits on the element. A click on it belongs to the element under it.
122
+ const target = raw?.closest("[data-backmatter-pointer]") ? current : raw;
123
+ if (!target || ignored(target)) return;
108
124
  event.preventDefault();
109
125
  event.stopPropagation();
110
- const target = event.target instanceof Element ? event.target : null;
111
126
  const grab = grabAt(target, options.bar());
112
127
  if (grab) options.onGrab(grab, isOpenModifier(event));
113
128
  }
@@ -174,6 +189,7 @@ function createPointerOutline(doc: Document): HTMLDivElement {
174
189
  label.style.background = "#18181b";
175
190
  label.style.color = "#ffffff";
176
191
  label.style.font = "11px/1.4 ui-sans-serif, system-ui, sans-serif";
192
+ label.style.pointerEvents = "none";
177
193
  label.style.display = "none";
178
194
  el.appendChild(label);
179
195
  doc.body.appendChild(el);
@@ -216,7 +232,10 @@ function positionPointerOutline(outline: HTMLDivElement, target: Element) {
216
232
  outline.style.width = `${rect.width}px`;
217
233
  outline.style.height = `${rect.height}px`;
218
234
  const label = outline.firstElementChild as HTMLElement | null;
219
- const name = target.closest("[data-backmatter-block]")?.getAttribute("data-backmatter-type") ?? "";
235
+ const name =
236
+ target.closest("[data-backmatter-block]")?.getAttribute("data-backmatter-type") ||
237
+ readElementOrigin(target)?.component ||
238
+ "";
220
239
  if (!label) return;
221
240
  if (!name) {
222
241
  label.textContent = "";
@@ -14,7 +14,7 @@ export type StudioConfig = {
14
14
  dev: boolean;
15
15
  /**
16
16
  * The CMS API origin as the outside world reaches it, for URLs the Studio
17
- * shows rather than fetches (the Content API popover). A site Studio fetches
17
+ * shows rather than fetches (the Content API URL in General settings). A site Studio fetches
18
18
  * through a relative proxy, so its host passes the real origin here.
19
19
  * Defaults to `baseUrl`.
20
20
  */