@backmatter/studio 0.0.14 → 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 (67) hide show
  1. package/dist/chunks/{UiKitApp-fYeVEf4-.js → UiKitApp-_EDqWID3.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-Dt7JvEWT.js → WorkspaceRoutes-B2PrFgpz.js} +29019 -27476
  3. package/dist/chunks/{dashboard-BuY2aGkO.js → dashboard-BnlnMnnr.js} +55 -54
  4. package/dist/chunks/{index-NWDqEZYW.js → index-D_BHF_8q.js} +1 -1
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +51 -52
  7. package/dist/site-widget.css +4 -0
  8. package/dist/site-widget.js +9419 -9156
  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 +64 -11
  13. package/src/chat/ChangeCard.tsx +9 -15
  14. package/src/chat/ChatMessage.tsx +172 -103
  15. package/src/chat/ChatPanel.tsx +54 -34
  16. package/src/chat/ChatProvider.tsx +120 -24
  17. package/src/chat/Composer.tsx +48 -7
  18. package/src/chat/DocListCard.tsx +2 -16
  19. package/src/chat/MentionPopover.tsx +8 -9
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +74 -10
  23. package/src/chat/chat-helpers.ts +15 -8
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +14 -10
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/UserMenu.tsx +34 -3
  29. package/src/chrome/WorkspaceStatusBar.tsx +215 -50
  30. package/src/chrome/content-api-url.ts +0 -78
  31. package/src/chrome/content-tree-visibility.ts +13 -3
  32. package/src/chrome/explorer-icons.tsx +5 -0
  33. package/src/code-session-entry.ts +9 -0
  34. package/src/components/WorkspaceSwitcher.tsx +2 -2
  35. package/src/element-origin.ts +149 -0
  36. package/src/host-pointer-bootstrap.ts +11 -0
  37. package/src/host-pointer.ts +147 -0
  38. package/src/install-preview-pointer.ts +19 -3
  39. package/src/pages/CodeSessionFiles.tsx +221 -0
  40. package/src/pages/CodeSessionPage.tsx +1188 -0
  41. package/src/pages/DocEditorPage.tsx +33 -15
  42. package/src/pages/EmbedChatPage.tsx +3 -3
  43. package/src/pages/EmbedPublishPage.tsx +2 -3
  44. package/src/pages/PreviewSize.tsx +181 -0
  45. package/src/pages/SettingsGeneralPage.tsx +67 -2
  46. package/src/pages/WorkspaceHomePage.tsx +313 -0
  47. package/src/pages/WorkspaceLayout.tsx +10 -1
  48. package/src/pages/code-session-boot.ts +43 -0
  49. package/src/pages/code-session-context.tsx +51 -0
  50. package/src/pages/code-session-files.ts +42 -0
  51. package/src/pages/code-session-frame.ts +52 -0
  52. package/src/pages/code-session-routes.ts +42 -0
  53. package/src/pages/home-rows.ts +140 -0
  54. package/src/pages/preview-size.ts +33 -0
  55. package/src/pages/session-preview.ts +109 -0
  56. package/src/pages/type-docs.ts +3 -6
  57. package/src/pages/use-session-preview.ts +70 -0
  58. package/src/replica/store.ts +13 -10
  59. package/src/replica/types.ts +7 -2
  60. package/src/site-pointer.ts +24 -5
  61. package/src/site-widget.tsx +2 -3
  62. package/src/studio-config.ts +1 -1
  63. package/src/ui/ListRow.tsx +12 -3
  64. package/src/ui/Page.tsx +12 -1
  65. package/src/workspace-path.ts +35 -3
  66. package/src/chrome/ContentApiPopover.tsx +0 -215
  67. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -46,6 +46,8 @@ import {
46
46
  persistPreviewPct,
47
47
  readPreviewPct,
48
48
  } from "./preview-split";
49
+ import { PreviewSizeSwitch, PreviewViewport } from "./PreviewSize";
50
+ import { persistPreviewSize, readPreviewSize, type PreviewSize } from "./preview-size";
49
51
  import {
50
52
  editorPreviewPath,
51
53
  modelForType,
@@ -214,6 +216,7 @@ export function DocEditorPage() {
214
216
  const [previewOffer, setPreviewOffer] = useState<PreviewChannelRef | null>(null);
215
217
  const [previewLive, setPreviewLive] = useState<"rendered" | "error" | null>(null);
216
218
  const [previewPct, setPreviewPct] = useState(readPreviewPct);
219
+ const [previewSize, setPreviewSize] = useState(readPreviewSize);
217
220
  const [dragging, setDragging] = useState(false);
218
221
  const [editEpoch, setEditEpoch] = useState(0);
219
222
  const [selection, setSelection] = useState<EditorSelection | null>(null);
@@ -323,6 +326,7 @@ export function DocEditorPage() {
323
326
  }
324
327
 
325
328
  function onPreviewGrab(grab: PreviewGrabMessage) {
329
+ if (typeof grab.blockId !== "string" || !grab.blockId) return;
326
330
  const ids = blocksFromFrontmatter(latest.current.frontmatter[blocksFieldRef.current]).map(
327
331
  (block) => block.id,
328
332
  );
@@ -330,7 +334,13 @@ export function DocEditorPage() {
330
334
  grabRef: grab.ref,
331
335
  openRef: channelRef.current,
332
336
  blockIds: ids,
333
- grab,
337
+ grab: {
338
+ blockId: grab.blockId,
339
+ blockType: grab.blockType,
340
+ blockIndex: grab.blockIndex,
341
+ quote: grab.quote,
342
+ shift: grab.shift,
343
+ },
334
344
  });
335
345
  if (decision.action === "add") {
336
346
  addPointer({
@@ -467,6 +477,11 @@ export function DocEditorPage() {
467
477
  setPreviewKey((k) => k + 1);
468
478
  }
469
479
 
480
+ function choosePreviewSize(size: PreviewSize) {
481
+ setPreviewSize(size);
482
+ persistPreviewSize(size);
483
+ }
484
+
470
485
  function updatePreviewPctFromPointer(clientX: number, clientY: number) {
471
486
  const split = splitRef.current;
472
487
  if (!split) return;
@@ -901,6 +916,7 @@ export function DocEditorPage() {
901
916
  </button>
902
917
  ) : null}
903
918
  </div>
919
+ <PreviewSizeSwitch size={previewSize} onSize={choosePreviewSize} />
904
920
  <span
905
921
  {...stylex.props(
906
922
  styles.badge,
@@ -911,20 +927,22 @@ export function DocEditorPage() {
911
927
  </span>
912
928
  </div>
913
929
  {previewUrl ? (
914
- <iframe
915
- key={`${previewKey}-${previewUrl}`}
916
- ref={previewFrameRef}
917
- title="Live preview"
918
- src={previewUrl}
919
- onLoad={() => {
920
- setPreviewLoading(false);
921
- setPreviewFrame((n) => n + 1);
922
- }}
923
- {...stylex.props(
924
- styles.iframe,
925
- dragging && styles.iframeDragging,
926
- )}
927
- />
930
+ <PreviewViewport size={previewSize} dragging={dragging}>
931
+ <iframe
932
+ key={`${previewKey}-${previewUrl}`}
933
+ ref={previewFrameRef}
934
+ title="Live preview"
935
+ src={previewUrl}
936
+ onLoad={() => {
937
+ setPreviewLoading(false);
938
+ setPreviewFrame((n) => n + 1);
939
+ }}
940
+ {...stylex.props(
941
+ styles.iframe,
942
+ dragging && styles.iframeDragging,
943
+ )}
944
+ />
945
+ </PreviewViewport>
928
946
  ) : !siteUrl ? (
929
947
  <div {...stylex.props(styles.iframe, styles.previewEmpty)}>
930
948
  <Text as="p" size="sm" weight="medium">
@@ -52,9 +52,9 @@ function FocusBridge() {
52
52
  const data = event.data as { type?: string; doc?: Partial<DocRef> };
53
53
  if (data?.type !== "backmatter:focus") return;
54
54
  const doc = data.doc;
55
- if (!doc?.locale || !doc.type || !doc.slug) return;
55
+ if (!doc?.type || !doc.slug) return;
56
56
  setFocus({
57
- docRef: { locale: doc.locale, type: doc.type, slug: doc.slug },
57
+ docRef: { type: doc.type, slug: doc.slug },
58
58
  frontmatter: {},
59
59
  body: "",
60
60
  selection: null,
@@ -89,7 +89,7 @@ function PointerBridge() {
89
89
  const data = event.data as { type?: string; grab?: unknown; doc?: unknown };
90
90
  if (data?.type !== "backmatter:pointer-grab") return;
91
91
  const pointer = chatPointerFromGrab(
92
- (data.doc ?? {}) as { locale?: unknown; type?: unknown; slug?: unknown },
92
+ (data.doc ?? {}) as { type?: unknown; slug?: unknown },
93
93
  data.grab,
94
94
  );
95
95
  if (pointer) addPointer(pointer);
@@ -39,7 +39,7 @@ function parentOrigin(): string {
39
39
  }
40
40
 
41
41
  function changeKey(doc: GitChange) {
42
- return `${doc.locale}/${doc.type}/${doc.slug}`;
42
+ return `${doc.type}/${doc.slug}`;
43
43
  }
44
44
 
45
45
  const STATUS_LABEL: Record<ChangeStatus, string> = {
@@ -129,10 +129,9 @@ export function EmbedPublishPage({
129
129
  setResolvingKey(key);
130
130
  setError(null);
131
131
  try {
132
- await resolveDoc(workspaceId, doc.locale, doc.type, doc.slug, strategy);
132
+ await resolveDoc(workspaceId, doc.type, doc.slug, strategy);
133
133
  notifyTreeChanged({
134
134
  workspaceId,
135
- locale: doc.locale,
136
135
  type: doc.type,
137
136
  slug: doc.slug,
138
137
  status: strategy === "theirs" ? "clean" : "modified",
@@ -0,0 +1,181 @@
1
+ // packages/studio/src/pages/PreviewSize.tsx
2
+ import type { KeyboardEvent, ReactNode } from "react";
3
+ import * as stylex from "@stylexjs/stylex";
4
+ import {
5
+ IconImac,
6
+ IconPhone,
7
+ IconTablet,
8
+ } from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
9
+ import { colors, radius } from "../styles/tokens.stylex";
10
+ import { Tooltip } from "../ui/Tooltip";
11
+ import {
12
+ previewFrameWidth,
13
+ type PreviewSize,
14
+ } from "./preview-size";
15
+
16
+ const OPTIONS: readonly { size: PreviewSize; label: string; icon: typeof IconImac }[] = [
17
+ { size: "desktop", label: "Desktop", icon: IconImac },
18
+ { size: "tablet", label: "Tablet", icon: IconTablet },
19
+ { size: "phone", label: "Phone", icon: IconPhone },
20
+ ];
21
+
22
+ export function PreviewSizeSwitch({
23
+ size,
24
+ onSize,
25
+ disabled = false,
26
+ }: {
27
+ size: PreviewSize;
28
+ onSize: (size: PreviewSize) => void;
29
+ disabled?: boolean;
30
+ }) {
31
+ function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
32
+ if (disabled) return;
33
+ const index = OPTIONS.findIndex((option) => option.size === size);
34
+ const next =
35
+ event.key === "ArrowRight" ? OPTIONS[(index + 1) % OPTIONS.length]
36
+ : event.key === "ArrowLeft" ? OPTIONS[(index - 1 + OPTIONS.length) % OPTIONS.length]
37
+ : null;
38
+ if (!next) return;
39
+ event.preventDefault();
40
+ onSize(next.size);
41
+ event.currentTarget.querySelector<HTMLButtonElement>(`[data-preview-size="${next.size}"]`)?.focus();
42
+ }
43
+
44
+ return (
45
+ <div
46
+ role="radiogroup"
47
+ aria-label="Preview size"
48
+ onKeyDown={onKeyDown}
49
+ {...stylex.props(styles.group)}
50
+ >
51
+ {OPTIONS.map((option) => {
52
+ const selected = option.size === size;
53
+ const Icon = option.icon;
54
+ return (
55
+ <Tooltip key={option.size} label={option.label}>
56
+ <button
57
+ type="button"
58
+ role="radio"
59
+ data-preview-size={option.size}
60
+ aria-label={option.label}
61
+ aria-checked={selected}
62
+ disabled={disabled}
63
+ tabIndex={selected ? 0 : -1}
64
+ onClick={() => onSize(option.size)}
65
+ {...stylex.props(
66
+ styles.option,
67
+ selected && styles.optionOn,
68
+ disabled && styles.optionDisabled,
69
+ )}
70
+ >
71
+ <Icon size={14} ariaHidden />
72
+ </button>
73
+ </Tooltip>
74
+ );
75
+ })}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ /** Centers a fixed-width preview and scrolls sideways when the pane is narrower. */
81
+ export function PreviewViewport({
82
+ size,
83
+ dragging = false,
84
+ children,
85
+ }: {
86
+ size: PreviewSize;
87
+ dragging?: boolean;
88
+ children: ReactNode;
89
+ }) {
90
+ const width = previewFrameWidth(size);
91
+ const viewport = stylex.props(styles.viewport, dragging && styles.dragging);
92
+ const slot = stylex.props(styles.slot, width !== null && styles.slotFixed);
93
+ return (
94
+ <div className={viewport.className} style={viewport.style}>
95
+ <div
96
+ className={slot.className}
97
+ style={width === null ? slot.style : { ...slot.style, width }}
98
+ >
99
+ {children}
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ const hover = "@media (hover: hover)";
106
+
107
+ const styles = stylex.create({
108
+ group: {
109
+ display: "inline-flex",
110
+ flexShrink: 0,
111
+ gap: "2px",
112
+ padding: "2px",
113
+ borderRadius: radius.md,
114
+ backgroundColor: colors.bgPanelInset,
115
+ },
116
+ option: {
117
+ display: "inline-flex",
118
+ alignItems: "center",
119
+ justifyContent: "center",
120
+ width: "28px",
121
+ height: "22px",
122
+ padding: 0,
123
+ borderWidth: 0,
124
+ borderRadius: radius.sm,
125
+ color: colors.textMuted,
126
+ backgroundColor: "transparent",
127
+ cursor: "pointer",
128
+ [hover]: {
129
+ ":hover:not(:disabled)": {
130
+ color: colors.text,
131
+ },
132
+ },
133
+ ":focus-visible": {
134
+ outlineWidth: 2,
135
+ outlineStyle: "solid",
136
+ outlineColor: colors.focus,
137
+ outlineOffset: 1,
138
+ },
139
+ },
140
+ optionOn: {
141
+ color: colors.text,
142
+ backgroundColor: colors.bgSurface,
143
+ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)",
144
+ },
145
+ optionDisabled: {
146
+ cursor: "default",
147
+ color: colors.border,
148
+ },
149
+ viewport: {
150
+ display: "flex",
151
+ flex: 1,
152
+ alignItems: "stretch",
153
+ justifyContent: "center",
154
+ minWidth: 0,
155
+ minHeight: 0,
156
+ overflowX: "auto",
157
+ overflowY: "hidden",
158
+ },
159
+ dragging: {
160
+ pointerEvents: "none",
161
+ },
162
+ slot: {
163
+ position: "relative",
164
+ display: "flex",
165
+ flexDirection: "column",
166
+ flexGrow: 1,
167
+ flexShrink: 1,
168
+ flexBasis: "0%",
169
+ minWidth: 0,
170
+ minHeight: 0,
171
+ backgroundColor: colors.bgSurface,
172
+ },
173
+ slotFixed: {
174
+ flexGrow: 0,
175
+ flexShrink: 0,
176
+ flexBasis: "auto",
177
+ outlineWidth: 1,
178
+ outlineStyle: "solid",
179
+ outlineColor: colors.border,
180
+ },
181
+ });
@@ -7,6 +7,8 @@ import {
7
7
  rotateHostToken,
8
8
  rotatePreviewToken,
9
9
  } from "../api";
10
+ import { publicContentApiUrl } from "../chrome/content-api-url";
11
+ import { useReplicaState } from "../replica/useReplica";
10
12
  import { hostEnvSnippet } from "./host-env";
11
13
  import { isStudioDev, publicApiUrl } from "../studio-config";
12
14
  import { revalidateWebhookUrl } from "./preview-url";
@@ -24,6 +26,7 @@ export function SettingsGeneralPage() {
24
26
  const [previewTokenOnce, setPreviewTokenOnce] = useState<string | null>(null);
25
27
  const [hostTokenOnce, setHostTokenOnce] = useState<string | null>(null);
26
28
  const [copiedId, setCopiedId] = useState(false);
29
+ const [copiedApi, setCopiedApi] = useState(false);
27
30
  const [copiedEnv, setCopiedEnv] = useState(false);
28
31
  const [siteUrl, setSiteUrl] = useState(workspace.siteUrls?.[0] ?? "");
29
32
  const [error, setError] = useState<string | null>(null);
@@ -34,13 +37,25 @@ export function SettingsGeneralPage() {
34
37
  const webhookUrl = revalidateWebhookUrl(siteUrl);
35
38
 
36
39
  useEffect(() => {
37
- if (!copiedId && !copiedEnv) return;
40
+ if (!copiedId && !copiedApi && !copiedEnv) return;
38
41
  const id = window.setTimeout(() => {
39
42
  setCopiedId(false);
43
+ setCopiedApi(false);
40
44
  setCopiedEnv(false);
41
45
  }, 1500);
42
46
  return () => window.clearTimeout(id);
43
- }, [copiedId, copiedEnv]);
47
+ }, [copiedId, copiedApi, copiedEnv]);
48
+
49
+ const git = useReplicaState().git(workspace.id);
50
+ const docsUrl = publicContentApiUrl({
51
+ apiUrl: publicApiUrl(),
52
+ workspaceId: workspace.id,
53
+ branch: git?.currentBranch,
54
+ defaultBranch: git?.defaultBranch,
55
+ });
56
+ const branchLocked = Boolean(
57
+ git?.currentBranch && git.defaultBranch && git.currentBranch !== git.defaultBranch,
58
+ );
44
59
 
45
60
  const envSnippet = hostEnvSnippet({
46
61
  workspaceId: workspace.id,
@@ -197,6 +212,38 @@ export function SettingsGeneralPage() {
197
212
  backmatter.config.ts workspaceId · public URLs use this id, not the slug
198
213
  </p>
199
214
  </Panel>
215
+ <Panel>
216
+ <Text as="h2" size="lg" weight="medium">
217
+ Content API
218
+ </Text>
219
+ <div {...stylex.props(styles.idRow)}>
220
+ <a
221
+ href={docsUrl}
222
+ target="_blank"
223
+ rel="noreferrer"
224
+ {...stylex.props(styles.pre, styles.idPre, styles.apiLink)}
225
+ >
226
+ {docsUrl}
227
+ </a>
228
+ <Button
229
+ variant="secondary"
230
+ aria-label={copiedApi ? "Copied content API URL" : "Copy content API URL"}
231
+ onClick={() => {
232
+ void navigator.clipboard.writeText(docsUrl).then(
233
+ () => setCopiedApi(true),
234
+ () => setCopiedApi(false),
235
+ );
236
+ }}
237
+ >
238
+ {copiedApi ? "Copied" : "Copy"}
239
+ </Button>
240
+ </div>
241
+ <p {...stylex.props(styles.caption)}>
242
+ {branchLocked
243
+ ? "This branch needs the content token below."
244
+ : "Default branch is public — no token. URLs use the workspace id."}
245
+ </p>
246
+ </Panel>
200
247
  <Panel>
201
248
  <Text as="h2" size="lg" weight="medium">
202
249
  Host environment
@@ -358,6 +405,8 @@ export function relativeTime(iso: string, now = Date.now()): string {
358
405
  return `${Math.round(hours / 24)}d ago`;
359
406
  }
360
407
 
408
+ const hover = "@media (hover: hover)";
409
+
361
410
  const styles = stylex.create({
362
411
  block: {
363
412
  marginTop: space.md,
@@ -379,6 +428,22 @@ const styles = stylex.create({
379
428
  flex: 1,
380
429
  minWidth: 0,
381
430
  },
431
+ apiLink: {
432
+ display: "block",
433
+ color: colors.text,
434
+ textDecoration: "none",
435
+ [hover]: {
436
+ ":hover": {
437
+ textDecoration: "underline",
438
+ },
439
+ },
440
+ ":focus-visible": {
441
+ outlineWidth: 2,
442
+ outlineStyle: "solid",
443
+ outlineColor: colors.focus,
444
+ outlineOffset: 2,
445
+ },
446
+ },
382
447
  caption: {
383
448
  marginTop: space.sm,
384
449
  marginBottom: 0,