@backmatter/studio 0.0.7 → 0.0.9

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 (72) hide show
  1. package/dist/chunks/UiKitApp-b8qFsmA-.js +2401 -0
  2. package/dist/chunks/WorkspaceRoutes-BKKEkAq7.js +90126 -0
  3. package/dist/chunks/{dashboard-DCHRI0WD.js → dashboard-BNUcbYU0.js} +380 -382
  4. package/dist/chunks/{index-BS0-Bvbk.js → index-laWAz5_p.js} +111 -112
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +287 -146
  7. package/dist/site-widget.css +1615 -0
  8. package/dist/site-widget.js +30896 -630
  9. package/dist/studio.css +303 -98
  10. package/package.json +4 -4
  11. package/src/App.tsx +12 -29
  12. package/src/api.ts +55 -0
  13. package/src/chat/ChangeCard.tsx +2 -0
  14. package/src/chat/ChatMessage.tsx +73 -62
  15. package/src/chat/ChatPanel.tsx +110 -15
  16. package/src/chat/DocListCard.tsx +2 -0
  17. package/src/chat/LiveStatus.tsx +78 -8
  18. package/src/chat/Reveal.tsx +34 -0
  19. package/src/chat/Shimmer.tsx +65 -23
  20. package/src/chat/chat-helpers.ts +35 -0
  21. package/src/chat/motion.ts +73 -0
  22. package/src/chat/useStickToBottom.ts +129 -14
  23. package/src/chrome/AppShell.tsx +47 -10
  24. package/src/chrome/AppSidebar.tsx +9 -34
  25. package/src/chrome/ContentExplorerSidebar.tsx +149 -96
  26. package/src/chrome/UserMenu.tsx +3 -1
  27. package/src/chrome/content-tree-visibility.ts +55 -0
  28. package/src/chrome/explorer-icons.tsx +2 -4
  29. package/src/devbar-state.ts +125 -0
  30. package/src/index.css +3 -131
  31. package/src/pages/CliAuthorizePage.tsx +61 -28
  32. package/src/pages/MediaGalleryPage.tsx +144 -85
  33. package/src/pages/MediaLightbox.tsx +195 -96
  34. package/src/pages/SelectRepositoriesLink.tsx +28 -4
  35. package/src/pages/SettingsGeneralPage.tsx +25 -28
  36. package/src/pages/SettingsGithubPage.tsx +413 -124
  37. package/src/pages/SettingsUsagePage.tsx +23 -1
  38. package/src/pages/media-grid.ts +14 -1
  39. package/src/pages/ui-kit/UiKitApp.tsx +164 -0
  40. package/src/pages/ui-kit/buttons.tsx +30 -0
  41. package/src/pages/ui-kit/catalog.tsx +41 -0
  42. package/src/pages/ui-kit/colors.tsx +95 -0
  43. package/src/pages/ui-kit/devbar.tsx +160 -0
  44. package/src/pages/ui-kit/feedback.tsx +39 -0
  45. package/src/pages/ui-kit/fields.tsx +47 -0
  46. package/src/pages/ui-kit/lists.tsx +19 -0
  47. package/src/pages/ui-kit/overlays.tsx +39 -0
  48. package/src/pages/ui-kit/path.ts +9 -0
  49. package/src/pages/ui-kit/sections.tsx +32 -0
  50. package/src/pages/ui-kit/surfaces.tsx +121 -0
  51. package/src/pages/ui-kit/topic.tsx +112 -0
  52. package/src/pages/ui-kit/topics.ts +71 -0
  53. package/src/pages/ui-kit/type.tsx +55 -0
  54. package/src/preview-frame-radius.ts +51 -0
  55. package/src/preview-frame.tsx +93 -49
  56. package/src/shell-resets.css +155 -0
  57. package/src/site-bar.tsx +1791 -425
  58. package/src/site-dashboard-href.ts +21 -0
  59. package/src/site-devbar-overlay-inject.ts +15 -0
  60. package/src/site-devbar-overlay.css +217 -0
  61. package/src/site-pointer.ts +1 -1
  62. package/src/site-publish-menu.tsx +33 -0
  63. package/src/site-widget-styles.ts +7 -0
  64. package/src/site-widget.tsx +61 -21
  65. package/src/studio-prose-ui.css +39 -0
  66. package/src/theme/ThemeProvider.tsx +4 -1
  67. package/src/workspace-path.ts +18 -1
  68. package/dist/chunks/EmbedPublishPage-88VsmOhi.js +0 -149
  69. package/dist/chunks/UiKitPage-X4hkFL2d.js +0 -173
  70. package/dist/chunks/WorkspaceRoutes-DXzdWqN3.js +0 -59523
  71. package/dist/chunks/status-bar-action-wj1KZ51m.js +0 -30612
  72. package/src/pages/UiKitPage.tsx +0 -331
@@ -6,10 +6,11 @@ import {
6
6
  IconSquareBehindSquare1,
7
7
  IconTrashCanSimple,
8
8
  } from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
9
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
10
10
  import { useOutletContext, useSearchParams } from "react-router";
11
11
  import * as stylex from "@stylexjs/stylex";
12
- import { deleteMedia, fetchMedia, uploadMedia } from "../api";
12
+ import { deleteMedia, describeMedia, fetchMedia, uploadMedia } from "../api";
13
+ import { Input } from "../ui/Input";
13
14
  import { colors, radius, space, type, z } from "../styles/tokens.stylex";
14
15
  import { Button } from "../ui/Button";
15
16
  import { ConfirmDialog } from "../ui/ConfirmDialog";
@@ -19,10 +20,10 @@ import { canWriteContent } from "../workspace/permissions";
19
20
  import type { WorkspaceOutletContext } from "../workspace/types";
20
21
  import {
21
22
  beginUploads,
23
+ describeMedia as rememberDescription,
22
24
  emptyMediaGrid,
23
25
  failUpload,
24
26
  finishUpload,
25
- gridLength,
26
27
  lightboxImageId,
27
28
  mediaIdAfterRemoval,
28
29
  nextMediaIndex,
@@ -57,38 +58,69 @@ export function MediaGalleryPage({
57
58
  const [deleteError, setDeleteError] = useState<string | null>(null);
58
59
  const [selectedId, setSelectedId] = useState<string | null>(null);
59
60
  const [settled, setSettled] = useState(false);
61
+ const urlQuery = searchParams.get("q") ?? "";
62
+ const [draft, setDraft] = useState(urlQuery);
63
+ const [query, setQuery] = useState(urlQuery);
60
64
  const dragDepth = useRef(0);
61
65
  const scroller = useRef<HTMLDivElement>(null);
62
- const [scroll, setScroll] = useState({ top: 0, height: 600, width: 800 });
66
+ const grid = useRef<HTMLDivElement>(null);
67
+ const [columns, setColumns] = useState(1);
63
68
  const loadingPage = useRef(false);
69
+ const request = useRef(0);
70
+
71
+ useEffect(() => {
72
+ const handle = window.setTimeout(() => setQuery(draft), 200);
73
+ return () => window.clearTimeout(handle);
74
+ }, [draft]);
75
+
76
+ useEffect(() => {
77
+ setSearchParams(
78
+ (current) => {
79
+ const next = new URLSearchParams(current);
80
+ if (query) next.set("q", query);
81
+ else next.delete("q");
82
+ if (next.get("q") === current.get("q")) return current;
83
+ return next;
84
+ },
85
+ { replace: true },
86
+ );
87
+ }, [query, setSearchParams]);
64
88
 
65
89
  const load = useCallback(
66
90
  async (cursor?: string) => {
67
- if (loadingPage.current) return;
91
+ if (cursor && loadingPage.current) return;
92
+ const ticket = ++request.current;
68
93
  loadingPage.current = true;
94
+ if (!cursor) setState((current) => ({ ...current, nextCursor: null }));
69
95
  try {
70
- const page = await fetchMedia(workspace.id, { cursor });
96
+ const page = await fetchMedia(workspace.id, { query: query || undefined, cursor });
97
+ if (ticket !== request.current) return;
71
98
  setState((current) => receivePage(current, page, cursor ? "append" : "replace"));
72
99
  setError(null);
73
100
  } catch (err) {
101
+ if (ticket !== request.current) return;
74
102
  setError(err instanceof Error ? err.message : "Could not load media");
75
103
  } finally {
76
- loadingPage.current = false;
77
- if (!cursor) setSettled(true);
104
+ if (ticket === request.current) {
105
+ loadingPage.current = false;
106
+ if (!cursor) setSettled(true);
107
+ }
78
108
  }
79
109
  },
80
- [workspace.id],
110
+ [query, workspace.id],
81
111
  );
82
112
 
83
113
  useEffect(() => {
84
114
  void load();
85
115
  }, [load]);
86
116
 
87
- useEffect(() => {
117
+ const maybeLoadMore = useCallback(() => {
88
118
  const node = scroller.current;
89
- if (!node) return;
90
- setScroll({ top: node.scrollTop, height: node.clientHeight || 600, width: node.clientWidth || 800 });
91
- }, [state.pending.length, state.loaded.length]);
119
+ if (!node || !state.nextCursor) return;
120
+ if (node.scrollTop + node.clientHeight > node.scrollHeight - CELL * 2) {
121
+ void load(state.nextCursor);
122
+ }
123
+ }, [load, state.nextCursor]);
92
124
 
93
125
  const uploadFiles = useCallback(
94
126
  (files: File[]) => {
@@ -250,14 +282,8 @@ export function MediaGalleryPage({
250
282
  void load(state.nextCursor);
251
283
  }, [sharedId, browsable, state.nextCursor, load]);
252
284
 
253
- const columns = Math.max(2, Math.floor((scroll.width - 24) / (CELL + 12)));
254
- const count = gridLength(state);
255
- const rows = Math.ceil(count / columns);
256
- const startRow = Math.max(0, Math.floor(scroll.top / (CELL + 12)) - 1);
257
- const endRow = Math.min(rows, Math.ceil((scroll.top + scroll.height) / (CELL + 12)) + 2);
258
-
259
285
  const cells = useMemo(() => {
260
- const out: Array<{ kind: "pending"; item: PendingUpload } | { kind: "media"; item: CatalogMedia } | { kind: "empty" }> = [];
286
+ const out: Array<{ kind: "pending"; item: PendingUpload } | { kind: "media"; item: CatalogMedia }> = [];
261
287
  for (const item of state.pending) {
262
288
  if (item.media) out.push({ kind: "media", item: item.media });
263
289
  else out.push({ kind: "pending", item });
@@ -266,26 +292,24 @@ export function MediaGalleryPage({
266
292
  if (state.pending.some((slot) => slot.media?.id === item.id)) continue;
267
293
  out.push({ kind: "media", item });
268
294
  }
269
- while (out.length < count) out.push({ kind: "empty" });
270
295
  return out;
271
- }, [state.pending, state.loaded, count]);
272
-
273
- const visible = [];
274
- for (let row = startRow; row < endRow; row++) {
275
- for (let column = 0; column < columns; column++) {
276
- const index = row * columns + column;
277
- if (index >= cells.length) break;
278
- visible.push({ index, row, column, cell: cells[index]! });
279
- }
280
- }
296
+ }, [state.pending, state.loaded]);
281
297
 
282
- const maybeLoadMore = () => {
283
- const node = scroller.current;
284
- if (!node || !state.nextCursor) return;
285
- if (node.scrollTop + node.clientHeight > node.scrollHeight - CELL * 2) {
286
- void load(state.nextCursor);
287
- }
288
- };
298
+ useLayoutEffect(() => {
299
+ const scrollNode = scroller.current;
300
+ const gridNode = grid.current;
301
+ if (!scrollNode) return;
302
+ const measure = () => {
303
+ const next = gridNode ? columnsInFirstRow(gridNode) : 1;
304
+ setColumns((current) => (current === next ? current : next));
305
+ maybeLoadMore();
306
+ };
307
+ measure();
308
+ const observer = new ResizeObserver(measure);
309
+ observer.observe(scrollNode);
310
+ if (gridNode) observer.observe(gridNode);
311
+ return () => observer.disconnect();
312
+ }, [maybeLoadMore, cells.length]);
289
313
 
290
314
  useEffect(() => {
291
315
  if (pick || sharedId) return;
@@ -343,21 +367,26 @@ export function MediaGalleryPage({
343
367
  state.pending.length,
344
368
  ]);
345
369
 
346
- useEffect(() => {
370
+ useLayoutEffect(() => {
347
371
  if (!selectedId || sharedId) return;
348
- const index = browsable.findIndex((item) => item.id === selectedId);
349
- const node = scroller.current;
350
- if (index < 0 || !node) return;
351
- const place = mediaPlaces[index];
352
- if (place === undefined) return;
353
- const row = Math.floor(place / columns);
354
- const top = row * (CELL + 12);
355
- const bottom = top + CELL;
356
- if (top < node.scrollTop) node.scrollTop = top;
357
- else if (bottom > node.scrollTop + node.clientHeight) node.scrollTop = bottom - node.clientHeight;
358
- }, [browsable, columns, mediaPlaces, selectedId, sharedId, state.pending.length]);
372
+ const frame = scroller.current;
373
+ const node = grid.current?.querySelector("[aria-current='true']");
374
+ if (!frame || !(node instanceof HTMLElement)) return;
375
+ const item = node.getBoundingClientRect();
376
+ const bounds = frame.getBoundingClientRect();
377
+ if (item.top < bounds.top) frame.scrollTop -= bounds.top - item.top;
378
+ else if (item.bottom > bounds.bottom) frame.scrollTop += item.bottom - bounds.bottom;
379
+ }, [columns, selectedId, sharedId]);
359
380
 
360
381
  const open = lightbox >= 0 ? browsable[lightbox] : undefined;
382
+ const saveText = useCallback(
383
+ async (text: { alt?: string; caption?: string }) => {
384
+ if (!open) return;
385
+ const result = await describeMedia(workspace.id, open.id, text);
386
+ setState((current) => rememberDescription(current, result.media));
387
+ },
388
+ [open, workspace.id],
389
+ );
361
390
  const showEmpty =
362
391
  settled && state.pending.length === 0 && state.loaded.length === 0 && state.total === 0;
363
392
 
@@ -367,22 +396,34 @@ export function MediaGalleryPage({
367
396
  <Text as="h1" size="xl" weight="medium">
368
397
  Media
369
398
  </Text>
370
- <Button variant="secondary" onClick={chooseFiles}>
371
- Upload
372
- </Button>
399
+ <div {...stylex.props(styles.toolbarActions)}>
400
+ <div {...stylex.props(styles.search)}>
401
+ <Input
402
+ aria-label="Search media"
403
+ placeholder="Search"
404
+ value={draft}
405
+ onChange={(event) => setDraft(event.target.value)}
406
+ />
407
+ </div>
408
+ <Button variant="secondary" onClick={chooseFiles}>
409
+ Upload
410
+ </Button>
411
+ </div>
373
412
  </div>
374
413
  {error ? <Text tone="danger">{error}</Text> : null}
375
414
  {rejected.length > 0 ? <Text tone="danger">{rejected.join(" · ")}</Text> : null}
376
415
  <div
377
416
  ref={scroller}
378
417
  {...stylex.props(styles.scroller)}
379
- onScroll={(event) => {
380
- const node = event.currentTarget;
381
- setScroll({ top: node.scrollTop, height: node.clientHeight, width: node.clientWidth });
418
+ onScroll={() => {
382
419
  maybeLoadMore();
383
420
  }}
384
421
  >
385
- {showEmpty ? (
422
+ {showEmpty && query ? (
423
+ <div {...stylex.props(styles.emptyState)}>
424
+ <Text tone="muted">No images match this search.</Text>
425
+ </div>
426
+ ) : showEmpty ? (
386
427
  <div
387
428
  {...stylex.props(styles.emptyState)}
388
429
  onDragOver={(event) => event.preventDefault()}
@@ -410,20 +451,14 @@ export function MediaGalleryPage({
410
451
  </div>
411
452
  </div>
412
453
  ) : (
413
- <div {...stylex.props(styles.canvas)} style={{ height: rows * (CELL + 12) }}>
414
- {visible.map(({ index, row, column, cell }) => (
454
+ <div ref={grid} {...stylex.props(styles.canvas)}>
455
+ {cells.map((cell) => (
415
456
  <div
416
- key={cell.kind === "media" ? cell.item.id : cell.kind === "pending" ? cell.item.key : `empty-${index}`}
457
+ key={cell.kind === "media" ? cell.item.id : cell.item.key}
417
458
  {...stylex.props(styles.cell)}
418
- style={{
419
- width: CELL,
420
- height: CELL,
421
- transform: `translate(${column * (CELL + 12)}px, ${row * (CELL + 12)}px)`,
422
- }}
423
459
  >
424
- {cell.kind === "empty" ? <div {...stylex.props(styles.empty)} /> : null}
425
460
  {cell.kind === "pending" ? (
426
- <figure {...stylex.props(styles.figure)}>
461
+ <figure {...stylex.props(styles.figure, styles.placed)}>
427
462
  <img src={cell.item.previewUrl} alt="" {...stylex.props(styles.image, styles.dimmed)} />
428
463
  {cell.item.status === "error" ? (
429
464
  <figcaption {...stylex.props(styles.caption)}>{cell.item.error}</figcaption>
@@ -478,6 +513,8 @@ export function MediaGalleryPage({
478
513
  onPrev={prevLightbox}
479
514
  onNext={nextLightbox}
480
515
  onDelete={canDelete ? () => requestDelete(open) : undefined}
516
+ canEdit={canDelete}
517
+ onDescribe={saveText}
481
518
  />
482
519
  ) : null}
483
520
  <ConfirmDialog
@@ -519,10 +556,10 @@ function MediaHit({
519
556
  <button
520
557
  type="button"
521
558
  aria-current={selected ? "true" : undefined}
522
- {...stylex.props(styles.hit, selected && styles.selected)}
559
+ {...stylex.props(styles.hit, !menu && styles.placed, selected && styles.selected)}
523
560
  onClick={onOpen}
524
561
  >
525
- <img src={item.thumbUrl} alt={item.filename} {...stylex.props(styles.image)} />
562
+ <img src={item.thumbUrl} alt={item.alt?.trim() || item.filename} {...stylex.props(styles.image)} />
526
563
  </button>
527
564
  );
528
565
  if (!menu) return image;
@@ -572,6 +609,18 @@ function MediaHit({
572
609
  );
573
610
  }
574
611
 
612
+ function columnsInFirstRow(grid: HTMLElement): number {
613
+ const first = grid.children[0];
614
+ if (!(first instanceof HTMLElement)) return 1;
615
+ const top = first.offsetTop;
616
+ let count = 0;
617
+ for (const kid of grid.children) {
618
+ if (!(kid instanceof HTMLElement) || kid.offsetTop - top > 1) break;
619
+ count += 1;
620
+ }
621
+ return Math.max(1, count);
622
+ }
623
+
575
624
  function gridKeyTarget(target: EventTarget | null, scroller: HTMLElement | null): boolean {
576
625
  if (!(target instanceof HTMLElement)) return false;
577
626
  if (target.closest("input, textarea, select, [contenteditable='true']")) return false;
@@ -594,6 +643,15 @@ const styles = stylex.create({
594
643
  gap: space.lg,
595
644
  padding: space.xl,
596
645
  },
646
+ toolbarActions: {
647
+ display: "flex",
648
+ alignItems: "center",
649
+ gap: space.md,
650
+ minWidth: 0,
651
+ },
652
+ search: {
653
+ width: "220px",
654
+ },
597
655
  scroller: {
598
656
  flex: 1,
599
657
  minHeight: 0,
@@ -645,28 +703,27 @@ const styles = stylex.create({
645
703
  },
646
704
  canvas: {
647
705
  position: "relative",
706
+ display: "grid",
707
+ gridTemplateColumns: "repeat(auto-fill, minmax(168px, 1fr))",
708
+ gap: space.lg,
709
+ alignItems: "start",
648
710
  },
649
711
  cell: {
650
- position: "absolute",
651
- top: 0,
652
- left: 0,
653
- boxSizing: "border-box",
654
- padding: 6,
655
- },
656
- empty: {
712
+ position: "relative",
657
713
  width: "100%",
658
- height: "100%",
659
- borderRadius: radius.md,
660
- backgroundColor: colors.bgPanel,
714
+ aspectRatio: "1",
715
+ minWidth: 0,
716
+ },
717
+ placed: {
718
+ position: "absolute",
719
+ inset: space.sm,
661
720
  },
662
721
  figure: {
663
- position: "relative",
664
722
  margin: 0,
665
- height: "100%",
666
- display: "flex",
667
- flexDirection: "column",
668
723
  },
669
724
  hit: {
725
+ position: "absolute",
726
+ inset: 0,
670
727
  display: "block",
671
728
  width: "100%",
672
729
  height: "100%",
@@ -683,9 +740,9 @@ const styles = stylex.create({
683
740
  outlineOffset: 3,
684
741
  },
685
742
  menuHit: {
743
+ position: "absolute",
744
+ inset: space.sm,
686
745
  display: "block",
687
- width: "100%",
688
- height: "100%",
689
746
  },
690
747
  menu: {
691
748
  minWidth: "11rem",
@@ -697,6 +754,8 @@ const styles = stylex.create({
697
754
  color: colors.danger,
698
755
  },
699
756
  image: {
757
+ position: "absolute",
758
+ inset: 0,
700
759
  width: "100%",
701
760
  height: "100%",
702
761
  objectFit: "cover",