Skip to content
kitn AI/UI

Cards

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

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 of CardEnvelope objects: { type, id, data, title?, resolution? }. The type string 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 resolution on 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? }.

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 };
  • onDismiss stamps { 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.
  • onReopen clears the resolution (card live again) when the card is re-openable, otherwise stamps { kind: 'expired' }. Override the rule with isReopenable, or cap it with staleAfterMs.

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.

Pass resolution on an envelope to lock it in its read-only view — useful when rehydrating conversation history.

PropertyTypeDefaultNotes
theme'auto'Color mode (`auto` follows prefers-color-scheme).
cardsThe stream of card envelopes to render. Set as a JS PROPERTY: `el.cards = [...]`.
typesOptional 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.
schemasJSON 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.
policyOptional CardPolicy handling child events. Property: `el.policy`.
validateCardstrueValidate 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.
MethodSignatureNotes
(cardId: string, resolution: CardResolution): voidProgrammatically 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.
(cardId: string): voidCollapse a card to its re-openable stub from the host side. Convenience for `resolve(cardId, { kind: 'dismissed' })`.
(cardId: string): HTMLElement | nullReturn 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.

This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.

CardFallback