kai-cards
Drop in <kai-cards> to render a live stream of AI-generated card envelopes — confirmations, task lists, choice pickers, link previews, and custom types — while a single policy object routes every user action back to your app.
- Shadow DOM
- Property-driven API
- Built-in card types
- Extensible type registry
- CardFallback for unknown types
Preview
Section titled “Preview”Set all three in JavaScript — they’re arrays and objects, so none work as HTML attributes:
<kai-cards id="cards"></kai-cards>
<script type="module"> import '@kitn.ai/ui/elements';
await customElements.whenDefined('kai-cards');
const el = document.getElementById('cards');
el.cards = [ { type: 'confirm', id: 'deploy', title: 'Deploy to production?', data: { body: 'Apply 3 migrations?', tone: 'warning', actions: [{ id: 'go', label: 'Deploy', style: 'primary', default: true }, { id: 'no', label: 'Cancel' }] } }, ];
el.policy = { onAction: (cardId, action, payload) => console.log('action', cardId, action, payload), onSubmit: (cardId, data) => console.log('submit', cardId, data), onOpen: (url, target) => window.open(url, target === 'tab' ? '_blank' : '_self'), onError: (cardId, message) => console.warn('card error', cardId, message), };</script>cards— array ofCardEnvelopeobjects:{ type, id, data, title?, resolution? }. Thetypestring selects which built-in child element renders the envelope.policy—{ onAction, onSubmit, onOpen, onDismiss, … }. Provide only the handlers your app needs; the rest are silently ignored. Swap it after mount — it is read at event time.types—{ 'my-type': 'my-card-element' }merges over the built-in registry; override a built-in by reusing its key.- Resolved cards — set
resolutionon an envelope to render it in its read-only state after the user has acted. The kinds: terminal{ kind: 'action', action, payload? }and{ kind: 'submit', data }, the deferred{ kind: 'dismissed' }, and the terminal{ kind: 'expired', reason? }.
Dismiss and recover
Section titled “Dismiss and recover”Add dismissible: true to a confirm, choice, form, or tasks card’s data to show a × on it. Dismissing a card defers it rather than deleting it: the card collapses to a compact, re-openable stub — “Proposed: Deploy to production? — dismissed · Reopen” — and onDismiss(cardId) fires.
Defer, don’t delete. A user who waves a card away may want it back; keep the option open until your app decides the card is truly done.
cards.policy = { onDismiss: (cardId) => console.log('dismissed', cardId), onReopen: (cardId) => console.log('reopen requested', cardId),};When the user taps Reopen on the stub, onReopen(cardId) fires. Your handler decides what happens — bring the card back live, or mark it expired if the moment has passed (the agent already proceeded, or too much time elapsed). “Already proceeded” is a fact only your app knows, so the decision is yours.
dismissRecovery() — the wiring, done for you
Section titled “dismissRecovery() — the wiring, done for you”dismissRecovery() builds the onDismiss / onReopen pair against your card store. It writes the dismissed resolution, offers an Undo toast, and applies a default reopen rule (live unless the card is terminal or stale):
import { dismissRecovery, toast } from '@kitn.ai/ui';
const recovery = dismissRecovery({ get: () => cards.cards, set: (next) => { cards.cards = next; }, // Inject a toast adapter for the "Dismissed · Undo" affordance. toast: { show: ({ message, action, durationMs }) => { const t = toast(message, { duration: durationMs, action: action && { label: action.label, onAction: action.onClick }, }); return { dismiss: t.dismiss }; }, }, staleAfterMs: 5 * 60_000, // a card dismissed over 5 minutes ago can't reopen});
cards.policy = { ...recovery, onAction, onSubmit };onDismissstamps{ kind: 'dismissed', at }on the card (a new array reference, so a Solid/React host re-renders) and shows the Undo toast. Undo restores whatever resolution the card had before.onReopenclears the resolution (card live again) when the card is re-openable, otherwise stamps{ kind: 'expired' }. Override the rule withisReopenable, or cap it withstaleAfterMs.
The toast is injected, never imported — cards stay decoupled from the toast module. Pass any adapter with a show() method, or omit toast to skip the Undo affordance entirely.
Examples
Section titled “Examples”Mixed Card Stream
Section titled “Mixed Card Stream”Single Confirm Card
Section titled “Single Confirm Card”Resolved Card
Section titled “Resolved Card”Pass resolution on an envelope to lock it in its read-only view — useful when rehydrating conversation history.
Unknown Type Fallback
Section titled “Unknown Type Fallback”| Property | Type | Default | Notes |
|---|---|---|---|
| theme | "light" | "dark" | "auto" | 'auto' | Color mode (`auto` follows prefers-color-scheme). |
| cards | — | The stream of card envelopes to render. Set as a JS PROPERTY: `el.cards = [...]`. | |
| types | Record<string, string> | — | Optional type→tag overrides/additions (merged over the built-ins). Property: `el.types`. Typed as a plain string map (not the `CardTagMap` alias) so the generated React wrapper inlines it instead of emitting an unresolved named type. |
| schemas | Record<string, object> | — | JSON Schemas for the card types this app renders, keyed by envelope type. The companion of `types`, which says what DRAWS a card while this says what a VALID one looks like. An OBJECT, so it is a JS property only: `el.schemas = { 'pricing-table': pricingSchema }`, never an attribute. `createCardRegistry(...).validationSchemas` is exactly this shape. Without it the kit validates its own seven built-ins and leaves your own card type, the one your app actually cares about, as the only unchecked thing on screen. A schema here WINS over a built-in of the same name, matching `mergeCardTags`, where your entry is spread over ours. Typed `Record<string, object>` rather than `Record<string, JsonSchema>` deliberately: an imported `.json` schema widens `"type"` to `string`, and an authored one carries `$schema`/`title`/`description`/`additionalProperties`, so the tighter type would reject both of the normal ways to supply one. See `CardSchemaMap` in components/card-renderer.tsx. |
| policy | — | Optional CardPolicy handling child events. Property: `el.policy`. | |
| validateCards | boolean | true | Validate each envelope's `data` against the schema for its type before rendering it, using a built-in's own schema or yours from `schemas`. Default `true`; set `validate-cards="false"` (or `el.validateCards = false`) to opt out. A hard failure (wrong type, a missing required field) renders a diagnostic naming the field instead of the card; a soft failure (bounds) renders the card unchanged. Both emit a contract `error` event. On in production too: a model emitting a bad shape is a production failure mode, so stripping the check there would hide it from exactly the person who needs to see it. |
Methods
Section titled “Methods”| Method | Signature | Notes |
|---|---|---|
| resolve | (cardId: string, resolution: CardResolution): void | Programmatically resolve a child card by id: set that envelope's `resolution` so the child re-renders into its read-only/resolved view. The imperative twin of the consumer mutating the cards array. No-op for an unknown id. |
| dismiss | (cardId: string): void | Collapse a card to its re-openable stub from the host side. Convenience for `resolve(cardId, { kind: 'dismissed' })`. |
| getCard | (cardId: string): HTMLElement | null | Return the live child element node for a card id (or null) so consumers can call that card's own methods (focus/expand/…) without a shadow-DOM query. |
Composed from
Section titled “Composed from”This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.