@guuey/chat 0.16.7 → 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.
Files changed (42) hide show
  1. package/README.md +23 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/native/markdown.d.ts +12 -0
  6. package/dist/native/markdown.d.ts.map +1 -1
  7. package/dist/native/markdown.js +21 -0
  8. package/dist/react/components.d.ts +1 -1
  9. package/dist/react/components.d.ts.map +1 -1
  10. package/dist/react/guuey-chat.d.ts +14 -1
  11. package/dist/react/guuey-chat.d.ts.map +1 -1
  12. package/dist/react/guuey-chat.js +27 -7
  13. package/dist/react/markdown.d.ts +9 -0
  14. package/dist/react/markdown.d.ts.map +1 -1
  15. package/dist/react/markdown.js +78 -2
  16. package/dist/react/theme-css.d.ts +8 -0
  17. package/dist/react/theme-css.d.ts.map +1 -1
  18. package/dist/react/theme-css.js +21 -13
  19. package/dist/react/transcript.d.ts +8 -0
  20. package/dist/react/transcript.d.ts.map +1 -1
  21. package/dist/react/transcript.js +2 -2
  22. package/dist/richtext-table.d.ts +26 -0
  23. package/dist/richtext-table.d.ts.map +1 -0
  24. package/dist/richtext-table.js +11 -0
  25. package/dist/strings.d.ts +8 -0
  26. package/dist/strings.d.ts.map +1 -1
  27. package/dist/strings.js +3 -0
  28. package/dist/theme.d.ts +24 -0
  29. package/dist/theme.d.ts.map +1 -1
  30. package/dist/theme.js +44 -2
  31. package/package.json +5 -5
  32. package/src/index.ts +1 -0
  33. package/src/native/markdown.tsx +57 -0
  34. package/src/react/components.tsx +1 -0
  35. package/src/react/guuey-chat.tsx +70 -4
  36. package/src/react/markdown.tsx +103 -1
  37. package/src/react/theme-css.ts +21 -13
  38. package/src/react/transcript.tsx +10 -1
  39. package/src/richtext-table.ts +30 -0
  40. package/src/strings.ts +13 -0
  41. package/src/theme.ts +48 -2
  42. package/styles.css +171 -98
package/dist/strings.js CHANGED
@@ -77,6 +77,9 @@ export const defaultChatStrings = {
77
77
  oauthConnected: (serverName) => `Connected ${serverName}. The agent can use it from your next message.`,
78
78
  oauthFailed: (reason) => `Couldn't connect: ${reason}`,
79
79
  noticeLabel: "Note",
80
+ linkAskLabel: (host) => `This card wants to open ${host}`,
81
+ linkOpen: "Open",
82
+ linkDismiss: "Dismiss",
80
83
  composerPlaceholder: "Message the agent…",
81
84
  composerUnavailable: "Chat is unavailable.",
82
85
  composerLabel: "Message",
package/dist/theme.d.ts CHANGED
@@ -246,6 +246,14 @@ export type GuueyChatTheme = z.infer<typeof GuueyChatTheme>;
246
246
  * The brand-neutral-but-polished package default — the theme a builder gets
247
247
  * before configuring anything, and the per-token fallback floor every other
248
248
  * theme resolves against.
249
+ *
250
+ * The accent is MONOCHROME (= ink) by founder ruling (guuey#521): an
251
+ * unthemed embed must never carry a foreign accent into a host's product —
252
+ * the old `#2f6bff` blue made every zero-config embed read "off-the-shelf
253
+ * chat vendor" inside someone else's brand (#414's lesson, mirrored). Ink
254
+ * as accent means the send button and user pill render as neutral
255
+ * ink-on-canvas and disappear into any host; a brand accent is a CHOICE
256
+ * (theme prop or one `--guuey-chat-accent` CSS variable), never a default.
249
257
  */
250
258
  export declare const DEFAULT_CHAT_THEME: GuueyChatTheme;
251
259
  /**
@@ -262,4 +270,20 @@ export declare const GUUEY_CHAT_THEME: GuueyChatTheme;
262
270
  * NEVER throws: unparseable input resolves to the base theme untouched.
263
271
  */
264
272
  export declare function resolveTheme(candidate: unknown, base?: GuueyChatTheme): GuueyChatTheme;
273
+ /**
274
+ * Per-court theme resolution (guuey#519 — the #414 rule generalized).
275
+ *
276
+ * A theme document may carry `courts`: explicit per-court override
277
+ * documents keyed by serving court (`"guuey"`, `"ggui"`, …). A surface
278
+ * resolves ITS court: the court's override resolved per-token OVER the
279
+ * resolved base document when declared, else the base alone. **Brand is
280
+ * never a default** — an undeclared or unknown court gets the neutral
281
+ * base by construction, and readers that never heard of `courts`
282
+ * (lenient parsers projecting only known tokens) keep reading the base
283
+ * untouched, so no court can inherit another court's brand.
284
+ *
285
+ * Resolved themes are court-free: `courts` never survives resolution.
286
+ * NEVER throws; unparseable input degrades exactly as `resolveTheme`.
287
+ */
288
+ export declare function resolveCourtTheme(candidate: unknown, court: string, base?: GuueyChatTheme): GuueyChatTheme;
265
289
  //# sourceMappingURL=theme.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;GAGG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;iBAWnB,CAAC;AACX,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;iBAUtB,CAAC;AACX,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,eAAO,MAAM,kBAAkB;;;;iBAMrB,CAAC;AACX,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,eAAO,MAAM,iBAAiB;;iBAAqD,CAAC;AACpF,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAElE,+DAA+D;AAC/D,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;iBAQnB,CAAC;AACX,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAKjB,CAAC;AACX,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8BjB,CAAC;AACX,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,EAAE,cA0BhC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,EAAE,cA0B9B,CAAC;AA4CF;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,OAAO,EAClB,IAAI,GAAE,cAAmC,GACxC,cAAc,CAuBhB"}
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;GAGG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;iBAWnB,CAAC;AACX,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;iBAUtB,CAAC;AACX,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,eAAO,MAAM,kBAAkB;;;;iBAMrB,CAAC;AACX,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,eAAO,MAAM,iBAAiB;;iBAAqD,CAAC;AACpF,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAElE,+DAA+D;AAC/D,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;iBAQnB,CAAC;AACX,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAKjB,CAAC;AACX,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8BjB,CAAC;AACX,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,EAAE,cA0BhC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,EAAE,cA0B9B,CAAC;AA4CF;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,OAAO,EAClB,IAAI,GAAE,cAAmC,GACxC,cAAc,CAuBhB;AAYD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,OAAO,EAClB,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,cAAmC,GACxC,cAAc,CAOhB"}
package/dist/theme.js CHANGED
@@ -115,12 +115,20 @@ export const GuueyChatTheme = z
115
115
  * The brand-neutral-but-polished package default — the theme a builder gets
116
116
  * before configuring anything, and the per-token fallback floor every other
117
117
  * theme resolves against.
118
+ *
119
+ * The accent is MONOCHROME (= ink) by founder ruling (guuey#521): an
120
+ * unthemed embed must never carry a foreign accent into a host's product —
121
+ * the old `#2f6bff` blue made every zero-config embed read "off-the-shelf
122
+ * chat vendor" inside someone else's brand (#414's lesson, mirrored). Ink
123
+ * as accent means the send button and user pill render as neutral
124
+ * ink-on-canvas and disappear into any host; a brand accent is a CHOICE
125
+ * (theme prop or one `--guuey-chat-accent` CSS variable), never a default.
118
126
  */
119
127
  export const DEFAULT_CHAT_THEME = {
120
128
  name: "default",
121
129
  colors: {
122
130
  light: {
123
- accent: "#2f6bff",
131
+ accent: "#111318",
124
132
  onAccent: "#ffffff",
125
133
  ink: "#111318",
126
134
  inkMuted: "#5b6270",
@@ -130,7 +138,7 @@ export const DEFAULT_CHAT_THEME = {
130
138
  error: "#d64545",
131
139
  },
132
140
  dark: {
133
- accent: "#5c8dff",
141
+ accent: "#e8e9ee",
134
142
  onAccent: "#0b0d12",
135
143
  ink: "#e8e9ee",
136
144
  inkMuted: "#9aa0ac",
@@ -245,6 +253,40 @@ export function resolveTheme(candidate, base = DEFAULT_CHAT_THEME) {
245
253
  },
246
254
  };
247
255
  }
256
+ /**
257
+ * The court-override member a theme DOCUMENT may carry (guuey#519).
258
+ * Values are theme documents themselves (validated by `resolveTheme`'s own
259
+ * lenient parse at resolution time, so a partial or future-schema court
260
+ * entry degrades per-token like any stored theme).
261
+ */
262
+ const CourtOverrides = z
263
+ .object({ courts: z.record(z.string(), z.unknown()).optional() })
264
+ .loose();
265
+ /**
266
+ * Per-court theme resolution (guuey#519 — the #414 rule generalized).
267
+ *
268
+ * A theme document may carry `courts`: explicit per-court override
269
+ * documents keyed by serving court (`"guuey"`, `"ggui"`, …). A surface
270
+ * resolves ITS court: the court's override resolved per-token OVER the
271
+ * resolved base document when declared, else the base alone. **Brand is
272
+ * never a default** — an undeclared or unknown court gets the neutral
273
+ * base by construction, and readers that never heard of `courts`
274
+ * (lenient parsers projecting only known tokens) keep reading the base
275
+ * untouched, so no court can inherit another court's brand.
276
+ *
277
+ * Resolved themes are court-free: `courts` never survives resolution.
278
+ * NEVER throws; unparseable input degrades exactly as `resolveTheme`.
279
+ */
280
+ export function resolveCourtTheme(candidate, court, base = DEFAULT_CHAT_THEME) {
281
+ const resolvedBase = resolveTheme(candidate, base);
282
+ const parsed = CourtOverrides.safeParse(candidate);
283
+ if (!parsed.success)
284
+ return resolvedBase;
285
+ const override = parsed.data.courts?.[court];
286
+ if (override === undefined)
287
+ return resolvedBase;
288
+ return resolveTheme(override, resolvedBase);
289
+ }
248
290
  /** Slot-level merge of two ramp statements (candidate slots win per slot). */
249
291
  function mergeRampSet(base, over) {
250
292
  if (base === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/chat",
3
- "version": "0.16.7",
3
+ "version": "0.16.9",
4
4
  "description": "The default end-user transcript UI for guuey agents — the headless view-model (planTranscript), calm/debug presets, the GuueyChatTheme token schema, the ChatStrings i18n seam, and (at ./react) the component kit that renders it: per-category components, <Transcript>, the live + history input assemblers, and stick-to-bottom/windowed/accessible transcript behavior; at ./native, the React Native renderer over the same view-model.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,11 +36,11 @@
36
36
  "./styles.css": "./styles.css"
37
37
  },
38
38
  "dependencies": {
39
- "@silverprotocol/core": "0.5.2",
40
- "@silverprotocol/richtext": "0.5.2",
39
+ "@silverprotocol/core": "0.5.3",
40
+ "@silverprotocol/richtext": "0.5.3",
41
41
  "zod": "^4.3.6",
42
- "@guuey/agent-client": "0.16.7",
43
- "@guuey/mcp-apps-host": "0.16.7"
42
+ "@guuey/agent-client": "0.16.9",
43
+ "@guuey/mcp-apps-host": "0.16.9"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "react": ">=18",
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export {
55
55
  GuueyChatTheme,
56
56
  GuueyChatToneRamp,
57
57
  resolveTheme,
58
+ resolveCourtTheme,
58
59
  } from "./theme.js";
59
60
  export type {
60
61
  ChatDebugEvent,
@@ -26,6 +26,7 @@ import {
26
26
  type RichTextInline,
27
27
  } from "@silverprotocol/richtext";
28
28
  import type { NativeChatTokens } from "./theme-native.js";
29
+ import { normalizeTableRow, type RichTextTableBlock } from "../richtext-table.js";
29
30
 
30
31
  function InlineRuns({
31
32
  nodes,
@@ -120,6 +121,8 @@ function BlockView({
120
121
  trailing?: ReactNode;
121
122
  }): ReactNode {
122
123
  switch (block.type) {
124
+ case "table":
125
+ return <NativeTableBlock block={block} color={color} tokens={tokens} trailing={trailing} />;
123
126
  case "paragraph":
124
127
  return (
125
128
  <Text style={{ fontSize: tokens.fontSize, lineHeight: Math.round(tokens.fontSize * 1.45) }}>
@@ -175,6 +178,60 @@ function BlockView({
175
178
  }
176
179
  }
177
180
 
181
+ /**
182
+ * The native table projection (guuey#370): header row bold with a
183
+ * separator, body rows as flex rows, per-column alignment mapped to RN
184
+ * textAlign. Exported for tests; not part of the package surface.
185
+ */
186
+ export function NativeTableBlock({
187
+ block,
188
+ color,
189
+ tokens,
190
+ trailing,
191
+ }: {
192
+ block: RichTextTableBlock;
193
+ color: string;
194
+ tokens: NativeChatTokens;
195
+ trailing?: ReactNode;
196
+ }): ReactNode {
197
+ const columnCount = block.header.cells.length;
198
+ const cellStyle = (i: number) => ({
199
+ flex: 1,
200
+ ...(block.align[i] !== undefined ? { textAlign: block.align[i] } : {}),
201
+ });
202
+ return (
203
+ <View style={{ gap: 2 }}>
204
+ <View
205
+ style={{
206
+ flexDirection: "row",
207
+ borderBottomWidth: 1,
208
+ borderBottomColor: tokens.palette.canvasMuted,
209
+ paddingBottom: 2,
210
+ }}
211
+ >
212
+ {normalizeTableRow(block.header, columnCount).map((cell, i) => (
213
+ <Text
214
+ key={i}
215
+ style={{ ...cellStyle(i), color, fontSize: tokens.fontSize, fontWeight: "700" }}
216
+ >
217
+ <InlineRuns nodes={cell.children} color={color} tokens={tokens} bold />
218
+ </Text>
219
+ ))}
220
+ </View>
221
+ {block.rows.map((row, r) => (
222
+ <View key={r} style={{ flexDirection: "row" }}>
223
+ {normalizeTableRow(row, columnCount).map((cell, i) => (
224
+ <Text key={i} style={{ ...cellStyle(i), fontSize: tokens.fontSize }}>
225
+ <InlineRuns nodes={cell.children} color={color} tokens={tokens} />
226
+ </Text>
227
+ ))}
228
+ </View>
229
+ ))}
230
+ {trailing}
231
+ </View>
232
+ );
233
+ }
234
+
178
235
  /** Sanitized markdown → themed RN elements (see the module docblock). */
179
236
  export function NativeMarkdown({
180
237
  text,
@@ -108,6 +108,7 @@ export type ViewSlotProps = Pick<
108
108
  | "onCallTool"
109
109
  | "onUpdateModelContext"
110
110
  | "onUserMessage"
111
+ | "onOpenLink"
111
112
  | "onReadResource"
112
113
  | "onSizeChanged"
113
114
  | "negotiationTimeoutMs"
@@ -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,
@@ -546,8 +564,9 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
546
564
  // REAL result envelope and its #440 classifier grades honestly; the
547
565
  // post-turn agent turn then starts through the ui/message doorbell
548
566
  // (the sink below). The #356 staging seam remains a PUBLIC export
549
- // (withActionStaging) for hosts that choose the composer-staging UX
550
- // the widget's own mountCallTool still applies it by its own choice.
567
+ // (withActionStaging) for hosts that choose the composer-staging UX;
568
+ // no first-party surface applies it any more (the widget converged on
569
+ // this relay+doorbell path in guuey#404).
551
570
  const stagedDefaultOnCallTool = defaultOnCallTool;
552
571
 
553
572
  // The ui/message sink (guuey#422): the view hands the host role-user
@@ -620,14 +639,33 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
620
639
  void live.invoke.send(next).catch(() => {});
621
640
  }, [busy]);
622
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
+
623
653
  const effectiveViewProps = useMemo<TranscriptItemContext["viewProps"]>(
624
654
  () =>
625
655
  viewPropsWithThemeAnnounce(viewProps, mode, {
626
656
  ...(stagedDefaultOnCallTool !== undefined ? { onCallTool: stagedDefaultOnCallTool } : {}),
627
657
  onUpdateModelContext: defaultOnUpdateModelContext,
628
658
  onUserMessage: defaultOnUserMessage,
659
+ onOpenLink: defaultOnOpenLink,
629
660
  }),
630
- [viewProps, mode, stagedDefaultOnCallTool, defaultOnUpdateModelContext, defaultOnUserMessage],
661
+ [
662
+ viewProps,
663
+ mode,
664
+ stagedDefaultOnCallTool,
665
+ defaultOnUpdateModelContext,
666
+ defaultOnUserMessage,
667
+ defaultOnOpenLink,
668
+ ],
631
669
  );
632
670
 
633
671
  // The canvas-host door (guuey#335): a host mounting views DIRECTLY from
@@ -643,6 +681,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
643
681
  ...(stagedDefaultOnCallTool !== undefined ? { onCallTool: stagedDefaultOnCallTool } : {}),
644
682
  onUpdateModelContext: defaultOnUpdateModelContext,
645
683
  onUserMessage: defaultOnUserMessage,
684
+ onOpenLink: defaultOnOpenLink,
646
685
  hostContext: { theme: mode },
647
686
  }
648
687
  : (effectiveViewProps ?? {});
@@ -776,6 +815,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
776
815
  strings={strings}
777
816
  theme={theme}
778
817
  mode={mode}
818
+ surface={surface}
779
819
  {...(windowing !== undefined ? { window: windowing } : {})}
780
820
  {...(components !== undefined ? { components } : {})}
781
821
  onToggle={toggle}
@@ -788,6 +828,30 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
788
828
  {...(onViewRef !== undefined ? { onViewRef } : {})}
789
829
  viewProps={effectiveViewProps}
790
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
+ )}
791
855
  {oauthReturn.notice !== null && (
792
856
  <p
793
857
  role="status"
@@ -801,6 +865,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
801
865
  </button>
802
866
  </p>
803
867
  )}
868
+ {composer && (
804
869
  <form
805
870
  className="guuey-chat-composer"
806
871
  onSubmit={(e) => {
@@ -842,6 +907,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
842
907
  </button>
843
908
  )}
844
909
  </form>
910
+ )}
845
911
  </div>
846
912
  );
847
913
  });
@@ -30,12 +30,77 @@ import {
30
30
  type RichTextBlock,
31
31
  type RichTextInline,
32
32
  } from "@silverprotocol/richtext";
33
+ import { normalizeTableRow, type RichTextTableBlock } from "../richtext-table.js";
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
+ }
33
98
 
34
99
  function Inline({ nodes }: { nodes: RichTextInline[] }): ReactNode {
35
100
  return nodes.map((node, i) => {
36
101
  switch (node.type) {
37
102
  case "text":
38
- return <span key={i}>{node.text}</span>;
103
+ return <span key={i}>{linkify(node.text)}</span>;
39
104
  case "break":
40
105
  return <br key={i} />;
41
106
  case "strong":
@@ -71,8 +136,45 @@ function Inline({ nodes }: { nodes: RichTextInline[] }): ReactNode {
71
136
  });
72
137
  }
73
138
 
139
+ /**
140
+ * The table arm (guuey#370 — sdk#23's node). Cells render through the
141
+ * SAME `<Inline>` sanitizer path as every other run. Exported for tests;
142
+ * not part of the package surface.
143
+ */
144
+ export function TableBlock({ block }: { block: RichTextTableBlock }): ReactNode {
145
+ const columnCount = block.header.cells.length;
146
+ return (
147
+ <div className="guuey-chat-table-wrap">
148
+ <table className="guuey-chat-table">
149
+ <thead>
150
+ <tr>
151
+ {normalizeTableRow(block.header, columnCount).map((cell, i) => (
152
+ <th key={i} {...(block.align[i] !== undefined ? { style: { textAlign: block.align[i] } } : {})}>
153
+ <Inline nodes={cell.children} />
154
+ </th>
155
+ ))}
156
+ </tr>
157
+ </thead>
158
+ <tbody>
159
+ {block.rows.map((row, r) => (
160
+ <tr key={r}>
161
+ {normalizeTableRow(row, columnCount).map((cell, i) => (
162
+ <td key={i} {...(block.align[i] !== undefined ? { style: { textAlign: block.align[i] } } : {})}>
163
+ <Inline nodes={cell.children} />
164
+ </td>
165
+ ))}
166
+ </tr>
167
+ ))}
168
+ </tbody>
169
+ </table>
170
+ </div>
171
+ );
172
+ }
173
+
74
174
  function Block({ block }: { block: RichTextBlock }): ReactNode {
75
175
  switch (block.type) {
176
+ case "table":
177
+ return <TableBlock block={block} />;
76
178
  case "paragraph":
77
179
  return (
78
180
  <p>
@@ -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
  >
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Table rendering support (guuey#370 — sdk#23's vocabulary, live since
3
+ * `@silverprotocol/richtext@0.5.3`).
4
+ *
5
+ * The block type DERIVES from the upstream union (strict-typing rule:
6
+ * the parser's types are the source of truth). History note: this module
7
+ * was born one cut earlier as a structural MIRROR + narrowing guard so
8
+ * both renderer arms could land BEFORE the bump (the renderers switch
9
+ * default-less on `block.type` — a bump without an arm would have
10
+ * rendered tables as NOTHING); with 0.5.3 pinned, the mirror and guard
11
+ * are deleted per the pre-launch one-contract rule and the arms moved
12
+ * into the switches proper.
13
+ */
14
+ import type { RichTextBlock, RichTextTableCell } from "@silverprotocol/richtext";
15
+
16
+ export type RichTextTableBlock = Extract<RichTextBlock, { type: "table" }>;
17
+
18
+ /**
19
+ * Renderer-side GFM normalization (defense in depth — the parser already
20
+ * pads/drops): every row exactly `columnCount` cells, short rows padded
21
+ * with empty-inline cells, excess dropped.
22
+ */
23
+ export function normalizeTableRow(
24
+ row: { cells: RichTextTableCell[] },
25
+ columnCount: number,
26
+ ): RichTextTableCell[] {
27
+ const cells = row.cells.slice(0, columnCount);
28
+ while (cells.length < columnCount) cells.push({ children: [] });
29
+ return cells;
30
+ }
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",