@backmatter/studio 0.0.16 → 0.0.18

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 (45) hide show
  1. package/dist/chunks/{UiKitApp-_EDqWID3.js → UiKitApp-DkuQBiQZ.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-B2PrFgpz.js → WorkspaceRoutes-DVvb3N-V.js} +27489 -26622
  3. package/dist/chunks/{dashboard-BnlnMnnr.js → dashboard-B4FITg6c.js} +2 -2
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-D_BHF_8q.js → index-CegtXnEI.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 +45 -1
  10. package/dist/site-widget.js +2799 -2791
  11. package/dist/studio.css +113 -15
  12. package/package.json +2 -2
  13. package/src/api.ts +22 -0
  14. package/src/chat/ChatProvider.tsx +6 -0
  15. package/src/chat/Composer.tsx +3 -1
  16. package/src/chrome/Toast.tsx +115 -0
  17. package/src/chrome/WorkspaceStatusBar.tsx +12 -3
  18. package/src/chrome/commit-error.ts +13 -0
  19. package/src/chrome/pull-failure.ts +61 -0
  20. package/src/{pages/code-session-routes.ts → content-routes.ts} +6 -6
  21. package/src/fields/FieldControl.tsx +771 -0
  22. package/src/fields/LinkFieldInput.tsx +315 -0
  23. package/src/fields/choose-field-input.ts +69 -0
  24. package/src/fields/link-targets.ts +60 -0
  25. package/src/fields/load-relation-documents.ts +35 -0
  26. package/src/fields/omit-field.ts +12 -0
  27. package/src/fields/relation-targets.ts +44 -0
  28. package/src/fields/resolve-object-fields.ts +21 -0
  29. package/src/host-path.ts +13 -0
  30. package/src/pages/BlockSettingsDialog.tsx +19 -115
  31. package/src/pages/BlocksList.tsx +43 -35
  32. package/src/pages/BodyProse.tsx +16 -1
  33. package/src/pages/CodeSessionPage.tsx +186 -248
  34. package/src/pages/DocEditorPage.tsx +168 -213
  35. package/src/pages/MediaFieldInput.tsx +609 -26
  36. package/src/pages/MediaGalleryPage.tsx +14 -3
  37. package/src/pages/PreviewAddress.tsx +263 -0
  38. package/src/pages/RenderedBody.tsx +17 -2
  39. package/src/pages/branch-notice.ts +16 -0
  40. package/src/pages/code-session-boot.ts +0 -12
  41. package/src/pages/code-session-context.tsx +2 -0
  42. package/src/pages/media-selection.ts +79 -0
  43. package/src/shell-resets.css +15 -0
  44. package/src/ui/Input.tsx +178 -20
  45. package/src/ui/popup.ts +11 -2
@@ -43,9 +43,13 @@ const CELL = 168;
43
43
  export function MediaGalleryPage({
44
44
  pick = false,
45
45
  onPick,
46
+ pickedUrls,
47
+ onToggleUrl,
46
48
  }: {
47
49
  pick?: boolean;
48
50
  onPick?: (url: string) => void;
51
+ pickedUrls?: string[];
52
+ onToggleUrl?: (url: string) => void;
49
53
  }) {
50
54
  const { workspace } = useOutletContext<WorkspaceOutletContext>();
51
55
  const [searchParams, setSearchParams] = useSearchParams();
@@ -168,7 +172,6 @@ export function MediaGalleryPage({
168
172
  }, [uploadFiles]);
169
173
 
170
174
  useEffect(() => {
171
- if (pick) return;
172
175
  const hasFiles = (event: DragEvent) =>
173
176
  Array.from(event.dataTransfer?.types ?? []).includes("Files");
174
177
  const onEnter = (event: DragEvent) => {
@@ -202,7 +205,7 @@ export function MediaGalleryPage({
202
205
  window.removeEventListener("dragleave", onLeave);
203
206
  window.removeEventListener("drop", onDrop);
204
207
  };
205
- }, [pick, uploadFiles]);
208
+ }, [uploadFiles]);
206
209
 
207
210
  const browsable = [
208
211
  ...state.pending.flatMap((item) => (item.media ? [item.media] : [])),
@@ -479,10 +482,18 @@ export function MediaGalleryPage({
479
482
  <MediaHit
480
483
  item={cell.item}
481
484
  menu={!pick}
482
- selected={!pick && !sharedId && cell.item.id === selectedId}
485
+ selected={
486
+ onToggleUrl
487
+ ? (pickedUrls?.includes(cell.item.url) ?? false)
488
+ : !pick && !sharedId && cell.item.id === selectedId
489
+ }
483
490
  canDelete={canDelete}
484
491
  onOpen={() => {
485
492
  setSelectedId(null);
493
+ if (onToggleUrl) {
494
+ onToggleUrl(cell.item.url);
495
+ return;
496
+ }
486
497
  if (pick) {
487
498
  if (onPick) onPick(cell.item.url);
488
499
  return;
@@ -0,0 +1,263 @@
1
+ // packages/studio/src/pages/PreviewAddress.tsx
2
+ import { useEffect, useRef, useState, type FormEvent } from "react";
3
+ import { Combobox } from "@base-ui/react/combobox";
4
+ import {
5
+ IconArrowRotateClockwise,
6
+ IconArrowUpRight,
7
+ IconGlobe,
8
+ } from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
9
+ import * as stylex from "@stylexjs/stylex";
10
+ import { colors, radius, space, type } from "../styles/tokens.stylex";
11
+ import { cx, popup, popupClassName } from "../ui/popup";
12
+ import { hostPath } from "../host-path";
13
+ import { routesMatching } from "../content-routes";
14
+
15
+ const hover = "@media (hover: hover)";
16
+
17
+ /** Reload, the page path, and open. The path opens the route list. */
18
+ export function PreviewAddress({
19
+ path,
20
+ enabled,
21
+ routes,
22
+ routesPending,
23
+ onOpenRoutes,
24
+ onPath,
25
+ onReload,
26
+ onOpen,
27
+ }: {
28
+ path: string;
29
+ enabled: boolean;
30
+ routes: readonly string[];
31
+ routesPending: boolean;
32
+ onOpenRoutes?: () => void;
33
+ onPath: (path: string) => void;
34
+ onReload: () => void;
35
+ onOpen: () => void;
36
+ }) {
37
+ const [query, setQuery] = useState<string | null>(null);
38
+ const [open, setOpen] = useState(false);
39
+ const picked = useRef(false);
40
+ const fieldRef = useRef<HTMLButtonElement>(null);
41
+ const inputRef = useRef<HTMLInputElement>(null);
42
+ const shown = query ? routesMatching(routes, query) : [...routes];
43
+
44
+ useEffect(() => {
45
+ if (!open) return;
46
+ const input = inputRef.current;
47
+ input?.focus();
48
+ input?.select();
49
+ }, [open]);
50
+
51
+ return (
52
+ <div {...stylex.props(styles.address)}>
53
+ <button
54
+ type="button"
55
+ aria-label="Reload preview"
56
+ disabled={!enabled}
57
+ onClick={onReload}
58
+ {...stylex.props(styles.iconButton)}
59
+ >
60
+ <IconArrowRotateClockwise size={14} ariaHidden />
61
+ </button>
62
+ <Combobox.Root
63
+ items={shown}
64
+ filteredItems={shown}
65
+ inputValue={query ?? ""}
66
+ disabled={!enabled}
67
+ open={open}
68
+ value={routes.includes(path) ? path : null}
69
+ onOpenChange={(next) => {
70
+ if (!enabled && next) return;
71
+ setOpen(next);
72
+ if (next) onOpenRoutes?.();
73
+ else setQuery(null);
74
+ }}
75
+ onInputValueChange={(value, details) => {
76
+ if (details.reason === "input-change" || details.reason === "input-clear") {
77
+ setQuery(value);
78
+ }
79
+ }}
80
+ onValueChange={(value) => {
81
+ if (typeof value !== "string") return;
82
+ picked.current = true;
83
+ setQuery(null);
84
+ onPath(hostPath(value));
85
+ queueMicrotask(() => {
86
+ picked.current = false;
87
+ });
88
+ }}
89
+ >
90
+ <Combobox.Trigger
91
+ ref={fieldRef}
92
+ aria-label="Preview path"
93
+ disabled={!enabled}
94
+ data-no-ring
95
+ className={cx(styles.addressTrigger)}
96
+ >
97
+ <IconGlobe size={13} ariaHidden />
98
+ <span {...stylex.props(styles.addressPath)}>{path}</span>
99
+ </Combobox.Trigger>
100
+ <Combobox.Portal>
101
+ <Combobox.Positioner
102
+ anchor={fieldRef}
103
+ side="bottom"
104
+ align="start"
105
+ sideOffset={6}
106
+ className={cx(popup.positioner)}
107
+ >
108
+ <Combobox.Popup
109
+ aria-label="Pages"
110
+ className={(state) => popupClassName(state, styles.routePopup)}
111
+ >
112
+ <form
113
+ onSubmit={(event: FormEvent<HTMLFormElement>) => {
114
+ event.preventDefault();
115
+ const fromList = picked.current;
116
+ picked.current = false;
117
+ if (fromList) return;
118
+ const typed = event.currentTarget.querySelector("input")?.value;
119
+ if (typed?.trim()) onPath(hostPath(typed));
120
+ setOpen(false);
121
+ setQuery(null);
122
+ }}
123
+ >
124
+ <Combobox.Input
125
+ ref={inputRef}
126
+ placeholder="Find a page"
127
+ aria-label="Find a page"
128
+ spellCheck={false}
129
+ autoComplete="off"
130
+ data-no-ring
131
+ className={cx(popup.quietInput, styles.routeSearch)}
132
+ onKeyDown={(event) => {
133
+ if (event.key !== "Enter") return;
134
+ // A highlighted row is chosen by the combobox. A bare Enter goes to the typed path.
135
+ if (event.currentTarget.getAttribute("aria-activedescendant")) return;
136
+ const typed = event.currentTarget.value.trim();
137
+ if (!typed) return;
138
+ picked.current = true;
139
+ onPath(hostPath(typed));
140
+ }}
141
+ />
142
+ </form>
143
+ <div {...stylex.props(popup.separator)} />
144
+ <Combobox.Empty className={cx(popup.empty)}>
145
+ {routesPending ? "Loading pages" : "No pages"}
146
+ </Combobox.Empty>
147
+ <Combobox.List className={cx(styles.routeList)}>
148
+ {(route: string) => (
149
+ <Combobox.Item
150
+ key={route}
151
+ value={route}
152
+ className={(state) =>
153
+ cx(
154
+ popup.item,
155
+ styles.routeItem,
156
+ state.highlighted && popup.itemHighlighted,
157
+ state.selected && popup.itemSelected,
158
+ )
159
+ }
160
+ >
161
+ {route}
162
+ </Combobox.Item>
163
+ )}
164
+ </Combobox.List>
165
+ </Combobox.Popup>
166
+ </Combobox.Positioner>
167
+ </Combobox.Portal>
168
+ </Combobox.Root>
169
+ <button
170
+ type="button"
171
+ aria-label="Open preview in a new tab"
172
+ disabled={!enabled}
173
+ onClick={onOpen}
174
+ {...stylex.props(styles.iconButton)}
175
+ >
176
+ <IconArrowUpRight size={14} ariaHidden />
177
+ </button>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ const styles = stylex.create({
183
+ address: {
184
+ display: "flex",
185
+ alignItems: "center",
186
+ gap: space.xs,
187
+ minWidth: 0,
188
+ },
189
+ addressTrigger: {
190
+ display: "flex",
191
+ flex: 1,
192
+ alignItems: "center",
193
+ gap: space.xs,
194
+ minWidth: 0,
195
+ height: "28px",
196
+ paddingInline: space.sm,
197
+ borderWidth: 0,
198
+ borderRadius: "999px",
199
+ font: "inherit",
200
+ textAlign: "left",
201
+ cursor: { default: "pointer", ":disabled": "default" },
202
+ color: { default: colors.textMuted, ":disabled": colors.textMuted },
203
+ backgroundColor: colors.bgPanelInset,
204
+ ":focus-visible": {
205
+ outlineWidth: 2,
206
+ outlineStyle: "solid",
207
+ outlineColor: colors.focus,
208
+ outlineOffset: 1,
209
+ },
210
+ },
211
+ addressPath: {
212
+ flex: 1,
213
+ minWidth: 0,
214
+ overflow: "hidden",
215
+ textOverflow: "ellipsis",
216
+ whiteSpace: "nowrap",
217
+ fontFamily: type.mono,
218
+ fontSize: type.xs,
219
+ color: colors.text,
220
+ },
221
+ routeSearch: {
222
+ fontFamily: type.mono,
223
+ fontSize: type.xs,
224
+ },
225
+ routePopup: {
226
+ width: "var(--anchor-width)",
227
+ minWidth: "220px",
228
+ maxHeight: "min(320px, var(--available-height))",
229
+ display: "flex",
230
+ flexDirection: "column",
231
+ overflowY: "hidden",
232
+ },
233
+ routeList: {
234
+ minHeight: 0,
235
+ overflowY: "auto",
236
+ },
237
+ routeItem: {
238
+ fontFamily: type.mono,
239
+ },
240
+ iconButton: {
241
+ display: "inline-flex",
242
+ alignItems: "center",
243
+ justifyContent: "center",
244
+ flexShrink: 0,
245
+ width: "28px",
246
+ height: "28px",
247
+ padding: 0,
248
+ borderWidth: 0,
249
+ borderRadius: radius.sm,
250
+ cursor: { default: "pointer", ":disabled": "default" },
251
+ color: { default: colors.textMuted, ":disabled": colors.border },
252
+ backgroundColor: {
253
+ default: "transparent",
254
+ [hover]: { default: null, ":hover:not(:disabled)": colors.bgHover },
255
+ },
256
+ ":focus-visible": {
257
+ outlineWidth: 2,
258
+ outlineStyle: "solid",
259
+ outlineColor: colors.focus,
260
+ outlineOffset: 1,
261
+ },
262
+ },
263
+ });
@@ -9,7 +9,7 @@ import {
9
9
  type ReactNode,
10
10
  } from "react";
11
11
  import * as stylex from "@stylexjs/stylex";
12
- import type { BlockSnapshot } from "../api";
12
+ import type { BlockSnapshot, SchemaField } from "../api";
13
13
  import { contentBlockFields, contentBlockLabel, contentBlockProps } from "../content-blocks";
14
14
  import { colors, radius, space } from "../styles/tokens.stylex";
15
15
  import {
@@ -40,16 +40,28 @@ export type RenderedBodyHandle = {
40
40
 
41
41
  const BODY_HOST = "body";
42
42
 
43
+ type ModelRef = { type: string; external?: boolean; fields?: Record<string, SchemaField> };
44
+ type RelationDocumentLists = Record<
45
+ string,
46
+ Array<{ slug: string; frontmatter: Record<string, unknown> }>
47
+ >;
48
+
43
49
  export const RenderedBody = forwardRef<
44
50
  RenderedBodyHandle,
45
51
  {
46
52
  value: string;
47
53
  catalog?: Array<BlockSnapshot & { label?: string }>;
54
+ models: ModelRef[];
55
+ objects?: Array<{ name: string; fields: Record<string, SchemaField> }>;
56
+ documentLists?: RelationDocumentLists;
48
57
  readOnly?: boolean;
49
58
  labelledBy: string;
50
59
  onChange: (value: string) => void;
51
60
  }
52
- >(function RenderedBody({ value, catalog = [], readOnly, labelledBy, onChange }, ref) {
61
+ >(function RenderedBody(
62
+ { value, catalog = [], models, objects = [], documentLists, readOnly, labelledBy, onChange },
63
+ ref,
64
+ ) {
53
65
  const local = useRef(value);
54
66
  const hosts = useRef(new Map<string, HTMLElement>());
55
67
  const linking = useRef(false);
@@ -218,6 +230,9 @@ export const RenderedBody = forwardRef<
218
230
  title={contentBlockLabel(settings.def.name)}
219
231
  fields={contentBlockFields(settings.def)}
220
232
  value={contentBlockProps(settings.block.markdown, settings.def.name)}
233
+ models={models}
234
+ objects={objects}
235
+ documentLists={documentLists}
221
236
  readOnly={readOnly}
222
237
  onOpenChange={(open) => {
223
238
  if (!open) setSettingsId(null);
@@ -0,0 +1,16 @@
1
+ // packages/studio/src/pages/branch-notice.ts
2
+
3
+ export type BranchNoticeKind = "update" | "edits" | "fold";
4
+
5
+ /** What to show when this session's commit is behind the branch tip. */
6
+ export function branchNoticeKind(input: {
7
+ baseSha: string | null;
8
+ headSha: string | null;
9
+ editCount: number;
10
+ foldPending: boolean;
11
+ }): BranchNoticeKind | null {
12
+ if (!input.baseSha || !input.headSha || input.baseSha === input.headSha) return null;
13
+ if (input.editCount > 0) return "edits";
14
+ if (input.foldPending) return "fold";
15
+ return "update";
16
+ }
@@ -29,15 +29,3 @@ export function bootSteps(input: {
29
29
  if (input.phase === "stopped") return at(0, "failed");
30
30
  return at(input.furthest === "starting" ? 2 : input.furthest === "installing" ? 1 : 0, "failed");
31
31
  }
32
-
33
- /** Normalizes what someone types in the address bar to a path inside the host. */
34
- export function hostPath(input: string): string {
35
- const trimmed = input.trim();
36
- if (!trimmed) return "/";
37
- try {
38
- const url = new URL(trimmed);
39
- return `${url.pathname}${url.search}${url.hash}` || "/";
40
- } catch {
41
- return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
42
- }
43
- }
@@ -13,6 +13,8 @@ export type CodeSessionContextValue = {
13
13
  sessionId: string;
14
14
  editCount: number;
15
15
  refresh: () => void;
16
+ /** The checkout is being replaced. Chat waits until the host boots. */
17
+ updating?: boolean;
16
18
  };
17
19
 
18
20
  const CodeSessionContext = createContext<CodeSessionContextValue | null>(null);
@@ -0,0 +1,79 @@
1
+ // packages/studio/src/pages/media-selection.ts
2
+
3
+ export function toggleMediaUrl(urls: readonly string[], url: string): string[] {
4
+ const index = urls.indexOf(url);
5
+ if (index >= 0) {
6
+ return urls.filter((_, i) => i !== index);
7
+ }
8
+ return [...urls, url];
9
+ }
10
+
11
+ export function appendMediaUrls(
12
+ existing: readonly string[],
13
+ incoming: readonly string[],
14
+ ): string[] {
15
+ const next = [...existing];
16
+ for (const url of incoming) {
17
+ if (!next.includes(url)) {
18
+ next.push(url);
19
+ }
20
+ }
21
+ return next;
22
+ }
23
+
24
+ export function moveMediaUrl(urls: readonly string[], from: number, to: number): string[] {
25
+ if (from < 0 || from >= urls.length || to < 0 || to >= urls.length) {
26
+ return [...urls];
27
+ }
28
+ const next = [...urls];
29
+ const [item] = next.splice(from, 1);
30
+ next.splice(to, 0, item!);
31
+ return next;
32
+ }
33
+
34
+ export function acceptedDrop(
35
+ existing: readonly string[],
36
+ accepted: readonly string[],
37
+ multiple: boolean,
38
+ ): { urls: string[]; notAdded: number } {
39
+ if (!multiple) {
40
+ if (accepted.length === 0) {
41
+ return { urls: [...existing], notAdded: 0 };
42
+ }
43
+ return {
44
+ urls: [accepted[0]!],
45
+ notAdded: Math.max(0, accepted.length - 1),
46
+ };
47
+ }
48
+ return { urls: appendMediaUrls(existing, accepted), notAdded: 0 };
49
+ }
50
+
51
+ export function notAddedMessage(count: number): string | null {
52
+ if (count === 0) {
53
+ return null;
54
+ }
55
+ if (count === 1) {
56
+ return "1 image was not added";
57
+ }
58
+ return `${count} images were not added`;
59
+ }
60
+
61
+ export function editorUrls(
62
+ value: unknown,
63
+ multiple: boolean,
64
+ ): { urls: string[]; raw: unknown } {
65
+ if (value === undefined || value === null || value === "") {
66
+ return { urls: [], raw: undefined };
67
+ }
68
+ if (typeof value === "string") {
69
+ return { urls: [value], raw: value };
70
+ }
71
+ if (Array.isArray(value)) {
72
+ if (multiple) {
73
+ const urls = value.filter((item): item is string => typeof item === "string");
74
+ return { urls, raw: value };
75
+ }
76
+ return { urls: [], raw: value };
77
+ }
78
+ return { urls: [], raw: undefined };
79
+ }
@@ -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
  }