@backmatter/next 0.0.22 → 0.0.24

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.
@@ -39,17 +39,6 @@ function referrerOrigin(): string {
39
39
  }
40
40
  }
41
41
 
42
- function dashboardOriginFromMeta(): string {
43
- const marked = document
44
- .querySelector('meta[name="backmatter:dashboard"]')
45
- ?.getAttribute("content");
46
- if (marked) return marked.replace(/\/$/, "");
47
- if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
48
- return `${location.protocol}//${location.hostname}:5173`;
49
- }
50
- return "";
51
- }
52
-
53
42
  function subscribeDocStream(
54
43
  ref: { locale: string; type: string; slug: string },
55
44
  onEvent: (event: { raw?: string; body?: string; frontmatter?: Record<string, unknown> }) => void,
@@ -169,13 +158,15 @@ export function PreviewClient({
169
158
  ...(branch ? { branch } : {}),
170
159
  ...(tree ? { tree } : {}),
171
160
  };
161
+ // Framed by the dashboard's split view: the parent is the dashboard.
162
+ // Otherwise the Studio mounted on this site posts from same-origin frames.
172
163
  const parentOrigin = referrerOrigin();
173
- const dashboardOrigin = dashboardOriginFromMeta();
174
- const allowed = [...originsKey.split("\0"), parentOrigin, dashboardOrigin].filter(
164
+ const selfOrigin = window.location.origin;
165
+ const allowed = [...originsKey.split("\0"), parentOrigin, selfOrigin].filter(
175
166
  (origin) => origin.length > 0,
176
167
  );
177
168
  const framed = window.parent !== window;
178
- const targetOrigin = framed ? parentOrigin || allowed[0] || "" : dashboardOrigin;
169
+ const targetOrigin = framed ? parentOrigin || allowed[0] || "" : selfOrigin;
179
170
  const windowTransport: PreviewTransport | null = targetOrigin
180
171
  ? createWindowPreviewTransport({
181
172
  target: framed ? window.parent : { postMessage() {} },
@@ -7,20 +7,50 @@ import {
7
7
  PREVIEW_SESSION_TTL_MS,
8
8
  previewLocationWithCheckout,
9
9
  readPreviewCookie,
10
+ readPreviewSession,
10
11
  readPreviewTicket,
12
+ resolveDashboardUrl,
13
+ studioEnterUrl,
11
14
  writePreviewCookie,
12
15
  type PreviewCheckout,
13
16
  } from "@backmatter/core";
14
17
  import { previewStreamUrl, previewTreeStreamUrl } from "@backmatter/core/preview";
15
18
  import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
19
+ import { MARKER_COOKIE, MARKER_TTL_S, safeReturnPath } from "./gate";
20
+ import { requestUrlFrom } from "./request-url";
16
21
 
17
22
  export type PreviewRouteDeps = {
18
23
  enableDraft?: () => void | Promise<void>;
19
24
  disableDraft?: () => void | Promise<void>;
20
25
  isDraftEnabled?: () => boolean | Promise<boolean>;
21
26
  now?: () => number;
27
+ /** Dashboard that holds the login, for `?enter=1`. Default: `resolveDashboardUrl()`. */
28
+ dashboardUrl?: string;
29
+ /**
30
+ * Exchange the current site session for a fresh token. Default calls
31
+ * `POST /preview/session/renew` on the API. Null means the session is dead.
32
+ * Throw when the API cannot be reached; that becomes a 503 and keeps the cookies.
33
+ */
34
+ renewSession?: (token: string) => Promise<string | null>;
35
+ /**
36
+ * Ask the API whether this site-session token is still logged in, epoch
37
+ * included. Default calls `GET /preview/session`.
38
+ */
39
+ confirmSession?: (token: string) => Promise<"ok" | "invalid" | "unavailable">;
22
40
  };
23
41
 
42
+ /** The gate's marker: readable by the page script, so never HttpOnly. */
43
+ function markerCookieHeader(secure: boolean, on: boolean): string {
44
+ const parts = [
45
+ `${MARKER_COOKIE}=${on ? "1" : ""}`,
46
+ "Path=/",
47
+ `Max-Age=${on ? MARKER_TTL_S : 0}`,
48
+ "SameSite=Lax",
49
+ ];
50
+ if (secure) parts.push("Secure");
51
+ return parts.join("; ");
52
+ }
53
+
24
54
  function cookieBase(name: string, secure: boolean, maxAge: number, value: string) {
25
55
  // HTTPS is cross-site from the CMS iframe (dash → customer origin). Lax is
26
56
  // not sent there, so draftMode can be on while the checkout cookie is missing
@@ -55,6 +85,42 @@ async function nextDraftEnabled(): Promise<boolean> {
55
85
  return (await mod.draftMode()).isEnabled;
56
86
  }
57
87
 
88
+ async function renewSessionOnApi(apiUrl: string, token: string): Promise<string | null> {
89
+ let res: Response;
90
+ try {
91
+ res = await fetch(`${apiUrl}/preview/session/renew`, {
92
+ method: "POST",
93
+ headers: { authorization: `Bearer ${token}` },
94
+ cache: "no-store",
95
+ });
96
+ } catch {
97
+ throw new Error("renew unavailable");
98
+ }
99
+ if (res.status === 401 || res.status === 403) return null;
100
+ if (!res.ok) throw new Error(`renew unavailable: ${res.status}`);
101
+ const body = (await res.json()) as { token?: unknown };
102
+ return typeof body.token === "string" && body.token ? body.token : null;
103
+ }
104
+
105
+ async function confirmSessionOnApi(
106
+ apiUrl: string | undefined,
107
+ token: string,
108
+ ): Promise<"ok" | "invalid" | "unavailable"> {
109
+ const base = apiUrl?.replace(/\/$/, "");
110
+ if (!base) return "unavailable";
111
+ try {
112
+ const res = await fetch(`${base}/preview/session`, {
113
+ headers: { authorization: `Bearer ${token}` },
114
+ cache: "no-store",
115
+ });
116
+ if (res.status === 401 || res.status === 403) return "invalid";
117
+ if (!res.ok) return "unavailable";
118
+ return "ok";
119
+ } catch {
120
+ return "unavailable";
121
+ }
122
+ }
123
+
58
124
  function cookieFrom(req: Request, name: string): string | undefined {
59
125
  const header = req.headers.get("cookie");
60
126
  if (!header) return undefined;
@@ -84,15 +150,56 @@ export function createPreviewRoute(
84
150
  const secure = url.protocol === "https:";
85
151
 
86
152
  if (url.searchParams.get("session") === "1") {
87
- const checkout = secret ? await checkoutFrom(req, secret, url) : null;
88
- if (checkout) {
89
- return Response.json({
90
- mode: "preview",
91
- ...checkout,
92
- });
153
+ // The bar's only login signal. The checkout cookie and draft mode still
154
+ // render drafts; they do not mean the visitor is signed in.
155
+ const token = cookieFrom(req, PREVIEW_SESSION_COOKIE);
156
+ const claims = secret && token ? await readPreviewSession(secret, token) : null;
157
+ const workspaceOk = !cms.config.workspaceId || claims?.workspaceId === cms.config.workspaceId;
158
+ const clearLogin = async () => {
159
+ await (deps.disableDraft ?? (() => nextDraft(false)))();
160
+ const headers = new Headers({ "Cache-Control": "no-store" });
161
+ headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
162
+ headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
163
+ headers.append("Set-Cookie", markerCookieHeader(secure, false));
164
+ return Response.json({ mode: "live", reason: "invalid" }, { headers });
165
+ };
166
+ if (!claims || !token || !workspaceOk) {
167
+ if (token) return clearLogin();
168
+ return Response.json(
169
+ { mode: "live", reason: "anonymous" },
170
+ { headers: { "Cache-Control": "no-store" } },
171
+ );
172
+ }
173
+ const confirm = deps.confirmSession ?? ((current: string) => confirmSessionOnApi(cms.config.apiUrl, current));
174
+ const verdict = await confirm(token);
175
+ if (verdict === "unavailable") {
176
+ return Response.json(
177
+ { mode: "live", reason: "unavailable" },
178
+ { headers: { "Cache-Control": "no-store" } },
179
+ );
180
+ }
181
+ if (verdict !== "ok") return clearLogin();
182
+ return Response.json(
183
+ { mode: "preview", branch: claims.branch, tree: claims.tree },
184
+ { headers: { "Cache-Control": "no-store" } },
185
+ );
186
+ }
187
+
188
+ // The bar found the marker but no session: go top-level to the dashboard,
189
+ // which mints a ticket after login and returns to this page.
190
+ if (url.searchParams.get("enter") === "1") {
191
+ if (!cms.config.workspaceId) {
192
+ return Response.json({ error: "preview_not_configured" }, { status: 503 });
93
193
  }
94
- const draftOn = await (deps.isDraftEnabled ?? nextDraftEnabled)();
95
- return Response.json({ mode: draftOn ? "preview" : "live" });
194
+ const returnUrl = requestUrlFrom(
195
+ (name) => req.headers.get(name),
196
+ safeReturnPath(url.searchParams.get("return")),
197
+ );
198
+ const dashboardUrl = resolveDashboardUrl(deps.dashboardUrl);
199
+ return Response.redirect(
200
+ studioEnterUrl(dashboardUrl, { workspaceId: cms.config.workspaceId, returnUrl }),
201
+ 302,
202
+ );
96
203
  }
97
204
 
98
205
  if (url.searchParams.get("stream") === "1") {
@@ -169,15 +276,47 @@ export function createPreviewRoute(
169
276
  ),
170
277
  );
171
278
  }
279
+ // Entry opts the visitor in, so the bar shows on later pages without a param.
280
+ headers.append("Set-Cookie", markerCookieHeader(secure, true));
172
281
  return new Response(null, { status: 302, headers });
173
282
  };
174
283
 
175
284
  const POST = async (req: Request): Promise<Response> => {
285
+ const url = new URL(req.url);
286
+ // Slide the site session while the bar is open. The cookie is HttpOnly, so
287
+ // the browser cannot renew it itself; a dead session is a 401 and the bar
288
+ // bounces to the dashboard instead of failing the next chat send.
289
+ if (url.searchParams.get("renew") === "1") {
290
+ const token = cookieFrom(req, PREVIEW_SESSION_COOKIE);
291
+ const apiUrl = cms.config.apiUrl?.replace(/\/$/, "");
292
+ if (!token || !apiUrl) return Response.json({ error: "unauthorized" }, { status: 401 });
293
+ let renewed: string | null;
294
+ try {
295
+ renewed = await (deps.renewSession ?? ((current) => renewSessionOnApi(apiUrl, current)))(token);
296
+ } catch {
297
+ return Response.json({ error: "unavailable" }, { status: 503 });
298
+ }
299
+ if (!renewed) return Response.json({ error: "unauthorized" }, { status: 401 });
300
+ const headers = new Headers();
301
+ headers.append(
302
+ "Set-Cookie",
303
+ cookieBase(
304
+ PREVIEW_SESSION_COOKIE,
305
+ url.protocol === "https:",
306
+ Math.floor(PREVIEW_SESSION_TTL_MS / 1000),
307
+ renewed,
308
+ ),
309
+ );
310
+ return new Response(null, { status: 204, headers });
311
+ }
312
+
176
313
  await (deps.disableDraft ?? (() => nextDraft(false)))();
177
314
  const secure = new URL(req.url).protocol === "https:";
178
315
  const headers = new Headers();
179
316
  headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
180
317
  headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
318
+ // Exit also drops the marker; otherwise the next page would bounce straight back in.
319
+ headers.append("Set-Cookie", markerCookieHeader(secure, false));
181
320
  return new Response(null, { status: 204, headers });
182
321
  };
183
322
 
@@ -0,0 +1,13 @@
1
+ // packages/next/src/request-url.ts
2
+
3
+ /** Absolute URL of the current request, from forwarded headers when behind a proxy. */
4
+ export function requestUrlFrom(
5
+ get: (name: string) => string | null,
6
+ pathWithSearch: string,
7
+ ): string {
8
+ const host = get("x-forwarded-host") ?? get("host") ?? "localhost:3000";
9
+ const proto =
10
+ get("x-forwarded-proto") ??
11
+ (host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "https");
12
+ return `${proto}://${host}${pathWithSearch}`;
13
+ }
@@ -3,8 +3,21 @@
3
3
 
4
4
  import { usePathname, useRouter } from "next/navigation";
5
5
  import { startTransition, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
6
+ import {
7
+ DEVBAR_CLASS,
8
+ DEVBAR_CLEARANCE,
9
+ DEVBAR_ENTER_CSS,
10
+ DEVBAR_RIGHT,
11
+ devbarShellStyle,
12
+ } from "./devbar-motion";
13
+ import { markerCookie } from "./gate";
6
14
  import { subscribeSiteDoc } from "./site-doc-bridge";
7
15
  import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
16
+ import { installSitePointer } from "./site-pointer";
17
+
18
+ const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
19
+ /** Slide the 12h site session while the bar is on screen. */
20
+ const SESSION_RENEW_MS = 30 * 60 * 1000;
8
21
 
9
22
  function useSiteDoc(): SiteDocRef | null {
10
23
  const pathname = usePathname();
@@ -32,14 +45,17 @@ function useSiteDoc(): SiteDocRef | null {
32
45
  return doc;
33
46
  }
34
47
 
35
- function dashboardOrigin(): string {
36
- if (typeof document === "undefined") return "";
37
- const marked = document.querySelector('meta[name="backmatter:dashboard"]')?.getAttribute("content");
38
- if (marked) return marked.replace(/\/$/, "");
39
- if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
40
- return `${location.protocol}//${location.hostname}:5173`;
41
- }
42
- return "";
48
+ /** Default route the host mounts `@backmatter/studio` on. */
49
+ export const DEFAULT_STUDIO_PATH = "/backmatter";
50
+
51
+ /**
52
+ * The panels are frames of the Studio mounted on this same origin. Same
53
+ * origin means the site session cookie is first-party and postMessage needs
54
+ * no foreign origin.
55
+ */
56
+ function studioBase(studioPath: string): string {
57
+ if (typeof location === "undefined") return "";
58
+ return `${location.origin}${studioPath.replace(/\/+$/, "")}`;
43
59
  }
44
60
 
45
61
  export function SiteChrome({
@@ -47,32 +63,38 @@ export function SiteChrome({
47
63
  tree,
48
64
  exiting,
49
65
  onExit,
66
+ studioPath = DEFAULT_STUDIO_PATH,
67
+ previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
50
68
  }: {
51
69
  branch?: string;
52
70
  tree?: string;
53
71
  exiting?: boolean;
54
72
  onExit: () => void;
73
+ /** Where `@backmatter/studio` is mounted on this site. */
74
+ studioPath?: string;
75
+ /** Ticket route. `POST ?renew=1` slides the site session. A 401 drops the bar. */
76
+ previewEntryPath?: string;
55
77
  }) {
56
78
  const router = useRouter();
57
79
  const doc = useSiteDoc();
58
80
  const [editorMounted, setEditorMounted] = useState(false);
59
81
  const [editorOpen, setEditorOpen] = useState(false);
60
- const [chatMounted, setChatMounted] = useState(false);
61
82
  const [chatOpen, setChatOpen] = useState(false);
62
83
  const [publishOpen, setPublishOpen] = useState(false);
63
84
  const [changeCount, setChangeCount] = useState(0);
64
- const [origin, setOrigin] = useState("");
85
+ // Set in an effect so SSR and the first client render agree.
86
+ const [base, setBase] = useState("");
87
+ const origin = typeof location === "undefined" ? "" : location.origin;
65
88
  const editorFrame = useRef<HTMLIFrameElement>(null);
66
89
 
67
90
  useEffect(() => {
68
- setOrigin(dashboardOrigin());
69
- }, []);
91
+ setBase(studioBase(studioPath));
92
+ }, [studioPath]);
70
93
 
71
94
  useEffect(() => {
72
95
  function onKey(event: KeyboardEvent) {
73
96
  if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "j") return;
74
97
  event.preventDefault();
75
- setChatMounted(true);
76
98
  setChatOpen((open) => !open);
77
99
  }
78
100
  document.addEventListener("keydown", onKey, true);
@@ -97,7 +119,6 @@ export function SiteChrome({
97
119
  data.key?.toLowerCase() === "j" &&
98
120
  (data.metaKey || data.ctrlKey)
99
121
  ) {
100
- setChatMounted(true);
101
122
  setChatOpen((open) => !open);
102
123
  }
103
124
  if (data?.type === "backmatter:published") {
@@ -111,10 +132,52 @@ export function SiteChrome({
111
132
  return () => window.removeEventListener("message", onMessage);
112
133
  }, [origin, router]);
113
134
 
135
+ useEffect(() => {
136
+ let stopped = false;
137
+ let left = false;
138
+ function leaveEditor() {
139
+ if (left || stopped) return;
140
+ left = true;
141
+ void (async () => {
142
+ try {
143
+ await fetch(previewEntryPath, { method: "POST", credentials: "same-origin", cache: "no-store" });
144
+ } catch {
145
+ // The cookies expire on their own. Still drop the bar.
146
+ }
147
+ document.cookie = markerCookie(false, location.protocol === "https:");
148
+ location.reload();
149
+ })();
150
+ }
151
+ async function renew() {
152
+ try {
153
+ const res = await fetch(`${previewEntryPath}?renew=1`, {
154
+ method: "POST",
155
+ credentials: "same-origin",
156
+ cache: "no-store",
157
+ });
158
+ if (res.status === 401) leaveEditor();
159
+ } catch {
160
+ // Offline. The next send, or the next interval, tries again.
161
+ }
162
+ }
163
+ void renew();
164
+ const timer = setInterval(() => void renew(), SESSION_RENEW_MS);
165
+ function onExpired(event: MessageEvent) {
166
+ if (event.origin !== origin) return;
167
+ if (event.data?.type === "backmatter:session-expired") leaveEditor();
168
+ }
169
+ window.addEventListener("message", onExpired);
170
+ return () => {
171
+ stopped = true;
172
+ clearInterval(timer);
173
+ window.removeEventListener("message", onExpired);
174
+ };
175
+ }, [origin, previewEntryPath]);
176
+
114
177
  const label = doc ? `${doc.type}/${doc.slug}` : "No document";
115
178
  const editorSrc =
116
- doc && origin
117
- ? `${origin}/workspaces/${doc.workspaceId}/docs/${doc.locale}/${doc.type}/${doc.slug}?embed=1`
179
+ doc && base
180
+ ? `${base}/workspaces/${doc.workspaceId}/docs/${doc.locale}/${doc.type}/${doc.slug}?embed=1`
118
181
  : "";
119
182
 
120
183
  function onChatWrote() {
@@ -124,7 +187,33 @@ export function SiteChrome({
124
187
 
125
188
  return (
126
189
  <>
127
- <style>{"body{padding-bottom:36px}"}</style>
190
+ <style>{`
191
+ ${DEVBAR_ENTER_CSS}
192
+ @media (hover: hover) {
193
+ .${DEVBAR_CLASS} button:hover:not(:disabled) {
194
+ background: #f4f4f5;
195
+ }
196
+ }
197
+ .${DEVBAR_CLASS} button:focus-visible {
198
+ outline: 2px solid #18181b;
199
+ outline-offset: 2px;
200
+ }
201
+ .${DEVBAR_CLASS} button:disabled {
202
+ cursor: default;
203
+ opacity: 0.5;
204
+ }
205
+ html.backmatter-pointing,
206
+ html.backmatter-pointing * {
207
+ cursor: crosshair !important;
208
+ }
209
+ html.backmatter-pointing .${DEVBAR_CLASS},
210
+ html.backmatter-pointing .${DEVBAR_CLASS} * {
211
+ cursor: pointer !important;
212
+ }
213
+ html.backmatter-pointing .${DEVBAR_CLASS} button:disabled {
214
+ cursor: default !important;
215
+ }
216
+ `}</style>
128
217
  {editorMounted && doc && editorSrc ? (
129
218
  <iframe
130
219
  ref={editorFrame}
@@ -137,18 +226,19 @@ export function SiteChrome({
137
226
  }}
138
227
  />
139
228
  ) : null}
140
- {chatMounted && doc ? (
229
+ {doc && base ? (
141
230
  <CmsChat
142
231
  doc={doc}
143
232
  hidden={!chatOpen}
144
233
  onWrote={onChatWrote}
145
234
  onClosed={() => setChatOpen(false)}
235
+ base={base}
146
236
  />
147
237
  ) : null}
148
- {doc && origin ? (
238
+ {doc && base ? (
149
239
  <iframe
150
240
  title="Publish"
151
- src={`${origin}/embed/publish/${doc.workspaceId}`}
241
+ src={`${base}/embed/publish/${doc.workspaceId}`}
152
242
  style={{
153
243
  ...publishStyle,
154
244
  visibility: publishOpen ? "visible" : "hidden",
@@ -156,7 +246,7 @@ export function SiteChrome({
156
246
  }}
157
247
  />
158
248
  ) : null}
159
- <footer aria-label="Backmatter" style={barStyle}>
249
+ <footer aria-label="Backmatter" className={DEVBAR_CLASS} style={barStyle}>
160
250
  <button
161
251
  type="button"
162
252
  aria-pressed={editorOpen}
@@ -172,17 +262,13 @@ export function SiteChrome({
172
262
  <BarText>Preview</BarText>
173
263
  {branch ? <BarText>{branch}</BarText> : null}
174
264
  {tree ? <BarText>draft</BarText> : null}
175
- <span style={{ flex: 1 }} />
176
265
  <button type="button" aria-pressed={publishOpen} onClick={() => setPublishOpen((open) => !open)} style={barButton}>
177
266
  Publish{changeCount ? ` ${changeCount}` : ""}
178
267
  </button>
179
268
  <button
180
269
  type="button"
181
270
  aria-pressed={chatOpen}
182
- onClick={() => {
183
- setChatMounted(true);
184
- setChatOpen((open) => !open);
185
- }}
271
+ onClick={() => setChatOpen((open) => !open)}
186
272
  title="Toggle AI chat (⌘J)"
187
273
  style={barButton}
188
274
  >
@@ -201,11 +287,14 @@ function CmsChat({
201
287
  hidden,
202
288
  onWrote,
203
289
  onClosed,
290
+ base,
204
291
  }: {
205
292
  doc: SiteDocRef;
206
293
  hidden: boolean;
207
294
  onWrote: () => void;
208
295
  onClosed: () => void;
296
+ /** Studio origin plus base path. */
297
+ base: string;
209
298
  }) {
210
299
  const frame = useRef<HTMLIFrameElement>(null);
211
300
  const onWroteRef = useRef(onWrote);
@@ -214,7 +303,7 @@ function CmsChat({
214
303
  onWroteRef.current = onWrote;
215
304
  onClosedRef.current = onClosed;
216
305
  hiddenRef.current = hidden;
217
- const origin = dashboardOrigin();
306
+ const origin = base ? new URL(base).origin : "";
218
307
 
219
308
  function openChat() {
220
309
  const frameEl = frame.current;
@@ -227,6 +316,9 @@ function CmsChat({
227
316
  if (!hidden) openChat();
228
317
  }, [hidden, origin]);
229
318
 
319
+ const docRef = useRef(doc);
320
+ docRef.current = doc;
321
+
230
322
  useEffect(() => {
231
323
  const focus = { locale: doc.locale, type: doc.type, slug: doc.slug };
232
324
  function postFocus() {
@@ -246,12 +338,47 @@ function CmsChat({
246
338
  return () => window.removeEventListener("message", onMessage);
247
339
  }, [doc.locale, doc.type, doc.slug, origin]);
248
340
 
249
- if (!origin) return null;
341
+ useEffect(() => {
342
+ if (!origin) return;
343
+ const pointer = installSitePointer(document, {
344
+ bar: () => document.querySelector(`.${DEVBAR_CLASS}`),
345
+ ignore: (target) => {
346
+ if (!(target instanceof Element)) return true;
347
+ const frameEl = frame.current;
348
+ if (frameEl && (target === frameEl || frameEl.contains(target))) return true;
349
+ return target.closest("iframe") !== null;
350
+ },
351
+ onGrab: (grab) => {
352
+ frame.current?.contentWindow?.postMessage(
353
+ { type: "backmatter:pointer-grab", grab, doc: docRef.current },
354
+ origin,
355
+ );
356
+ },
357
+ onEscape: () => {
358
+ frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
359
+ },
360
+ });
361
+ function onMessage(event: MessageEvent) {
362
+ if (event.origin !== origin || event.source !== frame.current?.contentWindow) return;
363
+ const data = event.data as { type?: string; on?: boolean };
364
+ if (data?.type !== "backmatter:pointer" || typeof data.on !== "boolean") return;
365
+ if (data.on && !hiddenRef.current) pointer.arm();
366
+ else pointer.disarm();
367
+ }
368
+ window.addEventListener("message", onMessage);
369
+ return () => {
370
+ window.removeEventListener("message", onMessage);
371
+ pointer.dispose();
372
+ frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
373
+ };
374
+ }, [origin, hidden]);
375
+
376
+ if (!base) return null;
250
377
  return (
251
378
  <iframe
252
379
  ref={frame}
253
380
  title="AI chat"
254
- src={`${origin}/embed/chat/${doc.workspaceId}`}
381
+ src={`${base}/embed/chat/${doc.workspaceId}`}
255
382
  style={{
256
383
  ...chatStyle,
257
384
  visibility: hidden ? "hidden" : "visible",
@@ -266,48 +393,51 @@ function BarText({ children }: { children: ReactNode }) {
266
393
  }
267
394
 
268
395
  const barStyle: CSSProperties = {
269
- position: "fixed",
270
- left: 0,
271
- right: 0,
272
- bottom: 0,
273
- zIndex: 2147483000,
274
- display: "flex",
275
- alignItems: "center",
276
- gap: 12,
277
- height: 36,
278
- padding: "0 12px",
279
- paddingBottom: "env(safe-area-inset-bottom, 0px)",
280
- boxSizing: "border-box",
281
- borderTop: "1px solid #e4e4e7",
282
- background: "#f4f4f5",
283
- color: "#71717a",
284
- font: "12px/1 ui-sans-serif, system-ui, sans-serif",
396
+ ...devbarShellStyle,
397
+ gap: 2,
398
+ padding: "0 6px 0 10px",
399
+ font: '12px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
285
400
  };
286
401
 
287
402
  const barButton: CSSProperties = {
288
403
  border: 0,
404
+ borderRadius: 8,
289
405
  background: "transparent",
290
406
  color: "#18181b",
291
407
  font: "inherit",
292
408
  cursor: "pointer",
293
- padding: "4px 6px",
409
+ padding: "0 10px",
410
+ minHeight: 44,
294
411
  };
295
412
 
296
413
  const docButton: CSSProperties = {
297
414
  ...barButton,
298
415
  fontWeight: 600,
416
+ maxWidth: 200,
417
+ overflow: "hidden",
418
+ textOverflow: "ellipsis",
419
+ whiteSpace: "nowrap",
299
420
  };
300
421
 
301
- const mutedStyle: CSSProperties = { color: "#71717a", fontSize: 12, margin: 0 };
422
+ const mutedStyle: CSSProperties = {
423
+ color: "#71717a",
424
+ fontSize: 12,
425
+ margin: 0,
426
+ padding: "0 6px",
427
+ maxWidth: 140,
428
+ overflow: "hidden",
429
+ textOverflow: "ellipsis",
430
+ whiteSpace: "nowrap",
431
+ };
302
432
 
303
433
  const editorStyle: CSSProperties = {
304
434
  position: "fixed",
305
435
  left: 0,
306
436
  right: 0,
307
- bottom: 36,
437
+ bottom: DEVBAR_CLEARANCE,
308
438
  zIndex: 2147482999,
309
439
  width: "100%",
310
- height: "min(78vh, calc(100vh - 48px))",
440
+ height: "min(78vh, calc(100vh - 104px))",
311
441
  border: 0,
312
442
  borderTop: "1px solid #e4e4e7",
313
443
  background: "#ffffff",
@@ -316,11 +446,11 @@ const editorStyle: CSSProperties = {
316
446
 
317
447
  const chatStyle: CSSProperties = {
318
448
  position: "fixed",
319
- right: 12,
320
- bottom: 48,
449
+ right: DEVBAR_RIGHT,
450
+ bottom: DEVBAR_CLEARANCE,
321
451
  zIndex: 2147483001,
322
- width: "min(440px, calc(100vw - 24px))",
323
- height: "min(640px, calc(100vh - 120px))",
452
+ width: "min(440px, calc(100vw - 32px))",
453
+ height: "min(640px, calc(100vh - 148px))",
324
454
  border: 0,
325
455
  borderRadius: 14,
326
456
  background: "#f4f4f5",
@@ -329,11 +459,11 @@ const chatStyle: CSSProperties = {
329
459
 
330
460
  const publishStyle: CSSProperties = {
331
461
  position: "fixed",
332
- right: 12,
333
- bottom: 48,
462
+ right: DEVBAR_RIGHT,
463
+ bottom: DEVBAR_CLEARANCE,
334
464
  zIndex: 2147483001,
335
- width: "min(420px, calc(100vw - 24px))",
336
- height: "min(520px, calc(100vh - 120px))",
465
+ width: "min(420px, calc(100vw - 32px))",
466
+ height: "min(520px, calc(100vh - 148px))",
337
467
  border: "1px solid #e4e4e7",
338
468
  borderRadius: 8,
339
469
  background: "#ffffff",