From 02b412b178a996b352c9723eb090178f6d53f7f0 Mon Sep 17 00:00:00 2001 From: Trung Nguyen Date: Tue, 27 Oct 2026 17:16:06 +0100 Subject: [PATCH] Docs: answer with cards (steps, tables, notes, follow-up questions) in Gordon chat (#26452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. ### 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 ## Reviews - [x] Technical review - [ ] Editorial review - [ ] Product review --- assets/js/gordon-cards/components.mjs | 121 +++++++++++ assets/js/gordon-cards/index.js | 8 + assets/js/gordon-cards/parse.mjs | 51 +++++ assets/js/gordon-cards/render.mjs | 161 ++++++++++++++ assets/js/gordon-cards/to-markdown.mjs | 127 +++++++++++ assets/js/src/alpine.js | 67 +++++- hugo.yaml | 6 + layouts/_partials/gordon-chat.html | 283 ++++++++++++++++++++++++- package-lock.json | 47 +++- package.json | 4 +- 10 files changed, 865 insertions(+), 10 deletions(-) create mode 100644 assets/js/gordon-cards/components.mjs create mode 100644 assets/js/gordon-cards/index.js create mode 100644 assets/js/gordon-cards/parse.mjs create mode 100644 assets/js/gordon-cards/render.mjs create mode 100644 assets/js/gordon-cards/to-markdown.mjs diff --git a/assets/js/gordon-cards/components.mjs b/assets/js/gordon-cards/components.mjs new file mode 100644 index 0000000000..689627bd3c --- /dev/null +++ b/assets/js/gordon-cards/components.mjs @@ -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 +} diff --git a/assets/js/gordon-cards/index.js b/assets/js/gordon-cards/index.js new file mode 100644 index 0000000000..894487c30e --- /dev/null +++ b/assets/js/gordon-cards/index.js @@ -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 } diff --git a/assets/js/gordon-cards/parse.mjs b/assets/js/gordon-cards/parse.mjs new file mode 100644 index 0000000000..532a696629 --- /dev/null +++ b/assets/js/gordon-cards/parse.mjs @@ -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' +} diff --git a/assets/js/gordon-cards/render.mjs b/assets/js/gordon-cards/render.mjs new file mode 100644 index 0000000000..3fca1e12bc --- /dev/null +++ b/assets/js/gordon-cards/render.mjs @@ -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
 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
+}
diff --git a/assets/js/gordon-cards/to-markdown.mjs b/assets/js/gordon-cards/to-markdown.mjs
new file mode 100644
index 0000000000..3918c42299
--- /dev/null
+++ b/assets/js/gordon-cards/to-markdown.mjs
@@ -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')
+}
diff --git a/assets/js/src/alpine.js b/assets/js/src/alpine.js
index c314dc641c..3868e9a096 100644
--- a/assets/js/src/alpine.js
+++ b/assets/js/src/alpine.js
@@ -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
diff --git a/hugo.yaml b/hugo.yaml
index 6ff798338c..6b8e955085 100644
--- a/hugo.yaml
+++ b/hugo.yaml
@@ -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.
diff --git a/layouts/_partials/gordon-chat.html b/layouts/_partials/gordon-chat.html
index ad9740ef90..01ecfd8819 100644
--- a/layouts/_partials/gordon-chat.html
+++ b/layouts/_partials/gordon-chat.html
@@ -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 }};
 
 
@@ -460,10 +490,13 @@
-
+
@@ -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; + } + } diff --git a/package-lock.json b/package-lock.json index 53d434980a..261609ab10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index bb4de3c29c..309ca5ef87 100644 --- a/package.json +++ b/package.json @@ -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",