UNPKG

@kitn.ai/ui

Version:

Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.

608 lines (554 loc) 34.3 kB
/** * Composition slots for `kai-*` elements. A slot is a named region a consumer * fills with their own markup. This registry is the SINGLE SOURCE OF TRUTH: * the facade derives its detection from it (and, later, docs are generated * from it). See docs/superpowers/specs/2026-06-23-kai-chat-composition-seams-design.md. */ /** `inject` = additive (the built-in region still renders, your markup is added * in). `replace` = your markup stands in for the whole region — you own that * region's data + events (a slotted light-DOM node can't read the component's * reactive state). */ export type SlotMode = 'inject' | 'replace'; export interface SlotDef { /** Slot name (kebab-case). Also the `::part` name when `part` is true. */ name: string; mode: SlotMode; /** Expose `::part(name)` on the region wrapper for consumer styling. */ part?: boolean; /** One-line contract: what the consumer projects / owns. Feeds the docs. */ doc: string; } /** Slots of `<kai-chat>`, in render order. */ export const CHAT_SLOTS: SlotDef[] = [ { name: 'header-start', mode: 'inject', doc: 'Leading header controls, left of the title.' }, { name: 'header-end', mode: 'inject', doc: 'Trailing header controls.' }, { name: 'header', mode: 'replace', part: true, doc: 'Full custom header; replaces the built-in title/model/context bar.' }, { name: 'sidebar', mode: 'inject', part: true, doc: 'Left column (your nav / conversation list). Fixed width; use compose-your-own for resizable.' }, { name: 'empty', mode: 'replace', doc: 'Custom zero-state rendered in the message area while the thread is empty. Replaces the empty message list only — the composer and any suggestions still render.' }, { name: 'composer', mode: 'replace', doc: 'Full custom composer; you own submit + loading, drive the thread via messages.' }, { name: 'composer-actions', mode: 'inject', doc: 'Accessory row above the composer.' }, { name: 'footer', mode: 'inject', part: true, doc: 'Row below the composer (disclaimers, token meter).' }, ]; /** Slots of `<kai-prompt-input>` (and the default composer inside `<kai-chat>`). Native * shadow slots — an empty slot renders nothing, so no facade flag-gating is required. * * These are ONLY positions inside the card's shadow boundary — places a consumer * cannot reach from their own DOM. Content ABOVE/BELOW the whole card is the * consumer's own light-DOM layout (a sibling element), so there is intentionally * no outer block slot here. (When the input is nested inside `<kai-chat>`'s shadow, * that surrounding hole belongs to `kai-chat` — see `composer-actions`/`footer`.) */ export const PROMPT_INPUT_SLOTS: SlotDef[] = [ { name: 'input-top', mode: 'inject', doc: 'Inside the card, above the textarea (e.g. an inline status strip). For content above/below the whole card, use your own layout — that is light DOM you control.' }, { name: 'toolbar-start', mode: 'inject', doc: 'Leading controls in the input toolbar — where a + menu goes.' }, { name: 'toolbar-end', mode: 'inject', doc: 'Trailing controls in the toolbar, before the Send button.' }, ]; /** Slots of `<kai-conversations>` (also used inside `<kai-chat>`'s `sidebar`). * Names mirror `<kai-chat>`'s so the two compose with one vocabulary. */ export const CONVERSATIONS_SLOTS: SlotDef[] = [ { name: 'header', mode: 'replace', doc: 'Full custom title bar; replaces the built-in toggle / "Chats" / New-chat row.' }, { name: 'empty', mode: 'replace', doc: 'Custom zero-state shown when there are no conversations; replaces the built-in "No conversations yet".' }, { name: 'footer', mode: 'inject', doc: 'A row below the list — account, settings, or usage.' }, ]; /** Styleable `::part`s of `<kai-conversations>`. */ export const CONVERSATIONS_PARTS: PartDef[] = [ { name: 'trailing', doc: 'The right-aligned trailing text on each conversation row (a count, status, or relative time). Set it per item via the `trailing` field; otherwise a short auto relative time is derived from `updatedAt`. Recolor or resize it from outside.', recipe: 'kai-conversations::part(trailing) { color: var(--color-primary); font-variant-numeric: tabular-nums }', }, ]; /** Slots of `<kai-message>` — per-message composition seams. `before-body` and * `after-body` are INJECT regions inside the message's body column; `avatar` * REPLACES the built-in avatar rail (pair it with `avatar="none"` to omit the * rail entirely). These are the keystone of compose-your-own message lists. */ export const MESSAGE_SLOTS: SlotDef[] = [ { name: 'before-body', mode: 'inject', doc: 'A per-message header at the TOP of the body, above reasoning/tools/content — a model-name label, a role + timestamp line.' }, { name: 'after-body', mode: 'inject', doc: 'A row at the BOTTOM of the body, below the action bar — a citation/sources row, a token-cost/latency line.' }, { name: 'avatar', mode: 'replace', part: true, doc: 'Replaces the built-in avatar rail with your own node. Use `avatar="none"` to omit the rail and let the body span the full row.' }, ]; /** Slots of `<kai-notice>`. The message is the default slot; these are the named seams. */ export const NOTICE_SLOTS: SlotDef[] = [ { name: 'action', mode: 'inject', doc: 'A trailing action beside the message — a link or button.' }, { name: 'icon', mode: 'replace', doc: 'A custom leading icon (any inline SVG, inherits `currentColor`). Overrides the severity default and the `icon` prop — the same escape hatch as `kai-button`.' }, ]; /** Slots of `<kai-button>` (the label is the default slot). */ export const BUTTON_SLOTS: SlotDef[] = [ { name: 'icon', mode: 'replace', doc: 'A custom leading icon (any inline SVG, inherits `currentColor`). Wins over the `icon` prop.' }, ]; /** Slots of `<kai-hover-card>` (the trigger is the default slot). */ export const HOVER_CARD_SLOTS: SlotDef[] = [ { name: 'card', mode: 'inject', doc: 'The rich content shown in the floating hover card.' }, ]; /** Slots of `<kai-menu>` (the menu items come from the `items` prop). */ export const MENU_SLOTS: SlotDef[] = [ { name: 'trigger', mode: 'replace', doc: 'Your own trigger element; replaces the built-in button driven by the `trigger-icon` / `trigger-label` props.' }, ]; /** A styleable `::part` the kit renders (NOT a slot — you don't project into it; * you restyle it from outside via `::part(name)`). This registry is the source * of truth so the styling surface is discoverable: docs + the `kai` MCP * component reference are generated from it, the same way slots are. The * `recipe` is a copy-pasteable example — including the "just hide it" case that * is pure CSS and therefore intentionally NOT a prop. */ export interface PartDef { /** `::part(name)` exposed for consumer styling. */ name: string; /** One-line contract: what the part is. */ doc: string; /** A copy-pasteable styling example for docs / the MCP reference. */ recipe?: string; } /** Styleable `::part`s of `<kai-chat>` (beyond the slot-backed `header`/`sidebar`/ * `footer` parts). */ export const CHAT_PARTS: PartDef[] = [ { name: 'header-bar', doc: 'The built-in header bar (the title / model-switcher / context row that hosts the header-start/header-end inject slots). Restyle its height, padding, or gap from outside without replacing the whole header via the `header` slot.', recipe: 'kai-chat::part(header-bar) { height: 3.5rem; padding-inline: 1rem; gap: 0.5rem }', }, ]; /** Styleable `::part`s of `<kai-prompt-input>`. */ export const PROMPT_INPUT_PARTS: PartDef[] = [ { name: 'send', doc: 'The send button. Restyle from outside, or hide it entirely (Enter-only) — hiding is pure CSS, which is why there is no `submit="never"`.', recipe: 'kai-prompt-input::part(send) { display: none } /* Enter-only; or restyle: background, border-radius, … */', }, ]; /** Styleable `::part`s of `<kai-button>`. */ export const BUTTON_PARTS: PartDef[] = [ { name: 'button', doc: 'The button element. Restyle radius, padding, colors, or weight from outside; the `variant`/`size` props set the defaults.', recipe: 'kai-button::part(button) { border-radius: 9999px; font-weight: 600 }', }, ]; /** Styleable `::part`s of `<kai-badge>`. */ export const BADGE_PARTS: PartDef[] = [ { name: 'badge', doc: 'The badge pill. Restyle its background, color, or shape; the `variant` prop (default/count/citation) sets the defaults.', recipe: 'kai-badge::part(badge) { background: var(--color-primary); color: var(--color-primary-foreground) }', }, ]; /** Styleable `::part`s of `<kai-icon>`. */ export const ICON_PARTS: PartDef[] = [ { name: 'icon', doc: 'The icon wrapper. Inherits `currentColor` and the `size` prop by default; recolor or resize it from outside.', recipe: 'kai-icon::part(icon) { color: var(--color-primary) }', }, ]; /** Styleable `::part`s of `<kai-separator>`. */ export const SEPARATOR_PARTS: PartDef[] = [ { name: 'separator', doc: 'The divider line. Restyle its color, thickness, or inset from outside.', recipe: 'kai-separator::part(separator) { background: var(--color-border) }', }, ]; /** Styleable `::part`s of `<kai-scroll-area>`. */ export const SCROLL_AREA_PARTS: PartDef[] = [ { name: 'viewport', doc: 'The scrolling container. Add padding or a max-height from outside; the thin scrollbar follows `--color-scrollbar-thumb`.', recipe: 'kai-scroll-area::part(viewport) { padding-right: 0.5rem }', }, ]; /** Styleable `::part`s of `<kai-skeleton>`. */ export const SKELETON_PARTS: PartDef[] = [ { name: 'skeleton', doc: 'The shimmer block(s). Recolor or change the opacity from outside; the default is a low-contrast foreground tint that reads in both light and dark.', recipe: 'kai-skeleton::part(skeleton) { background: var(--color-primary); opacity: 0.15 }', }, ]; /** Styleable `::part`s of `<kai-message>`. (The `avatar` part is contributed by * the `avatar` slot's `part: true` flag, so it is not repeated here.) */ export const MESSAGE_PARTS: PartDef[] = [ { name: 'row', doc: 'The message row wrapper (avatar rail + body column). Restyle its gap or alignment from outside.', recipe: 'kai-message::part(row) { gap: 0.75rem }', }, { name: 'bubble', doc: 'The content bubble wrapper. Restyle its background, radius, or padding; for a user message this is the rounded chat bubble.', recipe: 'kai-message::part(bubble) { background: var(--color-primary); color: var(--color-primary-foreground) }', }, { name: 'content', doc: 'The rendered message text/markdown region (same node as `bubble`). Target it to tune typography from outside.', recipe: 'kai-message::part(content) { font-size: 0.9375rem }', }, { name: 'actions', doc: 'The action-bar row (copy / like / regenerate …). Restyle its spacing or hide it entirely from outside.', recipe: 'kai-message::part(actions) { gap: 0.25rem }', }, ]; /** Styleable `::part`s of `<kai-attachments>`. */ export const ATTACHMENTS_PARTS: PartDef[] = [ { name: 'preview', doc: 'The image shown in an attachment’s hover-card preview. Bounded by default (max ~320×256, aspect preserved) so a large image never blows up the card — raise or lower the cap from outside.', recipe: 'kai-attachments::part(preview) { max-width: 32rem; max-height: 24rem }', }, ]; /** Styleable `::part`s of `<kai-status>`. */ export const STATUS_PARTS: PartDef[] = [ { name: 'dot', doc: 'The status dot. Recolor or resize it from outside; the `status` prop sets the default hue.', recipe: 'kai-status::part(dot) { background: var(--color-tool-green) }', }, ]; /** Styleable `::part`s of `<kai-tabs>`. */ export const TABS_PARTS: PartDef[] = [ { name: 'tablist', doc: 'The tab strip container (role="tablist"). Restyle its gap, padding, background, or radius from outside; the `variant` prop sets the segmented/underline defaults.', recipe: 'kai-tabs::part(tablist) { gap: 0.5rem; background: var(--color-card) }', }, { name: 'tab', doc: 'A single tab button. Restyle from outside; the active tab carries a `[data-active]` attribute, so target `::part(tab)[data-active]` for the selected look.', recipe: 'kai-tabs::part(tab)[data-active] { color: var(--color-primary); font-weight: 600 }', }, ]; /** Styleable `::part`s of `<kai-voice-output>`. */ export const VOICE_OUTPUT_PARTS: PartDef[] = [ { name: 'button', doc: 'The speaker/play button. Restyle radius, size, padding, or colors from outside; it is a ghost icon button by default.', recipe: 'kai-voice-output::part(button) { border-radius: 9999px; color: var(--color-primary) }', }, ]; /** Slots of `<kai-screen>` (the default slot is the screen body). */ export const SCREEN_SLOTS: SlotDef[] = [ { name: 'title', mode: 'replace', part: true, doc: 'Rich header title; overrides the `headline` prop.' }, { name: 'actions', mode: 'inject', doc: 'Header trailing cluster (e.g. an avatar or overflow menu).' }, ]; /** Styleable `::part`s of `<kai-screen>`. */ export const SCREEN_PARTS: PartDef[] = [ { name: 'header', doc: 'The back-header bar (back button + title + actions). Restyle its height, padding, or border from outside.', recipe: 'kai-screen::part(header) { height: 3.25rem; padding-inline: 1rem }', }, { name: 'back', doc: 'The back button. Restyle or hide it from outside; `back="false"` removes it entirely.', recipe: 'kai-screen::part(back) { border-radius: 9999px }', }, { name: 'body', doc: 'The full-bleed surface that fills the mount point and scrolls its content. Tune padding or background from outside.', recipe: 'kai-screen::part(body) { background: var(--color-card) }', }, ]; /** Slots of `<kai-card>` — structural regions only (the title/description are body * or `slot="header"` content you mark up). The body is the default slot. */ export const CARD_SLOTS: SlotDef[] = [ { name: 'media', mode: 'inject', doc: 'Full-bleed media (image/video/illustration) at the top (vertical) or start (horizontal). Clipped to the card corners.' }, { name: 'header', mode: 'inject', doc: 'Header content, e.g. a title. Rendered above the body.' }, { name: 'header-actions', mode: 'inject', doc: 'An actions cluster pinned to the end of the header row.' }, { name: 'footer', mode: 'inject', doc: 'Footer content rendered below the body.' }, { name: 'footer-actions', mode: 'inject', doc: 'Action buttons pinned to the end of the footer. Do NOT combine with a clickable/href card (nested interactive).' }, ]; /** Styleable `::part`s of `<kai-card>`. */ export const CARD_PARTS: PartDef[] = [ { name: 'card', doc: 'The card root (a div, or an a when href is set). Restyle its radius, border, or background; set --kai-card-spacing for padding/gaps (the dense prop sets the compact default).', recipe: 'kai-card::part(card) { border-radius: 1rem; --kai-card-spacing: 1.5rem }', }, { name: 'media', doc: 'The full-bleed media region. Cap or crop it from outside (e.g. a fixed height with object-fit).', recipe: 'kai-card::part(media) { max-height: 12rem }', }, { name: 'header', doc: 'The header row (header content + header-actions). Add a divider or adjust its alignment.', recipe: 'kai-card::part(header) { border-bottom: 1px solid var(--color-border) }', }, { name: 'body', doc: 'The default-slot body region.', recipe: 'kai-card::part(body) { font-size: 0.9375rem }', }, { name: 'footer', doc: 'The footer row (footer content + footer-actions).', recipe: 'kai-card::part(footer) { border-top: 1px solid var(--color-border) }', }, { name: 'dismiss', doc: 'The dismiss (×) button shown when dismissible. Recolor or reposition it from outside.', recipe: 'kai-card::part(dismiss) { color: var(--color-muted-foreground) }', }, ]; /** Injection slots of `<kai-workspace>` (the carrier regions the consumer drops * content into: brand/tabs, the upgrade/Design/user-menu cluster, a top banner). */ export const WORKSPACE_SLOTS: SlotDef[] = [ { name: 'sidebar-header', mode: 'inject', doc: 'Top of the conversation rail (brand, a kai-tabs strip).' }, { name: 'sidebar-footer', mode: 'inject', doc: 'Bottom of the rail: an upgrade card, a Design trigger, a user-menu cluster.' }, { name: 'main-header', mode: 'inject', doc: 'Top of the main region (a top-placed banner or a corner action).' }, { name: 'main', mode: 'replace', doc: 'Replace the built-in chat thread with your own main view (a home or dashboard screen). Omit to keep the thread.' }, ]; /** Styleable `::part`s of `<kai-workspace>`. */ export const WORKSPACE_PARTS: PartDef[] = [ { name: 'sidebar', doc: 'The conversation rail. Carries a subtle, theme-aware default background (bg-surface); override its background, border, or width from outside. `sidebar-min-width` sets its min px width and `collapse-below` auto-collapses it under a width.', recipe: 'kai-workspace::part(sidebar) { background: var(--color-card); border-right: 1px solid var(--color-border) }', }, ]; /** Styleable `::part`s of `<kai-nav>`. */ export const NAV_PARTS: PartDef[] = [ { name: 'nav', doc: 'The nav list container. Restyle its gap or padding from outside.', recipe: 'kai-nav::part(nav) { gap: 0.25rem }', }, { name: 'item', doc: 'A nav item button (leaf or group parent). The active leaf carries aria-current="page" and a group parent carries aria-expanded; target `::part(item)[aria-current]` for the selected look or `::part(item)[aria-expanded]` for a group row.', recipe: 'kai-nav::part(item)[aria-current] { background: var(--color-accent) }', }, { name: 'group', doc: 'The nested child list rendered under an expanded group item. Add a left guide line or tune its indent from outside.', recipe: 'kai-nav::part(group) { border-left: 1px solid var(--color-border); margin-left: 1.1rem }', }, { name: 'chevron', doc: 'The disclosure chevron on a group row (rotates when expanded). Recolor or resize it from outside.', recipe: 'kai-nav::part(chevron) { opacity: 1; color: var(--color-primary) }', }, { name: 'status', doc: 'The per-item status cluster (a colored dot in the tone hue + an optional label). Shown only when an item carries a `status`; the `pulse` flag animates the dot. Restyle from outside.', recipe: 'kai-nav::part(status) { gap: 0.5rem }', }, { name: 'meta', doc: 'The right-aligned muted trailing text on a row (e.g. a relative time). Shown only when an item carries `meta`; restyle from outside.', recipe: 'kai-nav::part(meta) { color: var(--color-foreground); font-variant-numeric: tabular-nums }', }, { name: 'item-action', doc: 'The trailing per-item action / close button, a sibling of the item button. Shown only when an item carries `action` or `closable`; reveal it on hover or pin it visible from outside.', recipe: 'kai-nav::part(item-action) { opacity: 1 }', }, ]; /** Slots of `<kai-coachmark>` (the anchor/trigger is the default slot). */ export const COACHMARK_SLOTS: SlotDef[] = [ { name: 'content', mode: 'replace', doc: 'The bubble body text shown under the headline.' }, ]; /** Styleable `::part`s of `<kai-coachmark>`. */ export const COACHMARK_PARTS: PartDef[] = [ { name: 'bubble', doc: 'The hint bubble panel. Restyle its background, radius, or padding from outside; the default is bg-primary.', recipe: 'kai-coachmark::part(bubble) { border-radius: 1rem }' }, { name: 'arrow', doc: 'The arrow pointing at the anchor. Inherits the bubble color; recolor it alongside the bubble.', recipe: 'kai-coachmark::part(arrow) { background: var(--color-accent) }' }, { name: 'badge', doc: 'The small badge pill beside the headline (e.g. "New").', recipe: 'kai-coachmark::part(badge) { text-transform: none }' }, { name: 'title', doc: 'The bold headline text.', recipe: 'kai-coachmark::part(title) { font-size: 0.9375rem }' }, { name: 'dismiss', doc: 'The dismiss button. Recolor or reposition it from outside.', recipe: 'kai-coachmark::part(dismiss) { color: var(--color-primary-foreground) }' }, ]; /** Styleable `::part`s of `<kai-progress-bar>`. */ export const PROGRESS_BAR_PARTS: PartDef[] = [ { name: 'track', doc: 'The progress track (the background bar). Restyle its height, radius, or background from outside.', recipe: 'kai-progress-bar::part(track) { height: 0.5rem }' }, { name: 'fill', doc: 'The filled portion; its width follows value/max. Recolor it from outside.', recipe: 'kai-progress-bar::part(fill) { background: var(--color-tool-green) }' }, ]; /** Styleable `::part`s of `<kai-file-tree>` — the changed-files / diff bits, shown * only when a file carries diff metadata (or the `summary` attribute is set). */ export const FILE_TREE_PARTS: PartDef[] = [ { name: 'summary', doc: 'The changed-files summary header (the file count, the summed +additions/-deletions, and the Collapse-all/Expand-all toggle). Rendered only when the `summary` attribute is set; restyle or hide it from outside.', recipe: 'kai-file-tree::part(summary) { border-bottom: none; padding-block: 0.5rem }', }, { name: 'status', doc: 'The per-row change-status letter (A/M/D/R/U), shown when a file carries a `status`. Colored with the conventional VCS tool hues; restyle from outside.', recipe: 'kai-file-tree::part(status) { font-weight: 700 }', }, { name: 'stat-additions', doc: 'The trailing `+N` additions stat on a file row (success/green tool hue, tabular-nums). Shown only when a file carries `additions`.', recipe: 'kai-file-tree::part(stat-additions) { color: var(--color-tool-green) }', }, { name: 'stat-deletions', doc: 'The trailing `-N` deletions stat on a file row (error/red tool hue, tabular-nums). Shown only when a file carries `deletions`.', recipe: 'kai-file-tree::part(stat-deletions) { color: var(--color-tool-red) }', }, ]; /** * Per-element composition surface — the SINGLE registry the build extracts * (`scripts/gen-element-api.mjs`) into `element-meta.json`, the Custom Elements * Manifest (`cssParts`/`slots`), `docs/web-components.md`, and the `kai` MCP * `component_reference`. Each entry maps a `kai-*` tag to the slots it projects * and the `::part`s it exposes for styling. Slots flagged `part: true` are ALSO * styleable parts, so they surface in both places. * * Adding a `part="…"` in a facade/component without registering it here fails the * `slots.test.ts` drift guard — keep this in sync with the source. */ export interface ElementComposition { slots?: SlotDef[]; parts?: PartDef[]; } /** Slots + styleable `::part`s of `<kai-prompt-dock>`. */ export const PROMPT_DOCK_SLOTS: SlotDef[] = [ { name: 'top', mode: 'inject', part: true, doc: 'The top lip: a notice or banner above the input. Rendered only when filled.' }, { name: 'bottom', mode: 'inject', part: true, doc: 'The bottom lip: a mode or controls row below the input. Rendered only when filled.' }, ]; export const PROMPT_DOCK_PARTS: PartDef[] = [ { name: 'tray', doc: 'The recessed tray that frames the input. The `appearance`/`frame` props set the defaults; the --kai-prompt-dock-* tokens fine-tune surface/border/radius/inset.', recipe: 'kai-prompt-dock::part(tray) { --kai-prompt-dock-radius: 1rem }', }, ]; /** Styleable `::part`s of `<kai-segmented>`. */ export const SEGMENTED_PARTS: PartDef[] = [ { name: 'track', doc: 'The segmented track (the pill container holding the segments). Restyle its background, radius, or padding.', recipe: 'kai-segmented::part(track) { border-radius: 9999px }' }, { name: 'segment', doc: 'Each segment button. Restyle padding, font weight, or the selected look.', recipe: 'kai-segmented::part(segment) { font-weight: 600 }' }, ]; /** Styleable `::part`s of `<kai-settings-group>`. */ export const SETTINGS_GROUP_PARTS: PartDef[] = [ { name: 'header', doc: 'The group heading + description block. Restyle its spacing or typography.', recipe: 'kai-settings-group::part(header) { margin-bottom: 0.75rem }' }, { name: 'body', doc: 'The bordered card holding the setting rows. Restyle its surface, border, or radius.', recipe: 'kai-settings-group::part(body) { border-radius: 1rem }' }, ]; /** Slots + styleable `::part`s of `<kai-setting-item>`. */ export const SETTING_ITEM_SLOTS: SlotDef[] = [ { name: 'control', mode: 'inject', part: true, doc: 'The row control (a switch, segmented, select, etc.), right-aligned. Omit it for a label-only row.' }, ]; export const SETTING_ITEM_PARTS: PartDef[] = [ { name: 'label', doc: 'The label + description block on the left of the row. Restyle its typography or spacing.', recipe: 'kai-setting-item::part(label) { gap: 0.125rem }' }, ]; /** Slots + styleable `::part`s of `<kai-pane>` (the editor-group pane frame). */ export const PANE_SLOTS: SlotDef[] = [ { name: 'leading', mode: 'inject', doc: 'A glyph or avatar at the start of the pane header.' }, { name: 'actions', mode: 'inject', doc: 'Extra header controls, before the built-in window controls.' }, { name: 'footer', mode: 'inject', part: true, doc: 'A pinned row below the body (e.g. a composer).' }, ]; export const PANE_PARTS: PartDef[] = [ { name: 'header', doc: 'The pane header bar (leading + title/status + actions + window controls).', recipe: 'kai-pane::part(header) { padding-inline: 0.75rem }' }, { name: 'body', doc: 'The scrolling body region (the default slot).', recipe: 'kai-pane::part(body) { padding: 1rem }' }, { name: 'controls', doc: 'The window-control cluster (maximize/close, and split/dock when enabled).', recipe: 'kai-pane::part(controls) { gap: 0.25rem }' }, ]; /** Styleable `::part`s of `<kai-agent-card>`. */ export const AGENT_CARD_PARTS: PartDef[] = [ { name: 'status', doc: 'The leading tone-colored status dot.', recipe: 'kai-agent-card::part(status) { width: 0.625rem; height: 0.625rem }' }, { name: 'menu', doc: 'The trailing overflow ("...") menu button.', recipe: 'kai-agent-card::part(menu) { opacity: 1 }' }, ]; /** Slots + styleable `::part`s of `<kai-dialog>` (the centered modal). */ export const DIALOG_SLOTS: SlotDef[] = [ { name: 'header', mode: 'inject', part: true, doc: 'Optional title region at the top of the panel.' }, { name: 'footer', mode: 'inject', part: true, doc: 'Optional actions region at the bottom of the panel.' }, ]; export const DIALOG_PARTS: PartDef[] = [ { name: 'backdrop', doc: 'The full-area scrim behind the panel. Restyle its color/blur.', recipe: 'kai-dialog::part(backdrop) { background: rgb(0 0 0 / 0.6) }' }, { name: 'panel', doc: 'The centered modal panel. Restyle width, radius, padding.', recipe: 'kai-dialog::part(panel) { max-width: 32rem }' }, { name: 'body', doc: 'The scrolling content region (the default slot).', recipe: 'kai-dialog::part(body) { padding: 1.25rem }' }, ]; /** Styleable `::part`s of `<kai-pane-group>` (the editor group: a tab strip over * the active tab's pane). The per-tab content slots are NAMED DYNAMICALLY by tab * id (`slot="<tab id>"`) plus a default slot, so they are not enumerable here — * only the styleable parts are registered. */ export const PANE_GROUP_PARTS: PartDef[] = [ { name: 'tabs', doc: 'The tab strip (role="tablist"). Restyle its background, height, padding, or gap from outside.', recipe: 'kai-pane-group::part(tabs) { background: var(--color-card); gap: 0.25rem }' }, { name: 'tab', doc: 'A single tab button. The active tab carries `[aria-selected="true"]`; target `::part(tab)[aria-selected="true"]` for the selected look.', recipe: 'kai-pane-group::part(tab)[aria-selected="true"] { background: var(--color-accent) }' }, { name: 'body', doc: 'The active tab\'s content region (the named/default slot host).', recipe: 'kai-pane-group::part(body) { padding: 0.75rem }' }, { name: 'menu', doc: 'The per-tab "…" overflow button. Reveal it on hover or pin it visible from outside.', recipe: 'kai-pane-group::part(menu) { opacity: 1 }' }, { name: 'close', doc: 'The per-tab close ("×") button. Recolor, resize, or hide it from outside.', recipe: 'kai-pane-group::part(close) { color: var(--color-muted-foreground) }' }, ]; /** Affix slots + styleable `::part`s of `<kai-input>` (the field shell). */ export const INPUT_SLOTS: SlotDef[] = [ { name: 'leading', mode: 'inject', doc: 'A glyph, prefix, or affix at the start of the field, inside the border.' }, { name: 'trailing', mode: 'inject', doc: 'A button, unit, or affix at the end of the field, inside the border.' }, ]; export const INPUT_PARTS: PartDef[] = [ { name: 'field', doc: 'The bordered control box (the row wrapping any affixes plus the input). Restyle its border, radius, surface, or focus ring.', recipe: 'kai-input::part(field) { border-radius: 0.75rem }' }, { name: 'input', doc: 'The inner input element. Restyle its text, padding, or placeholder.', recipe: 'kai-input::part(input) { font-variant-numeric: tabular-nums }' }, { name: 'label', doc: 'The field label above the control. Restyle its typography or spacing.', recipe: 'kai-input::part(label) { font-weight: 600 }' }, { name: 'hint', doc: 'The hint or error line below the control. Restyle its typography.', recipe: 'kai-input::part(hint) { font-style: italic }' }, ]; /** Styleable `::part`s of `<kai-search>` (the debounced filter field; composes the * kai-input field plus a clear button). */ export const SEARCH_PARTS: PartDef[] = [ { name: 'field', doc: 'The bordered control box (the row wrapping the search icon, input, and clear button).', recipe: 'kai-search::part(field) { border-radius: 9999px }' }, { name: 'input', doc: 'The inner search input element.', recipe: 'kai-search::part(input) { font-size: 0.875rem }' }, { name: 'clear', doc: 'The trailing clear ("x") button, shown when the field is non-empty.', recipe: 'kai-search::part(clear) { opacity: 1 }' }, ]; /** Styleable `::part`s of `<kai-editable-label>` (inline rename; the edit field is the kai-input field). */ export const EDITABLE_LABEL_PARTS: PartDef[] = [ { name: 'text', doc: 'The read-mode label text. Restyle its typography; it swaps to the input on edit.', recipe: 'kai-editable-label::part(text) { font-weight: 600 }' }, { name: 'input', doc: 'The edit-mode input (the composed kai-input field).', recipe: 'kai-editable-label::part(input) { font: inherit }' }, ]; /** Styleable `::part`s of `<kai-kbd>` (the keyboard-shortcut display). */ export const KBD_PARTS: PartDef[] = [ { name: 'key', doc: 'Each key cap. Restyle its surface, border, radius, or font.', recipe: 'kai-kbd::part(key) { border-radius: 0.375rem }' }, { name: 'separator', doc: 'The gap between key caps. Inject a literal joiner (e.g. a plus sign) from outside.', recipe: 'kai-kbd::part(separator)::after { content: "+" }' }, ]; /** Styleable `::part`s of `<kai-command>` (the command palette). */ export const COMMAND_PARTS: PartDef[] = [ { name: 'shortcut', doc: 'The right-aligned per-row keyboard shortcut, rendered as kai-kbd key caps. Shown only when a row carries a `shortcut`.', recipe: 'kai-command::part(shortcut) { opacity: 0.8 }' }, ]; /** Styleable `::part`s of `<kai-menu>` (the actions dropdown). */ export const MENU_PARTS: PartDef[] = [ { name: 'shortcut', doc: 'The right-aligned per-item keyboard shortcut, rendered as kai-kbd key caps. Shown only when an item carries a `shortcut`.', recipe: 'kai-menu::part(shortcut) { opacity: 0.8 }' }, ]; export const ELEMENT_COMPOSITION: Record<string, ElementComposition> = { 'kai-chat': { slots: CHAT_SLOTS, parts: CHAT_PARTS }, 'kai-command': { parts: COMMAND_PARTS }, 'kai-conversations': { slots: CONVERSATIONS_SLOTS, parts: CONVERSATIONS_PARTS }, 'kai-message': { slots: MESSAGE_SLOTS, parts: MESSAGE_PARTS }, 'kai-prompt-input': { slots: PROMPT_INPUT_SLOTS, parts: PROMPT_INPUT_PARTS }, 'kai-button': { slots: BUTTON_SLOTS, parts: BUTTON_PARTS }, 'kai-badge': { parts: BADGE_PARTS }, 'kai-icon': { parts: ICON_PARTS }, 'kai-separator': { parts: SEPARATOR_PARTS }, 'kai-scroll-area': { parts: SCROLL_AREA_PARTS }, 'kai-notice': { slots: NOTICE_SLOTS }, 'kai-hover-card': { slots: HOVER_CARD_SLOTS }, 'kai-menu': { slots: MENU_SLOTS, parts: MENU_PARTS }, 'kai-skeleton': { parts: SKELETON_PARTS }, 'kai-attachments': { parts: ATTACHMENTS_PARTS }, 'kai-status': { parts: STATUS_PARTS }, 'kai-tabs': { parts: TABS_PARTS }, 'kai-voice-output': { parts: VOICE_OUTPUT_PARTS }, 'kai-screen': { slots: SCREEN_SLOTS, parts: SCREEN_PARTS }, 'kai-card': { slots: CARD_SLOTS, parts: CARD_PARTS }, 'kai-workspace': { slots: WORKSPACE_SLOTS, parts: WORKSPACE_PARTS }, 'kai-nav': { parts: NAV_PARTS }, 'kai-coachmark': { slots: COACHMARK_SLOTS, parts: COACHMARK_PARTS }, 'kai-progress-bar': { parts: PROGRESS_BAR_PARTS }, 'kai-file-tree': { parts: FILE_TREE_PARTS }, 'kai-prompt-dock': { slots: PROMPT_DOCK_SLOTS, parts: PROMPT_DOCK_PARTS }, 'kai-segmented': { parts: SEGMENTED_PARTS }, 'kai-settings-group': { parts: SETTINGS_GROUP_PARTS }, 'kai-setting-item': { slots: SETTING_ITEM_SLOTS, parts: SETTING_ITEM_PARTS }, 'kai-pane': { slots: PANE_SLOTS, parts: PANE_PARTS }, 'kai-pane-group': { parts: PANE_GROUP_PARTS }, 'kai-agent-card': { parts: AGENT_CARD_PARTS }, 'kai-dialog': { slots: DIALOG_SLOTS, parts: DIALOG_PARTS }, 'kai-input': { slots: INPUT_SLOTS, parts: INPUT_PARTS }, 'kai-search': { parts: SEARCH_PARTS }, 'kai-kbd': { parts: KBD_PARTS }, 'kai-editable-label': { parts: EDITABLE_LABEL_PARTS }, }; /** * Which slots have projected light-DOM content — a DIRECT child of `host` * carrying the matching `slot` attribute. Pure and synchronous; safe in jsdom * and SSR (returns all-false when `host` has no matching children). The facade * calls this on mount and on every childList mutation. */ export function readSlots(host: Element, defs: SlotDef[] = CHAT_SLOTS): Record<string, boolean> { const out: Record<string, boolean> = {}; for (const def of defs) { out[def.name] = !!host.querySelector(`:scope > [slot="${def.name}"]`); } return out; }