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",