@guuey/chat 0.16.8 → 0.16.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.
@@ -60,6 +60,7 @@ import {
60
60
  createUiActionRelay,
61
61
  createUiResourceReader,
62
62
  createWebAdapters,
63
+ deleteThread,
63
64
  type AgentInvokeAdapters,
64
65
  } from "@guuey/agent-client";
65
66
  import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
@@ -93,7 +94,10 @@ import { oauthPromptAction, useOAuthReturn } from "./oauth-return.js";
93
94
  export function viewPropsWithThemeAnnounce(
94
95
  viewProps: TranscriptItemContext["viewProps"],
95
96
  mode: ThemeMode,
96
- defaults: Pick<ViewSlotProps, "onCallTool" | "onUpdateModelContext" | "onUserMessage"> = {},
97
+ defaults: Pick<
98
+ ViewSlotProps,
99
+ "onCallTool" | "onUpdateModelContext" | "onUserMessage" | "onOpenLink"
100
+ > = {},
97
101
  ): TranscriptItemContext["viewProps"] {
98
102
  const themed = (base: ViewSlotProps | undefined): ViewSlotProps => ({
99
103
  // Kit-default host wires (guuey#335): the ACTION RELAY (Confirm inside
@@ -166,6 +170,28 @@ export interface GuueyChatHandle {
166
170
  * to the transcript).
167
171
  */
168
172
  viewSlotProps(): ViewSlotProps;
173
+ /**
174
+ * Forget this conversation (guuey#526 — the public-computer story).
175
+ *
176
+ * Local half, unconditional and immediate: aborts any in-flight turn
177
+ * FIRST (a completing turn can re-persist its own rows), drops the
178
+ * durable thread pointer AND its persisted storage key, wipes the
179
+ * visible transcript, clears the typed draft and any pending link ask —
180
+ * the next send mints a fresh thread.
181
+ *
182
+ * Server half, best-effort: when the surface has a platform door
183
+ * (`apiBaseUrl`) and a minted thread, fires
184
+ * `DELETE /v1/threads/:threadId` with the SAME identity the thread was
185
+ * created under (captured before any rotation) — real, child-first
186
+ * server-side erasure. A denied or failed delete still clears the
187
+ * device (the contract's unlinkability fallback) and surfaces nothing
188
+ * louder than the `thread-delete` debug event.
189
+ *
190
+ * Afterwards `onGuestSecretRotate` fires (when wired), so the identity
191
+ * owner mints a fresh guest secret — even an orphaned server row is
192
+ * unreachable from a rotated identity.
193
+ */
194
+ clearConversation(): void;
169
195
  }
170
196
 
171
197
  export interface GuueyChatProps {
@@ -214,6 +240,19 @@ export interface GuueyChatProps {
214
240
  strings?: Partial<ChatStrings>;
215
241
  theme?: GuueyChatTheme;
216
242
  mode?: ThemeMode;
243
+ /**
244
+ * Message-surface presentation (guuey#521). `"card"` (default) keeps
245
+ * assistant text on its own card; `"bare"` sits the transcript directly
246
+ * on the host's background so the embed reads native to the page.
247
+ */
248
+ surface?: "card" | "bare";
249
+ /**
250
+ * Render the built-in composer (default `true`). `false` hides it for
251
+ * hosts that drive the conversation through the imperative handle
252
+ * (guuey#210 `ask`/`compose`) or their own input — pairs with the
253
+ * handle instead of forcing a CSS hide (guuey#521).
254
+ */
255
+ composer?: boolean;
217
256
  /** DOM windowing (§3.2). `false` renders everything. */
218
257
  window?: TranscriptWindowing | false;
219
258
  /**
@@ -222,6 +261,39 @@ export interface GuueyChatProps {
222
261
  * pod door) when omitted; an explicit reader always wins.
223
262
  */
224
263
  reader?: UiResourceReader;
264
+ /**
265
+ * The forget-this-device affordance (guuey#526 — the public-computer
266
+ * story): a quiet "Clear conversation" control with a two-tap confirm,
267
+ * wired to {@link GuueyChatHandle.clearConversation}. Default: ON when
268
+ * the surface runs GUEST identity (`getGuestSecret` present — the
269
+ * founder's seamless principle; a signed-in surface has account-level
270
+ * recourse), OFF otherwise; pass a boolean to override either way.
271
+ */
272
+ clearAffordance?: boolean;
273
+ /**
274
+ * Guest-secret rotation hook (guuey#526): called at the END of every
275
+ * {@link GuueyChatHandle.clearConversation} — after the server delete
276
+ * has been dispatched with the OLD identity — so the owner of the
277
+ * secret's storage (the kit never stores it; `getGuestSecret` is a
278
+ * getter) mints a fresh one. Rotation is what makes even an orphaned
279
+ * server row unreachable: the new identity is a stranger to it. Guest
280
+ * KV state is (user,mcp)-scoped and is orphaned by rotation, by design.
281
+ */
282
+ onGuestSecretRotate?: () => void;
283
+ /**
284
+ * Suggestion chips (guuey#533 — declared content, the one-truth design):
285
+ * rendered on the EMPTY transcript only (they retire the moment a
286
+ * message exists — live send or restored history both count), one tap
287
+ * SENDS the chip text verbatim through exactly the Send button's gate.
288
+ * Default-ON when content exists; `false` opts the surface out. The kit
289
+ * mirrors the card projection's caps render-side (4 shown, ≤80 chars
290
+ * each, blank-rejected) — a direct consumer bypassing the platform's
291
+ * emit gate gets the same bounds. Content is the host's (the widget
292
+ * passes the card's `suggestions`; a direct consumer passes its own);
293
+ * the kit never invents chips — an honest empty state beats invented
294
+ * questions in the builder's voice.
295
+ */
296
+ suggestions?: readonly string[] | false;
225
297
  /** The debug sink (spec §5) — fires only under the debug policy. */
226
298
  onDebugEvent?: (event: ChatDebugEvent) => void;
227
299
  /** R6 pass-through (relay hook, sandbox page/flags, host context…). */
@@ -323,6 +395,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
323
395
  strings: stringOverrides,
324
396
  theme = DEFAULT_CHAT_THEME,
325
397
  mode = "light",
398
+ surface = "card",
399
+ composer = true,
326
400
  window: windowing,
327
401
  reader,
328
402
  onDebugEvent,
@@ -335,6 +409,9 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
335
409
  oauthReturnTo,
336
410
  onOAuthAuthorize,
337
411
  onErrorAction,
412
+ clearAffordance,
413
+ onGuestSecretRotate,
414
+ suggestions,
338
415
  onActivity,
339
416
  onReady,
340
417
  onThread,
@@ -352,8 +429,14 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
352
429
  // host back → setState → re-render → "Maximum update depth exceeded".
353
430
  const getAccessTokenRef = useRef(getAccessToken);
354
431
  getAccessTokenRef.current = getAccessToken;
432
+ // The handle is created once ([] deps) but must read the LIVE base —
433
+ // same ref discipline as every other handle-read value in this file.
434
+ const apiBaseUrlRef = useRef(apiBaseUrl);
435
+ apiBaseUrlRef.current = apiBaseUrl;
355
436
  const getGuestSecretRef = useRef(getGuestSecret);
356
437
  getGuestSecretRef.current = getGuestSecret;
438
+ const onGuestSecretRotateRef = useRef(onGuestSecretRotate);
439
+ onGuestSecretRotateRef.current = onGuestSecretRotate;
357
440
  const hasAccessToken = getAccessToken !== undefined;
358
441
  const hasGuestSecret = getGuestSecret !== undefined;
359
442
 
@@ -621,14 +704,64 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
621
704
  void live.invoke.send(next).catch(() => {});
622
705
  }, [busy]);
623
706
 
707
+ // guuey#522: the kit default for `ui/open-link` NEVER navigates
708
+ // sight-unseen — the ask surfaces as a disclosure affordance (host +
709
+ // full URL + Open/Dismiss) and the OPEN is the human's own anchor
710
+ // click, so the browser's activation gate and the review posture agree
711
+ // by construction. ONE pending ask at a time, newest replaces (a
712
+ // card spamming asks can never grow an unbounded stack).
713
+ const [pendingLink, setPendingLink] = useState<string | null>(null);
714
+ // guuey#526: the two-tap confirm window for the clear affordance (an
715
+ // accidental tap must not nuke a conversation; a second tap within the
716
+ // window does — the state auto-expires).
717
+ const [clearArmed, setClearArmed] = useState(false);
718
+ useEffect(() => {
719
+ if (!clearArmed) return;
720
+ const timer = setTimeout(() => setClearArmed(false), 4000);
721
+ return () => clearTimeout(timer);
722
+ }, [clearArmed]);
723
+ const showClearAffordance = clearAffordance ?? getGuestSecret !== undefined;
724
+ // guuey#533: the render-side mirror of the card projection's emit caps
725
+ // (4 shown / <=80 chars / blank-rejected) — same defense posture as the
726
+ // table normalizer: the kit is public npm and cannot assume every host
727
+ // routed content through platform's write gate.
728
+ const chips = useMemo<readonly string[]>(() => {
729
+ if (suggestions === false || suggestions === undefined) return [];
730
+ return suggestions
731
+ .map((c) => c.trim())
732
+ .filter((c) => c.length > 0 && c.length <= 80)
733
+ .slice(0, 4);
734
+ }, [suggestions]);
735
+ const sendSuggestion = useCallback((text: string): void => {
736
+ const live = liveRef.current;
737
+ // Exactly the Send button's gate (the #210 rule): unavailable, busy,
738
+ // or blank -> the tap is a no-op, never a bypass. The typed draft is
739
+ // left untouched (a chip send must not eat a half-typed message).
740
+ if (!live.available || live.busy || text.trim() === "") return;
741
+ void live.invoke.send(text).catch(() => {
742
+ // Same contract as submit: the hook owns failure surfacing.
743
+ });
744
+ }, []);
745
+ const defaultOnOpenLink = useCallback((url: string) => {
746
+ setPendingLink(url);
747
+ }, []);
748
+
624
749
  const effectiveViewProps = useMemo<TranscriptItemContext["viewProps"]>(
625
750
  () =>
626
751
  viewPropsWithThemeAnnounce(viewProps, mode, {
627
752
  ...(stagedDefaultOnCallTool !== undefined ? { onCallTool: stagedDefaultOnCallTool } : {}),
628
753
  onUpdateModelContext: defaultOnUpdateModelContext,
629
754
  onUserMessage: defaultOnUserMessage,
755
+ onOpenLink: defaultOnOpenLink,
630
756
  }),
631
- [viewProps, mode, stagedDefaultOnCallTool, defaultOnUpdateModelContext, defaultOnUserMessage],
757
+ [
758
+ viewProps,
759
+ mode,
760
+ stagedDefaultOnCallTool,
761
+ defaultOnUpdateModelContext,
762
+ defaultOnUserMessage,
763
+ defaultOnOpenLink,
764
+ ],
632
765
  );
633
766
 
634
767
  // The canvas-host door (guuey#335): a host mounting views DIRECTLY from
@@ -644,6 +777,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
644
777
  ...(stagedDefaultOnCallTool !== undefined ? { onCallTool: stagedDefaultOnCallTool } : {}),
645
778
  onUpdateModelContext: defaultOnUpdateModelContext,
646
779
  onUserMessage: defaultOnUserMessage,
780
+ onOpenLink: defaultOnOpenLink,
647
781
  hostContext: { theme: mode },
648
782
  }
649
783
  : (effectiveViewProps ?? {});
@@ -671,6 +805,48 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
671
805
  focusComposer: (): void => {
672
806
  inputRef.current?.focus();
673
807
  },
808
+ clearConversation: (): void => {
809
+ const live = liveRef.current;
810
+ // Contract ordering (guuey#526, platform's frozen comment): abort
811
+ // FIRST — a completing turn can re-persist its own rows after the
812
+ // delete. Capture the id + guest secret BEFORE anything clears or
813
+ // rotates them: the delete must authenticate as the identity that
814
+ // OWNS the thread.
815
+ live.invoke.abort();
816
+ const threadId = threadIdRef.current;
817
+ const getGuest = getGuestSecretRef.current;
818
+ const guestSecret = getGuest !== undefined ? getGuest() : null;
819
+ if (apiBaseUrlRef.current !== undefined && threadId !== null) {
820
+ // Best-effort server erasure — fire-and-forget, never blocks the
821
+ // local clear. 200 and 404 are both "deleted" (idempotent by
822
+ // contract); denied/failed still cleared locally below (the
823
+ // unlinkability fallback) and surface ONLY as a debug line.
824
+ void deleteThread({
825
+ apiBaseUrl: apiBaseUrlRef.current,
826
+ threadId,
827
+ ...(getAccessTokenRef.current !== undefined
828
+ ? { getAccessToken: getAccessTokenRef.current }
829
+ : {}),
830
+ guestSecret,
831
+ }).then((outcome) => {
832
+ onDebugEventRef.current?.({ type: "thread-delete", threadId, outcome });
833
+ });
834
+ } else {
835
+ onDebugEventRef.current?.({ type: "thread-delete", threadId, outcome: "skipped" });
836
+ }
837
+ // The hook's reset owns the durable local half (pointer + storage
838
+ // key, transcript/fold/cards); the kit clears ITS OWN residue —
839
+ // the typed draft, a pending link ask, queued doorbells — so the
840
+ // device holds nothing of the conversation.
841
+ live.invoke.reset();
842
+ setInput("");
843
+ setPendingLink(null);
844
+ pendingDoorbellsRef.current = [];
845
+ // Rotation LAST: the delete above already captured the old
846
+ // identity, so the fresh secret is a stranger to even an orphaned
847
+ // server row.
848
+ onGuestSecretRotateRef.current?.();
849
+ },
674
850
  // A getter, not a captured value: the handle is created once, but the
675
851
  // thread hydrates after mount and can change — reads go through the
676
852
  // same ref the default reader uses, so it is always the live id.
@@ -777,6 +953,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
777
953
  strings={strings}
778
954
  theme={theme}
779
955
  mode={mode}
956
+ surface={surface}
780
957
  {...(windowing !== undefined ? { window: windowing } : {})}
781
958
  {...(components !== undefined ? { components } : {})}
782
959
  onToggle={toggle}
@@ -789,6 +966,62 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
789
966
  {...(onViewRef !== undefined ? { onViewRef } : {})}
790
967
  viewProps={effectiveViewProps}
791
968
  />
969
+ {chips.length > 0 && inputs.messages.length === 0 && (
970
+ <nav className="guuey-chat-chips-row" aria-label={strings.suggestionsLabel}>
971
+ {chips.map((chip) => (
972
+ <button
973
+ key={chip}
974
+ type="button"
975
+ className="guuey-chat-chip"
976
+ onClick={() => sendSuggestion(chip)}
977
+ >
978
+ {chip}
979
+ </button>
980
+ ))}
981
+ </nav>
982
+ )}
983
+ {showClearAffordance && inputs.messages.length > 0 && (
984
+ <div className="guuey-chat-clear-row">
985
+ <button
986
+ type="button"
987
+ className={`guuey-chat-clear${clearArmed ? " guuey-chat-clear-armed" : ""}`}
988
+ onClick={() => {
989
+ if (clearArmed) {
990
+ setClearArmed(false);
991
+ handle.clearConversation();
992
+ } else {
993
+ setClearArmed(true);
994
+ }
995
+ }}
996
+ >
997
+ {clearArmed ? strings.clearConversationConfirm : strings.clearConversationLabel}
998
+ </button>
999
+ </div>
1000
+ )}
1001
+ {pendingLink !== null && (
1002
+ <div role="status" className="guuey-chat-link-ask">
1003
+ <span className="guuey-chat-link-ask-label">
1004
+ {strings.linkAskLabel(new URL(pendingLink).host)}
1005
+ </span>
1006
+ <span className="guuey-chat-link-ask-url">{pendingLink}</span>
1007
+ <a
1008
+ className="guuey-chat-link-ask-open"
1009
+ href={pendingLink}
1010
+ target="_blank"
1011
+ rel="noopener noreferrer"
1012
+ onClick={() => setPendingLink(null)}
1013
+ >
1014
+ {strings.linkOpen}
1015
+ </a>
1016
+ <button
1017
+ type="button"
1018
+ className="guuey-chat-link-ask-dismiss"
1019
+ onClick={() => setPendingLink(null)}
1020
+ >
1021
+ {strings.linkDismiss}
1022
+ </button>
1023
+ </div>
1024
+ )}
792
1025
  {oauthReturn.notice !== null && (
793
1026
  <p
794
1027
  role="status"
@@ -802,6 +1035,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
802
1035
  </button>
803
1036
  </p>
804
1037
  )}
1038
+ {composer && (
805
1039
  <form
806
1040
  className="guuey-chat-composer"
807
1041
  onSubmit={(e) => {
@@ -843,6 +1077,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
843
1077
  </button>
844
1078
  )}
845
1079
  </form>
1080
+ )}
846
1081
  </div>
847
1082
  );
848
1083
  });
@@ -24,6 +24,7 @@
24
24
  * Streaming-tolerant by upstream design: unclosed emphasis mid-delta
25
25
  * renders as formatted-so-far.
26
26
  */
27
+ import { useState } from "react";
27
28
  import type { ReactNode } from "react";
28
29
  import {
29
30
  parseRichText,
@@ -32,11 +33,75 @@ import {
32
33
  } from "@silverprotocol/richtext";
33
34
  import { normalizeTableRow, type RichTextTableBlock } from "../richtext-table.js";
34
35
 
36
+ /**
37
+ * Bare-URL autolink (guuey#515): richtext mints `link` nodes only from
38
+ * explicit `[text](url)` markdown, so a plain "https://…" in agent prose
39
+ * parses as a text node and rendered as untappable static text — on a
40
+ * phone that means retyping a URL out of a chat bubble. Linkify at the
41
+ * render boundary, conservatively:
42
+ * - the scheme is part of the match (http/https only), so no
43
+ * `javascript:`/`data:` target can ever qualify — the SAFE_HREF
44
+ * property holds by construction;
45
+ * - built as React elements (no HTML string exists to inject into),
46
+ * same `rel`/`target` contract as explicit links;
47
+ * - trailing punctuation stays prose ("see https://guuey.com." must not
48
+ * link the dot), while a balanced closing paren/bracket stays in the
49
+ * URL (wiki-style paths);
50
+ * - schemeless "www.foo.com" is deliberately NOT linkified — guessing
51
+ * schemes is how autolinkers start lying;
52
+ * - code spans are untouched (they render `node.code`, not this path).
53
+ */
54
+ const BARE_URL = /https?:\/\/[^\s<>]+/g;
55
+
56
+ function trimTrailingPunctuation(url: string): string {
57
+ for (;;) {
58
+ const last = url[url.length - 1];
59
+ if (last === undefined) return url;
60
+ if (last === ")" || last === "]") {
61
+ const open = last === ")" ? "(" : "[";
62
+ const opens = url.split(open).length - 1;
63
+ const closes = url.split(last).length - 1;
64
+ if (closes > opens) {
65
+ url = url.slice(0, -1);
66
+ continue;
67
+ }
68
+ return url;
69
+ }
70
+ if (".,;:!?'\"»›".includes(last)) {
71
+ url = url.slice(0, -1);
72
+ continue;
73
+ }
74
+ return url;
75
+ }
76
+ }
77
+
78
+ function linkify(text: string): ReactNode {
79
+ if (!text.includes("http")) return text;
80
+ const out: ReactNode[] = [];
81
+ let consumed = 0;
82
+ for (const match of text.matchAll(BARE_URL)) {
83
+ const start = match.index;
84
+ if (start === undefined) continue;
85
+ const url = trimTrailingPunctuation(match[0]);
86
+ if (url.length === 0) continue;
87
+ if (start > consumed) out.push(text.slice(consumed, start));
88
+ out.push(
89
+ <a key={`url-${start}`} href={url} target="_blank" rel="noopener noreferrer">
90
+ {url}
91
+ </a>,
92
+ );
93
+ consumed = start + url.length;
94
+ }
95
+ if (out.length === 0) return text;
96
+ if (consumed < text.length) out.push(text.slice(consumed));
97
+ return out;
98
+ }
99
+
35
100
  function Inline({ nodes }: { nodes: RichTextInline[] }): ReactNode {
36
101
  return nodes.map((node, i) => {
37
102
  switch (node.type) {
38
103
  case "text":
39
- return <span key={i}>{node.text}</span>;
104
+ return <span key={i}>{linkify(node.text)}</span>;
40
105
  case "break":
41
106
  return <br key={i} />;
42
107
  case "strong":
@@ -124,7 +189,7 @@ function Block({ block }: { block: RichTextBlock }): ReactNode {
124
189
  </p>
125
190
  );
126
191
  case "code-fence":
127
- return <pre className="guuey-chat-code-fence">{block.code}</pre>;
192
+ return <CodeFence code={block.code} />;
128
193
  case "list": {
129
194
  const Tag = block.ordered ? "ol" : "ul";
130
195
  return (
@@ -140,6 +205,43 @@ function Block({ block }: { block: RichTextBlock }): ReactNode {
140
205
  }
141
206
  }
142
207
 
208
+ /**
209
+ * A fenced code block with its copy affordance (guuey#529 — the
210
+ * seamless-default family: every embedder's visitors get it with zero
211
+ * setup). The button lives in a wrapper DIV so the `<pre>` holds ONLY the
212
+ * code (select-all inside the fence never picks up UI text); the
213
+ * transcript's sibling-margin rules name the wrapper class alongside
214
+ * `pre`. Copy failure is shown truthfully: the label simply stays "Copy"
215
+ * (clipboard access needs a secure context + the iframe's
216
+ * clipboard-write grant — manual selection always still works).
217
+ */
218
+ function CodeFence({ code }: { code: string }): ReactNode {
219
+ const [copied, setCopied] = useState(false);
220
+ return (
221
+ <div className="guuey-chat-code-wrap">
222
+ <pre className="guuey-chat-code-fence">{code}</pre>
223
+ <button
224
+ type="button"
225
+ className="guuey-chat-code-copy"
226
+ aria-label={copied ? "Copied" : "Copy code"}
227
+ onClick={() => {
228
+ const clipboard = navigator.clipboard;
229
+ if (clipboard === undefined) return;
230
+ clipboard.writeText(code).then(
231
+ () => {
232
+ setCopied(true);
233
+ setTimeout(() => setCopied(false), 1600);
234
+ },
235
+ () => setCopied(false),
236
+ );
237
+ }}
238
+ >
239
+ {copied ? "Copied" : "Copy"}
240
+ </button>
241
+ </div>
242
+ );
243
+ }
244
+
143
245
  /** Sanitized markdown → React elements (see the module docblock). */
144
246
  export function Markdown({ text }: { text: string }): ReactNode {
145
247
  return (
@@ -24,27 +24,38 @@ const DENSITY_GAP: Record<GuueyChatTheme["shape"]["density"], string> = {
24
24
  * One mode's tokens as inline custom properties for the transcript root.
25
25
  * Returned as a plain record so callers can spread it into `style` or emit
26
26
  * a stylesheet from it.
27
+ *
28
+ * Stamped under INTERNAL names (`--_guuey-chat-*`), never the documented
29
+ * `--guuey-chat-*` channel (guuey#521): the stylesheet reads every token
30
+ * as `var(--guuey-chat-<t>, var(--_guuey-chat-<t>))`, so a host CSS
31
+ * variable — set on any ancestor — wins per-token over the resolved
32
+ * theme, and the two theming channels COMPOSE instead of the inline
33
+ * stamp silently shadowing the documented one (the trap the landing
34
+ * home-hero embed hit four rounds of).
27
35
  */
28
36
  export function themeCssVars(theme: GuueyChatTheme, mode: ThemeMode): Record<string, string> {
29
37
  const palette = theme.colors[mode];
30
38
  const vars: Record<string, string> = {
31
- "--guuey-chat-accent": palette.accent,
32
- "--guuey-chat-on-accent": palette.onAccent,
33
- "--guuey-chat-ink": palette.ink,
34
- "--guuey-chat-ink-muted": palette.inkMuted,
35
- "--guuey-chat-surface": palette.surface,
36
- "--guuey-chat-canvas": palette.canvas,
37
- "--guuey-chat-canvas-muted": palette.canvasMuted,
38
- "--guuey-chat-error": palette.error,
39
- "--guuey-chat-radius": RADIUS_PX[theme.shape.radius],
40
- "--guuey-chat-gap": DENSITY_GAP[theme.shape.density],
41
- "--guuey-chat-scale": String(theme.typography.scale ?? 1),
39
+ "--_guuey-chat-accent": palette.accent,
40
+ "--_guuey-chat-on-accent": palette.onAccent,
41
+ "--_guuey-chat-ink": palette.ink,
42
+ "--_guuey-chat-ink-muted": palette.inkMuted,
43
+ "--_guuey-chat-surface": palette.surface,
44
+ "--_guuey-chat-canvas": palette.canvas,
45
+ "--_guuey-chat-canvas-muted": palette.canvasMuted,
46
+ "--_guuey-chat-error": palette.error,
47
+ "--_guuey-chat-radius": RADIUS_PX[theme.shape.radius],
48
+ "--_guuey-chat-gap": DENSITY_GAP[theme.shape.density],
49
+ "--_guuey-chat-scale": String(theme.typography.scale ?? 1),
42
50
  };
51
+ if (palette.link !== undefined) {
52
+ vars["--_guuey-chat-link"] = palette.link;
53
+ }
43
54
  if (theme.typography.fontFamily !== undefined) {
44
- vars["--guuey-chat-font"] = theme.typography.fontFamily;
55
+ vars["--_guuey-chat-font"] = theme.typography.fontFamily;
45
56
  }
46
57
  if (theme.typography.monoFontFamily !== undefined) {
47
- vars["--guuey-chat-mono-font"] = theme.typography.monoFontFamily;
58
+ vars["--_guuey-chat-mono-font"] = theme.typography.monoFontFamily;
48
59
  }
49
60
  return vars;
50
61
  }
@@ -63,6 +63,14 @@ export interface TranscriptProps
63
63
  strings?: ChatStrings;
64
64
  theme?: GuueyChatTheme;
65
65
  mode?: ThemeMode;
66
+ /**
67
+ * Message-surface presentation (guuey#521). `"card"` (default) renders
68
+ * assistant text on its own surface-tinted, hairline-bordered card;
69
+ * `"bare"` sits the transcript directly on the host's background — the
70
+ * embed disappears into the page it lives on. The user pill stays a
71
+ * bubble in both.
72
+ */
73
+ surface?: "card" | "bare";
66
74
  /** DOM windowing (§3.2). `false` renders everything. Default tail 80. */
67
75
  window?: TranscriptWindowing | false;
68
76
  className?: string;
@@ -76,6 +84,7 @@ export function Transcript(props: TranscriptProps): ReactNode {
76
84
  strings = defaultChatStrings,
77
85
  theme = DEFAULT_CHAT_THEME,
78
86
  mode = "light",
87
+ surface = "card",
79
88
  window: windowing = { tail: 80 },
80
89
  className,
81
90
  style,
@@ -169,7 +178,7 @@ export function Transcript(props: TranscriptProps): ReactNode {
169
178
 
170
179
  return (
171
180
  <div
172
- className={`guuey-chat${className !== undefined ? ` ${className}` : ""}`}
181
+ className={`guuey-chat${surface === "bare" ? " guuey-chat--bare" : ""}${className !== undefined ? ` ${className}` : ""}`}
173
182
  style={rootStyle}
174
183
  data-guuey-chat-mode={mode}
175
184
  >
package/src/strings.ts CHANGED
@@ -128,6 +128,28 @@ export interface ChatStrings {
128
128
  /** R16 — the notice row's label (provenance shows only under debug). */
129
129
  noticeLabel: string;
130
130
 
131
+ /**
132
+ * The `ui/open-link` disclosure affordance (guuey#522): a card asked to
133
+ * open a URL — the kit shows WHERE before anything navigates, and the
134
+ * human's own click on `linkOpen` is the only door out.
135
+ */
136
+ linkAskLabel: (host: string) => string;
137
+ linkOpen: string;
138
+ linkDismiss: string;
139
+
140
+ /**
141
+ * The forget-this-device affordance (guuey#526): the quiet clear
142
+ * control and its two-tap confirm face.
143
+ */
144
+ clearConversationLabel: string;
145
+ clearConversationConfirm: string;
146
+
147
+ /**
148
+ * The suggestion-chip row's accessible name (guuey#533) — the chips
149
+ * themselves are declared content (the app's own words), never kit copy.
150
+ */
151
+ suggestionsLabel: string;
152
+
131
153
  /** The 3c composer (`<GuueyChat>`). */
132
154
  composerPlaceholder: string;
133
155
  composerUnavailable: string;
@@ -233,6 +255,14 @@ export const defaultChatStrings: ChatStrings = {
233
255
 
234
256
  noticeLabel: "Note",
235
257
 
258
+ linkAskLabel: (host) => `This card wants to open ${host}`,
259
+ linkOpen: "Open",
260
+ linkDismiss: "Dismiss",
261
+
262
+ clearConversationLabel: "Clear conversation",
263
+ suggestionsLabel: "Suggestions",
264
+ clearConversationConfirm: "Tap again to clear this device",
265
+
236
266
  composerPlaceholder: "Message the agent…",
237
267
  composerUnavailable: "Chat is unavailable.",
238
268
  composerLabel: "Message",