@botiverse/porthole 0.1.2 → 0.1.4

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 (84) hide show
  1. package/dist/annotate/types.d.ts +120 -0
  2. package/dist/annotate/types.js +42 -0
  3. package/dist/annotate/validate.d.ts +11 -0
  4. package/dist/annotate/validate.js +156 -0
  5. package/dist/bridge/bridge.d.ts +4 -0
  6. package/dist/bridge/bridge.js +12 -1
  7. package/dist/compile/policy.d.ts +10 -3
  8. package/dist/compile/policy.js +16 -5
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/config/schema.d.ts +11 -0
  12. package/dist/config/validate.js +12 -0
  13. package/dist/conformance/clipboard.d.ts +2 -0
  14. package/dist/conformance/clipboard.js +167 -0
  15. package/dist/conformance/kit.d.ts +1 -1
  16. package/dist/conformance/probes.js +2 -0
  17. package/dist/conformance/served.d.ts +8 -1
  18. package/dist/conformance/served.js +12 -8
  19. package/dist/guide/guide.js +11 -5
  20. package/dist/host/clipboard.d.ts +11 -0
  21. package/dist/host/clipboard.js +43 -0
  22. package/dist/host/create-host.js +165 -7
  23. package/dist/host/types.d.ts +68 -2
  24. package/dist/index.d.ts +8 -5
  25. package/dist/index.js +7 -3
  26. package/dist/mcp/adapter.js +10 -1
  27. package/dist/presets/presets.d.ts +3 -1
  28. package/dist/presets/presets.js +1 -0
  29. package/dist/protocol/mcp-apps.d.ts +5 -0
  30. package/dist/protocol/mcp-apps.js +5 -0
  31. package/dist/protocol/shell-adapter.js +6 -2
  32. package/dist/react/dialog.js +2 -0
  33. package/dist/react/frame.d.ts +50 -2
  34. package/dist/react/frame.js +153 -11
  35. package/dist/react/index.d.ts +2 -1
  36. package/dist/react/index.js +2 -1
  37. package/dist/react/toast.d.ts +33 -0
  38. package/dist/react/toast.js +42 -0
  39. package/dist/server/naming.d.ts +6 -0
  40. package/dist/server/naming.js +38 -0
  41. package/dist/server/pages.js +2 -0
  42. package/dist/server/server.js +49 -17
  43. package/dist/server/types.d.ts +6 -3
  44. package/dist/shell/annotate.d.ts +20 -0
  45. package/dist/shell/annotate.js +700 -0
  46. package/dist/shell/document.d.ts +6 -0
  47. package/dist/shell/document.js +12 -3
  48. package/dist/shell/plan.d.ts +2 -0
  49. package/dist/shell/plan.js +2 -1
  50. package/dist/shell/runtime.d.ts +4 -1
  51. package/dist/shell/runtime.js +71 -3
  52. package/package.json +1 -1
  53. package/src/annotate/types.ts +126 -0
  54. package/src/annotate/validate.ts +186 -0
  55. package/src/bridge/bridge.ts +13 -0
  56. package/src/compile/policy.ts +17 -5
  57. package/src/config/capabilities.ts +33 -3
  58. package/src/config/schema.ts +12 -0
  59. package/src/config/validate.ts +15 -0
  60. package/src/conformance/clipboard.ts +184 -0
  61. package/src/conformance/kit.ts +1 -1
  62. package/src/conformance/probes.ts +2 -0
  63. package/src/conformance/served.ts +24 -9
  64. package/src/guide/guide.ts +22 -5
  65. package/src/host/clipboard.ts +41 -0
  66. package/src/host/create-host.ts +181 -7
  67. package/src/host/types.ts +64 -2
  68. package/src/index.ts +24 -0
  69. package/src/mcp/adapter.ts +10 -1
  70. package/src/presets/presets.ts +4 -0
  71. package/src/protocol/mcp-apps.ts +6 -0
  72. package/src/protocol/shell-adapter.ts +11 -2
  73. package/src/react/dialog.tsx +2 -0
  74. package/src/react/frame.tsx +231 -13
  75. package/src/react/index.ts +9 -1
  76. package/src/react/toast.tsx +96 -0
  77. package/src/server/naming.ts +41 -0
  78. package/src/server/pages.ts +2 -0
  79. package/src/server/server.ts +61 -20
  80. package/src/server/types.ts +6 -3
  81. package/src/shell/annotate.ts +700 -0
  82. package/src/shell/document.ts +21 -3
  83. package/src/shell/plan.ts +4 -1
  84. package/src/shell/runtime.ts +71 -3
@@ -14,8 +14,10 @@ import {
14
14
  type ReactElement,
15
15
  type ReactNode,
16
16
  } from "react";
17
+ import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate/types.ts";
17
18
  import type {
18
19
  CallContext,
20
+ CapabilityHandler,
19
21
  CapabilityHandlers,
20
22
  Confirm,
21
23
  ConfirmAnswer,
@@ -25,11 +27,13 @@ import type {
25
27
  } from "../bridge/types.ts";
26
28
  import type { CapabilityName } from "../config/capabilities.ts";
27
29
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
30
+ import { writeClipboard } from "../host/clipboard.ts";
28
31
  import { createHost } from "../host/create-host.ts";
29
- import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
32
+ import type { DisplayEvents, DisplayHandle, FrameOutcome, HostErrorKind } from "../host/types.ts";
30
33
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
31
34
  import { ConfirmDialog } from "./dialog.tsx";
32
35
  import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
36
+ import { CopyToast, type CopyNotice } from "./toast.tsx";
33
37
 
34
38
  /**
35
39
  * The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
@@ -49,7 +53,11 @@ interface PageState {
49
53
  readonly heights: Map<string, number>;
50
54
  readonly confirm?: Confirm;
51
55
  readonly hostInfo?: { readonly name: string; readonly version: string };
52
- readonly locator?: InstanceLocator;
56
+ readonly nonce?: string;
57
+ /** The provider's locator, read when a frame locates (so an inline one restarts nothing). */
58
+ readonly locate?: InstanceLocator["locate"];
59
+ /** The default `copyText`: copies on the host's page and shows the toast (P052). */
60
+ readonly copyText?: CapabilityHandler<"copyText">;
53
61
  }
54
62
 
55
63
  /** Without a provider: one registry for the page, and no one to confirm (gated calls refused). */
@@ -69,6 +77,13 @@ export interface PortholeProviderProps {
69
77
  readonly locator?: InstanceLocator;
70
78
  /** A class for the default dialog, to restyle it. */
71
79
  readonly dialogClassName?: string;
80
+ /** A class for the default copy toast, to restyle it. */
81
+ readonly toastClassName?: string;
82
+ /**
83
+ * The page's CSP nonce, where its `script-src` is nonce-based: opaque frames inherit the page's
84
+ * policy, so their shell carries it (P054).
85
+ */
86
+ readonly nonce?: string;
72
87
  readonly children?: ReactNode;
73
88
  }
74
89
 
@@ -96,16 +111,42 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
96
111
  ),
97
112
  [],
98
113
  );
99
- const { hostInfo, locator } = props;
114
+ // The default copyText: the host's page writes the clipboard (in the frame's click, which
115
+ // activated this page too) and shows the person what went on it, or offers to copy it.
116
+ const [notice, setNotice] = useState<CopyNotice | null>(null);
117
+ const copyText = useCallback<CapabilityHandler<"copyText">>(async ({ text }, context) => {
118
+ const copied = await writeClipboard(text, globalThis.document);
119
+ setNotice({ text, copied, title: context.title });
120
+ return copied;
121
+ }, []);
122
+ const closeNotice = useCallback(() => setNotice(null), []);
123
+ // Compared by value, and read when used: an inline hostInfo or locator doesn't start every
124
+ // frame again on each render.
125
+ const hostInfo = useByValue(props.hostInfo);
126
+ const ownLocator = useRef(props.locator);
127
+ useLayoutEffect(() => {
128
+ ownLocator.current = props.locator;
129
+ });
130
+ const locate = useCallback<InstanceLocator["locate"]>(
131
+ (instance, signal) =>
132
+ ownLocator.current === undefined
133
+ ? Promise.reject(new Error("No locator (PortholeProvider.locator)"))
134
+ : ownLocator.current.locate(instance, signal),
135
+ [],
136
+ );
137
+ const locates = props.locator !== undefined;
138
+ const { nonce } = props;
100
139
  const value = useMemo<PageState>(
101
140
  () => ({
102
141
  live,
103
142
  heights,
104
143
  confirm,
144
+ copyText,
105
145
  ...(hostInfo === undefined ? {} : { hostInfo }),
106
- ...(locator === undefined ? {} : { locator }),
146
+ ...(locates ? { locate } : {}),
147
+ ...(nonce === undefined ? {} : { nonce }),
107
148
  }),
108
- [live, heights, confirm, hostInfo, locator],
149
+ [live, heights, confirm, copyText, hostInfo, locates, locate, nonce],
109
150
  );
110
151
  const current = queue[0] ?? null;
111
152
  return (
@@ -125,6 +166,17 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
125
166
  }}
126
167
  />
127
168
  ) : null}
169
+ <CopyToast
170
+ notice={notice}
171
+ {...(props.toastClassName === undefined ? {} : { className: props.toastClassName })}
172
+ onClose={closeNotice}
173
+ onCopy={(failed) => {
174
+ // A click on the host's own page: the write has the gesture it needs now.
175
+ void writeClipboard(failed.text, globalThis.document).then((copied) =>
176
+ setNotice({ ...failed, copied }),
177
+ );
178
+ }}
179
+ />
128
180
  </Page.Provider>
129
181
  );
130
182
  }
@@ -163,9 +215,28 @@ export interface PortholeFrameProps {
163
215
  readonly generation?: number;
164
216
  /** Run at once, without waiting to near the screen (it still counts toward the page's most). */
165
217
  readonly eager?: boolean;
218
+ /**
219
+ * Fill the box (give it, or its parent, a height) and let the content scroll inside, instead of
220
+ * growing with the content: an artifact viewer's page. A url-mode app always fills.
221
+ */
222
+ readonly fill?: boolean;
166
223
  readonly onInitialized?: () => void;
167
224
  readonly onResize?: (size: { readonly height: number; readonly width?: number }) => void;
168
- readonly onError?: (message: string) => void;
225
+ /**
226
+ * What the content did wrong (what it threw, what the sandbox blocked, that it kept navigating
227
+ * away): the agent's to hear and fix.
228
+ */
229
+ readonly onContentError?: (message: string) => void;
230
+ /**
231
+ * What failed on the host's side: its locator (`locate`), a display that couldn't be made
232
+ * (`mount`), an annotation call refused (`annotations`). Never the agent's.
233
+ */
234
+ readonly onHostError?: (error: {
235
+ readonly message: string;
236
+ readonly kind: HostErrorKind;
237
+ }) => void;
238
+ /** Both, with their source (as before 0.1.4). Prefer `onContentError` and `onHostError`. */
239
+ readonly onError?: (message: string, source: "content" | "host") => void;
169
240
  /** The content navigated its frame away; it was started again (or stopped, after a few). */
170
241
  readonly onNavigated?: () => void;
171
242
  readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
@@ -186,6 +257,27 @@ export interface PortholeFrameProps {
186
257
  * restyle): the running frame reads them again.
187
258
  */
188
259
  readonly themeKey?: string | number;
260
+ /**
261
+ * Annotations, where the config declares `annotate` (P053): what the person may point at now
262
+ * (elements, text, or both), or null to stop. A picked element ends element picking: set it
263
+ * again to pick again.
264
+ */
265
+ readonly annotating?: AnnotateMode | null;
266
+ /** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
267
+ readonly annotations?: readonly AnnotationMark[];
268
+ /**
269
+ * The mark to bring into view and make the active one; each new id focuses it. To focus the
270
+ * same mark again (a second click on its comment), give `{ id, key }` with a new key.
271
+ */
272
+ readonly focusedAnnotation?:
273
+ | string
274
+ | { readonly id: string; readonly key: string | number }
275
+ | null;
276
+ /**
277
+ * What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
278
+ * pick, where marks are. The content's word: UI hints, never permissions.
279
+ */
280
+ readonly onAnnotation?: (report: AnnotationReport) => void;
189
281
  /** Draws the placeholder instead of the default one. */
190
282
  readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
191
283
  readonly className?: string;
@@ -299,6 +391,8 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
299
391
  style={{
300
392
  position: "relative",
301
393
  width: "100%",
394
+ // A display that fills takes its parent's height (the host sizes the parent, or this box).
395
+ ...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
302
396
  ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
303
397
  ...props.style,
304
398
  }}
@@ -336,8 +430,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
336
430
  const [ready, setReady] = useState(false);
337
431
  // The height this display started at: later reports don't make it start again.
338
432
  const [startHeight] = useState(initialHeightPx);
339
- const { config, document: view, instance, content, partial, scheme } = frame;
340
- const fills = config.mode === "url";
433
+ const { document: view, content, partial, scheme } = frame;
434
+ // By value: a config or instance made inline (each render) is the same display.
435
+ const config = useByValue(frame.config);
436
+ const instance = useByValue(frame.instance);
437
+ const fills = config.mode === "url" || frame.fill === true;
341
438
 
342
439
  // The latest props, read when the display calls back (never during render).
343
440
  const latest = useEffectEvent(() => frame);
@@ -354,9 +451,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
354
451
  frame.onResize?.(size);
355
452
  return;
356
453
  }
357
- case "error":
358
- frame.onError?.((detail as DisplayEvents["error"]).message);
454
+ case "contentError":
455
+ frame.onContentError?.((detail as DisplayEvents["contentError"]).message);
359
456
  return;
457
+ case "hostError":
458
+ frame.onHostError?.(detail as DisplayEvents["hostError"]);
459
+ return;
460
+ case "error": {
461
+ const { message, source } = detail as DisplayEvents["error"];
462
+ frame.onError?.(message, source);
463
+ return;
464
+ }
360
465
  case "navigated":
361
466
  frame.onNavigated?.();
362
467
  return;
@@ -371,6 +476,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
371
476
  case "outcome":
372
477
  frame.onOutcome?.(detail as DisplayEvents["outcome"]);
373
478
  return;
479
+ case "annotation":
480
+ frame.onAnnotation?.(detail as DisplayEvents["annotation"]);
481
+ return;
374
482
  }
375
483
  },
376
484
  );
@@ -397,6 +505,14 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
397
505
  named(context),
398
506
  );
399
507
  }
508
+ // Copy buttons without a handler of the host's: the provider's, which shows a toast.
509
+ if (config.capabilities.copyText !== undefined && handlers.copyText === undefined) {
510
+ const fallback = page.copyText;
511
+ if (fallback !== undefined) {
512
+ handlers.copyText = (args, context) =>
513
+ fallback(args as { readonly text: string }, named(context));
514
+ }
515
+ }
400
516
  const asks = first.confirm !== undefined || page.confirm !== undefined;
401
517
  let display: DisplayHandle;
402
518
  try {
@@ -413,7 +529,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
413
529
  : {}),
414
530
  theme: (each) => latest().theme?.(each) ?? {},
415
531
  ...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
416
- ...(page.locator === undefined ? {} : { locator: page.locator }),
532
+ ...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
533
+ ...(page.nonce === undefined ? {} : { nonce: page.nonce }),
417
534
  }).mount(container, {
418
535
  displayId: first.displayId,
419
536
  title: first.title,
@@ -421,9 +538,10 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
421
538
  initialHeightPx: startHeight,
422
539
  ...(view === undefined ? {} : { document: view }),
423
540
  ...(instance === undefined ? {} : { instance }),
541
+ ...(fills ? { fill: true } : {}),
424
542
  });
425
543
  } catch (error) {
426
- first.onError?.(error instanceof Error ? error.message : String(error));
544
+ hostFailed(first, error, "mount");
427
545
  return;
428
546
  }
429
547
  handle.current = display;
@@ -435,10 +553,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
435
553
  "refused",
436
554
  "muted",
437
555
  "outcome",
556
+ "annotation",
557
+ "contentError",
558
+ "hostError",
438
559
  ];
439
560
  for (const event of events) {
440
561
  display.on(event, (detail) => notify(event, detail));
441
562
  }
563
+ if (config.capabilities.annotate !== undefined) {
564
+ applyAnnotations(display, first);
565
+ }
442
566
  if (first.content !== undefined) {
443
567
  display.setContent(first.content, { partial: first.partial === true });
444
568
  }
@@ -448,13 +572,56 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
448
572
  };
449
573
  // A new display only for a new config, document, instance or page (or generation, by key);
450
574
  // content, theme, handlers, id and title are fed to the running one.
451
- }, [config, view, instance, page, startHeight]);
575
+ }, [config, view, instance, page, startHeight, fills]);
452
576
 
453
577
  useEffect(() => {
454
578
  if (content !== undefined) {
455
579
  handle.current?.setContent(content, { partial: partial === true });
456
580
  }
457
581
  }, [content, partial]);
582
+ // Annotations follow their props (a new display gets them all when it is made, above).
583
+ const annotates = config.capabilities.annotate !== undefined;
584
+ const { annotating } = frame;
585
+ const focusedAnnotation = focusOf(frame.focusedAnnotation);
586
+ const focusToken =
587
+ focusedAnnotation === null
588
+ ? ""
589
+ : typeof frame.focusedAnnotation === "object" && frame.focusedAnnotation !== null
590
+ ? JSON.stringify([frame.focusedAnnotation.id, frame.focusedAnnotation.key])
591
+ : JSON.stringify([focusedAnnotation]);
592
+ // By value: the same marks given again don't mark them again.
593
+ const annotations = useByValue(frame.annotations);
594
+ const mode =
595
+ annotating === undefined || annotating === null
596
+ ? ""
597
+ : JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
598
+ useEffect(() => {
599
+ const display = handle.current;
600
+ if (!annotates || display === null) {
601
+ return;
602
+ }
603
+ const wanted = mode === "" ? null : (JSON.parse(mode) as [boolean, boolean]);
604
+ attempt(
605
+ () =>
606
+ wanted === null
607
+ ? display.annotations.stop()
608
+ : display.annotations.start({ elements: wanted[0], text: wanted[1] }),
609
+ latest(),
610
+ );
611
+ }, [annotates, mode]);
612
+ useEffect(() => {
613
+ const display = handle.current;
614
+ if (annotates && display !== null) {
615
+ attempt(() => display.annotations.show(annotations ?? []), latest());
616
+ }
617
+ }, [annotates, annotations]);
618
+ useEffect(() => {
619
+ const display = handle.current;
620
+ const id = focusOf(latest().focusedAnnotation);
621
+ if (annotates && display !== null && focusToken !== "" && id !== null) {
622
+ attempt(() => display.annotations.focus(id), latest());
623
+ }
624
+ }, [annotates, focusToken]);
458
625
  useEffect(() => {
459
626
  handle.current?.setTheme(scheme);
460
627
  }, [scheme]);
@@ -484,3 +651,54 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
484
651
  </div>
485
652
  );
486
653
  }
654
+
655
+ /** Tells the frame's props what failed on the host's side. */
656
+ function hostFailed(frame: PortholeFrameProps, error: unknown, kind: HostErrorKind): void {
657
+ const message = error instanceof Error ? error.message : String(error);
658
+ frame.onHostError?.({ message, kind });
659
+ frame.onError?.(message, "host");
660
+ }
661
+
662
+ /** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
663
+ function attempt(run: () => void, frame: PortholeFrameProps): void {
664
+ try {
665
+ run();
666
+ } catch (error) {
667
+ hostFailed(frame, error, "annotations");
668
+ }
669
+ }
670
+
671
+ /** A new display's annotations, from the props as they are. */
672
+ function applyAnnotations(display: DisplayHandle, frame: PortholeFrameProps): void {
673
+ attempt(() => {
674
+ if (frame.annotations !== undefined && frame.annotations.length > 0) {
675
+ display.annotations.show(frame.annotations);
676
+ }
677
+ const focused = focusOf(frame.focusedAnnotation);
678
+ if (focused !== null) {
679
+ display.annotations.focus(focused);
680
+ }
681
+ if (frame.annotating !== undefined && frame.annotating !== null) {
682
+ display.annotations.start(frame.annotating);
683
+ }
684
+ }, frame);
685
+ }
686
+
687
+ /**
688
+ * `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
689
+ * render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
690
+ * only when it really changes.
691
+ */
692
+ function useByValue<T>(value: T): T {
693
+ const key = value === undefined ? "" : JSON.stringify(value);
694
+ return useMemo(() => (key === "" ? undefined : JSON.parse(key)) as T, [key]);
695
+ }
696
+
697
+ /** The id `focusedAnnotation` names, if any. */
698
+ function focusOf(value: PortholeFrameProps["focusedAnnotation"]): string | null {
699
+ return typeof value === "string"
700
+ ? value
701
+ : value === null || value === undefined
702
+ ? null
703
+ : value.id;
704
+ }
@@ -2,7 +2,7 @@
2
2
  * `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
3
3
  * package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
4
4
  *
5
- * <PortholeProvider> // per page: live-frame cap, heights, the dialog
5
+ * <PortholeProvider> // per page: live-frame cap, heights, the dialog, the copy toast
6
6
  * <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
7
7
  * content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
8
8
  * theme={tokensFor} onError={report} />
@@ -17,4 +17,12 @@ export {
17
17
  type PortholeProviderProps,
18
18
  } from "./frame.tsx";
19
19
  export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
20
+ export {
21
+ CopyToast,
22
+ TOAST_CHARS,
23
+ TOAST_MS,
24
+ clipped,
25
+ type CopyNotice,
26
+ type CopyToastProps,
27
+ } from "./toast.tsx";
20
28
  export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -0,0 +1,96 @@
1
+ import { useEffect, type ReactElement } from "react";
2
+
3
+ /**
4
+ * The default copy notice (P052): when content's `copyText` copied something, the host's page
5
+ * shows the person what went on their clipboard, clipped, in a small toast outside every frame.
6
+ * If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
7
+ * host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
8
+ * styles it through the `porthole-toast` class and data attributes.
9
+ */
10
+
11
+ /** How long a toast for a copy that worked stays. */
12
+ export const TOAST_MS = 5_000;
13
+ /** The most of the copied text a toast shows. */
14
+ export const TOAST_CHARS = 280;
15
+ const TOAST_LINES = 4;
16
+
17
+ export interface CopyNotice {
18
+ /** The whole text copied (or to copy). */
19
+ readonly text: string;
20
+ /** Whether it is on the clipboard. */
21
+ readonly copied: boolean;
22
+ /** The display's title, as the host named it. */
23
+ readonly title: string;
24
+ }
25
+
26
+ export interface CopyToastProps {
27
+ readonly notice: CopyNotice | null;
28
+ /** The person asked to copy it again (it failed), from this click. */
29
+ readonly onCopy: (notice: CopyNotice) => void;
30
+ readonly onClose: () => void;
31
+ readonly className?: string;
32
+ }
33
+
34
+ /** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
35
+ export function clipped(text: string): { readonly shown: string; readonly clipped: boolean } {
36
+ const lines = text.split("\n");
37
+ let shown = lines.slice(0, TOAST_LINES).join("\n");
38
+ if (shown.length > TOAST_CHARS) {
39
+ shown = shown.slice(0, TOAST_CHARS);
40
+ }
41
+ return shown.length < text.length
42
+ ? { shown: `${shown}…`, clipped: true }
43
+ : { shown, clipped: false };
44
+ }
45
+
46
+ const STYLE = `.porthole-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;color-scheme:light dark;color:CanvasText;background:Canvas;border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:12px;padding:12px 14px;width:min(420px,calc(100vw - 32px));box-shadow:0 8px 28px rgb(0 0 0 / .25);font:inherit;font-size:.9em}
47
+ .porthole-toast p{margin:0 0 6px}
48
+ .porthole-toast [data-porthole-source]{opacity:.72}
49
+ .porthole-toast pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:8em;overflow:hidden;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,CanvasText 7%,transparent);font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
50
+ .porthole-toast menu{display:flex;justify-content:flex-end;gap:8px;margin:8px 0 0;padding:0}
51
+ .porthole-toast button{font:inherit;min-height:32px;padding:0 12px;border-radius:8px;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);background:transparent;color:inherit;cursor:pointer}`;
52
+
53
+ export function CopyToast({ notice, onCopy, onClose, className }: CopyToastProps): ReactElement {
54
+ useEffect(() => {
55
+ if (notice === null || !notice.copied) {
56
+ return;
57
+ }
58
+ const timer = setTimeout(onClose, TOAST_MS);
59
+ return () => clearTimeout(timer);
60
+ }, [notice, onClose]);
61
+ const view = notice === null ? null : clipped(notice.text);
62
+ return (
63
+ <div
64
+ role="status"
65
+ aria-live="polite"
66
+ className={className === undefined ? "porthole-toast" : `porthole-toast ${className}`}
67
+ data-porthole-toast=""
68
+ data-porthole-copied={notice === null ? "" : String(notice.copied)}
69
+ hidden={notice === null}
70
+ >
71
+ <style>{STYLE}</style>
72
+ {notice === null || view === null ? null : (
73
+ <>
74
+ <p>
75
+ <strong>{notice.copied ? "Copied" : "Not copied yet"}</strong>{" "}
76
+ <span data-porthole-source="">{`from “${notice.title}”`}</span>
77
+ </p>
78
+ <pre data-porthole-copied-text="">{view.shown}</pre>
79
+ {view.clipped ? (
80
+ <p data-porthole-length="">{`${notice.text.length.toLocaleString("en-US")} characters in all`}</p>
81
+ ) : null}
82
+ <menu>
83
+ {notice.copied ? null : (
84
+ <button type="button" data-porthole-copy="" onClick={() => onCopy(notice)}>
85
+ Copy
86
+ </button>
87
+ )}
88
+ <button type="button" data-porthole-close="" onClick={onClose}>
89
+ {notice.copied ? "OK" : "Close"}
90
+ </button>
91
+ </menu>
92
+ </>
93
+ )}
94
+ </div>
95
+ );
96
+ }
@@ -40,3 +40,44 @@ export function prefixedName(prefix: string): (scope: string) => string {
40
40
  return label;
41
41
  };
42
42
  }
43
+
44
+ const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
45
+
46
+ /**
47
+ * Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
48
+ * (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
49
+ * reserves the label (fail closed: the request goes to the host's routes, never served as ours).
50
+ */
51
+ export function reservedLabels(
52
+ reserved: readonly string[] | ((label: string) => boolean) | undefined,
53
+ ): (label: string) => boolean {
54
+ if (reserved === undefined) {
55
+ return () => false;
56
+ }
57
+ if (typeof reserved === "function") {
58
+ return (label) => {
59
+ try {
60
+ return reserved(label);
61
+ } catch {
62
+ return true;
63
+ }
64
+ };
65
+ }
66
+ if (!Array.isArray(reserved)) {
67
+ throw new TypeError("content.reserved: a list of labels and prefixes, or a function");
68
+ }
69
+ const whole = new Set<string>();
70
+ const prefixes: string[] = [];
71
+ for (const entry of reserved) {
72
+ if (typeof entry === "string" && PREFIX_ENTRY.test(entry)) {
73
+ prefixes.push(entry.slice(0, -1));
74
+ } else if (isHostLabel(entry)) {
75
+ whole.add(entry);
76
+ } else {
77
+ throw new TypeError(
78
+ `content.reserved: ${JSON.stringify(entry)} is not a lowercase DNS label or a prefix ending in *`,
79
+ );
80
+ }
81
+ }
82
+ return (label) => whole.has(label) || prefixes.some((prefix) => label.startsWith(prefix));
83
+ }
@@ -93,6 +93,8 @@ const pageHeaders = (frameAncestors: string, connect: boolean) => ({
93
93
  "content-security-policy": policy(frameAncestors, connect),
94
94
  "referrer-policy": "no-referrer",
95
95
  "x-content-type-options": "nosniff",
96
+ // Content origins are behind tickets: nothing on them is for search engines.
97
+ "x-robots-tag": "noindex, nofollow",
96
98
  });
97
99
 
98
100
  export function statusPage(