@backmatter/studio 0.0.8 → 0.0.10

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 (65) hide show
  1. package/dist/chunks/UiKitApp-BxvTwsko.js +2410 -0
  2. package/dist/chunks/WorkspaceRoutes-BKKEkAq7.js +90126 -0
  3. package/dist/chunks/{dashboard-bNp6AbKE.js → dashboard-8rjkfEXl.js} +380 -382
  4. package/dist/chunks/{index-DoDE-hwB.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 +30903 -628
  9. package/dist/studio.css +268 -67
  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/ContentExplorerSidebar.tsx +23 -8
  24. package/src/chrome/content-tree-visibility.ts +55 -0
  25. package/src/devbar-state.ts +125 -0
  26. package/src/index.css +3 -131
  27. package/src/pages/CliAuthorizePage.tsx +61 -28
  28. package/src/pages/MediaGalleryPage.tsx +74 -11
  29. package/src/pages/MediaLightbox.tsx +93 -1
  30. package/src/pages/SettingsUsagePage.tsx +23 -1
  31. package/src/pages/media-grid.ts +13 -0
  32. package/src/pages/ui-kit/UiKitApp.tsx +164 -0
  33. package/src/pages/ui-kit/buttons.tsx +30 -0
  34. package/src/pages/ui-kit/catalog.tsx +41 -0
  35. package/src/pages/ui-kit/colors.tsx +95 -0
  36. package/src/pages/ui-kit/devbar.tsx +160 -0
  37. package/src/pages/ui-kit/feedback.tsx +39 -0
  38. package/src/pages/ui-kit/fields.tsx +47 -0
  39. package/src/pages/ui-kit/lists.tsx +19 -0
  40. package/src/pages/ui-kit/overlays.tsx +39 -0
  41. package/src/pages/ui-kit/path.ts +9 -0
  42. package/src/pages/ui-kit/sections.tsx +32 -0
  43. package/src/pages/ui-kit/surfaces.tsx +121 -0
  44. package/src/pages/ui-kit/topic.tsx +112 -0
  45. package/src/pages/ui-kit/topics.ts +71 -0
  46. package/src/pages/ui-kit/type.tsx +55 -0
  47. package/src/preview-frame-radius.ts +51 -0
  48. package/src/preview-frame.tsx +108 -49
  49. package/src/shell-resets.css +155 -0
  50. package/src/site-bar.tsx +1810 -425
  51. package/src/site-dashboard-href.ts +21 -0
  52. package/src/site-devbar-overlay-inject.ts +15 -0
  53. package/src/site-devbar-overlay.css +217 -0
  54. package/src/site-pointer.ts +1 -1
  55. package/src/site-publish-menu.tsx +33 -0
  56. package/src/site-widget-styles.ts +7 -0
  57. package/src/site-widget.tsx +61 -21
  58. package/src/studio-prose-ui.css +39 -0
  59. package/src/theme/ThemeProvider.tsx +4 -1
  60. package/src/workspace-path.ts +10 -6
  61. package/dist/chunks/EmbedPublishPage-CtIlaeGO.js +0 -149
  62. package/dist/chunks/UiKitPage-DcnSQ-Hf.js +0 -173
  63. package/dist/chunks/WorkspaceRoutes-DVpUOaMV.js +0 -59550
  64. package/dist/chunks/status-bar-action-C0fsAWvD.js +0 -30618
  65. package/src/pages/UiKitPage.tsx +0 -331
@@ -0,0 +1,55 @@
1
+ // packages/studio/src/chrome/content-tree-visibility.ts
2
+ import { useEffect, useState } from "react";
3
+ import type { KeyValueStore } from "../last-workspace";
4
+ import { isWorkspaceContentPath, isWorkspaceEditorPath } from "../workspace-path";
5
+
6
+ export const CONTENT_TREE_OPEN_KEY = "backmatter-content-tree-open";
7
+ const CONTENT_TREE_EVENT = "backmatter-content-tree";
8
+
9
+ /**
10
+ * The type index is the file tree, so it stays open.
11
+ * A document follows the editor toggle. Other routes have no tree.
12
+ */
13
+ export function contentTreeVisible(pathname: string, open: boolean) {
14
+ if (!isWorkspaceContentPath(pathname)) return false;
15
+ if (isWorkspaceEditorPath(pathname)) return open;
16
+ return true;
17
+ }
18
+
19
+ export function readContentTreeOpen(storage: KeyValueStore = localStorage) {
20
+ try {
21
+ return storage.getItem(CONTENT_TREE_OPEN_KEY) !== "0";
22
+ } catch {
23
+ return true;
24
+ }
25
+ }
26
+
27
+ export function writeContentTreeOpen(
28
+ open: boolean,
29
+ storage: KeyValueStore = localStorage,
30
+ ) {
31
+ try {
32
+ storage.setItem(CONTENT_TREE_OPEN_KEY, open ? "1" : "0");
33
+ } catch {
34
+ // ignore quota / private mode
35
+ }
36
+ }
37
+
38
+ export function toggleContentTree() {
39
+ writeContentTreeOpen(!readContentTreeOpen());
40
+ window.dispatchEvent(new Event(CONTENT_TREE_EVENT));
41
+ }
42
+
43
+ export function useContentTreeOpen() {
44
+ const [open, setOpen] = useState(() => readContentTreeOpen());
45
+ useEffect(() => {
46
+ const sync = () => setOpen(readContentTreeOpen());
47
+ window.addEventListener(CONTENT_TREE_EVENT, sync);
48
+ window.addEventListener("storage", sync);
49
+ return () => {
50
+ window.removeEventListener(CONTENT_TREE_EVENT, sync);
51
+ window.removeEventListener("storage", sync);
52
+ };
53
+ }, []);
54
+ return open;
55
+ }
@@ -0,0 +1,125 @@
1
+ // packages/studio/src/devbar-state.ts
2
+ // Which row the on-site bar shows, and the motion numbers the stylesheet shares.
3
+
4
+ export type DevbarMode = "preview" | "live";
5
+
6
+ export type DevbarStep = {
7
+ label: string;
8
+ state: "done" | "active" | "pending";
9
+ };
10
+
11
+ export type DevbarActivity =
12
+ | { type: "editing"; field: string }
13
+ | { type: "publishing"; steps: readonly DevbarStep[]; progress: number }
14
+ | { type: "published"; sha: string; when: string };
15
+
16
+ export type DevbarRow = "idle" | "changes" | "editing" | "publishing" | "published";
17
+
18
+ export type DevbarChrome = {
19
+ mode: DevbarMode;
20
+ row: DevbarRow;
21
+ modeLabel: string;
22
+ announce: string;
23
+ };
24
+
25
+ export const DEVBAR_EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
26
+ export const DEVBAR_EASE_IN = "cubic-bezier(0.55, 0, 1, 0.45)";
27
+ export const DEVBAR_EASE_DRAWER = "cubic-bezier(0.32, 0.72, 0, 1)";
28
+
29
+ /** Entering the violet flood is slower than leaving it. */
30
+ export const DEVBAR_FLOOD_ENTER_MS = 550;
31
+ export const DEVBAR_FLOOD_EXIT_MS = 300;
32
+
33
+ /** The viewport frame draws out slower than it retracts. */
34
+ export const DEVBAR_FRAME_ENTER_MS = 750;
35
+ export const DEVBAR_FRAME_EXIT_MS = 360;
36
+
37
+ const MODE_KEY = "backmatter.devbar.mode";
38
+
39
+ export function devbarChrome(input: {
40
+ siteMode: DevbarMode;
41
+ changeCount: number;
42
+ activity?: DevbarActivity | null;
43
+ }): DevbarChrome {
44
+ const activity = input.activity ?? null;
45
+ const row: DevbarRow = activity
46
+ ? activity.type
47
+ : input.siteMode === "preview" && input.changeCount > 0
48
+ ? "changes"
49
+ : "idle";
50
+ const mode: DevbarMode = row === "published" ? "live" : input.siteMode;
51
+ const modeLabel = mode === "preview" ? "Exit preview" : "Enter preview";
52
+ const announce = mode === "preview" ? "Preview on, drafts visible" : "Live site";
53
+ return { mode, row, modeLabel, announce };
54
+ }
55
+
56
+ export function originWithin(
57
+ container: { left: number; top: number },
58
+ target: { left: number; top: number; width: number; height: number },
59
+ ) {
60
+ return {
61
+ x: target.left + target.width / 2 - container.left,
62
+ y: target.top + target.height / 2 - container.top,
63
+ };
64
+ }
65
+
66
+ export function shouldAnimateWidth(before: number, after: number) {
67
+ return Math.abs(after - before) > 1;
68
+ }
69
+
70
+ export function prefersReducedMotion() {
71
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
72
+ }
73
+
74
+ export function readStoredMode(): DevbarMode | null {
75
+ if (typeof sessionStorage === "undefined") return null;
76
+ try {
77
+ const value = sessionStorage.getItem(MODE_KEY);
78
+ return value === "live" || value === "preview" ? value : null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ export type SiteThemePreference = "light" | "dark" | "system";
85
+
86
+ export const HIGHLIGHT_BLOCKS_KEY = "backmatter.devbar.highlight";
87
+ export const PREVIEW_WITH_AGENT_KEY = "backmatter.devbar.agent-preview";
88
+ const THEME_KEY = "backmatter-theme";
89
+
90
+ type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
91
+
92
+ export function readSiteTheme(storage: StorageLike | null): SiteThemePreference {
93
+ const value = storage?.getItem(THEME_KEY);
94
+ if (value === "light" || value === "dark" || value === "system") return value;
95
+ return "system";
96
+ }
97
+
98
+ export function rememberSiteTheme(storage: StorageLike | null, preference: SiteThemePreference) {
99
+ storage?.setItem(THEME_KEY, preference);
100
+ }
101
+
102
+ export function resolveSiteTheme(preference: SiteThemePreference, prefersDark: boolean): "light" | "dark" {
103
+ if (preference === "system") return prefersDark ? "dark" : "light";
104
+ return preference;
105
+ }
106
+
107
+ export function readStoredFlag(storage: StorageLike | null, key: string, fallback: boolean) {
108
+ const value = storage?.getItem(key);
109
+ if (value === "1") return true;
110
+ if (value === "0") return false;
111
+ return fallback;
112
+ }
113
+
114
+ export function rememberStoredFlag(storage: StorageLike | null, key: string, on: boolean) {
115
+ storage?.setItem(key, on ? "1" : "0");
116
+ }
117
+
118
+ export function rememberMode(mode: DevbarMode) {
119
+ if (typeof sessionStorage === "undefined") return;
120
+ try {
121
+ sessionStorage.setItem(MODE_KEY, mode);
122
+ } catch {
123
+ /* private mode */
124
+ }
125
+ }
package/src/index.css CHANGED
@@ -1,131 +1,3 @@
1
- /* packages/studio/src/index.css */
2
- *,
3
- *::before,
4
- *::after {
5
- box-sizing: border-box;
6
- }
7
-
8
- html,
9
- body,
10
- #root {
11
- margin: 0;
12
- min-height: 100dvh;
13
- }
14
-
15
- html {
16
- background: #f7f7f8;
17
- color-scheme: light;
18
- }
19
-
20
- html[data-theme="dark"] {
21
- background: #18181b;
22
- color-scheme: dark;
23
- }
24
-
25
- body {
26
- -webkit-font-smoothing: antialiased;
27
- font-family:
28
- "Geist Sans",
29
- Inter,
30
- ui-sans-serif,
31
- system-ui,
32
- sans-serif;
33
- }
34
-
35
- button,
36
- input,
37
- textarea,
38
- select {
39
- /* Don't use `font: inherit` — it overrides StyleX sizes in @layer. */
40
- font-family: inherit;
41
- }
42
-
43
- button,
44
- [role="button"] {
45
- cursor: default;
46
- }
47
-
48
- button:disabled,
49
- button[aria-disabled="true"] {
50
- cursor: not-allowed;
51
- }
52
-
53
- [data-drag-handle] {
54
- cursor: grab;
55
- }
56
-
57
- [data-drag-handle][data-dragging] {
58
- cursor: grabbing;
59
- }
60
-
61
- /* StyleX colors live in @layer priorityN, which the Vite config orders after
62
- `reset`. An unlayered color on `a` would beat them, so a primary link
63
- button inherits dark text on a dark fill and the label disappears. */
64
- @layer reset {
65
- a {
66
- color: inherit;
67
- }
68
- }
69
-
70
- a {
71
- text-decoration: none;
72
- cursor: default;
73
- }
74
-
75
- a[href^="http"],
76
- a[href^="//"],
77
- a[target="_blank"] {
78
- cursor: pointer;
79
- }
80
-
81
- :focus-visible {
82
- outline: 2px solid #18181b;
83
- outline-offset: 2px;
84
- }
85
-
86
- html[data-theme="dark"] :focus-visible {
87
- outline-color: #fafafa;
88
- }
89
-
90
- /* Controls whose container already indicates focus (e.g. the chat composer). */
91
- [data-no-ring]:focus-visible {
92
- outline: none;
93
- }
94
-
95
- /* Prose UI reads these on the rendered body. Match the app fonts. */
96
- .prose-ui {
97
- --p-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
98
- --p-font-family-heading: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
99
- --p-font-family-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
100
- monospace;
101
- --p-body-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
102
- --p-h1-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
103
- --p-h2-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
104
- --p-h3-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
105
- --p-h4-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
106
- --p-h5-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
107
- --p-h6-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
108
- }
109
-
110
- .prose-ui img {
111
- max-width: 100%;
112
- height: auto;
113
- }
114
-
115
- .prose-ui > :first-child {
116
- margin-top: 0;
117
- }
118
-
119
- .prose-ui > :last-child {
120
- margin-bottom: 0;
121
- }
122
-
123
- .prose-ui [data-body-block] :is(p, h1, h2, h3, h4, h5, h6, ul, ol, blockquote) {
124
- margin-top: 0;
125
- margin-bottom: 0;
126
- }
127
-
128
- .prose-ui [data-body-block] :focus,
129
- .prose-ui [data-body-block] :focus-visible {
130
- outline: none;
131
- }
1
+ /* packages/studio/src/index.css — full studio stylesheet entry (dashboard + SiteStudio). */
2
+ @import "./shell-resets.css";
3
+ @import "./studio-prose-ui.css";
@@ -1,43 +1,76 @@
1
1
  // packages/studio/src/pages/CliAuthorizePage.tsx
2
2
  import { useState } from "react";
3
3
  import { useSearchParams } from "react-router";
4
+ import * as stylex from "@stylexjs/stylex";
4
5
  import { approveCliLogin } from "../api";
6
+ import { colors, radius, space, type } from "../styles/tokens.stylex";
5
7
  import { Button } from "../ui/Button";
6
- import { Stack } from "../ui/Page";
7
8
  import { Text } from "../ui/Text";
8
9
 
9
- export function CliAuthorizePage() {
10
+ export function CliAuthorizePage({ email }: { email: string }) {
10
11
  const [params] = useSearchParams();
11
12
  const id = params.get("id") ?? "";
12
13
  const [status, setStatus] = useState<"idle" | "done" | "error">("idle");
13
14
  const [error, setError] = useState<string | null>(null);
14
15
 
15
16
  return (
16
- <Stack gap="lg">
17
- <Text as="h1" size="xl" weight="medium">
18
- Allow the CLI
19
- </Text>
20
- <Text tone="muted">
21
- This connects the Backmatter CLI on your computer to your account.
22
- </Text>
23
- {status === "done" ? <Text>You can return to the terminal.</Text> : null}
24
- {error ? <Text tone="danger">{error}</Text> : null}
25
- {status === "done" ? null : (
26
- <Button
27
- disabled={!id}
28
- onClick={() => {
29
- setError(null);
30
- void approveCliLogin(id)
31
- .then(() => setStatus("done"))
32
- .catch((err: unknown) => {
33
- setStatus("error");
34
- setError(err instanceof Error ? err.message : "Could not approve");
35
- });
36
- }}
37
- >
38
- Allow
39
- </Button>
40
- )}
41
- </Stack>
17
+ <main {...stylex.props(styles.screen)}>
18
+ <div {...stylex.props(styles.card)}>
19
+ <Text as="h1" size="xl" weight="medium">
20
+ Allow the CLI
21
+ </Text>
22
+ <Text tone="muted">
23
+ This signs the CLI in as {email}. It can act in any workspace you belong to. It does not pick one.
24
+ </Text>
25
+ {status === "done" ? <Text>You can return to the terminal.</Text> : null}
26
+ {error ? <Text tone="danger">{error}</Text> : null}
27
+ {status === "done" ? null : (
28
+ <Button
29
+ disabled={!id}
30
+ onClick={() => {
31
+ setError(null);
32
+ void approveCliLogin(id)
33
+ .then(() => setStatus("done"))
34
+ .catch((err: unknown) => {
35
+ setStatus("error");
36
+ setError(err instanceof Error ? err.message : "Could not approve");
37
+ });
38
+ }}
39
+ >
40
+ Allow
41
+ </Button>
42
+ )}
43
+ </div>
44
+ </main>
42
45
  );
43
46
  }
47
+
48
+ const styles = stylex.create({
49
+ screen: {
50
+ display: "flex",
51
+ boxSizing: "border-box",
52
+ width: "100%",
53
+ minHeight: "100dvh",
54
+ alignItems: "center",
55
+ justifyContent: "center",
56
+ padding: space.xxl,
57
+ backgroundColor: colors.bgApp,
58
+ color: colors.text,
59
+ },
60
+ card: {
61
+ display: "flex",
62
+ width: "100%",
63
+ maxWidth: "320px",
64
+ flexDirection: "column",
65
+ alignItems: "flex-start",
66
+ gap: space.lg,
67
+ boxSizing: "border-box",
68
+ borderWidth: 1,
69
+ borderStyle: "solid",
70
+ borderColor: colors.border,
71
+ borderRadius: radius.md,
72
+ backgroundColor: colors.bgSurface,
73
+ padding: space.xxl,
74
+ fontSize: type.sm,
75
+ },
76
+ });
@@ -9,7 +9,8 @@ import {
9
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,6 +20,7 @@ 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,
@@ -56,28 +58,56 @@ export function MediaGalleryPage({
56
58
  const [deleteError, setDeleteError] = useState<string | null>(null);
57
59
  const [selectedId, setSelectedId] = useState<string | null>(null);
58
60
  const [settled, setSettled] = useState(false);
61
+ const urlQuery = searchParams.get("q") ?? "";
62
+ const [draft, setDraft] = useState(urlQuery);
63
+ const [query, setQuery] = useState(urlQuery);
59
64
  const dragDepth = useRef(0);
60
65
  const scroller = useRef<HTMLDivElement>(null);
61
66
  const grid = useRef<HTMLDivElement>(null);
62
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(() => {
@@ -349,6 +379,14 @@ export function MediaGalleryPage({
349
379
  }, [columns, selectedId, sharedId]);
350
380
 
351
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
+ );
352
390
  const showEmpty =
353
391
  settled && state.pending.length === 0 && state.loaded.length === 0 && state.total === 0;
354
392
 
@@ -358,9 +396,19 @@ export function MediaGalleryPage({
358
396
  <Text as="h1" size="xl" weight="medium">
359
397
  Media
360
398
  </Text>
361
- <Button variant="secondary" onClick={chooseFiles}>
362
- Upload
363
- </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>
364
412
  </div>
365
413
  {error ? <Text tone="danger">{error}</Text> : null}
366
414
  {rejected.length > 0 ? <Text tone="danger">{rejected.join(" · ")}</Text> : null}
@@ -371,7 +419,11 @@ export function MediaGalleryPage({
371
419
  maybeLoadMore();
372
420
  }}
373
421
  >
374
- {showEmpty ? (
422
+ {showEmpty && query ? (
423
+ <div {...stylex.props(styles.emptyState)}>
424
+ <Text tone="muted">No images match this search.</Text>
425
+ </div>
426
+ ) : showEmpty ? (
375
427
  <div
376
428
  {...stylex.props(styles.emptyState)}
377
429
  onDragOver={(event) => event.preventDefault()}
@@ -461,6 +513,8 @@ export function MediaGalleryPage({
461
513
  onPrev={prevLightbox}
462
514
  onNext={nextLightbox}
463
515
  onDelete={canDelete ? () => requestDelete(open) : undefined}
516
+ canEdit={canDelete}
517
+ onDescribe={saveText}
464
518
  />
465
519
  ) : null}
466
520
  <ConfirmDialog
@@ -505,7 +559,7 @@ function MediaHit({
505
559
  {...stylex.props(styles.hit, !menu && styles.placed, selected && styles.selected)}
506
560
  onClick={onOpen}
507
561
  >
508
- <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)} />
509
563
  </button>
510
564
  );
511
565
  if (!menu) return image;
@@ -589,6 +643,15 @@ const styles = stylex.create({
589
643
  gap: space.lg,
590
644
  padding: space.xl,
591
645
  },
646
+ toolbarActions: {
647
+ display: "flex",
648
+ alignItems: "center",
649
+ gap: space.md,
650
+ minWidth: 0,
651
+ },
652
+ search: {
653
+ width: "220px",
654
+ },
592
655
  scroller: {
593
656
  flex: 1,
594
657
  minHeight: 0,