Docs: answer with cards (steps, tables, notes, follow-up questions) in Gordon chat (#26452)

## Description

Gordon answers everything in plain markdown, even when the answer is a
procedure or a comparison. Those answers are harder to scan than they
need to be. This PR lets Gordon put a small card inside its answer when
that helps:

- **Steps** for procedures, with commands in code boxes
- **Tables** for comparisons (limits, plans, options)
- **Notes** for warnings, active incidents or tips
- **Code snippets** for config files
- **Follow-up questions** as chips. Selecting one asks it as your next
question.

```
You can containerize it with docker init, then run it with Compose.

┌───────────────────────────────────────────────┐
│ Containerize a Node.js app                    │
│ ① Generate the Docker assets                  │
│   [ docker init ]                             │
│ ② Build and run the app                       │
│   [ docker compose up --build ]               │
│ ▌ Use Docker Hardened Images for security     │
│ ( How do I add a database service? )          │
└───────────────────────────────────────────────┘

Sources:
- https://docs.docker.com/guides/nodejs/
```

Short answers, explanations and greetings stay plain markdown.

### How it works

```
chat ── "I can draw cards" (ui: "openui-v1") ──▶ Gordon
chat ◀── markdown answer, with at most one ```openui-lang block ── Gordon
chat: draws the block as a card, everything else as markdown
```

- The chat sends one extra field with each question. Only then does
Gordon's backend tell the model it may add a card. Without the field,
answers are the same as today.
- A card is a fenced block of [OpenUI
Lang](https://www.openui.com/docs/openui-lang) inside the normal answer
text, so the conversation history and stored messages stay plain text.
- The chat draws the block with its own styles. Anything it doesn't
recognize is left out, and if the card code can't load, the block shows
as a plain code block.

### Visual change to all answers

Gordon's answers are no longer inside a grey bubble. A framed card
inside the grey bubble looked like a box in a box. Answers now sit on
the panel background at full width, and the card stands out as its own
block. Your own messages keep the blue bubble.

<!-- screenshots: before / after, light and dark -->

### Rollout

- Cards are **on in local builds and deploy previews**, and **off in
production**.
- To turn them on in production, set `params.gordon.cards: true` in
`hugo.yaml`.
- Gordon's backend already supports this, so no other change is needed.

### Other notes

- **Safety:** card text from the model is only ever inserted as text,
never as HTML.
- **Page weight:** the card code (about 120 KB gzipped) is a separate
bundle. It's only downloaded when an answer first contains a card, never
on page load.
- **Copy button:** copies the answer with cards written out as markdown
(numbered steps, a table, `> [!NOTE]`), not the raw card code. Follow-up
chips are left out.
- **Instructions Gordon gets:** the prompt lives entirely in Gordon's
backend, which injects it when the chat sends `ui: "openui-v1"`. This
repo only has the component list the chat draws
(`assets/js/gordon-cards/components.mjs`). The backend's card prompt
describes the same components, so when adding or changing one, the
backend's prompt changes with it.
- **New dependencies:** `@openuidev/lang-core` (the OpenUI Lang parser)
and `zod`, pinned. Its usage telemetry never runs in a browser, and
`.npmrc` already blocks install scripts.

### How to test

1. Run `docker compose watch` and open http://localhost:1313. Local
builds use Gordon's staging backend.
2. Open Gordon from the header and ask:
- **Should show a card:** "How do I containerize my Node.js app?", "What
are the Docker Hub pull limits?", "How do I set up a registry mirror?"
   - **Should stay plain text:** "What is Docker Scout?", "hi"
3. Select a follow-up chip. It should ask that question.
4. Select **Copy** on an answer with a card. The pasted text should be
markdown.
5. Reload the page. Cards should come back from the conversation
history.
6. Check dark mode.
7. In the browser's Network tab, check:
   - each question to `/public/ask` includes `"ui": "openui-v1"`;
   - `gordon-cards.js` only loads after the first answer with a card.

## Related issues or tickets

<!-- none public -->

## Reviews

- [x] Technical review
- [ ] Editorial review
- [ ] Product review
This commit is contained in:
Trung Nguyen authored and GitHub committed 2026-10-27 17:16:06 +01:00
1 parent fc5b3aaf1f
commit 02b412b178
10 files changed
+865 -10

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
// The components Gordon may draw in a ```openui-lang card in a docs chat answer (OpenUI Lang, see
// https://www.openui.com/docs/openui-lang): each one's name, what the model is told it's for, and
// its props. render.js draws each of them. Gordon's backend tells the model about these same
// components, so when adding or changing one here, the backend's card prompt must change with it:
// a component the model writes but the chat can't draw is dropped, content and all.
//
// OpenUI Lang passes props positionally, in each schema's key order. Reordering or removing a key
// changes the meaning of cards already in a conversation: only ever append optional keys.
//
// Children come before the parents that reference them: OpenUI names a parent's child types by
// looking their schemas up among the components registered with it.
import { createLibrary, defineComponent } from '@openuidev/lang-core'
import { z } from 'zod/v4'
// The fence language that marks a markdown code block in Gordon's answer as a card.
export const GORDON_CARD_FENCE_LANGUAGE = 'openui-lang'
// Sent to the Gordon backend as `ui` in the request context to say the chat can draw cards. The
// backend only teaches the model to write them when it's present.
export const GORDON_CARD_UI_CAPABILITY = 'openui-v1'
// Every card's `root` is a `Card`.
export const GORDON_CARD_ROOT = 'Card'
const tableProps = z.object({
columns: z.array(z.string()),
rows: z.array(z.array(z.union([z.string(), z.number()]))),
})
const stepProps = z.object({
title: z.string(),
detail: z.string().optional(),
command: z.string().optional(),
})
const stepsProps = z.object({
items: z.array(stepProps),
})
const calloutProps = z.object({
variant: z.enum(['info', 'success', 'warning', 'destructive']),
title: z.string(),
text: z.string().optional(),
})
const codeProps = z.object({
code: z.string(),
language: z.string().optional(),
})
const followUpsProps = z.object({
items: z.array(z.string()),
})
const cardProps = z.object({
title: z.string(),
children: z.array(
z.union([stepsProps, calloutProps, tableProps, codeProps, followUpsProps]),
),
description: z.string().optional(),
})
export const gordonCardComponents = [
{
name: 'Table',
description:
'A table. rows is a list of rows; each row has one cell per column, in column order. A cell is a string or a number.',
props: tableProps,
},
{
name: 'Step',
description:
'One step of a procedure: a short title, an optional detail sentence, and an optional shell command to run.',
props: stepProps,
},
{
name: 'Steps',
description: 'A numbered procedure.',
props: stepsProps,
},
{
name: 'Callout',
description:
'A highlighted note: a cause, a warning, an active incident or a result worth noticing.',
props: calloutProps,
},
{
name: 'Code',
description:
'A code or config snippet, e.g. a Dockerfile, a compose.yaml or a daemon.json excerpt.',
props: codeProps,
},
{
name: 'FollowUps',
description:
'Up to 3 short follow-up questions the user is likely to ask next, as clickable chips. Last child of the Card.',
props: followUpsProps,
},
{
name: 'Card',
description:
'The root of every block: a titled container for the other components, stacked vertically.',
props: cardProps,
},
]
// The OpenUI library for the components above. Only their schemas matter: render.js draws the
// parsed tree itself, so no component gets a renderer here.
export function createGordonCardLibrary() {
return createLibrary({
root: GORDON_CARD_ROOT,
components: gordonCardComponents.map((component) =>
defineComponent({ ...component, component: renderNothing }),
),
})
}
function renderNothing() {
return null
}
+8
View File
@@ -0,0 +1,8 @@
// Entry point of the gordon-cards.js bundle. It's built apart from scripts.js and only loaded by
// the Gordon chat when an answer first contains a card (see loadGordonCards in src/alpine.js), so
// the OpenUI parser doesn't weigh on every docs page.
import { renderGordonCard } from './render.mjs'
import { gordonAnswerToMarkdown } from './to-markdown.mjs'
window.GordonCards = { renderGordonCard, gordonAnswerToMarkdown }
+51
View File
@@ -0,0 +1,51 @@
import { createParser } from '@openuidev/lang-core'
import { createGordonCardLibrary, GORDON_CARD_ROOT } from './components.mjs'
let parser
// The card in a ```openui-lang block, as an element tree ({ type: 'element', typeName, props }),
// or null if nothing in it parses into a Card (yet, while it streams). Components that aren't in
// components.mjs, unresolved references and unused statements are left out.
export function parseGordonCard(code) {
parser ??= createParser(
createGordonCardLibrary().toJSONSchema(),
GORDON_CARD_ROOT,
)
return parser.parse(code).root
}
// The props of a parsed child component, or undefined if `value` isn't one (yet). Props are
// model output, possibly half-streamed, so nothing about their shape can be assumed.
export function readElement(value) {
if (
typeof value !== 'object' ||
value === null ||
value.type !== 'element' ||
typeof value.props !== 'object' ||
value.props === null
) {
return undefined
}
return { typeName: value.typeName, props: value.props }
}
export function readArray(value) {
return Array.isArray(value) ? value : []
}
export function readString(value) {
return typeof value === 'string' ? value : undefined
}
export function readCell(value) {
return typeof value === 'string' || typeof value === 'number'
? String(value)
: ''
}
export const CALLOUT_VARIANTS = ['info', 'success', 'warning', 'destructive']
export function readCalloutVariant(value) {
return CALLOUT_VARIANTS.includes(value) ? value : 'info'
}
+161
View File
@@ -0,0 +1,161 @@
import {
parseGordonCard,
readArray,
readCalloutVariant,
readCell,
readElement,
readString,
} from './parse.mjs'
// Draws a ```openui-lang block from Gordon's answer as a card element, or returns null if nothing
// in it parses into a card (yet). Text from the model only ever goes in through textContent and
// setAttribute, never as HTML. Code blocks come out as <pre><code> for the chat's usual syntax
// highlighting, and follow-up chips as buttons carrying data-gordon-follow-up, which the chat
// turns into the user's next question.
export function renderGordonCard(code) {
const root = parseGordonCard(code)
return root === null ? null : renderCard(root.props)
}
function renderCard(props) {
const card = element('section', 'gordon-card not-prose')
const title = readString(props.title)
const description = readString(props.description)
if (title !== undefined) {
card.append(element('h4', 'gordon-card-title', title))
}
if (description !== undefined) {
card.append(element('p', 'gordon-card-description', description))
}
for (const child of readArray(props.children)) {
const rendered = renderChild(child)
if (rendered !== undefined) {
card.append(rendered)
}
}
return card
}
function renderChild(node) {
const child = readElement(node)
if (child === undefined) {
return undefined
}
switch (child.typeName) {
case 'Steps':
return renderSteps(child.props)
case 'Callout':
return renderCallout(child.props)
case 'Table':
return renderTable(child.props)
case 'Code':
return renderCode(
readString(child.props.code) ?? '',
readString(child.props.language),
)
case 'FollowUps':
return renderFollowUps(child.props)
default:
return undefined
}
}
function renderSteps(props) {
const list = element('ol', 'gordon-card-steps')
for (const item of readArray(props.items)) {
const step = readElement(item)
if (step === undefined) {
continue
}
const title = readString(step.props.title)
const detail = readString(step.props.detail)
const command = readString(step.props.command)
const entry = element('li', 'gordon-card-step')
if (title !== undefined) {
entry.append(element('p', 'gordon-card-step-title', title))
}
if (detail !== undefined) {
entry.append(element('p', 'gordon-card-step-detail', detail))
}
if (command !== undefined) {
entry.append(renderCode(command, 'bash'))
}
list.append(entry)
}
return list
}
function renderCallout(props) {
const variant = readCalloutVariant(props.variant)
const callout = element(
'div',
`gordon-card-callout gordon-card-callout-${variant}`,
)
const title = readString(props.title)
const text = readString(props.text)
if (title !== undefined) {
callout.append(element('p', 'gordon-card-callout-title', title))
}
if (text !== undefined) {
callout.append(element('p', 'gordon-card-callout-text', text))
}
return callout
}
function renderTable(props) {
const wrapper = element('div', 'gordon-card-table')
const table = element('table')
const head = element('thead')
const headRow = element('tr')
for (const column of readArray(props.columns)) {
headRow.append(element('th', undefined, readCell(column)))
}
head.append(headRow)
const body = element('tbody')
for (const row of readArray(props.rows)) {
const bodyRow = element('tr')
for (const cell of readArray(row)) {
bodyRow.append(element('td', undefined, readCell(cell)))
}
body.append(bodyRow)
}
table.append(head, body)
wrapper.append(table)
return wrapper
}
function renderCode(code, language) {
const pre = element('pre', 'not-prose')
const codeElement = element('code', undefined, code)
if (language !== undefined && /^[\w+-]+$/.test(language)) {
codeElement.className = `language-${language}`
}
pre.append(codeElement)
return pre
}
function renderFollowUps(props) {
const list = element('div', 'gordon-card-follow-ups')
const questions = readArray(props.items)
.map(readString)
.filter((question) => question !== undefined && question.trim() !== '')
.slice(0, 3)
for (const question of questions) {
const chip = element('button', 'gordon-card-follow-up', question)
chip.type = 'button'
chip.setAttribute('data-gordon-follow-up', question)
list.append(chip)
}
return list
}
function element(tag, className, text) {
const node = document.createElement(tag)
if (className !== undefined) {
node.className = className
}
if (text !== undefined) {
node.textContent = text
}
return node
}
+127
View File
@@ -0,0 +1,127 @@
import { GORDON_CARD_FENCE_LANGUAGE } from './components.mjs'
import {
parseGordonCard,
readArray,
readCalloutVariant,
readCell,
readElement,
readString,
} from './parse.mjs'
// Gordon's answer with each ```openui-lang card written out as plain markdown, for where the
// answer is read as text rather than drawn: what the Copy button copies. Follow-up chips are left
// out, as they only make sense in the chat. A block that doesn't parse into a card is kept as is.
export function gordonAnswerToMarkdown(answer) {
return answer.replace(CARD_FENCE, (block, code) => {
const root = parseGordonCard(code)
return root === null ? block : cardToMarkdown(root.props)
})
}
const CARD_FENCE = new RegExp(
`^\`\`\`${GORDON_CARD_FENCE_LANGUAGE}[^\\S\\n]*\\n([\\s\\S]*?)^\`\`\`[^\\S\\n]*$`,
'gm',
)
const CALLOUT_ALERTS = {
info: 'NOTE',
success: 'TIP',
warning: 'WARNING',
destructive: 'CAUTION',
}
function cardToMarkdown(props) {
const title = readString(props.title)
const description = readString(props.description)
const children = readArray(props.children)
.map(componentToMarkdown)
.filter((markdown) => markdown !== undefined)
return [
title === undefined ? undefined : `**${title}**`,
description,
...children,
]
.filter((part) => part !== undefined)
.join('\n\n')
}
function componentToMarkdown(node) {
const child = readElement(node)
if (child === undefined) {
return undefined
}
switch (child.typeName) {
case 'Steps':
return stepsToMarkdown(child.props)
case 'Callout':
return calloutToMarkdown(child.props)
case 'Table':
return tableToMarkdown(child.props)
case 'Code':
return codeToMarkdown(
readString(child.props.code) ?? '',
readString(child.props.language),
)
default:
return undefined
}
}
function stepsToMarkdown(props) {
return readArray(props.items)
.map(readElement)
.filter((step) => step !== undefined)
.map(({ props: step }, index) => {
const detail = readString(step.detail)
const command = readString(step.command)
return [
`${index + 1}. ${readString(step.title) ?? ''}`,
...(detail === undefined ? [] : [` ${detail}`]),
...(command === undefined
? []
: [indent(codeToMarkdown(command, 'bash'), ' ')]),
].join('\n')
})
.join('\n')
}
function calloutToMarkdown(props) {
const title = readString(props.title)
const text = readString(props.text)
return [
`> [!${CALLOUT_ALERTS[readCalloutVariant(props.variant)]}]`,
...(title === undefined ? [] : [`> **${title}**`]),
...(text === undefined ? [] : [`> ${text}`]),
].join('\n')
}
function tableToMarkdown(props) {
const columns = readArray(props.columns).map(toTableCell)
const rows = readArray(props.rows).map((row) =>
readArray(row).map(toTableCell),
)
return [
toTableRow(columns),
toTableRow(columns.map(() => '---')),
...rows.map(toTableRow),
].join('\n')
}
function codeToMarkdown(code, language) {
return [`\`\`\`${language ?? ''}`, code, '```'].join('\n')
}
function toTableRow(cells) {
return `| ${cells.join(' | ')} |`
}
function toTableCell(value) {
return readCell(value).replaceAll('|', '\\|').replaceAll('\n', ' ')
}
function indent(text, prefix) {
return text
.split('\n')
.map((line) => prefix + line)
.join('\n')
}
+66 -1
View File
@@ -51,9 +51,72 @@ marked.use({
}
})
// Gordon's answers can contain ```openui-lang cards. The code that draws them is a separate
// bundle (assets/js/gordon-cards), loaded the first time a card shows up.
Alpine.store('gordonCards', { ready: false, failed: false })
let gordonCardsLoading
function loadGordonCards() {
gordonCardsLoading ??= new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = window.GORDON_CARDS_SRC
script.onload = () => {
Alpine.store('gordonCards').ready = true
resolve(window.GordonCards)
}
script.onerror = () => {
// Cards then stay plain code blocks, but a later call (such as Copy) can try again.
gordonCardsLoading = undefined
script.remove()
Alpine.store('gordonCards').failed = true
reject(new Error('Failed to load gordon-cards.js'))
}
document.head.append(script)
})
return gordonCardsLoading
}
Alpine.magic('gordonCards', () => loadGordonCards)
// Swaps each ```openui-lang block for the card it describes. Until the card code has loaded, or
// while a block is still streaming in and doesn't parse yet, it shows a placeholder. If the card
// code can't load, or a finished block isn't a card, the block stays a plain code block.
function replaceGordonCards(div, { streaming }) {
const blocks = div.querySelectorAll('pre > code.language-openui-lang')
if (blocks.length === 0) return
const cards = Alpine.store('gordonCards')
if (!cards.ready && !cards.failed) {
loadGordonCards().catch((err) => console.error('Gordon cards:', err))
}
blocks.forEach((code) => {
const pre = code.parentElement
if (cards.ready) {
const card = window.GordonCards.renderGordonCard(code.textContent)
if (card) {
pre.replaceWith(card)
} else if (streaming) {
pre.replaceWith(gordonCardPlaceholder())
}
} else if (!cards.failed) {
pre.replaceWith(gordonCardPlaceholder())
}
})
}
function gordonCardPlaceholder() {
const placeholder = document.createElement('div')
placeholder.className = 'gordon-card-loading not-prose'
placeholder.setAttribute('role', 'status')
placeholder.setAttribute('aria-label', 'Drawing card')
placeholder.append(document.createElement('span'), document.createElement('span'))
return placeholder
}
// Add $markdown magic for rendering markdown with syntax highlighting
Alpine.magic('markdown', () => {
return (content) => {
return (content, { streaming = false } = {}) => {
if (!content) return ''
const html = marked(content)
@@ -61,6 +124,8 @@ Alpine.magic('markdown', () => {
const div = document.createElement('div')
div.innerHTML = html
replaceGordonCards(div, { streaming })
// Handle code blocks (pre > code)
div.querySelectorAll('pre').forEach((pre) => {
// Add not-prose to prevent Tailwind Typography styling
+6
View File
@@ -166,6 +166,12 @@ params:
# Docs repository URL
repo: https://github.com/docker/docs
# Gordon chat
gordon:
# Lets Gordon answer with cards (steps, tables, notes, snippets, follow-up
# questions) in production builds. Always on in other environments.
cards: false
# === Example versions ===
# The following parameters are used in various ways in content and templates.
# Mostly as examples in code snippets, but also in other exotic ways.
+276 -7
View File
@@ -7,6 +7,12 @@
{{- else -}}
'https://ai-backend-service-stage.docker.com'
{{- end }};
{{- $gordonCards := resources.Get "js/gordon-cards/index.js"
| js.Build (dict "minify" true "targetPath" "gordon-cards.js")
}}
window.GORDON_CARDS_SRC = '{{ $gordonCards.RelPermalink }}';
// Lets Gordon answer with cards. Always on outside production builds.
window.GORDON_CARDS_ENABLED = {{ or (ne hugo.Environment "production") site.Params.gordon.cards }};
</script>
<div
x-data="{
@@ -157,6 +163,7 @@
email: 'docs@docker.com',
uuid: this.getSessionId(),
action: 'AskGordon',
...(window.GORDON_CARDS_ENABLED && { ui: 'openui-v1' }),
...(this.includePageContext && {
page_url: window.location.href,
{{ with .Title }}page_title: {{ . | jsonify }}{{ end }}
@@ -254,8 +261,30 @@
}
},
formatMessageContent(content) {
return this.$markdown(content)
formatMessageContent(message) {
return this.$markdown(message.content, { streaming: message.isStreaming === true })
},
askFollowUp(event) {
const chip = event.target.closest('[data-gordon-follow-up]')
if (!chip || this.isLoading || this.isThreadLimitReached()) {
return
}
this.currentQuestion = chip.dataset.gordonFollowUp
this.askQuestion()
},
async getCopyText(content) {
if (!content.includes('```openui-lang')) {
return content
}
try {
const cards = await this.$gordonCards()
return cards.gordonAnswerToMarkdown(content)
} catch (err) {
console.error('Gordon cards:', err)
return content
}
},
async submitFeedback(messageIndex, feedbackType) {
@@ -301,7 +330,7 @@
if (!message.content) return
try {
await navigator.clipboard.writeText(message.content)
await navigator.clipboard.writeText(await this.getCopyText(message.content))
message.copied = true
this.$nextTick(() => {
setTimeout(() => {
@@ -385,6 +414,7 @@
<!-- Messages container -->
<div
x-ref="messagesContainer"
@click="askFollowUp($event)"
class="flex min-h-0 flex-1 flex-col space-y-4 overflow-y-auto overscroll-contain p-6"
style="scrollbar-width: none; &::-webkit-scrollbar: { display: none; }"
>
@@ -460,10 +490,13 @@
<div
:class="message.role === 'user' ? 'flex justify-end' : 'flex justify-start'"
>
<div class="flex max-w-full flex-col gap-2">
<div
:class="message.role === 'user' ? '' : 'w-full'"
class="flex max-w-full flex-col gap-2"
>
<div
:class="message.role === 'user' ? 'bg-blue-500 dark:bg-blue-800 text-white' : 'max-w-none bg-gray-100 dark:bg-gray-800'"
class="prose prose-sm dark:prose-invert rounded-lg px-4"
:class="message.role === 'user' ? 'rounded-lg bg-blue-500 px-4 text-white dark:bg-blue-800' : 'max-w-none'"
class="prose prose-sm dark:prose-invert"
>
<template x-if="!message.content && message.isStreaming">
<div class="flex gap-1 py-3">
@@ -482,7 +515,7 @@
</div>
</template>
<template x-if="message.content">
<div x-html="formatMessageContent(message.content)"></div>
<div x-html="formatMessageContent(message)"></div>
</template>
</div>
<!-- Feedback buttons for assistant messages -->
@@ -750,4 +783,240 @@
background-color: rgb(55 65 81);
color: rgb(229 231 235);
}
/* Cards Gordon draws in an answer (assets/js/gordon-cards). Plain CSS rather than Tailwind
classes: the cards are built in JavaScript, and Tailwind only sees classes in rendered HTML. */
#gordon-chat .gordon-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.75rem 0;
padding: 1rem;
border: 1px solid rgba(200, 207, 218, 1);
border-radius: 0.5rem;
background: #fff;
color: rgba(30, 33, 41, 1);
}
.dark #gordon-chat .gordon-card {
border-color: rgba(67, 76, 95, 1);
background: rgba(30, 33, 41, 1);
color: rgba(231, 234, 239, 1);
}
#gordon-chat .gordon-card-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
#gordon-chat .gordon-card-description {
margin: -0.5rem 0 0;
font-size: 0.851rem;
color: rgba(86, 101, 129, 1);
}
.dark #gordon-chat .gordon-card-description {
color: rgba(169, 180, 198, 1);
}
#gordon-chat .gordon-card pre {
margin: 0;
}
#gordon-chat .gordon-card-steps {
counter-reset: gordon-step;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
#gordon-chat .gordon-card-step {
counter-increment: gordon-step;
position: relative;
display: flex;
flex-direction: column;
gap: 0.375rem;
min-width: 0;
padding-left: 2rem;
}
#gordon-chat .gordon-card-step::before {
content: counter(gordon-step);
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border-radius: 9999px;
background: rgba(37, 96, 255, 1);
color: #fff;
font-size: 0.75rem;
font-weight: 600;
}
#gordon-chat .gordon-card-step-title {
margin: 0;
font-weight: 600;
}
#gordon-chat .gordon-card-step-detail {
margin: 0;
font-size: 0.851rem;
color: rgba(86, 101, 129, 1);
}
.dark #gordon-chat .gordon-card-step-detail {
color: rgba(169, 180, 198, 1);
}
#gordon-chat .gordon-card-callout {
padding: 0.625rem 0.875rem;
border-left: 4px solid;
border-radius: 0.25rem;
font-size: 0.851rem;
}
#gordon-chat .gordon-card-callout p {
margin: 0;
}
#gordon-chat .gordon-card-callout-title {
font-weight: 600;
}
#gordon-chat .gordon-card-callout-info {
border-color: rgba(37, 96, 255, 1);
background: rgba(217, 229, 252, 1);
}
#gordon-chat .gordon-card-callout-success {
border-color: rgba(45, 149, 104, 1);
background: rgba(235, 249, 238, 1);
}
#gordon-chat .gordon-card-callout-warning {
border-color: rgba(235, 156, 0, 1);
background: rgba(255, 245, 219, 1);
}
#gordon-chat .gordon-card-callout-destructive {
border-color: rgba(228, 12, 44, 1);
background: rgba(255, 223, 223, 1);
}
.dark #gordon-chat .gordon-card-callout-info {
background: rgba(0, 29, 86, 0.6);
}
.dark #gordon-chat .gordon-card-callout-success {
background: rgba(23, 75, 50, 0.6);
}
.dark #gordon-chat .gordon-card-callout-warning {
background: rgba(133, 73, 0, 0.45);
}
.dark #gordon-chat .gordon-card-callout-destructive {
background: rgba(137, 0, 0, 0.45);
}
#gordon-chat .gordon-card-table {
overflow-x: auto;
}
#gordon-chat .gordon-card-table table {
width: 100%;
margin: 0;
border-collapse: collapse;
font-size: 0.851rem;
font-variant-numeric: tabular-nums;
}
#gordon-chat .gordon-card-table th,
#gordon-chat .gordon-card-table td {
padding: 0.375rem 0.625rem;
border-bottom: 1px solid rgba(231, 234, 239, 1);
text-align: left;
vertical-align: top;
}
#gordon-chat .gordon-card-table th {
font-weight: 600;
}
.dark #gordon-chat .gordon-card-table th,
.dark #gordon-chat .gordon-card-table td {
border-bottom-color: rgba(67, 76, 95, 1);
}
#gordon-chat .gordon-card-follow-ups {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
#gordon-chat .gordon-card-follow-up {
padding: 0.25rem 0.75rem;
border: 1px solid rgba(37, 96, 255, 1);
border-radius: 9999px;
background: transparent;
color: rgba(37, 96, 255, 1);
font-size: 0.851rem;
text-align: left;
cursor: pointer;
transition: background-color 150ms;
}
#gordon-chat .gordon-card-follow-up:hover {
background: rgba(217, 229, 252, 1);
}
.dark #gordon-chat .gordon-card-follow-up {
border-color: rgba(123, 164, 244, 1);
color: rgba(123, 164, 244, 1);
}
.dark #gordon-chat .gordon-card-follow-up:hover {
background: rgba(0, 29, 86, 1);
}
#gordon-chat .gordon-card-loading {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.75rem 0;
}
#gordon-chat .gordon-card-loading span {
display: block;
height: 0.875rem;
border-radius: 0.25rem;
background: rgba(200, 207, 218, 1);
animation: gordon-card-pulse 1.5s ease-in-out infinite;
}
#gordon-chat .gordon-card-loading span:first-child {
width: 33%;
}
#gordon-chat .gordon-card-loading span:last-child {
height: 4rem;
}
.dark #gordon-chat .gordon-card-loading span {
background: rgba(67, 76, 95, 1);
}
@keyframes gordon-card-pulse {
50% {
opacity: 0.5;
}
}
</style>
+46 -1
View File
@@ -14,6 +14,7 @@
"@alpinejs/persist": "3.15.8",
"@docker/marlin-sdk-web-public": "0.6.0",
"@floating-ui/dom": "1.7.6",
"@openuidev/lang-core": "1.0.1",
"@tailwindcss/cli": "4.2.1",
"@tailwindcss/typography": "0.5.19",
"alpinejs": "3.15.8",
@@ -22,7 +23,8 @@
"marked": "17.0.4",
"mermaid": "11.15.0",
"micromark": "4.0.2",
"tailwindcss": "4.2.1"
"tailwindcss": "4.2.1",
"zod": "4.6.7"
},
"devDependencies": {
"prettier": "3.8.1",
@@ -214,6 +216,25 @@
"@chevrotain/types": "~11.1.1"
}
},
"node_modules/@openuidev/lang-core": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@openuidev/lang-core/-/lang-core-1.0.1.tgz",
"integrity": "sha512-4vBa7FRM6C4v4aCvBAmAtTgFWiEQiNTazG9h4Xy9fC6P/p6+9kZu4RdTyLToBEvdxrYn/gDy/PakEH8PU3lUcw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"ci-info": "^4.4.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": ">=1.0.0",
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -1252,6 +1273,21 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ci-info": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
@@ -3060,6 +3096,15 @@
"bin": {
"uuidv7": "cli.js"
}
},
"node_modules/zod": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+3 -1
View File
@@ -19,6 +19,7 @@
"@alpinejs/persist": "3.15.8",
"@docker/marlin-sdk-web-public": "0.6.0",
"@floating-ui/dom": "1.7.6",
"@openuidev/lang-core": "1.0.1",
"@tailwindcss/cli": "4.2.1",
"@tailwindcss/typography": "0.5.19",
"alpinejs": "3.15.8",
@@ -27,7 +28,8 @@
"marked": "17.0.4",
"mermaid": "11.15.0",
"micromark": "4.0.2",
"tailwindcss": "4.2.1"
"tailwindcss": "4.2.1",
"zod": "4.6.7"
},
"devDependencies": {
"prettier": "3.8.1",