@backmatter/studio 0.0.15 → 0.0.16

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 (61) hide show
  1. package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-_EDqWID3.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-B2PrFgpz.js} +28979 -27418
  3. package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-BnlnMnnr.js} +55 -54
  4. package/dist/chunks/{index-DHJvh5Uy.js → index-D_BHF_8q.js} +1 -1
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/site-widget.css +4 -0
  8. package/dist/site-widget.js +8304 -8029
  9. package/dist/studio.css +186 -12
  10. package/package.json +2 -2
  11. package/src/WorkspaceRoutes.tsx +5 -2
  12. package/src/api.ts +63 -7
  13. package/src/chat/ChangeCard.tsx +8 -2
  14. package/src/chat/ChatMessage.tsx +171 -102
  15. package/src/chat/ChatPanel.tsx +54 -34
  16. package/src/chat/ChatProvider.tsx +116 -20
  17. package/src/chat/Composer.tsx +48 -7
  18. package/src/chat/PointerChip.tsx +9 -5
  19. package/src/chat/ThreadHistory.tsx +3 -3
  20. package/src/chat/chat-context.ts +67 -3
  21. package/src/chat/chat-helpers.ts +12 -0
  22. package/src/chat/chat-state.ts +11 -0
  23. package/src/chrome/AppShell.tsx +16 -5
  24. package/src/chrome/CommandPalette.tsx +12 -0
  25. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  26. package/src/chrome/UserMenu.tsx +34 -3
  27. package/src/chrome/WorkspaceStatusBar.tsx +209 -27
  28. package/src/chrome/content-api-url.ts +0 -78
  29. package/src/chrome/content-tree-visibility.ts +13 -3
  30. package/src/chrome/explorer-icons.tsx +5 -0
  31. package/src/code-session-entry.ts +9 -0
  32. package/src/components/WorkspaceSwitcher.tsx +2 -2
  33. package/src/element-origin.ts +149 -0
  34. package/src/host-pointer-bootstrap.ts +11 -0
  35. package/src/host-pointer.ts +147 -0
  36. package/src/install-preview-pointer.ts +19 -3
  37. package/src/pages/CodeSessionFiles.tsx +221 -0
  38. package/src/pages/CodeSessionPage.tsx +1188 -0
  39. package/src/pages/DocEditorPage.tsx +33 -15
  40. package/src/pages/PreviewSize.tsx +181 -0
  41. package/src/pages/SettingsGeneralPage.tsx +67 -2
  42. package/src/pages/WorkspaceHomePage.tsx +313 -0
  43. package/src/pages/WorkspaceLayout.tsx +10 -1
  44. package/src/pages/code-session-boot.ts +43 -0
  45. package/src/pages/code-session-context.tsx +51 -0
  46. package/src/pages/code-session-files.ts +42 -0
  47. package/src/pages/code-session-frame.ts +52 -0
  48. package/src/pages/code-session-routes.ts +42 -0
  49. package/src/pages/home-rows.ts +140 -0
  50. package/src/pages/preview-size.ts +33 -0
  51. package/src/pages/session-preview.ts +109 -0
  52. package/src/pages/use-session-preview.ts +70 -0
  53. package/src/replica/store.ts +11 -0
  54. package/src/replica/types.ts +6 -0
  55. package/src/site-pointer.ts +24 -5
  56. package/src/studio-config.ts +1 -1
  57. package/src/ui/ListRow.tsx +12 -3
  58. package/src/ui/Page.tsx +12 -1
  59. package/src/workspace-path.ts +35 -3
  60. package/src/chrome/ContentApiPopover.tsx +0 -215
  61. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -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
  */
@@ -11,13 +11,13 @@ export function ListRow({
11
11
  meta,
12
12
  trailing,
13
13
  }: {
14
- to: string;
14
+ to?: string;
15
15
  title: string;
16
16
  meta?: string;
17
17
  trailing?: string;
18
18
  }) {
19
- return (
20
- <Link to={to} {...stylex.props(styles.row)}>
19
+ const body = (
20
+ <>
21
21
  <span {...stylex.props(styles.body)}>
22
22
  <span {...stylex.props(styles.title)}>{title}</span>
23
23
  {meta ? <span {...stylex.props(styles.meta)}>{meta}</span> : null}
@@ -25,6 +25,12 @@ export function ListRow({
25
25
  {trailing ? (
26
26
  <span {...stylex.props(styles.trailing)}>{trailing}</span>
27
27
  ) : null}
28
+ </>
29
+ );
30
+ if (!to) return <div {...stylex.props(styles.row, styles.static)}>{body}</div>;
31
+ return (
32
+ <Link to={to} {...stylex.props(styles.row)}>
33
+ {body}
28
34
  </Link>
29
35
  );
30
36
  }
@@ -66,6 +72,9 @@ const styles = stylex.create({
66
72
  },
67
73
  },
68
74
  },
75
+ static: {
76
+ cursor: "default",
77
+ },
69
78
  body: {
70
79
  display: "flex",
71
80
  minWidth: 0,
package/src/ui/Page.tsx CHANGED
@@ -6,10 +6,12 @@ export function PageFrame({
6
6
  children,
7
7
  flush,
8
8
  centered,
9
+ wide,
9
10
  }: {
10
11
  children: React.ReactNode;
11
12
  flush?: boolean;
12
13
  centered?: boolean;
14
+ wide?: boolean;
13
15
  }) {
14
16
  return (
15
17
  <div
@@ -22,7 +24,13 @@ export function PageFrame({
22
24
  {flush ? (
23
25
  children
24
26
  ) : (
25
- <div {...stylex.props(styles.inner, centered && styles.innerCentered)}>
27
+ <div
28
+ {...stylex.props(
29
+ styles.inner,
30
+ centered && styles.innerCentered,
31
+ wide && styles.innerWide,
32
+ )}
33
+ >
26
34
  {children}
27
35
  </div>
28
36
  )}
@@ -108,6 +116,9 @@ const styles = stylex.create({
108
116
  innerCentered: {
109
117
  maxWidth: "440px",
110
118
  },
119
+ innerWide: {
120
+ maxWidth: "960px",
121
+ },
111
122
  header: {
112
123
  display: "flex",
113
124
  alignItems: "flex-start",
@@ -3,6 +3,14 @@ export function workspaceHomePath(slug: string) {
3
3
  return `/${slug}`;
4
4
  }
5
5
 
6
+ /** The workspace index. Content, media, and settings are deeper routes. */
7
+ export function isWorkspaceHomePath(pathname: string) {
8
+ const slug = cmsWorkspaceSlug(pathname);
9
+ if (!slug) return false;
10
+ const path = pathname.split("?")[0]?.replace(/\/$/, "") || "/";
11
+ return path === `/${slug}`;
12
+ }
13
+
6
14
  export function contentTypePath(slug: string, type: string) {
7
15
  return `/${slug}/content/${type}`;
8
16
  }
@@ -15,6 +23,10 @@ export function mediaPath(slug: string) {
15
23
  return `/${slug}/media`;
16
24
  }
17
25
 
26
+ export function codeSessionPath(slug: string) {
27
+ return `/${slug}/code`;
28
+ }
29
+
18
30
  export function settingsPath(slug: string, page: string) {
19
31
  return `/${slug}/settings/${page}`;
20
32
  }
@@ -71,19 +83,33 @@ export function isWorkspaceMediaPath(pathname: string) {
71
83
  return /\/media\/?$/.test(pathname);
72
84
  }
73
85
 
86
+ export function isWorkspaceCodePath(pathname: string) {
87
+ return /\/code\/?$/.test(pathname);
88
+ }
89
+
74
90
  /** The content file tree belongs on content routes only. */
75
91
  export function isWorkspaceContentPath(pathname: string) {
76
92
  return /\/content(?:\/|$)/.test(pathname);
77
93
  }
78
94
 
95
+ /** The content index. The file tree lives here; the workspace home does not. */
96
+ export function contentIndexPath(slug: string) {
97
+ return `/${slug}/content`;
98
+ }
99
+
100
+ export function isWorkspaceContentIndexPath(pathname: string) {
101
+ const path = pathname.split("?")[0]?.replace(/\/$/, "") || "/";
102
+ return /\/content$/.test(path);
103
+ }
104
+
79
105
  /**
80
106
  * Where the content rail icon navigates.
81
107
  * On a content route it stays put: a document toggles the file tree, and the
82
- * type index is already the file tree.
83
- * Anywhere else (settings, media, connect): the workspace home, which opens the docs.
108
+ * content index is already the file tree.
109
+ * Anywhere else (home, settings, media, connect): `/content`.
84
110
  */
85
111
  export function contentRailHref(pathname: string, slug: string): string | null {
86
- if (!isWorkspaceContentPath(pathname)) return workspaceHomePath(slug);
112
+ if (!isWorkspaceContentPath(pathname)) return contentIndexPath(slug);
87
113
  return null;
88
114
  }
89
115
 
@@ -101,6 +127,12 @@ export function isOrgPickerPath(pathname: string) {
101
127
  return path === "/invite";
102
128
  }
103
129
 
130
+ /** The workspace list and create form. The app sidebar repeats this list. */
131
+ export function isWorkspaceListPath(pathname: string) {
132
+ const path = pathname.split("?")[0]?.replace(/\/$/, "") || "/";
133
+ return path === "/workspaces" || path === "/workspaces/new";
134
+ }
135
+
104
136
  /** CLI approval is a full-screen form, not a workspace. */
105
137
  export function isCliAuthorizePath(pathname: string) {
106
138
  const path = pathname.replace(/\/$/, "") || "/";
@@ -1,215 +0,0 @@
1
- // packages/studio/src/chrome/ContentApiPopover.tsx
2
- import { useEffect, useState } from "react";
3
- import { Popover } from "@base-ui/react/popover";
4
- import * as stylex from "@stylexjs/stylex";
5
- import { publicApiUrl } from "../studio-config";
6
- import { colors, radius, space, type } from "../styles/tokens.stylex";
7
- import { cx, popup, popupClassName } from "../ui/popup";
8
- import { contentApiLinks } from "./content-api-url";
9
- import { ApiIcon } from "./icons";
10
-
11
- const hover = "@media (hover: hover)";
12
-
13
- function CopyUrl({ label, href }: { label: string; href: string }) {
14
- const [copied, setCopied] = useState(false);
15
-
16
- useEffect(() => {
17
- if (!copied) return;
18
- const id = window.setTimeout(() => setCopied(false), 1500);
19
- return () => window.clearTimeout(id);
20
- }, [copied]);
21
-
22
- async function copy() {
23
- try {
24
- await navigator.clipboard.writeText(href);
25
- setCopied(true);
26
- } catch {
27
- setCopied(false);
28
- }
29
- }
30
-
31
- return (
32
- <div {...stylex.props(styles.row)}>
33
- <p {...stylex.props(popup.groupLabel)}>{label}</p>
34
- <div {...stylex.props(styles.urlRow)}>
35
- <a
36
- href={href}
37
- target="_blank"
38
- rel="noreferrer"
39
- {...stylex.props(styles.href)}
40
- >
41
- {href}
42
- </a>
43
- <button
44
- type="button"
45
- onClick={() => void copy()}
46
- aria-label={copied ? "Copied" : `Copy ${label} URL`}
47
- {...stylex.props(styles.copy)}
48
- >
49
- {copied ? "Copied" : "Copy"}
50
- </button>
51
- </div>
52
- </div>
53
- );
54
- }
55
-
56
- export function ContentApiPopover({
57
- workspaceId,
58
- branch,
59
- defaultBranch,
60
- pathname,
61
- search,
62
- }: {
63
- workspaceId: string;
64
- branch?: string | null;
65
- defaultBranch?: string | null;
66
- pathname: string;
67
- search?: string;
68
- }) {
69
- const links = contentApiLinks({
70
- apiUrl: publicApiUrl(),
71
- workspaceId,
72
- branch,
73
- defaultBranch,
74
- pathname,
75
- search,
76
- });
77
- const locked = Boolean(branch && defaultBranch && branch !== defaultBranch);
78
-
79
- return (
80
- <Popover.Root>
81
- <Popover.Trigger aria-label="Content API" className={cx(styles.trigger)}>
82
- <ApiIcon />
83
- <span>API</span>
84
- </Popover.Trigger>
85
- <Popover.Portal>
86
- <Popover.Positioner
87
- side="bottom"
88
- align="start"
89
- sideOffset={6}
90
- className={cx(popup.positioner)}
91
- >
92
- <Popover.Popup
93
- aria-label="Content API"
94
- className={(state) => popupClassName(state, styles.popup)}
95
- >
96
- <p {...stylex.props(popup.groupLabel)}>Content API</p>
97
- <p {...stylex.props(styles.hint)}>
98
- {locked
99
- ? "This branch needs the content token from Settings → General."
100
- : "Default branch is public — no token. URLs use the workspace id."}
101
- </p>
102
- {links.map((link) => (
103
- <CopyUrl key={link.href} label={link.label} href={link.href} />
104
- ))}
105
- </Popover.Popup>
106
- </Popover.Positioner>
107
- </Popover.Portal>
108
- </Popover.Root>
109
- );
110
- }
111
-
112
- const styles = stylex.create({
113
- trigger: {
114
- display: "inline-flex",
115
- alignItems: "center",
116
- gap: space.xs,
117
- minHeight: "24px",
118
- paddingInline: space.xs,
119
- borderWidth: 0,
120
- backgroundColor: "transparent",
121
- color: colors.textMuted,
122
- fontSize: type.xs,
123
- fontFamily: type.mono,
124
- cursor: "pointer",
125
- touchAction: "manipulation",
126
- ":focus-visible": {
127
- outlineWidth: 2,
128
- outlineStyle: "solid",
129
- outlineColor: colors.focus,
130
- outlineOffset: "-2px",
131
- },
132
- [hover]: {
133
- ":hover": {
134
- color: colors.text,
135
- },
136
- },
137
- },
138
- popup: {
139
- width: "380px",
140
- paddingBottom: space.md,
141
- overflow: "hidden",
142
- },
143
- hint: {
144
- margin: 0,
145
- paddingInline: space.lg,
146
- paddingBottom: space.sm,
147
- fontFamily: type.mono,
148
- fontSize: type.xs,
149
- lineHeight: 1.45,
150
- color: colors.textMuted,
151
- },
152
- row: {
153
- display: "flex",
154
- flexDirection: "column",
155
- gap: space.xs,
156
- paddingBottom: space.sm,
157
- },
158
- urlRow: {
159
- display: "flex",
160
- alignItems: "center",
161
- gap: space.sm,
162
- minWidth: 0,
163
- marginInline: space.lg,
164
- },
165
- href: {
166
- minWidth: 0,
167
- flex: 1,
168
- overflow: "hidden",
169
- textOverflow: "ellipsis",
170
- whiteSpace: "nowrap",
171
- color: colors.text,
172
- fontFamily: type.mono,
173
- fontSize: type.xs,
174
- textDecoration: "none",
175
- [hover]: {
176
- ":hover": {
177
- textDecoration: "underline",
178
- },
179
- },
180
- ":focus-visible": {
181
- outlineWidth: 2,
182
- outlineStyle: "solid",
183
- outlineColor: colors.focus,
184
- outlineOffset: 2,
185
- },
186
- },
187
- copy: {
188
- flexShrink: 0,
189
- minWidth: "52px",
190
- minHeight: "24px",
191
- paddingInline: space.sm,
192
- borderWidth: 1,
193
- borderStyle: "solid",
194
- borderColor: colors.border,
195
- borderRadius: radius.md,
196
- backgroundColor: colors.bgSurface,
197
- color: colors.textMuted,
198
- fontSize: type.xs,
199
- fontFamily: type.sans,
200
- cursor: "pointer",
201
- touchAction: "manipulation",
202
- [hover]: {
203
- ":hover": {
204
- color: colors.text,
205
- backgroundColor: colors.bgHover,
206
- },
207
- },
208
- ":focus-visible": {
209
- outlineWidth: 2,
210
- outlineStyle: "solid",
211
- outlineColor: colors.focus,
212
- outlineOffset: 1,
213
- },
214
- },
215
- });
@@ -1,17 +0,0 @@
1
- // packages/studio/src/pages/WorkspaceHomeRedirect.tsx
2
- import { Navigate, useOutletContext } from "react-router";
3
- import type { WorkspaceOutletContext } from "../workspace/types";
4
- import { contentTypePath, settingsPath } from "../workspace-path";
5
-
6
- export function WorkspaceHomeRedirect() {
7
- const { workspace, models } = useOutletContext<WorkspaceOutletContext>();
8
- const first = models[0]?.type;
9
- if (first) {
10
- return (
11
- <Navigate to={contentTypePath(workspace.slug, first)} replace />
12
- );
13
- }
14
- return (
15
- <Navigate to={settingsPath(workspace.slug, "schema")} replace />
16
- );
17
- }