@backmatter/studio 0.0.5 → 0.0.6

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.
package/dist/studio.css CHANGED
@@ -1455,14 +1455,14 @@
1455
1455
  bottom: 44px;
1456
1456
  }
1457
1457
 
1458
- .xy75b87 {
1459
- bottom: 64px;
1460
- }
1461
-
1462
1458
  .x1bs4c7o {
1463
1459
  bottom: calc(100% - 12px);
1464
1460
  }
1465
1461
 
1462
+ .xpfn2fm {
1463
+ bottom: calc(48px + env(safe-area-inset-bottom, 0px));
1464
+ }
1465
+
1466
1466
  .x1sf7pbd {
1467
1467
  bottom: var(--xvc7yhn);
1468
1468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/studio",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "The Backmatter editing surface: document editor, AI chat, publish. Mounted on your site or hosted on the dashboard.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@ai-sdk/react": "^4.0.100",
55
- "@backmatter/core": "^0.0.21",
55
+ "@backmatter/core": "^0.0.22",
56
56
  "@base-ui/react": "^1.6.0",
57
57
  "@dnd-kit/abstract": "^0.5.0",
58
58
  "@dnd-kit/react": "^0.5.0",
@@ -6,6 +6,7 @@ import { WorkspaceLayout } from "./pages/WorkspaceLayout";
6
6
  import { WorkspaceHomeRedirect } from "./pages/WorkspaceHomeRedirect";
7
7
  import { TypeDocsPage } from "./pages/TypeDocsPage";
8
8
  import { SettingsGeneralPage } from "./pages/SettingsGeneralPage";
9
+ import { SettingsUsagePage } from "./pages/SettingsUsagePage";
9
10
  import { SettingsGithubPage } from "./pages/SettingsGithubPage";
10
11
  import { SettingsIndexRedirect, SettingsLayout } from "./pages/SettingsLayout";
11
12
  import { SettingsSchemaPage } from "./pages/SettingsSchemaPage";
@@ -26,6 +27,7 @@ export function workspaceRoutes() {
26
27
  <Route element={<SettingsLayout />}>
27
28
  <Route path="settings" element={<SettingsIndexRedirect />} />
28
29
  <Route path="settings/general" element={<SettingsGeneralPage />} />
30
+ <Route path="settings/usage" element={<SettingsUsagePage />} />
29
31
  <Route path="settings/github" element={<SettingsGithubPage />} />
30
32
  <Route path="settings/schema" element={<SettingsSchemaPage />} />
31
33
  <Route path="settings/mcp" element={<SettingsMcpPage />} />
package/src/api.ts CHANGED
@@ -169,10 +169,34 @@ export type WorkspaceDetail = {
169
169
  hasHostToken: boolean;
170
170
  };
171
171
 
172
+ export type WorkspaceUsage = {
173
+ period: string;
174
+ dataRequests: {
175
+ public: number;
176
+ studio: number;
177
+ replica: number;
178
+ preview: number;
179
+ ai: number;
180
+ };
181
+ work: {
182
+ commits: number;
183
+ commitFiles: number;
184
+ imports: number;
185
+ importFiles: number;
186
+ saves: number;
187
+ };
188
+ ai: { inputTokens: number; outputTokens: number };
189
+ members: number;
190
+ };
191
+
172
192
  export function fetchWorkspace(workspaceId: string) {
173
193
  return api<{ workspace: WorkspaceDetail }>(`/workspaces/${workspaceId}`);
174
194
  }
175
195
 
196
+ export function fetchWorkspaceUsage(workspaceId: string) {
197
+ return api<WorkspaceUsage>(`/workspaces/${workspaceId}/usage`);
198
+ }
199
+
176
200
  export function fetchReplica(workspaceId: string, cache?: ReplicaCache) {
177
201
  const query = cache ? `?${replicaQuery(cache)}` : "";
178
202
  return api<ReplicaBody>(`/workspaces/${workspaceId}/replica${query}`);
@@ -252,7 +252,7 @@ const styles = stylex.create({
252
252
  },
253
253
  panelSite: {
254
254
  right: 16,
255
- bottom: 64,
255
+ bottom: "calc(48px + env(safe-area-inset-bottom, 0px))",
256
256
  zIndex: 2147483001,
257
257
  },
258
258
  panelFill: {
@@ -20,6 +20,19 @@ export function DocIcon() {
20
20
  );
21
21
  }
22
22
 
23
+ export function UsageIcon() {
24
+ return (
25
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
26
+ <path
27
+ d="M3 12.5V8.5M8 12.5V3.5M13 12.5V6"
28
+ stroke="currentColor"
29
+ strokeWidth="1.25"
30
+ strokeLinecap="round"
31
+ />
32
+ </svg>
33
+ );
34
+ }
35
+
23
36
  export function GearIcon() {
24
37
  return (
25
38
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
@@ -2,7 +2,7 @@
2
2
  import { Navigate, Outlet, useLocation, useOutletContext } from "react-router";
3
3
  import * as stylex from "@stylexjs/stylex";
4
4
  import { NavItem } from "../chrome/NavItem";
5
- import { GearIcon, GithubIcon, PlugIcon, SchemaIcon, UsersIcon } from "../chrome/icons";
5
+ import { GearIcon, GithubIcon, PlugIcon, SchemaIcon, UsageIcon, UsersIcon } from "../chrome/icons";
6
6
  import { colors, space, type } from "../styles/tokens.stylex";
7
7
  import type { WorkspaceOutletContext } from "../workspace/types";
8
8
  import { isGithubSettingsPath, settingsPath } from "../workspace-path";
@@ -33,6 +33,12 @@ export function SettingsLayout() {
33
33
  General
34
34
  </NavItem>
35
35
  </li>
36
+ <li>
37
+ <NavItem to={settingsPath(slug, "usage")}>
38
+ <UsageIcon />
39
+ Usage
40
+ </NavItem>
41
+ </li>
36
42
  {develop ? (
37
43
  <li>
38
44
  <NavItem
@@ -0,0 +1,168 @@
1
+ // packages/studio/src/pages/SettingsUsagePage.tsx
2
+ import { useEffect, useState } from "react";
3
+ import { useOutletContext } from "react-router";
4
+ import { fetchWorkspaceUsage, type WorkspaceUsage } from "../api";
5
+ import { PageHeader, Panel, Stack } from "../ui/Page";
6
+ import { Text } from "../ui/Text";
7
+ import * as stylex from "@stylexjs/stylex";
8
+ import { colors, space, type } from "../styles/tokens.stylex";
9
+ import type { WorkspaceOutletContext } from "../workspace/types";
10
+
11
+ const SURFACES: { key: keyof WorkspaceUsage["dataRequests"]; label: string }[] = [
12
+ { key: "public", label: "Public" },
13
+ { key: "studio", label: "Studio" },
14
+ { key: "replica", label: "Replica" },
15
+ { key: "preview", label: "Preview" },
16
+ { key: "ai", label: "Assistant" },
17
+ ];
18
+
19
+ const WORK: { key: keyof WorkspaceUsage["work"]; label: string }[] = [
20
+ { key: "commits", label: "Commits" },
21
+ { key: "commitFiles", label: "Files committed" },
22
+ { key: "imports", label: "Imports" },
23
+ { key: "importFiles", label: "Files imported" },
24
+ { key: "saves", label: "Saves" },
25
+ ];
26
+
27
+ function formatCount(value: number): string {
28
+ return new Intl.NumberFormat("en-US").format(value);
29
+ }
30
+
31
+ function monthLabel(period: string): string {
32
+ const [year, month] = period.split("-").map(Number);
33
+ if (!year || !month) return period;
34
+ return new Intl.DateTimeFormat("en-US", {
35
+ month: "long",
36
+ year: "numeric",
37
+ timeZone: "UTC",
38
+ }).format(new Date(Date.UTC(year, month - 1, 1)));
39
+ }
40
+
41
+ export function SettingsUsagePage() {
42
+ const { workspace } = useOutletContext<WorkspaceOutletContext>();
43
+ const [usage, setUsage] = useState<WorkspaceUsage | null>(null);
44
+ const [error, setError] = useState<string | null>(null);
45
+
46
+ useEffect(() => {
47
+ let cancelled = false;
48
+ setUsage(null);
49
+ setError(null);
50
+ fetchWorkspaceUsage(workspace.id)
51
+ .then((body) => {
52
+ if (!cancelled) setUsage(body);
53
+ })
54
+ .catch((err: unknown) => {
55
+ if (!cancelled) setError(err instanceof Error ? err.message : "Could not load usage");
56
+ });
57
+ return () => {
58
+ cancelled = true;
59
+ };
60
+ }, [workspace.id]);
61
+
62
+ const total = usage
63
+ ? SURFACES.reduce((sum, surface) => sum + usage.dataRequests[surface.key], 0)
64
+ : 0;
65
+
66
+ return (
67
+ <Stack gap="lg">
68
+ <PageHeader
69
+ title="Usage"
70
+ description={
71
+ usage
72
+ ? `${monthLabel(usage.period)}, UTC. Counts update within about a minute.`
73
+ : "Content reads and AI tokens for this workspace."
74
+ }
75
+ />
76
+ {error ? <Text tone="danger">{error}</Text> : null}
77
+ {!usage && !error ? <Text tone="muted">Loading…</Text> : null}
78
+ {usage ? (
79
+ <>
80
+ <Panel>
81
+ <Text as="h2" size="lg" weight="medium">
82
+ Data requests
83
+ </Text>
84
+ <dl {...stylex.props(styles.list)}>
85
+ <div {...stylex.props(styles.row)}>
86
+ <dt {...stylex.props(styles.label)}>Total</dt>
87
+ <dd {...stylex.props(styles.value)}>{formatCount(total)}</dd>
88
+ </div>
89
+ {SURFACES.map((surface) => (
90
+ <div key={surface.key} {...stylex.props(styles.row)}>
91
+ <dt {...stylex.props(styles.label)}>{surface.label}</dt>
92
+ <dd {...stylex.props(styles.value)}>
93
+ {formatCount(usage.dataRequests[surface.key])}
94
+ </dd>
95
+ </div>
96
+ ))}
97
+ </dl>
98
+ </Panel>
99
+ <Panel>
100
+ <Text as="h2" size="lg" weight="medium">
101
+ Work
102
+ </Text>
103
+ <dl {...stylex.props(styles.list)}>
104
+ {WORK.map((item) => (
105
+ <div key={item.key} {...stylex.props(styles.row)}>
106
+ <dt {...stylex.props(styles.label)}>{item.label}</dt>
107
+ <dd {...stylex.props(styles.value)}>{formatCount(usage.work[item.key])}</dd>
108
+ </div>
109
+ ))}
110
+ </dl>
111
+ </Panel>
112
+ <Panel>
113
+ <Text as="h2" size="lg" weight="medium">
114
+ AI
115
+ </Text>
116
+ <dl {...stylex.props(styles.list)}>
117
+ <div {...stylex.props(styles.row)}>
118
+ <dt {...stylex.props(styles.label)}>Input tokens</dt>
119
+ <dd {...stylex.props(styles.value)}>{formatCount(usage.ai.inputTokens)}</dd>
120
+ </div>
121
+ <div {...stylex.props(styles.row)}>
122
+ <dt {...stylex.props(styles.label)}>Output tokens</dt>
123
+ <dd {...stylex.props(styles.value)}>{formatCount(usage.ai.outputTokens)}</dd>
124
+ </div>
125
+ </dl>
126
+ </Panel>
127
+ <Panel>
128
+ <div {...stylex.props(styles.row)}>
129
+ <Text as="h2" size="lg" weight="medium">
130
+ Members
131
+ </Text>
132
+ <Text size="lg" weight="medium">
133
+ {formatCount(usage.members)}
134
+ </Text>
135
+ </div>
136
+ </Panel>
137
+ </>
138
+ ) : null}
139
+ </Stack>
140
+ );
141
+ }
142
+
143
+ const styles = stylex.create({
144
+ list: {
145
+ margin: 0,
146
+ marginTop: space.lg,
147
+ display: "flex",
148
+ flexDirection: "column",
149
+ gap: space.sm,
150
+ },
151
+ row: {
152
+ display: "flex",
153
+ alignItems: "baseline",
154
+ justifyContent: "space-between",
155
+ gap: space.lg,
156
+ },
157
+ label: {
158
+ margin: 0,
159
+ fontSize: type.sm,
160
+ color: colors.textMuted,
161
+ },
162
+ value: {
163
+ margin: 0,
164
+ fontSize: type.sm,
165
+ fontVariantNumeric: "tabular-nums",
166
+ color: colors.text,
167
+ },
168
+ });
@@ -0,0 +1,78 @@
1
+ // packages/studio/src/preview-frame.tsx
2
+ // Full-viewport ring and a top-right preview label while the site session is active.
3
+ export const PREVIEW_FRAME_CLASS = "backmatter-preview-frame";
4
+
5
+ /** Shared with the on-site dev bar while preview is active. */
6
+ export const PREVIEW_CHROME_BORDER = "#d97706";
7
+
8
+ function EyeIcon() {
9
+ return (
10
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
11
+ <path
12
+ d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8Z"
13
+ stroke="currentColor"
14
+ strokeWidth="1.25"
15
+ strokeLinejoin="round"
16
+ />
17
+ <circle cx="8" cy="8" r="1.75" stroke="currentColor" strokeWidth="1.25" />
18
+ </svg>
19
+ );
20
+ }
21
+
22
+ export function PreviewFrame() {
23
+ return (
24
+ <>
25
+ <style>{FRAME_CSS}</style>
26
+ <div
27
+ className={PREVIEW_FRAME_CLASS}
28
+ data-backmatter-preview-frame=""
29
+ aria-hidden
30
+ >
31
+ <div className={`${PREVIEW_FRAME_CLASS}-badge`}>
32
+ <EyeIcon />
33
+ <span>Preview</span>
34
+ </div>
35
+ </div>
36
+ </>
37
+ );
38
+ }
39
+
40
+ const FRAME_CSS = `
41
+ .${PREVIEW_FRAME_CLASS} {
42
+ position: fixed;
43
+ inset: 0;
44
+ z-index: 2147482999;
45
+ pointer-events: none;
46
+ box-shadow: inset 0 0 0 2px ${PREVIEW_CHROME_BORDER};
47
+ overflow: visible;
48
+ }
49
+ .${PREVIEW_FRAME_CLASS}-badge {
50
+ position: absolute;
51
+ top: env(safe-area-inset-top, 0px);
52
+ right: env(safe-area-inset-right, 0px);
53
+ display: inline-flex;
54
+ align-items: center;
55
+ gap: 6px;
56
+ padding: 6px 12px;
57
+ border: 2px solid ${PREVIEW_CHROME_BORDER};
58
+ border-top: 0;
59
+ border-radius: 0 0 14px 14px;
60
+ background: #fff7ed;
61
+ color: #9a3412;
62
+ font: 500 12px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
63
+ white-space: nowrap;
64
+ -webkit-font-smoothing: antialiased;
65
+ }
66
+ @keyframes backmatter-preview-frame-in {
67
+ from { opacity: 0; }
68
+ to { opacity: 1; }
69
+ }
70
+ @media (prefers-reduced-motion: no-preference) {
71
+ .${PREVIEW_FRAME_CLASS} {
72
+ animation: backmatter-preview-frame-in 240ms cubic-bezier(0.23, 1, 0.32, 1) both;
73
+ }
74
+ }
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .${PREVIEW_FRAME_CLASS} { animation: none; }
77
+ }
78
+ `;