@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.
- package/README.md +23 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/react/components.d.ts +1 -1
- package/dist/react/components.d.ts.map +1 -1
- package/dist/react/guuey-chat.d.ts +14 -1
- package/dist/react/guuey-chat.d.ts.map +1 -1
- package/dist/react/guuey-chat.js +24 -5
- package/dist/react/markdown.d.ts.map +1 -1
- package/dist/react/markdown.js +65 -1
- package/dist/react/theme-css.d.ts +8 -0
- package/dist/react/theme-css.d.ts.map +1 -1
- package/dist/react/theme-css.js +21 -13
- package/dist/react/transcript.d.ts +8 -0
- package/dist/react/transcript.d.ts.map +1 -1
- package/dist/react/transcript.js +2 -2
- package/dist/strings.d.ts +8 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +3 -0
- package/dist/theme.d.ts +24 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +44 -2
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/react/components.tsx +1 -0
- package/src/react/guuey-chat.tsx +67 -2
- package/src/react/markdown.tsx +65 -1
- package/src/react/theme-css.ts +21 -13
- package/src/react/transcript.tsx +10 -1
- package/src/strings.ts +13 -0
- package/src/theme.ts +48 -2
- package/styles.css +154 -100
package/src/react/guuey-chat.tsx
CHANGED
|
@@ -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<
|
|
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
|
-
[
|
|
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
|
});
|
package/src/react/markdown.tsx
CHANGED
|
@@ -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":
|
package/src/react/theme-css.ts
CHANGED
|
@@ -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
|
-
"--
|
|
32
|
-
"--
|
|
33
|
-
"--
|
|
34
|
-
"--
|
|
35
|
-
"--
|
|
36
|
-
"--
|
|
37
|
-
"--
|
|
38
|
-
"--
|
|
39
|
-
"--
|
|
40
|
-
"--
|
|
41
|
-
"--
|
|
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["--
|
|
52
|
+
vars["--_guuey-chat-font"] = theme.typography.fontFamily;
|
|
45
53
|
}
|
|
46
54
|
if (theme.typography.monoFontFamily !== undefined) {
|
|
47
|
-
vars["--
|
|
55
|
+
vars["--_guuey-chat-mono-font"] = theme.typography.monoFontFamily;
|
|
48
56
|
}
|
|
49
57
|
return vars;
|
|
50
58
|
}
|
package/src/react/transcript.tsx
CHANGED
|
@@ -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: "#
|
|
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: "#
|
|
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,
|