@dbx-tools/ui-teams 0.3.39
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 +101 -0
- package/index.ts +12 -0
- package/package.json +56 -0
- package/src/react/adaptive-card-gallery.tsx +175 -0
- package/src/react/adaptive-card.tsx +90 -0
- package/src/react/index.ts +13 -0
- package/src/react/samples.ts +57 -0
- package/src/react/teams-chat.tsx +252 -0
- package/src/styles.css +92 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @dbx-tools/ui-teams
|
|
2
|
+
|
|
3
|
+
React surface for the Teams add-on: render Microsoft Teams Adaptive Cards in the
|
|
4
|
+
browser with the [`adaptivecards`](https://www.npmjs.com/package/adaptivecards)
|
|
5
|
+
JavaScript renderer.
|
|
6
|
+
|
|
7
|
+
Import this package when a UI needs to display the Adaptive Card documents that
|
|
8
|
+
[`@dbx-tools/teams`](../../node/teams) builds. It consumes the browser-safe card
|
|
9
|
+
contract from [`@dbx-tools/shared-teams`](../../shared/teams) and renders through
|
|
10
|
+
[`@dbx-tools/ui-appkit`](../appkit)'s UI kit.
|
|
11
|
+
|
|
12
|
+
**Key features:**
|
|
13
|
+
|
|
14
|
+
- `TeamsChat` - a Teams conversation surface: each turn posts a Bot Framework
|
|
15
|
+
activity to the server's `POST /api/teams/messages` endpoint - the SAME route a
|
|
16
|
+
real Teams channel calls, so this is a faithful preview rather than a parallel
|
|
17
|
+
implementation - and the agent's reply renders as Adaptive Card attachments,
|
|
18
|
+
the way a Teams channel shows a bot's cards. (That route only answers in the
|
|
19
|
+
HTTP response when the server enables its `allowUnauthenticated` development
|
|
20
|
+
bypass; against a real bot registration replies go out over the Connector API,
|
|
21
|
+
so point `endpoint` at `/api/teams/activity` instead.)
|
|
22
|
+
Mints one conversation id per mount (which threads the server's
|
|
23
|
+
agent memory), echoes the user's turn optimistically, cancels an in-flight turn
|
|
24
|
+
on unmount, and offers tap-to-send starters while the transcript is empty.
|
|
25
|
+
- `AdaptiveCardView` - a thin React wrapper over the imperative `adaptivecards`
|
|
26
|
+
renderer: feed it a compiled Adaptive Card document and it mounts the rendered
|
|
27
|
+
node, re-rendering on change and wiring `Action.OpenUrl` clicks (open in a new
|
|
28
|
+
tab by default, or intercept with `onOpenUrl`).
|
|
29
|
+
- `AdaptiveCardGallery` - a self-contained dev tool: edit a `CardSpec` (or pick a
|
|
30
|
+
sample), compile it through the server's `POST /api/teams/card` route, and see
|
|
31
|
+
the card render live. This is the drop-in "display them" surface for a dev
|
|
32
|
+
preview page.
|
|
33
|
+
- Built-in `CARD_SAMPLES` covering the different parts of a card (facts, link
|
|
34
|
+
actions, plain notes) so the preview shows something before you edit.
|
|
35
|
+
- Markdown in a `TextBlock` actually renders. A `TextBlock` is markdown per the
|
|
36
|
+
Adaptive Cards spec, but `adaptivecards` ships no parser - it leaves the
|
|
37
|
+
implementation (and its sanitization) to the host, so `**bold**` renders
|
|
38
|
+
literally until one is installed. Importing this package registers `marked`
|
|
39
|
+
as the renderer's `onProcessMarkdown` processor, degrading to plain text if a
|
|
40
|
+
string fails to parse, and the stylesheet re-applies list markers / paragraph
|
|
41
|
+
spacing that Tailwind's preflight resets away.
|
|
42
|
+
|
|
43
|
+
## Simulate A Teams Chat
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
import { TeamsChat } from "@dbx-tools/ui-teams/react";
|
|
47
|
+
|
|
48
|
+
const Chat = () => <TeamsChat className="h-full" />;
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Point `endpoint` elsewhere if the plugin is mounted under a different name, and
|
|
52
|
+
pass `agentId` to converse with a specific agent instead of the server's default.
|
|
53
|
+
`starters` replaces the built-in `DEFAULT_STARTERS` prompts.
|
|
54
|
+
|
|
55
|
+
## Render A Card
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { AdaptiveCardView } from "@dbx-tools/ui-teams/react";
|
|
59
|
+
|
|
60
|
+
const Preview = ({ card }: { card: AdaptiveCard }) => (
|
|
61
|
+
<AdaptiveCardView card={card} className="ac-container" />
|
|
62
|
+
);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`AdaptiveCardView` renders any compiled Adaptive Card document - one returned by
|
|
66
|
+
the `create_teams_card` tool, the plugin's `buildCard` export, or the
|
|
67
|
+
`/api/teams/card` route. Card text is treated as markdown, matching how Teams
|
|
68
|
+
itself renders a `TextBlock`.
|
|
69
|
+
|
|
70
|
+
## Live Preview Page
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { AdaptiveCardGallery } from "@dbx-tools/ui-teams/react";
|
|
74
|
+
|
|
75
|
+
const Cards = () => <AdaptiveCardGallery />;
|
|
76
|
+
export default Cards;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Drop `<AdaptiveCardGallery/>` on a route. It posts the edited `CardSpec` to the
|
|
80
|
+
`@dbx-tools/teams` plugin's `/api/teams/card` route and renders the compiled
|
|
81
|
+
card, so you exercise the real server builder end to end. Point it at a
|
|
82
|
+
different mount with `buildEndpoint`.
|
|
83
|
+
|
|
84
|
+
## Styling
|
|
85
|
+
|
|
86
|
+
Import the stylesheet once from the host app's Tailwind entry, after Tailwind and
|
|
87
|
+
your AppKit-UI theme:
|
|
88
|
+
|
|
89
|
+
```css
|
|
90
|
+
@import "tailwindcss";
|
|
91
|
+
@import "@databricks/appkit-ui/styles.css";
|
|
92
|
+
@import "@dbx-tools/ui-teams/styles.css";
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Modules
|
|
96
|
+
|
|
97
|
+
- `./react` - `TeamsChat`, `DEFAULT_STARTERS`, `AdaptiveCardView`,
|
|
98
|
+
`AdaptiveCardGallery`, `CARD_SAMPLES`, and the
|
|
99
|
+
re-exported `AdaptiveCard` / `CardResult` / `CardSpec` types.
|
|
100
|
+
- `./styles.css` - the AppKit-token-based styling for the gallery plus a minimal
|
|
101
|
+
Adaptive Card container skin.
|
package/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// GENERATED by projen watch - DO NOT EDIT.
|
|
2
|
+
// Regenerated from the exporting modules in ./src.
|
|
3
|
+
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
|
+
|
|
5
|
+
export * as reactAdaptiveCard from "./src/react/adaptive-card";
|
|
6
|
+
export * as reactAdaptiveCardGallery from "./src/react/adaptive-card-gallery";
|
|
7
|
+
export * as reactSamples from "./src/react/samples";
|
|
8
|
+
export * as reactTeamsChat from "./src/react/teams-chat";
|
|
9
|
+
export type { AdaptiveCardViewProps } from "./src/react/adaptive-card";
|
|
10
|
+
export type { AdaptiveCardGalleryProps } from "./src/react/adaptive-card-gallery";
|
|
11
|
+
export type { CardSample } from "./src/react/samples";
|
|
12
|
+
export type { TeamsChatProps } from "./src/react/teams-chat";
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dbx-tools/ui-teams",
|
|
3
|
+
"repository": {
|
|
4
|
+
"type": "git",
|
|
5
|
+
"url": "git+https://github.com/reggie-db/dbx-tools.git",
|
|
6
|
+
"directory": "workspaces/ui/teams"
|
|
7
|
+
},
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@types/node": "^24.6.0",
|
|
10
|
+
"@types/react": "^19.2.2",
|
|
11
|
+
"@types/react-dom": "^19.2.2",
|
|
12
|
+
"tsx": "^4.23.0",
|
|
13
|
+
"typescript": "^5.9.3"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"adaptivecards": "^3.0.5",
|
|
17
|
+
"marked": "^18.0.5",
|
|
18
|
+
"react": "^19.2.4",
|
|
19
|
+
"react-dom": "^19.2.4",
|
|
20
|
+
"@dbx-tools/ui-appkit": "0.3.39",
|
|
21
|
+
"@dbx-tools/shared-teams": "0.3.39",
|
|
22
|
+
"@dbx-tools/shared-core": "0.3.39"
|
|
23
|
+
},
|
|
24
|
+
"license": "UNLICENSED",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"version": "0.3.39",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"exports": {
|
|
31
|
+
"./react": "./src/react/index.ts",
|
|
32
|
+
"./styles.css": "./src/styles.css",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"index.ts",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"dbxToolsConfig": {
|
|
40
|
+
"tags": [
|
|
41
|
+
"ui"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "projen build",
|
|
47
|
+
"compile": "projen compile",
|
|
48
|
+
"default": "projen default",
|
|
49
|
+
"package": "projen package",
|
|
50
|
+
"post-compile": "projen post-compile",
|
|
51
|
+
"pre-compile": "projen pre-compile",
|
|
52
|
+
"test": "projen test",
|
|
53
|
+
"watch": "projen watch",
|
|
54
|
+
"projen": "projen"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// A self-contained dev tool for the Teams add-on: pick or paste a `CardSpec`,
|
|
2
|
+
// send it to the server's `POST /api/teams/card` route to compile it into an
|
|
3
|
+
// Adaptive Card, and render the result with the `adaptivecards` renderer. This
|
|
4
|
+
// is the "have this display them" surface - drop `<AdaptiveCardGallery/>` on a
|
|
5
|
+
// page and you get a live card preview backed by the real server builder.
|
|
6
|
+
|
|
7
|
+
import { json } from "@dbx-tools/shared-core";
|
|
8
|
+
import { card, type CardResult, type CardSpec } from "@dbx-tools/shared-teams";
|
|
9
|
+
import {
|
|
10
|
+
Button,
|
|
11
|
+
Card,
|
|
12
|
+
CardContent,
|
|
13
|
+
CardDescription,
|
|
14
|
+
CardHeader,
|
|
15
|
+
CardTitle,
|
|
16
|
+
Label,
|
|
17
|
+
Select,
|
|
18
|
+
SelectContent,
|
|
19
|
+
SelectItem,
|
|
20
|
+
SelectTrigger,
|
|
21
|
+
SelectValue,
|
|
22
|
+
Textarea,
|
|
23
|
+
cn,
|
|
24
|
+
} from "@dbx-tools/ui-appkit/react";
|
|
25
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
26
|
+
import { AdaptiveCardView } from "./adaptive-card";
|
|
27
|
+
import { CARD_SAMPLES } from "./samples";
|
|
28
|
+
|
|
29
|
+
/** Props for {@link AdaptiveCardGallery}. */
|
|
30
|
+
export interface AdaptiveCardGalleryProps {
|
|
31
|
+
/**
|
|
32
|
+
* The server route that compiles a `CardSpec` into an Adaptive Card. Defaults
|
|
33
|
+
* to the `@dbx-tools/teams` plugin's mount (`/api/teams/card`).
|
|
34
|
+
*/
|
|
35
|
+
buildEndpoint?: string;
|
|
36
|
+
/** Extra classes merged onto the root container. */
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Pretty-print a spec for the editable textarea. */
|
|
41
|
+
const format = (spec: CardSpec): string => JSON.stringify(spec, null, 2);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Live Adaptive Card preview. Edits to the JSON are debounced and posted to the
|
|
45
|
+
* server builder; the returned card is rendered with the `adaptivecards`
|
|
46
|
+
* package. Falls back to a local build only for the initial paint so the panel
|
|
47
|
+
* is never empty while the first request is in flight.
|
|
48
|
+
*/
|
|
49
|
+
export const AdaptiveCardGallery = ({
|
|
50
|
+
buildEndpoint = "/api/teams/card",
|
|
51
|
+
className,
|
|
52
|
+
}: AdaptiveCardGalleryProps) => {
|
|
53
|
+
const [sampleIndex, setSampleIndex] = useState(0);
|
|
54
|
+
const [source, setSource] = useState(() => format(CARD_SAMPLES[0]!.spec));
|
|
55
|
+
const [result, setResult] = useState<CardResult | null>(null);
|
|
56
|
+
const [error, setError] = useState<string | null>(null);
|
|
57
|
+
|
|
58
|
+
const spec = useMemo(() => {
|
|
59
|
+
const parsed = card.cardSpecSchema.safeParse(json.parse(source, undefined));
|
|
60
|
+
return parsed.success ? parsed.data : null;
|
|
61
|
+
}, [source]);
|
|
62
|
+
|
|
63
|
+
const build = useCallback(
|
|
64
|
+
async (draft: CardSpec, signal: AbortSignal) => {
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(buildEndpoint, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "content-type": "application/json" },
|
|
69
|
+
body: JSON.stringify(draft),
|
|
70
|
+
signal,
|
|
71
|
+
});
|
|
72
|
+
const payload = await response.json();
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
setError(payload?.error ?? `Build failed (${response.status})`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const parsed = card.cardResultSchema.safeParse(payload);
|
|
78
|
+
if (!parsed.success) {
|
|
79
|
+
setError("Server returned an unexpected card shape.");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setResult(parsed.data);
|
|
83
|
+
setError(null);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if ((err as Error).name === "AbortError") return;
|
|
86
|
+
setError((err as Error).message);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[buildEndpoint],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!spec) {
|
|
94
|
+
setError("The card JSON is not a valid CardSpec.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timer = setTimeout(() => void build(spec, controller.signal), 250);
|
|
99
|
+
return () => {
|
|
100
|
+
controller.abort();
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
};
|
|
103
|
+
}, [spec, build]);
|
|
104
|
+
|
|
105
|
+
const chooseSample = (value: string) => {
|
|
106
|
+
const index = Number(value);
|
|
107
|
+
setSampleIndex(index);
|
|
108
|
+
setSource(format(CARD_SAMPLES[index]!.spec));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className={cn("grid gap-4 md:grid-cols-2", className)}>
|
|
113
|
+
<Card className="min-h-0">
|
|
114
|
+
<CardHeader>
|
|
115
|
+
<CardTitle>Card spec</CardTitle>
|
|
116
|
+
<CardDescription>
|
|
117
|
+
Edit the description a model would produce, or pick a sample. It is compiled by the
|
|
118
|
+
server's <code>/api/teams/card</code> route.
|
|
119
|
+
</CardDescription>
|
|
120
|
+
</CardHeader>
|
|
121
|
+
<CardContent className="flex flex-col gap-3">
|
|
122
|
+
<div className="flex flex-col gap-1.5">
|
|
123
|
+
<Label htmlFor="teams-sample">Sample</Label>
|
|
124
|
+
<Select value={String(sampleIndex)} onValueChange={chooseSample}>
|
|
125
|
+
<SelectTrigger id="teams-sample" className="w-full">
|
|
126
|
+
<SelectValue />
|
|
127
|
+
</SelectTrigger>
|
|
128
|
+
<SelectContent>
|
|
129
|
+
{CARD_SAMPLES.map((sample, index) => (
|
|
130
|
+
<SelectItem key={sample.label} value={String(index)}>
|
|
131
|
+
{sample.label}
|
|
132
|
+
</SelectItem>
|
|
133
|
+
))}
|
|
134
|
+
</SelectContent>
|
|
135
|
+
</Select>
|
|
136
|
+
</div>
|
|
137
|
+
<Textarea
|
|
138
|
+
aria-label="Card spec JSON"
|
|
139
|
+
className="min-h-72 font-mono text-xs"
|
|
140
|
+
spellCheck={false}
|
|
141
|
+
value={source}
|
|
142
|
+
onChange={(event) => setSource(event.target.value)}
|
|
143
|
+
/>
|
|
144
|
+
<div className="flex items-center gap-2">
|
|
145
|
+
<Button
|
|
146
|
+
size="sm"
|
|
147
|
+
variant="outline"
|
|
148
|
+
onClick={() => setSource(format(CARD_SAMPLES[sampleIndex]!.spec))}
|
|
149
|
+
>
|
|
150
|
+
Reset
|
|
151
|
+
</Button>
|
|
152
|
+
{error ? <span className="text-xs text-destructive">{error}</span> : null}
|
|
153
|
+
</div>
|
|
154
|
+
</CardContent>
|
|
155
|
+
</Card>
|
|
156
|
+
|
|
157
|
+
<Card className="min-h-0">
|
|
158
|
+
<CardHeader>
|
|
159
|
+
<CardTitle>Adaptive Card preview</CardTitle>
|
|
160
|
+
<CardDescription>
|
|
161
|
+
Rendered with the <code>adaptivecards</code> JavaScript renderer, the same one Teams
|
|
162
|
+
preview tools embed.
|
|
163
|
+
</CardDescription>
|
|
164
|
+
</CardHeader>
|
|
165
|
+
<CardContent>
|
|
166
|
+
{result ? (
|
|
167
|
+
<AdaptiveCardView card={result.card} className="ac-container" />
|
|
168
|
+
) : (
|
|
169
|
+
<p className="text-sm text-muted-foreground">No card yet.</p>
|
|
170
|
+
)}
|
|
171
|
+
</CardContent>
|
|
172
|
+
</Card>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// React wrapper over the Adaptive Cards JavaScript renderer (the
|
|
2
|
+
// `adaptivecards` npm package). The renderer is imperative - you feed it card
|
|
3
|
+
// JSON and it returns a rendered `HTMLElement` - so this component parses the
|
|
4
|
+
// document and mounts the rendered node into a ref on every change, and wires
|
|
5
|
+
// `Action.OpenUrl` clicks to an optional handler (defaulting to opening the URL
|
|
6
|
+
// in a new tab). This is the same renderer many internal Teams preview tools
|
|
7
|
+
// embed to show cards outside Teams.
|
|
8
|
+
//
|
|
9
|
+
// The renderer deliberately ships NO markdown parser: a `TextBlock` is markdown
|
|
10
|
+
// per the Adaptive Cards spec, but `adaptivecards` leaves the implementation to
|
|
11
|
+
// the host so each host can pick its own (and its own sanitization). Without a
|
|
12
|
+
// host processor Teams' own `**bold**` renders literally, so this module
|
|
13
|
+
// installs `marked` as the processor once at import - see
|
|
14
|
+
// {@link installMarkdownProcessor}.
|
|
15
|
+
|
|
16
|
+
import type { AdaptiveCard as AdaptiveCardDocument } from "@dbx-tools/shared-teams";
|
|
17
|
+
import * as AdaptiveCards from "adaptivecards";
|
|
18
|
+
import { marked } from "marked";
|
|
19
|
+
import { useEffect, useRef } from "react";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Teach the renderer to process `TextBlock` markdown with `marked`.
|
|
23
|
+
*
|
|
24
|
+
* `onProcessMarkdown` is a STATIC hook on `AdaptiveCard`, so this runs once per
|
|
25
|
+
* module load rather than per component. `didProcess` must be set to `true` or
|
|
26
|
+
* the renderer discards the HTML and falls back to the raw text.
|
|
27
|
+
*
|
|
28
|
+
* `marked` is called in a try/catch and falls back to leaving the text alone:
|
|
29
|
+
* card text can come from a model, and a markdown edge case should degrade to
|
|
30
|
+
* plain text rather than blanking the card.
|
|
31
|
+
*/
|
|
32
|
+
const installMarkdownProcessor = () => {
|
|
33
|
+
AdaptiveCards.AdaptiveCard.onProcessMarkdown = (text, result) => {
|
|
34
|
+
try {
|
|
35
|
+
result.outputHtml = marked.parse(text, { async: false, breaks: true }) as string;
|
|
36
|
+
result.didProcess = true;
|
|
37
|
+
} catch {
|
|
38
|
+
result.didProcess = false;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
installMarkdownProcessor();
|
|
44
|
+
|
|
45
|
+
/** Props for {@link AdaptiveCardView}. */
|
|
46
|
+
export interface AdaptiveCardViewProps {
|
|
47
|
+
/** The compiled Adaptive Card document to render. */
|
|
48
|
+
card: AdaptiveCardDocument;
|
|
49
|
+
/**
|
|
50
|
+
* Called when an `Action.OpenUrl` button is tapped. Defaults to opening the
|
|
51
|
+
* URL in a new tab. Pass a handler to intercept (e.g. to post back instead).
|
|
52
|
+
*/
|
|
53
|
+
onOpenUrl?: (url: string) => void;
|
|
54
|
+
/** Extra classes merged onto the mount container. */
|
|
55
|
+
className?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render a single Adaptive Card document with the `adaptivecards` renderer.
|
|
60
|
+
* Re-renders whenever the card JSON changes.
|
|
61
|
+
*/
|
|
62
|
+
export const AdaptiveCardView = ({ card, onOpenUrl, className }: AdaptiveCardViewProps) => {
|
|
63
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const host = hostRef.current;
|
|
67
|
+
if (!host) return;
|
|
68
|
+
const rendered = new AdaptiveCards.AdaptiveCard();
|
|
69
|
+
rendered.onExecuteAction = (action) => {
|
|
70
|
+
if (action instanceof AdaptiveCards.OpenUrlAction && action.url) {
|
|
71
|
+
if (onOpenUrl) onOpenUrl(action.url);
|
|
72
|
+
else window.open(action.url, "_blank", "noopener,noreferrer");
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
rendered.parse(card as unknown as Record<string, unknown>);
|
|
77
|
+
const element = rendered.render();
|
|
78
|
+
host.replaceChildren(...(element ? [element] : []));
|
|
79
|
+
} catch (err) {
|
|
80
|
+
host.replaceChildren(
|
|
81
|
+
Object.assign(document.createElement("pre"), {
|
|
82
|
+
textContent: `Failed to render card: ${(err as Error).message}`,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return () => host.replaceChildren();
|
|
87
|
+
}, [card, onOpenUrl]);
|
|
88
|
+
|
|
89
|
+
return <div ref={hostRef} className={className} data-testid="adaptive-card" />;
|
|
90
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// React surface for `@dbx-tools/ui-teams`: a `TeamsChat` that talks to the
|
|
2
|
+
// conversation endpoint and renders each reply's Adaptive Card attachments like
|
|
3
|
+
// a Teams channel, an `AdaptiveCardView` that renders a compiled Adaptive Card
|
|
4
|
+
// document with the `adaptivecards` JavaScript renderer, and a self-contained
|
|
5
|
+
// `AdaptiveCardGallery` dev tool that edits a `CardSpec`, compiles it through
|
|
6
|
+
// the server's `/api/teams/card` route, and previews the result live. Styled
|
|
7
|
+
// with AppKit tokens.
|
|
8
|
+
|
|
9
|
+
export type { Activity, AdaptiveCard, CardResult, CardSpec } from "@dbx-tools/shared-teams";
|
|
10
|
+
export { TeamsChat, DEFAULT_STARTERS, type TeamsChatProps } from "./teams-chat";
|
|
11
|
+
export { AdaptiveCardView, type AdaptiveCardViewProps } from "./adaptive-card";
|
|
12
|
+
export { AdaptiveCardGallery, type AdaptiveCardGalleryProps } from "./adaptive-card-gallery";
|
|
13
|
+
export { CARD_SAMPLES, type CardSample } from "./samples";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// A few ready-made `CardSpec` samples the dev gallery starts from, so the
|
|
2
|
+
// preview shows something interesting before you edit anything. Each exercises
|
|
3
|
+
// a different combination of the spec's optional parts (subtitle, text, facts,
|
|
4
|
+
// actions).
|
|
5
|
+
|
|
6
|
+
import type { CardSpec } from "@dbx-tools/shared-teams";
|
|
7
|
+
|
|
8
|
+
/** A named sample card spec shown in the gallery picker. */
|
|
9
|
+
export interface CardSample {
|
|
10
|
+
/** Label shown in the picker. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** The spec to build/render. */
|
|
13
|
+
spec: CardSpec;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The built-in sample specs. */
|
|
17
|
+
export const CARD_SAMPLES: CardSample[] = [
|
|
18
|
+
{
|
|
19
|
+
label: "Deployment",
|
|
20
|
+
spec: {
|
|
21
|
+
title: "Deployment succeeded",
|
|
22
|
+
subtitle: "prod • 2 minutes ago",
|
|
23
|
+
text: "The **api** service rolled out cleanly across all regions.",
|
|
24
|
+
facts: [
|
|
25
|
+
{ title: "Version", value: "1.4.2" },
|
|
26
|
+
{ title: "Owner", value: "alice" },
|
|
27
|
+
{ title: "Duration", value: "3m 12s" },
|
|
28
|
+
],
|
|
29
|
+
actions: [
|
|
30
|
+
{ title: "View run", url: "https://example.com/runs/42" },
|
|
31
|
+
{ title: "Rollback", url: "https://example.com/runs/42/rollback" },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: "Incident",
|
|
37
|
+
spec: {
|
|
38
|
+
title: "PagerDuty: high latency",
|
|
39
|
+
subtitle: "SEV-2 • checkout-service",
|
|
40
|
+
text: "p95 latency exceeded 800ms for 5 minutes. Auto-scaling triggered.",
|
|
41
|
+
facts: [
|
|
42
|
+
{ title: "Status", value: "Investigating" },
|
|
43
|
+
{ title: "On-call", value: "bob" },
|
|
44
|
+
{ title: "Started", value: "14:02 UTC" },
|
|
45
|
+
],
|
|
46
|
+
actions: [{ title: "Open incident", url: "https://example.com/incidents/7" }],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
label: "Simple note",
|
|
51
|
+
spec: {
|
|
52
|
+
title: "Weekly report is ready",
|
|
53
|
+
text: "Numbers look healthy this week. See the dashboard for the breakdown.",
|
|
54
|
+
actions: [{ title: "Open dashboard", url: "https://example.com/dashboard" }],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
];
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// A Teams-like chat surface for the `@dbx-tools/teams` conversation endpoint.
|
|
2
|
+
// Each turn POSTs a Bot Framework activity to `POST /api/teams/activity` and
|
|
3
|
+
// appends the activities the bot answers with; a reply's Adaptive Card
|
|
4
|
+
// attachments render through the `adaptivecards` renderer, so the transcript
|
|
5
|
+
// looks like a Teams channel where the agent always answers in cards.
|
|
6
|
+
|
|
7
|
+
import { hash, json, string } from "@dbx-tools/shared-core";
|
|
8
|
+
import { activity as activityContract, type Activity } from "@dbx-tools/shared-teams";
|
|
9
|
+
import { Avatar, AvatarFallback, Button, Input, Spinner, cn } from "@dbx-tools/ui-appkit/react";
|
|
10
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
+
import { AdaptiveCardView } from "./adaptive-card";
|
|
12
|
+
|
|
13
|
+
/** Props for {@link TeamsChat}. */
|
|
14
|
+
export interface TeamsChatProps {
|
|
15
|
+
/**
|
|
16
|
+
* Conversation endpoint. Defaults to the Bot Framework messaging endpoint
|
|
17
|
+
* (`/api/teams/messages`) - the SAME route a real Teams channel calls, which
|
|
18
|
+
* is why this surface is a faithful preview rather than a parallel
|
|
19
|
+
* implementation.
|
|
20
|
+
*
|
|
21
|
+
* That route only answers in the HTTP response when the server enables its
|
|
22
|
+
* development bypass (`allowUnauthenticated`); against a real bot
|
|
23
|
+
* registration it validates a Bot Service JWT and delivers replies through
|
|
24
|
+
* the Connector API, so this component cannot drive it. Point at
|
|
25
|
+
* `/api/teams/activity` for a server that keeps `/messages` locked down.
|
|
26
|
+
*/
|
|
27
|
+
endpoint?: string;
|
|
28
|
+
/** Mastra agent to answer with. Defaults to the server's default agent. */
|
|
29
|
+
agentId?: string;
|
|
30
|
+
/** Display name of the local user, shown on their messages. */
|
|
31
|
+
userName?: string;
|
|
32
|
+
/** Prompts offered as one-tap starters while the transcript is empty. */
|
|
33
|
+
starters?: string[];
|
|
34
|
+
/** Extra classes merged onto the root container. */
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default starters. Deliberately generic - they exercise the different parts of
|
|
40
|
+
* a card (facts, bullets, actions) without assuming what the agent is wired to.
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_STARTERS = [
|
|
43
|
+
"Summarize today's deployment status",
|
|
44
|
+
"What should I know about this workspace?",
|
|
45
|
+
"Draft a release note for version 1.4.2",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** The local user's identity on outbound activities. */
|
|
49
|
+
const localUser = (name: string) => ({ id: "local-user", name });
|
|
50
|
+
|
|
51
|
+
/** Whether an activity was authored by the local user rather than the bot. */
|
|
52
|
+
const isFromUser = (item: Activity): boolean => item.from?.id === "local-user";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A Teams-style conversation with an agent that answers in Adaptive Cards.
|
|
56
|
+
*
|
|
57
|
+
* The transcript is a list of Bot Framework activities - the same objects the
|
|
58
|
+
* endpoint exchanges - so what renders here is exactly what a real channel
|
|
59
|
+
* would receive. A conversation id is minted once per mount and sent on every
|
|
60
|
+
* turn, which is what threads the server's agent memory.
|
|
61
|
+
*/
|
|
62
|
+
export const TeamsChat = ({
|
|
63
|
+
endpoint = "/api/teams/messages",
|
|
64
|
+
agentId,
|
|
65
|
+
userName = "You",
|
|
66
|
+
starters = DEFAULT_STARTERS,
|
|
67
|
+
className,
|
|
68
|
+
}: TeamsChatProps) => {
|
|
69
|
+
const [transcript, setTranscript] = useState<Activity[]>([]);
|
|
70
|
+
const [draft, setDraft] = useState("");
|
|
71
|
+
const [pending, setPending] = useState(false);
|
|
72
|
+
const [error, setError] = useState<string | null>(null);
|
|
73
|
+
// One conversation id for the lifetime of the mount: the server maps it onto
|
|
74
|
+
// an agent memory thread, so reusing it is what makes the chat continuous.
|
|
75
|
+
const conversationRef = useRef(`conv-${hash.id()}`);
|
|
76
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
77
|
+
// Tracks the in-flight turn so unmounting (or a rapid second send) cancels it
|
|
78
|
+
// instead of resolving into a dead component.
|
|
79
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
80
|
+
|
|
81
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
82
|
+
|
|
83
|
+
// Keep the newest activity in view as the transcript grows.
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const node = scrollRef.current;
|
|
86
|
+
if (node) node.scrollTop = node.scrollHeight;
|
|
87
|
+
}, [transcript, pending]);
|
|
88
|
+
|
|
89
|
+
const send = useCallback(
|
|
90
|
+
async (text: string) => {
|
|
91
|
+
const prompt = string.trimToNull(text);
|
|
92
|
+
if (!prompt || pending) return;
|
|
93
|
+
|
|
94
|
+
abortRef.current?.abort();
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
abortRef.current = controller;
|
|
97
|
+
|
|
98
|
+
const outbound: Activity = {
|
|
99
|
+
type: "message",
|
|
100
|
+
id: hash.id(),
|
|
101
|
+
timestamp: new Date().toISOString(),
|
|
102
|
+
text: prompt,
|
|
103
|
+
from: localUser(userName),
|
|
104
|
+
conversation: { id: conversationRef.current },
|
|
105
|
+
};
|
|
106
|
+
// Echo the user's turn immediately; the reply is appended when it lands.
|
|
107
|
+
setTranscript((current) => [...current, outbound]);
|
|
108
|
+
setDraft("");
|
|
109
|
+
setError(null);
|
|
110
|
+
setPending(true);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetch(endpoint, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "content-type": "application/json" },
|
|
116
|
+
body: JSON.stringify({ activity: outbound, ...(agentId ? { agentId } : {}) }),
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
const body = json.parseRecord(await response.text());
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
string.trimToNull(String(body?.error ?? "")) ?? `Request failed (${response.status})`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const parsed = activityContract.activityResponseSchema.safeParse(body);
|
|
126
|
+
if (!parsed.success) throw new Error("The server returned an unexpected reply.");
|
|
127
|
+
setTranscript((current) => [...current, ...parsed.data.activities]);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// An aborted turn is a deliberate cancel, not a failure to report.
|
|
130
|
+
if ((err as Error)?.name === "AbortError") return;
|
|
131
|
+
setError((err as Error).message);
|
|
132
|
+
} finally {
|
|
133
|
+
setPending(false);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
[agentId, endpoint, pending, userName],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className={cn("flex h-full flex-col overflow-hidden rounded-lg border", className)}>
|
|
141
|
+
<div className="flex items-center gap-2 border-b px-4 py-3">
|
|
142
|
+
<span className="text-sm font-semibold">Teams conversation</span>
|
|
143
|
+
<span className="text-muted-foreground text-xs">
|
|
144
|
+
answers arrive as Adaptive Cards from <code>{endpoint}</code>
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
|
149
|
+
{transcript.length === 0 && (
|
|
150
|
+
<div className="space-y-3 py-6 text-center">
|
|
151
|
+
<p className="text-muted-foreground text-sm">
|
|
152
|
+
Ask the agent something. It replies with an Adaptive Card, the way a Teams bot does.
|
|
153
|
+
</p>
|
|
154
|
+
<div className="flex flex-wrap justify-center gap-2">
|
|
155
|
+
{starters.map((starter) => (
|
|
156
|
+
<Button
|
|
157
|
+
key={starter}
|
|
158
|
+
variant="outline"
|
|
159
|
+
size="sm"
|
|
160
|
+
onClick={() => void send(starter)}
|
|
161
|
+
>
|
|
162
|
+
{starter}
|
|
163
|
+
</Button>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{transcript.map((item, index) => (
|
|
170
|
+
<ActivityBubble key={item.id ?? index} activity={item} userName={userName} />
|
|
171
|
+
))}
|
|
172
|
+
|
|
173
|
+
{pending && (
|
|
174
|
+
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
175
|
+
<Spinner className="size-4" />
|
|
176
|
+
<span>The agent is composing a card…</span>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{error && (
|
|
181
|
+
<p className="text-destructive text-sm" role="alert">
|
|
182
|
+
{error}
|
|
183
|
+
</p>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<form
|
|
188
|
+
className="flex items-center gap-2 border-t p-3"
|
|
189
|
+
onSubmit={(event) => {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
void send(draft);
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<Input
|
|
195
|
+
value={draft}
|
|
196
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
197
|
+
placeholder="Message the agent…"
|
|
198
|
+
aria-label="Message the agent"
|
|
199
|
+
disabled={pending}
|
|
200
|
+
/>
|
|
201
|
+
<Button type="submit" disabled={pending || string.trimToNull(draft) === null}>
|
|
202
|
+
Send
|
|
203
|
+
</Button>
|
|
204
|
+
</form>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* One activity in the transcript: the user's text on the right, the bot's reply
|
|
211
|
+
* on the left with each Adaptive Card attachment rendered.
|
|
212
|
+
*/
|
|
213
|
+
const ActivityBubble = ({ activity, userName }: { activity: Activity; userName: string }) => {
|
|
214
|
+
const fromUser = isFromUser(activity);
|
|
215
|
+
const cards = activityContract.cardsOf(activity);
|
|
216
|
+
const label = fromUser ? userName : (activity.from?.name ?? "Agent");
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div className={cn("flex gap-3", fromUser && "flex-row-reverse")}>
|
|
220
|
+
<Avatar className="size-8 shrink-0">
|
|
221
|
+
<AvatarFallback className="text-xs">{initials(label)}</AvatarFallback>
|
|
222
|
+
</Avatar>
|
|
223
|
+
<div className={cn("min-w-0 max-w-[85%] space-y-2", fromUser && "items-end text-right")}>
|
|
224
|
+
<span className="text-muted-foreground text-xs">{label}</span>
|
|
225
|
+
{activity.text && (
|
|
226
|
+
<div
|
|
227
|
+
className={cn(
|
|
228
|
+
"rounded-lg px-3 py-2 text-sm",
|
|
229
|
+
fromUser ? "bg-primary text-primary-foreground" : "bg-muted",
|
|
230
|
+
)}
|
|
231
|
+
>
|
|
232
|
+
{activity.text}
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
{cards.map((document, index) => (
|
|
236
|
+
<div key={index} className="bg-card rounded-lg border p-3 text-left shadow-sm">
|
|
237
|
+
<AdaptiveCardView card={document} />
|
|
238
|
+
</div>
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** Up-to-two-letter initials for an avatar fallback. */
|
|
246
|
+
const initials = (name: string): string =>
|
|
247
|
+
name
|
|
248
|
+
.split(/\s+/)
|
|
249
|
+
.filter(Boolean)
|
|
250
|
+
.slice(0, 2)
|
|
251
|
+
.map((part) => part[0]?.toUpperCase() ?? "")
|
|
252
|
+
.join("") || "?";
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @dbx-tools/ui-teams stylesheet.
|
|
3
|
+
*
|
|
4
|
+
* Import once from the host app's Tailwind entry, after Tailwind and
|
|
5
|
+
* your AppKit-UI theme:
|
|
6
|
+
*
|
|
7
|
+
* @import "tailwindcss";
|
|
8
|
+
* @import "@databricks/appkit-ui/styles.css";
|
|
9
|
+
* @import "@dbx-tools/ui-teams/styles.css";
|
|
10
|
+
*
|
|
11
|
+
* The gallery UI styles exclusively with AppKit semantic tokens and
|
|
12
|
+
* inherits the host app's theme. The rendered Adaptive Card itself is
|
|
13
|
+
* styled by the `adaptivecards` renderer's own host config; the small
|
|
14
|
+
* rules below only give the mount container sane spacing and let the
|
|
15
|
+
* card's own colors show through on both light and dark themes.
|
|
16
|
+
*
|
|
17
|
+
* Pulls shared styling from `@dbx-tools/ui-appkit` and registers this
|
|
18
|
+
* package's React components with Tailwind via `@source`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
@import "@dbx-tools/ui-appkit/styles.css";
|
|
22
|
+
|
|
23
|
+
@source "./react";
|
|
24
|
+
|
|
25
|
+
.ac-container {
|
|
26
|
+
--ac-font-family: inherit;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.ac-container .ac-adaptiveCard {
|
|
30
|
+
border-radius: var(--radius, 0.5rem);
|
|
31
|
+
background: var(--card, #fff);
|
|
32
|
+
color: var(--card-foreground, inherit);
|
|
33
|
+
padding: 0.75rem 1rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* Restore markdown block styling inside a rendered card.
|
|
38
|
+
*
|
|
39
|
+
* A `TextBlock` is markdown, and the host markdown processor (`marked`, wired
|
|
40
|
+
* up in `adaptive-card.tsx`) emits real `<ul>` / `<ol>` / `<p>` elements. But
|
|
41
|
+
* Tailwind's preflight resets list-style and margins globally, so a bulleted
|
|
42
|
+
* list in a card renders as unmarked, unindented lines. These rules re-apply
|
|
43
|
+
* list markers and paragraph spacing to card-rendered markdown ONLY, scoped
|
|
44
|
+
* under `.ac-textBlock` so nothing outside a card is affected.
|
|
45
|
+
*/
|
|
46
|
+
.ac-textBlock ul,
|
|
47
|
+
.ac-textBlock ol {
|
|
48
|
+
margin: 0.25rem 0;
|
|
49
|
+
padding-inline-start: 1.25rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.ac-textBlock ul {
|
|
53
|
+
list-style: disc outside;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ac-textBlock ol {
|
|
57
|
+
list-style: decimal outside;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.ac-textBlock li {
|
|
61
|
+
display: list-item;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.ac-textBlock p {
|
|
65
|
+
margin: 0.25rem 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/*
|
|
69
|
+
* The renderer sets `line-height` INLINE on the `TextBlock` container to the
|
|
70
|
+
* value the card's host config asked for, but the host app's base typography
|
|
71
|
+
* sets a taller `line-height` on `p` itself, which wins over the inherited
|
|
72
|
+
* container value. That double-spaces every card paragraph and makes a wrapped
|
|
73
|
+
* `FactSet` title look like two separate rows. Inheriting puts the renderer's
|
|
74
|
+
* own metrics back in charge.
|
|
75
|
+
*/
|
|
76
|
+
.ac-textBlock p,
|
|
77
|
+
.ac-textBlock li {
|
|
78
|
+
line-height: inherit;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.ac-textBlock p:first-child {
|
|
82
|
+
margin-top: 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.ac-textBlock p:last-child {
|
|
86
|
+
margin-bottom: 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.ac-textBlock a {
|
|
90
|
+
color: var(--primary, #0b6bcb);
|
|
91
|
+
text-decoration: underline;
|
|
92
|
+
}
|