@guuey/chat 0.16.8 → 0.16.9

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.
@@ -93,7 +93,10 @@ import { oauthPromptAction, useOAuthReturn } from "./oauth-return.js";
93
93
  export function viewPropsWithThemeAnnounce(
94
94
  viewProps: TranscriptItemContext["viewProps"],
95
95
  mode: ThemeMode,
96
- defaults: Pick<ViewSlotProps, "onCallTool" | "onUpdateModelContext" | "onUserMessage"> = {},
96
+ defaults: Pick<
97
+ ViewSlotProps,
98
+ "onCallTool" | "onUpdateModelContext" | "onUserMessage" | "onOpenLink"
99
+ > = {},
97
100
  ): TranscriptItemContext["viewProps"] {
98
101
  const themed = (base: ViewSlotProps | undefined): ViewSlotProps => ({
99
102
  // Kit-default host wires (guuey#335): the ACTION RELAY (Confirm inside
@@ -214,6 +217,19 @@ export interface GuueyChatProps {
214
217
  strings?: Partial<ChatStrings>;
215
218
  theme?: GuueyChatTheme;
216
219
  mode?: ThemeMode;
220
+ /**
221
+ * Message-surface presentation (guuey#521). `"card"` (default) keeps
222
+ * assistant text on its own card; `"bare"` sits the transcript directly
223
+ * on the host's background so the embed reads native to the page.
224
+ */
225
+ surface?: "card" | "bare";
226
+ /**
227
+ * Render the built-in composer (default `true`). `false` hides it for
228
+ * hosts that drive the conversation through the imperative handle
229
+ * (guuey#210 `ask`/`compose`) or their own input — pairs with the
230
+ * handle instead of forcing a CSS hide (guuey#521).
231
+ */
232
+ composer?: boolean;
217
233
  /** DOM windowing (§3.2). `false` renders everything. */
218
234
  window?: TranscriptWindowing | false;
219
235
  /**
@@ -323,6 +339,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
323
339
  strings: stringOverrides,
324
340
  theme = DEFAULT_CHAT_THEME,
325
341
  mode = "light",
342
+ surface = "card",
343
+ composer = true,
326
344
  window: windowing,
327
345
  reader,
328
346
  onDebugEvent,
@@ -621,14 +639,33 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
621
639
  void live.invoke.send(next).catch(() => {});
622
640
  }, [busy]);
623
641
 
642
+ // guuey#522: the kit default for `ui/open-link` NEVER navigates
643
+ // sight-unseen — the ask surfaces as a disclosure affordance (host +
644
+ // full URL + Open/Dismiss) and the OPEN is the human's own anchor
645
+ // click, so the browser's activation gate and the review posture agree
646
+ // by construction. ONE pending ask at a time, newest replaces (a
647
+ // card spamming asks can never grow an unbounded stack).
648
+ const [pendingLink, setPendingLink] = useState<string | null>(null);
649
+ const defaultOnOpenLink = useCallback((url: string) => {
650
+ setPendingLink(url);
651
+ }, []);
652
+
624
653
  const effectiveViewProps = useMemo<TranscriptItemContext["viewProps"]>(
625
654
  () =>
626
655
  viewPropsWithThemeAnnounce(viewProps, mode, {
627
656
  ...(stagedDefaultOnCallTool !== undefined ? { onCallTool: stagedDefaultOnCallTool } : {}),
628
657
  onUpdateModelContext: defaultOnUpdateModelContext,
629
658
  onUserMessage: defaultOnUserMessage,
659
+ onOpenLink: defaultOnOpenLink,
630
660
  }),
631
- [viewProps, mode, stagedDefaultOnCallTool, defaultOnUpdateModelContext, defaultOnUserMessage],
661
+ [
662
+ viewProps,
663
+ mode,
664
+ stagedDefaultOnCallTool,
665
+ defaultOnUpdateModelContext,
666
+ defaultOnUserMessage,
667
+ defaultOnOpenLink,
668
+ ],
632
669
  );
633
670
 
634
671
  // The canvas-host door (guuey#335): a host mounting views DIRECTLY from
@@ -644,6 +681,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
644
681
  ...(stagedDefaultOnCallTool !== undefined ? { onCallTool: stagedDefaultOnCallTool } : {}),
645
682
  onUpdateModelContext: defaultOnUpdateModelContext,
646
683
  onUserMessage: defaultOnUserMessage,
684
+ onOpenLink: defaultOnOpenLink,
647
685
  hostContext: { theme: mode },
648
686
  }
649
687
  : (effectiveViewProps ?? {});
@@ -777,6 +815,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
777
815
  strings={strings}
778
816
  theme={theme}
779
817
  mode={mode}
818
+ surface={surface}
780
819
  {...(windowing !== undefined ? { window: windowing } : {})}
781
820
  {...(components !== undefined ? { components } : {})}
782
821
  onToggle={toggle}
@@ -789,6 +828,30 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
789
828
  {...(onViewRef !== undefined ? { onViewRef } : {})}
790
829
  viewProps={effectiveViewProps}
791
830
  />
831
+ {pendingLink !== null && (
832
+ <div role="status" className="guuey-chat-link-ask">
833
+ <span className="guuey-chat-link-ask-label">
834
+ {strings.linkAskLabel(new URL(pendingLink).host)}
835
+ </span>
836
+ <span className="guuey-chat-link-ask-url">{pendingLink}</span>
837
+ <a
838
+ className="guuey-chat-link-ask-open"
839
+ href={pendingLink}
840
+ target="_blank"
841
+ rel="noopener noreferrer"
842
+ onClick={() => setPendingLink(null)}
843
+ >
844
+ {strings.linkOpen}
845
+ </a>
846
+ <button
847
+ type="button"
848
+ className="guuey-chat-link-ask-dismiss"
849
+ onClick={() => setPendingLink(null)}
850
+ >
851
+ {strings.linkDismiss}
852
+ </button>
853
+ </div>
854
+ )}
792
855
  {oauthReturn.notice !== null && (
793
856
  <p
794
857
  role="status"
@@ -802,6 +865,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
802
865
  </button>
803
866
  </p>
804
867
  )}
868
+ {composer && (
805
869
  <form
806
870
  className="guuey-chat-composer"
807
871
  onSubmit={(e) => {
@@ -843,6 +907,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
843
907
  </button>
844
908
  )}
845
909
  </form>
910
+ )}
846
911
  </div>
847
912
  );
848
913
  });
@@ -32,11 +32,75 @@ import {
32
32
  } from "@silverprotocol/richtext";
33
33
  import { normalizeTableRow, type RichTextTableBlock } from "../richtext-table.js";
34
34
 
35
+ /**
36
+ * Bare-URL autolink (guuey#515): richtext mints `link` nodes only from
37
+ * explicit `[text](url)` markdown, so a plain "https://…" in agent prose
38
+ * parses as a text node and rendered as untappable static text — on a
39
+ * phone that means retyping a URL out of a chat bubble. Linkify at the
40
+ * render boundary, conservatively:
41
+ * - the scheme is part of the match (http/https only), so no
42
+ * `javascript:`/`data:` target can ever qualify — the SAFE_HREF
43
+ * property holds by construction;
44
+ * - built as React elements (no HTML string exists to inject into),
45
+ * same `rel`/`target` contract as explicit links;
46
+ * - trailing punctuation stays prose ("see https://guuey.com." must not
47
+ * link the dot), while a balanced closing paren/bracket stays in the
48
+ * URL (wiki-style paths);
49
+ * - schemeless "www.foo.com" is deliberately NOT linkified — guessing
50
+ * schemes is how autolinkers start lying;
51
+ * - code spans are untouched (they render `node.code`, not this path).
52
+ */
53
+ const BARE_URL = /https?:\/\/[^\s<>]+/g;
54
+
55
+ function trimTrailingPunctuation(url: string): string {
56
+ for (;;) {
57
+ const last = url[url.length - 1];
58
+ if (last === undefined) return url;
59
+ if (last === ")" || last === "]") {
60
+ const open = last === ")" ? "(" : "[";
61
+ const opens = url.split(open).length - 1;
62
+ const closes = url.split(last).length - 1;
63
+ if (closes > opens) {
64
+ url = url.slice(0, -1);
65
+ continue;
66
+ }
67
+ return url;
68
+ }
69
+ if (".,;:!?'\"»›".includes(last)) {
70
+ url = url.slice(0, -1);
71
+ continue;
72
+ }
73
+ return url;
74
+ }
75
+ }
76
+
77
+ function linkify(text: string): ReactNode {
78
+ if (!text.includes("http")) return text;
79
+ const out: ReactNode[] = [];
80
+ let consumed = 0;
81
+ for (const match of text.matchAll(BARE_URL)) {
82
+ const start = match.index;
83
+ if (start === undefined) continue;
84
+ const url = trimTrailingPunctuation(match[0]);
85
+ if (url.length === 0) continue;
86
+ if (start > consumed) out.push(text.slice(consumed, start));
87
+ out.push(
88
+ <a key={`url-${start}`} href={url} target="_blank" rel="noopener noreferrer">
89
+ {url}
90
+ </a>,
91
+ );
92
+ consumed = start + url.length;
93
+ }
94
+ if (out.length === 0) return text;
95
+ if (consumed < text.length) out.push(text.slice(consumed));
96
+ return out;
97
+ }
98
+
35
99
  function Inline({ nodes }: { nodes: RichTextInline[] }): ReactNode {
36
100
  return nodes.map((node, i) => {
37
101
  switch (node.type) {
38
102
  case "text":
39
- return <span key={i}>{node.text}</span>;
103
+ return <span key={i}>{linkify(node.text)}</span>;
40
104
  case "break":
41
105
  return <br key={i} />;
42
106
  case "strong":
@@ -24,27 +24,35 @@ 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
  };
43
51
  if (theme.typography.fontFamily !== undefined) {
44
- vars["--guuey-chat-font"] = theme.typography.fontFamily;
52
+ vars["--_guuey-chat-font"] = theme.typography.fontFamily;
45
53
  }
46
54
  if (theme.typography.monoFontFamily !== undefined) {
47
- vars["--guuey-chat-mono-font"] = theme.typography.monoFontFamily;
55
+ vars["--_guuey-chat-mono-font"] = theme.typography.monoFontFamily;
48
56
  }
49
57
  return vars;
50
58
  }
@@ -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,15 @@ 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
+
131
140
  /** The 3c composer (`<GuueyChat>`). */
132
141
  composerPlaceholder: string;
133
142
  composerUnavailable: string;
@@ -233,6 +242,10 @@ export const defaultChatStrings: ChatStrings = {
233
242
 
234
243
  noticeLabel: "Note",
235
244
 
245
+ linkAskLabel: (host) => `This card wants to open ${host}`,
246
+ linkOpen: "Open",
247
+ linkDismiss: "Dismiss",
248
+
236
249
  composerPlaceholder: "Message the agent…",
237
250
  composerUnavailable: "Chat is unavailable.",
238
251
  composerLabel: "Message",
package/src/theme.ts CHANGED
@@ -130,12 +130,20 @@ export type GuueyChatTheme = z.infer<typeof GuueyChatTheme>;
130
130
  * The brand-neutral-but-polished package default — the theme a builder gets
131
131
  * before configuring anything, and the per-token fallback floor every other
132
132
  * theme resolves against.
133
+ *
134
+ * The accent is MONOCHROME (= ink) by founder ruling (guuey#521): an
135
+ * unthemed embed must never carry a foreign accent into a host's product —
136
+ * the old `#2f6bff` blue made every zero-config embed read "off-the-shelf
137
+ * chat vendor" inside someone else's brand (#414's lesson, mirrored). Ink
138
+ * as accent means the send button and user pill render as neutral
139
+ * ink-on-canvas and disappear into any host; a brand accent is a CHOICE
140
+ * (theme prop or one `--guuey-chat-accent` CSS variable), never a default.
133
141
  */
134
142
  export const DEFAULT_CHAT_THEME: GuueyChatTheme = {
135
143
  name: "default",
136
144
  colors: {
137
145
  light: {
138
- accent: "#2f6bff",
146
+ accent: "#111318",
139
147
  onAccent: "#ffffff",
140
148
  ink: "#111318",
141
149
  inkMuted: "#5b6270",
@@ -145,7 +153,7 @@ export const DEFAULT_CHAT_THEME: GuueyChatTheme = {
145
153
  error: "#d64545",
146
154
  },
147
155
  dark: {
148
- accent: "#5c8dff",
156
+ accent: "#e8e9ee",
149
157
  onAccent: "#0b0d12",
150
158
  ink: "#e8e9ee",
151
159
  inkMuted: "#9aa0ac",
@@ -269,6 +277,44 @@ export function resolveTheme(
269
277
  };
270
278
  }
271
279
 
280
+ /**
281
+ * The court-override member a theme DOCUMENT may carry (guuey#519).
282
+ * Values are theme documents themselves (validated by `resolveTheme`'s own
283
+ * lenient parse at resolution time, so a partial or future-schema court
284
+ * entry degrades per-token like any stored theme).
285
+ */
286
+ const CourtOverrides = z
287
+ .object({ courts: z.record(z.string(), z.unknown()).optional() })
288
+ .loose();
289
+
290
+ /**
291
+ * Per-court theme resolution (guuey#519 — the #414 rule generalized).
292
+ *
293
+ * A theme document may carry `courts`: explicit per-court override
294
+ * documents keyed by serving court (`"guuey"`, `"ggui"`, …). A surface
295
+ * resolves ITS court: the court's override resolved per-token OVER the
296
+ * resolved base document when declared, else the base alone. **Brand is
297
+ * never a default** — an undeclared or unknown court gets the neutral
298
+ * base by construction, and readers that never heard of `courts`
299
+ * (lenient parsers projecting only known tokens) keep reading the base
300
+ * untouched, so no court can inherit another court's brand.
301
+ *
302
+ * Resolved themes are court-free: `courts` never survives resolution.
303
+ * NEVER throws; unparseable input degrades exactly as `resolveTheme`.
304
+ */
305
+ export function resolveCourtTheme(
306
+ candidate: unknown,
307
+ court: string,
308
+ base: GuueyChatTheme = DEFAULT_CHAT_THEME,
309
+ ): GuueyChatTheme {
310
+ const resolvedBase = resolveTheme(candidate, base);
311
+ const parsed = CourtOverrides.safeParse(candidate);
312
+ if (!parsed.success) return resolvedBase;
313
+ const override = parsed.data.courts?.[court];
314
+ if (override === undefined) return resolvedBase;
315
+ return resolveTheme(override, resolvedBase);
316
+ }
317
+
272
318
  /** Slot-level merge of two ramp statements (candidate slots win per slot). */
273
319
  function mergeRampSet(
274
320
  base: GuueyChatRampSet | undefined,