@kitnai/chat 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +11 -0
  2. package/dist/custom-elements.json +2494 -0
  3. package/dist/kitn-chat.es.js +52 -39
  4. package/dist/llms/llms-full.txt +667 -0
  5. package/dist/llms/llms.txt +104 -0
  6. package/dist/theme.tokens.css +133 -0
  7. package/frameworks/react/index.tsx +530 -0
  8. package/frameworks/react/runtime.tsx +94 -0
  9. package/llms-full.txt +667 -0
  10. package/llms.txt +104 -0
  11. package/package.json +34 -5
  12. package/src/components/attachments.tsx +4 -2
  13. package/src/components/chain-of-thought.tsx +1 -1
  14. package/src/components/chat-scope-picker.tsx +2 -2
  15. package/src/components/checkpoint.tsx +7 -3
  16. package/src/components/context.tsx +14 -18
  17. package/src/components/conversation-item.tsx +1 -1
  18. package/src/components/conversation-list.tsx +5 -4
  19. package/src/components/message-skills.tsx +1 -1
  20. package/src/components/message.tsx +1 -0
  21. package/src/components/model-switcher.tsx +3 -3
  22. package/src/components/prompt-input.tsx +15 -2
  23. package/src/components/reasoning.tsx +2 -2
  24. package/src/components/scroll-button.tsx +1 -0
  25. package/src/components/slash-command.tsx +17 -8
  26. package/src/components/source.tsx +2 -2
  27. package/src/components/thinking-bar.tsx +2 -2
  28. package/src/components/tool.tsx +17 -6
  29. package/src/components/voice-input.tsx +5 -1
  30. package/src/elements/attachments.tsx +132 -0
  31. package/src/elements/chain-of-thought.tsx +45 -0
  32. package/src/elements/chat-scope-picker.tsx +36 -0
  33. package/src/elements/chat.tsx +51 -7
  34. package/src/elements/checkpoint.tsx +43 -0
  35. package/src/elements/code-block.tsx +42 -0
  36. package/src/elements/compiled.css +1 -1
  37. package/src/elements/context-meter.tsx +71 -0
  38. package/src/elements/conversation-list.tsx +6 -0
  39. package/src/elements/default-input.tsx +22 -1
  40. package/src/elements/define.tsx +97 -11
  41. package/src/elements/element-types.d.ts +404 -0
  42. package/src/elements/empty.tsx +29 -0
  43. package/src/elements/feedback-bar.tsx +33 -0
  44. package/src/elements/file-upload.tsx +44 -0
  45. package/src/elements/image.tsx +32 -0
  46. package/src/elements/kitn-attachments.stories.tsx +181 -0
  47. package/src/elements/kitn-chain-of-thought.stories.tsx +75 -0
  48. package/src/elements/kitn-chat-scope-picker.stories.tsx +72 -0
  49. package/src/elements/kitn-checkpoint.stories.tsx +71 -0
  50. package/src/elements/kitn-code-block.stories.tsx +82 -0
  51. package/src/elements/kitn-context-meter.stories.tsx +85 -0
  52. package/src/elements/kitn-empty.stories.tsx +110 -0
  53. package/src/elements/kitn-feedback-bar.stories.tsx +73 -0
  54. package/src/elements/kitn-file-upload.stories.tsx +81 -0
  55. package/src/elements/kitn-image.stories.tsx +70 -0
  56. package/src/elements/kitn-loader.stories.tsx +87 -0
  57. package/src/elements/kitn-markdown.stories.tsx +75 -0
  58. package/src/elements/kitn-message-skills.stories.tsx +74 -0
  59. package/src/elements/kitn-message.stories.tsx +105 -0
  60. package/src/elements/kitn-model-switcher.stories.tsx +80 -0
  61. package/src/elements/kitn-prompt-input.stories.tsx +74 -16
  62. package/src/elements/kitn-prompt-suggestions.stories.tsx +157 -0
  63. package/src/elements/kitn-reasoning.stories.tsx +76 -0
  64. package/src/elements/kitn-response-stream.stories.tsx +79 -0
  65. package/src/elements/kitn-source-list.stories.tsx +77 -0
  66. package/src/elements/kitn-source.stories.tsx +87 -0
  67. package/src/elements/kitn-text-shimmer.stories.tsx +63 -0
  68. package/src/elements/kitn-thinking-bar.stories.tsx +72 -0
  69. package/src/elements/kitn-tool.stories.tsx +88 -0
  70. package/src/elements/kitn-voice-input.stories.tsx +87 -0
  71. package/src/elements/loader.tsx +25 -0
  72. package/src/elements/markdown.tsx +38 -0
  73. package/src/elements/message-skills.tsx +22 -0
  74. package/src/elements/message.tsx +125 -0
  75. package/src/elements/model-switcher.tsx +35 -0
  76. package/src/elements/prompt-input.tsx +83 -7
  77. package/src/elements/prompt-suggestions.tsx +58 -0
  78. package/src/elements/reasoning.tsx +50 -0
  79. package/src/elements/register.ts +31 -0
  80. package/src/elements/response-stream.tsx +40 -0
  81. package/src/elements/source.tsx +67 -0
  82. package/src/elements/text-shimmer.tsx +28 -0
  83. package/src/elements/thinking-bar.tsx +34 -0
  84. package/src/elements/tool.tsx +23 -0
  85. package/src/elements/voice-input.tsx +41 -0
  86. package/src/index.ts +0 -1
  87. package/src/primitives/chat-config.tsx +2 -2
  88. package/src/stories/docs/Accessibility.mdx +119 -0
  89. package/src/stories/docs/ForAIAgents.mdx +93 -0
  90. package/src/stories/docs/GettingStarted.mdx +2 -2
  91. package/src/stories/docs/Installation.mdx +2 -2
  92. package/src/stories/docs/Integrations.mdx +415 -15
  93. package/src/stories/docs/Introduction.mdx +5 -5
  94. package/src/stories/docs/Theming.mdx +1 -1
  95. package/src/stories/typography.stories.tsx +78 -0
  96. package/src/ui/button.tsx +1 -1
  97. package/src/ui/collapsible.tsx +119 -8
  98. package/src/ui/dropdown.tsx +177 -12
  99. package/src/ui/hover-card.tsx +147 -26
  100. package/src/ui/overlay.tsx +151 -0
  101. package/src/ui/textarea.tsx +1 -1
  102. package/src/ui/tooltip.stories.tsx +1 -1
  103. package/src/ui/tooltip.tsx +59 -13
  104. package/src/utils/cn.ts +19 -1
  105. package/theme.css +72 -43
  106. package/src/ui/dialog.tsx +0 -21
package/llms.txt ADDED
@@ -0,0 +1,104 @@
1
+ <!-- AUTO-GENERATED by scripts/gen-llms.mjs — do not edit by hand. Run `npm run build`. -->
2
+ # @kitnai/chat
3
+
4
+ > SolidJS + Shadow-DOM web component kit for building AI chat interfaces. 27 `kitn-*` custom elements: streaming responses, markdown + code rendering, reasoning/tool panels, attachments, conversation sidebar, voice input. Zero framework dependency for consumers; SolidJS is bundled in.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install @kitnai/chat
10
+ # SolidJS consumers also need the peer dep:
11
+ npm install solid-js
12
+ ```
13
+
14
+ ## #1 rule: array/object data goes on JS PROPERTIES, not HTML attributes
15
+
16
+ This is the single most common mistake. Arrays and objects (`messages`, `models`, `context`, `suggestions`, `slashCommands`, …) MUST be assigned as JavaScript properties on the element. They CANNOT be passed as HTML attributes — an HTML attribute is always a string and will be ignored or mis-parsed.
17
+
18
+ ```js
19
+ const chat = document.querySelector('kitn-chat');
20
+ chat.messages = [{ id: '1', role: 'assistant', content: 'Hi!' }]; // ✅ property
21
+ ```
22
+ ```html
23
+ <kitn-chat messages="[...]"></kitn-chat> <!-- ❌ never works -->
24
+ ```
25
+
26
+ Only scalar values (string/number/boolean) work as attributes (e.g. `placeholder`, `loading`, `theme`).
27
+
28
+ ## Two layers
29
+
30
+ **Layer 1 — batteries-included web components** (`import '@kitnai/chat/elements'`):
31
+ Drop an element into any framework (React, Vue, plain HTML). Data in via JS properties; interactions out via non-bubbling CustomEvents.
32
+
33
+ - `<kitn-chat>` — full chat UI (message list + prompt input). The primary starting point.
34
+ - `<kitn-conversation-list>` — sidebar conversation browser with group support.
35
+ - `<kitn-prompt-input>` — standalone composer with send button.
36
+
37
+ **Layer 2 — composable primitives** (`import { … } from '@kitnai/chat'`):
38
+ All 27 elements are also exported individually. Use them for custom layouts or features `<kitn-chat>` does not expose (ChainOfThought, FeedbackBar, ThinkingBar, VoiceInput, …). Your bundler tree-shakes the rest.
39
+
40
+ ## Key rules for the web components
41
+
42
+ 1. **Array/object data = JS properties** (see above). Scalars may be attributes.
43
+ 2. **Events are non-bubbling `CustomEvent`s** — listen directly on the element:
44
+ `chat.addEventListener('submit', (e) => console.log(e.detail.value))`
45
+ 3. **`theme` attribute** (`'light' | 'dark' | 'auto'`) works on every element. Default `auto` follows `prefers-color-scheme`.
46
+ 4. **Theming via CSS custom properties** — override `--kitn-color-*` tokens on `:root`; they pierce Shadow DOM.
47
+
48
+ ## ChatMessage schema (required for `<kitn-chat>`)
49
+
50
+ ```ts
51
+ interface ChatMessage {
52
+ id: string;
53
+ role: 'user' | 'assistant';
54
+ content: string;
55
+ reasoning?: { text: string; label?: string };
56
+ tools?: ToolPart[];
57
+ attachments?: AttachmentData[];
58
+ actions?: ('copy' | 'like' | 'dislike' | 'regenerate' | 'edit')[];
59
+ }
60
+ ```
61
+
62
+ ## Framework wiring
63
+
64
+ **Plain HTML / CDN**
65
+ ```html
66
+ <script type="module" src="https://unpkg.com/@kitnai/chat/elements"></script>
67
+ <kitn-chat style="display:block;height:100vh"></kitn-chat>
68
+ <script type="module">
69
+ const chat = document.querySelector('kitn-chat');
70
+ chat.messages = [];
71
+ </script>
72
+ ```
73
+
74
+ **React** — typed wrappers auto-set properties and expose `on<Event>` props:
75
+ ```tsx
76
+ import { KitnChat } from '@kitnai/chat/react';
77
+ <KitnChat messages={messages} onSubmit={(e) => send(e.detail.value)} />
78
+ ```
79
+
80
+ **Vue** — use the element directly; pass arrays via `.prop`:
81
+ ```vue
82
+ <kitn-chat :messages.prop="messages" @submit="send" />
83
+ ```
84
+
85
+ ## Theming
86
+
87
+ ```css
88
+ :root {
89
+ --kitn-color-background: #0f0f0f;
90
+ --kitn-color-primary: #7c3aed;
91
+ --kitn-color-muted: #1e1e1e;
92
+ }
93
+ ```
94
+
95
+ For plain HTML/CDN: `<link rel="stylesheet" href="…/@kitnai/chat/theme.tokens.css">`.
96
+ For Tailwind builds: `@import "@kitnai/chat/theme.css"` in your CSS.
97
+
98
+ ## Docs
99
+
100
+ - Full element reference (all 27 elements, every prop/event): ./llms-full.txt — https://kitn.dev/llms-full.txt
101
+ - Machine-readable Custom Elements Manifest: https://unpkg.com/@kitnai/chat/dist/custom-elements.json
102
+ - Working examples: https://github.com/kitn-ai/chat/tree/main/examples
103
+ - Storybook: https://storybook.kitn.dev
104
+ - Repository: https://github.com/kitn-ai/chat
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitnai/chat",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "SolidJS + Shadow-DOM web component kit for building AI chat interfaces.",
6
6
  "license": "MIT",
@@ -16,22 +16,37 @@
16
16
  "types": "src/index.ts",
17
17
  "unpkg": "./dist/kitn-chat.es.js",
18
18
  "jsdelivr": "./dist/kitn-chat.es.js",
19
+ "customElements": "dist/custom-elements.json",
19
20
  "exports": {
20
21
  ".": "./src/index.ts",
21
- "./elements": "./dist/kitn-chat.es.js",
22
- "./theme.css": "./theme.css"
22
+ "./elements": {
23
+ "types": "./src/elements/element-types.d.ts",
24
+ "default": "./dist/kitn-chat.es.js"
25
+ },
26
+ "./theme.css": "./theme.css",
27
+ "./theme.tokens.css": "./dist/theme.tokens.css",
28
+ "./react": "./frameworks/react/index.tsx"
23
29
  },
24
30
  "files": [
25
31
  "dist",
26
32
  "src",
27
- "theme.css"
33
+ "frameworks",
34
+ "theme.css",
35
+ "llms.txt",
36
+ "llms-full.txt"
28
37
  ],
29
38
  "scripts": {
30
39
  "prepublishOnly": "npm run build",
31
40
  "prebuild": "npm run build:css",
32
41
  "build": "vite build --config vite.config.ts",
42
+ "postbuild": "npm run build:theme && npm run build:api",
43
+ "build:theme": "node scripts/build-theme-tokens.mjs",
44
+ "build:api": "node scripts/gen-element-api.mjs",
33
45
  "test": "vitest run",
46
+ "test:react": "vitest run --config vitest.react.config.ts",
34
47
  "test:watch": "vitest",
48
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.react.json && tsc --noEmit -p tsconfig.react.test.json",
49
+ "examples": "echo 'Serving repo root on http://localhost:8000 — open http://localhost:8000/examples/composable/index.html' && python3 -m http.server 8000",
35
50
  "dev": "npm run build:css && storybook dev -p 6006",
36
51
  "storybook": "npm run build:css && storybook dev -p 6006",
37
52
  "build-storybook": "npm run build:css && storybook build",
@@ -39,7 +54,7 @@
39
54
  "build:css:watch": "tailwindcss -i src/elements/styles.css -o src/elements/compiled.css --watch"
40
55
  },
41
56
  "dependencies": {
42
- "@kobalte/core": "^0.13.0",
57
+ "@floating-ui/dom": "^1.7.6",
43
58
  "@shikijs/langs": "^4.2.0",
44
59
  "@shikijs/themes": "^4.2.0",
45
60
  "class-variance-authority": "^0.7.0",
@@ -60,10 +75,18 @@
60
75
  "@tailwindcss/postcss": "^4.2.2",
61
76
  "@tailwindcss/typography": "^0.5.19",
62
77
  "@testing-library/jest-dom": "^6.0.0",
78
+ "@testing-library/react": "^16.3.2",
79
+ "@types/react": "^19.2.17",
80
+ "@types/react-dom": "^19.2.3",
81
+ "@vitejs/plugin-react": "^4.7.0",
63
82
  "@vitest/browser-playwright": "4.1.2",
64
83
  "@vitest/coverage-v8": "4.1.2",
84
+ "axe-core": "^4.12.1",
65
85
  "jsdom": "^24.0.0",
66
86
  "playwright": "^1.59.1",
87
+ "react": "^19.2.7",
88
+ "react-dom": "^19.2.7",
89
+ "remark-gfm": "^4.0.1",
67
90
  "storybook": "^10.3.5",
68
91
  "storybook-dark-mode": "^5.0.0",
69
92
  "storybook-solidjs-vite": "^10.0.12",
@@ -75,6 +98,12 @@
75
98
  "vitest": "^4.1.0"
76
99
  },
77
100
  "peerDependencies": {
101
+ "react": ">=18",
78
102
  "solid-js": "^1.9.0"
103
+ },
104
+ "peerDependenciesMeta": {
105
+ "react": {
106
+ "optional": true
107
+ }
79
108
  }
80
109
  }
@@ -255,8 +255,10 @@ function AttachmentInfo(props: AttachmentInfoProps) {
255
255
  <Show when={ctx.variant !== 'grid'}>
256
256
  <div class={cn('min-w-0 flex-1', local.class)} {...rest}>
257
257
  <span class="block truncate">{label()}</span>
258
- <Show when={local.showMediaType && ctx.data.mediaType}>
259
- <span class="block truncate text-muted-foreground text-xs">
258
+ {/* The media-type subtitle is a two-line affordance — only the `list`
259
+ variant has room for it; `inline` chips are a fixed single-line height. */}
260
+ <Show when={local.showMediaType && ctx.variant === 'list' && ctx.data.mediaType}>
261
+ <span class="text-muted-foreground text-caption block truncate">
260
262
  {ctx.data.mediaType}
261
263
  </span>
262
264
  </Show>
@@ -33,7 +33,7 @@ function ChainOfThoughtTrigger(props: ChainOfThoughtTriggerProps) {
33
33
  return (
34
34
  <CollapsibleTrigger
35
35
  class={cn(
36
- 'group text-muted-foreground hover:text-foreground flex cursor-pointer items-center justify-start gap-1 text-left text-sm transition-colors',
36
+ 'group text-muted-foreground hover:text-foreground flex cursor-pointer items-center justify-start gap-1 text-left text-meta transition-colors',
37
37
  props.class
38
38
  )}
39
39
  >
@@ -26,7 +26,7 @@ export function ChatScopePicker(props: ChatScopePickerProps) {
26
26
  <DropdownContent class="min-w-[180px]">
27
27
  <DropdownItem onSelect={() => local.onScopeChange(undefined)}>All Content</DropdownItem>
28
28
  <Show when={local.availableAuthors?.length}>
29
- <div class="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/60">Authors</div>
29
+ <div class="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">Authors</div>
30
30
  <For each={local.availableAuthors}>
31
31
  {(author) => (
32
32
  <DropdownItem onSelect={() => local.onScopeChange({ authors: [author] })}>{author}</DropdownItem>
@@ -34,7 +34,7 @@ export function ChatScopePicker(props: ChatScopePickerProps) {
34
34
  </For>
35
35
  </Show>
36
36
  <Show when={local.availableTags?.length}>
37
- <div class="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/60">Tags</div>
37
+ <div class="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">Tags</div>
38
38
  <For each={local.availableTags}>
39
39
  {(tag) => (
40
40
  <DropdownItem onSelect={() => local.onScopeChange({ tags: [tag] })}>{tag}</DropdownItem>
@@ -61,7 +61,11 @@ export function CheckpointTrigger(props: CheckpointTriggerProps) {
61
61
  const variant = () => props.variant ?? 'ghost';
62
62
  const size = () => props.size ?? 'sm';
63
63
 
64
- const button = (
64
+ // A factory (not a single shared node): the Show fallback and the Tooltip
65
+ // child each need their OWN element instance, otherwise Solid reuses one DOM
66
+ // node across both branches and throws HierarchyRequestError when the tooltip
67
+ // branch mounts it.
68
+ const renderButton = () => (
65
69
  <Button
66
70
  variant={variant()}
67
71
  size={size()}
@@ -74,8 +78,8 @@ export function CheckpointTrigger(props: CheckpointTriggerProps) {
74
78
  );
75
79
 
76
80
  return (
77
- <Show when={props.tooltip} fallback={button}>
78
- <Tooltip content={props.tooltip!}>{button}</Tooltip>
81
+ <Show when={props.tooltip} fallback={renderButton()}>
82
+ <Tooltip content={props.tooltip!}>{renderButton()}</Tooltip>
79
83
  </Show>
80
84
  );
81
85
  }
@@ -1,8 +1,7 @@
1
- import { type JSX, Show, createContext, createMemo, splitProps, useContext } from 'solid-js';
2
- import { HoverCard as KHoverCard } from '@kobalte/core/hover-card';
1
+ import { type JSX, Show, createContext, createMemo, useContext } from 'solid-js';
2
+ import { HoverCardRoot, HoverCardTrigger, HoverCardContent } from '../ui/hover-card';
3
3
  import { cn } from '../utils/cn';
4
4
  import { Button } from '../ui/button';
5
- import { useChatConfig } from '../primitives/chat-config';
6
5
 
7
6
  const ICON_RADIUS = 10;
8
7
  const ICON_VIEWBOX = 24;
@@ -60,9 +59,9 @@ export function Context(props: ContextProps) {
60
59
 
61
60
  return (
62
61
  <ContextCtx.Provider value={value()}>
63
- <KHoverCard openDelay={0} closeDelay={0}>
62
+ <HoverCardRoot openDelay={0}>
64
63
  {props.children}
65
- </KHoverCard>
64
+ </HoverCardRoot>
66
65
  </ContextCtx.Provider>
67
66
  );
68
67
  }
@@ -123,7 +122,7 @@ export function ContextTrigger(props: ContextTriggerProps) {
123
122
  const renderedPercent = createMemo(() => fmtPercent.format(usedPercent()));
124
123
 
125
124
  return (
126
- <KHoverCard.Trigger as="span">
125
+ <HoverCardTrigger>
127
126
  <Show
128
127
  when={!props.children}
129
128
  fallback={props.children}
@@ -133,7 +132,7 @@ export function ContextTrigger(props: ContextTriggerProps) {
133
132
  <ContextIcon />
134
133
  </Button>
135
134
  </Show>
136
- </KHoverCard.Trigger>
135
+ </HoverCardTrigger>
137
136
  );
138
137
  }
139
138
 
@@ -145,18 +144,15 @@ export interface ContextContentProps {
145
144
  }
146
145
 
147
146
  export function ContextContent(props: ContextContentProps) {
148
- const config = useChatConfig();
149
147
  return (
150
- <KHoverCard.Portal mount={config.portalMount()}>
151
- <KHoverCard.Content
152
- class={cn(
153
- 'z-50 min-w-60 divide-y divide-border overflow-hidden rounded-lg bg-card shadow-lg animate-in fade-in-0 zoom-in-95',
154
- props.class
155
- )}
156
- >
157
- {props.children}
158
- </KHoverCard.Content>
159
- </KHoverCard.Portal>
148
+ <HoverCardContent
149
+ class={cn(
150
+ 'min-w-60 divide-y divide-border overflow-hidden',
151
+ props.class
152
+ )}
153
+ >
154
+ {props.children}
155
+ </HoverCardContent>
160
156
  );
161
157
  }
162
158
 
@@ -10,7 +10,7 @@ export function ConversationItem(props: ConversationItemProps) {
10
10
  <button data-conversation-id={local.conversation.id} onClick={() => local.onSelect(local.conversation.id)}
11
11
  class={cn('w-full text-left rounded-lg px-2.5 py-2 transition-colors', local.isActive ? 'bg-muted' : 'hover:bg-muted/50', local.class)}>
12
12
  <div class={cn('truncate text-sm', local.isActive ? 'text-foreground font-medium' : 'text-muted-foreground')}>{local.conversation.title}</div>
13
- <div class="text-muted-foreground/60 truncate mt-0.5 text-xs">{local.conversation.messageCount} messages</div>
13
+ <div class="text-muted-foreground truncate mt-0.5 text-xs">{local.conversation.messageCount} messages</div>
14
14
  </button>
15
15
  );
16
16
  }
@@ -43,20 +43,21 @@ export function ConversationList(props: ConversationListProps) {
43
43
  <div class={cn('flex flex-col h-full bg-sidebar', local.class)}>
44
44
  <div class="flex items-center justify-between p-3 pb-2">
45
45
  <div class="flex items-center gap-2">
46
- <Button variant="ghost" size="icon-sm" onClick={local.onToggleSidebar}>
46
+ <Button variant="ghost" size="icon-sm" aria-label="Toggle sidebar" onClick={local.onToggleSidebar}>
47
47
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
48
48
  </Button>
49
49
  <span class="text-sm font-semibold text-foreground">Chats</span>
50
50
  </div>
51
- <Button variant="ghost" size="icon-sm" onClick={local.onNewChat}>
51
+ <Button variant="ghost" size="icon-sm" aria-label="New chat" onClick={local.onNewChat}>
52
52
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
53
53
  </Button>
54
54
  </div>
55
55
  <div class="px-3 pb-2">
56
56
  <div class="flex items-center gap-2 rounded-md bg-muted/40 px-2.5 py-1.5">
57
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted-foreground/60"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
57
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted-foreground"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
58
58
  <input type="text" value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} placeholder="Search chats..."
59
- class="bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none w-full" />
59
+ aria-label="Search chats"
60
+ class="bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring w-full" />
60
61
  </div>
61
62
  </div>
62
63
  <ScrollArea class="flex-1 px-2">
@@ -23,7 +23,7 @@ function MessageSkills(props: MessageSkillsProps) {
23
23
  <div class={cn("flex items-center gap-1 flex-wrap", props.class)}>
24
24
  <For each={props.skills}>
25
25
  {(skill) => (
26
- <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-400/10 text-violet-400">
26
+ <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-400/10 text-violet-600 dark:text-violet-400">
27
27
  {skill.name}
28
28
  </span>
29
29
  )}
@@ -133,6 +133,7 @@ function MessageCopyButton(props: MessageCopyButtonProps) {
133
133
  return (
134
134
  <button
135
135
  class={props.class}
136
+ aria-label={copied() ? 'Copied' : 'Copy message'}
136
137
  onClick={() => {
137
138
  navigator.clipboard.writeText(props.content);
138
139
  setCopied(true);
@@ -13,7 +13,7 @@ export function ModelSwitcher(props: ModelSwitcherProps) {
13
13
  <Show when={local.models.length > 1}>
14
14
  <Dropdown>
15
15
  <DropdownTrigger as={(triggerProps: any) => (
16
- <Button variant="ghost" size="sm" class={cn('gap-1 text-xs text-muted-foreground', local.class)} {...triggerProps}>
16
+ <Button variant="ghost" size="sm" class={cn('gap-1 text-meta text-muted-foreground', local.class)} {...triggerProps}>
17
17
  {currentModel()?.name ?? local.currentModelId}
18
18
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
19
19
  </Button>
@@ -23,8 +23,8 @@ export function ModelSwitcher(props: ModelSwitcherProps) {
23
23
  {(model) => (
24
24
  <DropdownItem onSelect={() => local.onModelChange(model.id)}>
25
25
  <div class="flex flex-col">
26
- <span class={cn('text-sm', model.id === local.currentModelId && 'font-medium text-foreground')}>{model.name}</span>
27
- <Show when={model.provider}><span class="text-xs text-muted-foreground">{model.provider}</span></Show>
26
+ <span class={cn('text-body', model.id === local.currentModelId && 'font-medium text-foreground')}>{model.name}</span>
27
+ <Show when={model.provider}><span class="text-caption text-muted-foreground">{model.provider}</span></Show>
28
28
  </div>
29
29
  </DropdownItem>
30
30
  )}
@@ -121,8 +121,21 @@ function PromptInputTextarea(props: PromptInputTextareaProps) {
121
121
  ));
122
122
 
123
123
  function handleInput(e: InputEvent & { currentTarget: HTMLTextAreaElement }) {
124
- adjustHeight(e.currentTarget);
125
- ctx.setValue(e.currentTarget.value);
124
+ const el = e.currentTarget;
125
+ let value = el.value;
126
+ // Disallow leading whitespace — a prompt can't start with a space or blank
127
+ // line. Strip it (covers typing a space at the start AND pasting) and keep
128
+ // the caret in the right place.
129
+ if (/^\s/.test(value)) {
130
+ const stripped = value.replace(/^\s+/, '');
131
+ const removed = value.length - stripped.length;
132
+ const caret = Math.max(0, (el.selectionStart ?? 0) - removed);
133
+ el.value = stripped;
134
+ el.setSelectionRange(caret, caret);
135
+ value = stripped;
136
+ }
137
+ adjustHeight(el);
138
+ ctx.setValue(value);
126
139
  }
127
140
 
128
141
  function handleKeyDown(e: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) {
@@ -74,7 +74,7 @@ function ReasoningTrigger(props: ReasoningTriggerProps) {
74
74
 
75
75
  return (
76
76
  <button
77
- class={cn('flex cursor-pointer items-center gap-2', local.class)}
77
+ class={cn('flex cursor-pointer items-center gap-2 text-meta', local.class)}
78
78
  onClick={() => onOpenChange(!isOpen())}
79
79
  {...rest}
80
80
  >
@@ -142,7 +142,7 @@ function ReasoningContent(props: ReasoningContentProps) {
142
142
  // Markdown content is styled by the token-based `.chat-markdown` (see
143
143
  // Markdown component), which themes via design tokens — so no Tailwind
144
144
  // `prose`/`dark:prose-invert` is needed (those wouldn't follow a scoped theme).
145
- 'text-muted-foreground',
145
+ 'text-muted-foreground text-body',
146
146
  local.contentClass
147
147
  )}
148
148
  >
@@ -16,6 +16,7 @@ function ScrollButton(props: ScrollButtonProps) {
16
16
  <Button
17
17
  variant={props.variant ?? 'outline'}
18
18
  size={props.size ?? 'sm'}
19
+ aria-label="Scroll to bottom"
19
20
  class={cn(
20
21
  'h-10 w-10 rounded-full transition-all duration-150 ease-out',
21
22
  !isAtBottom()
@@ -99,11 +99,20 @@ function SlashCommand(props: SlashCommandProps) {
99
99
  });
100
100
 
101
101
  function selectItem(item: SlashCommandItem) {
102
- ctx.setValue("");
102
+ // Insert the chosen command into the prompt (e.g. "/summarize ") so it
103
+ // appears in the input ready to send or edit. The trailing space ends the
104
+ // slash token, which closes the palette. Still fire onSelect so consumers
105
+ // can react to the selection.
106
+ ctx.setValue(item.label + " ");
103
107
  setOpen(false);
104
108
  props.onSelect(item);
105
- // Refocus textarea
106
- setTimeout(() => ctx.textareaRef?.focus(), 0);
109
+ // Refocus the textarea and place the caret at the end.
110
+ setTimeout(() => {
111
+ const ta = ctx.textareaRef;
112
+ if (!ta) return;
113
+ ta.focus();
114
+ ta.setSelectionRange(ta.value.length, ta.value.length);
115
+ }, 0);
107
116
  }
108
117
 
109
118
  function handleKeyDown(e: KeyboardEvent) {
@@ -163,7 +172,7 @@ function SlashCommand(props: SlashCommandProps) {
163
172
  {([category, items]) => (
164
173
  <>
165
174
  <Show when={category}>
166
- <div class="px-3 pt-2 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wide">
175
+ <div class="px-3 pt-2 pb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
167
176
  {category}
168
177
  </div>
169
178
  </Show>
@@ -189,11 +198,11 @@ function SlashCommand(props: SlashCommandProps) {
189
198
  <div class="text-xs flex items-center gap-1.5">
190
199
  {item.label}
191
200
  <Show when={isActive()}>
192
- <span class="text-[10px] text-violet-400">active</span>
201
+ <span class="text-[10px] text-violet-600 dark:text-violet-400">active</span>
193
202
  </Show>
194
203
  </div>
195
204
  <Show when={item.description}>
196
- <div class="text-xs text-muted-foreground/50 truncate">
205
+ <div class="text-xs text-muted-foreground truncate">
197
206
  {item.description}
198
207
  </div>
199
208
  </Show>
@@ -202,9 +211,9 @@ function SlashCommand(props: SlashCommandProps) {
202
211
  <Show when={isActive()}>
203
212
  <span class="w-1 h-1 rounded-full bg-violet-400 flex-shrink-0" />
204
213
  </Show>
205
- <span class={cn("text-xs flex-shrink-0", isActive() && "text-violet-400")}>{item.label}</span>
214
+ <span class={cn("text-xs flex-shrink-0", isActive() && "text-violet-600 dark:text-violet-400")}>{item.label}</span>
206
215
  <Show when={item.description}>
207
- <span class="text-xs text-muted-foreground/40 truncate">{item.description}</span>
216
+ <span class="text-xs text-muted-foreground truncate">{item.description}</span>
208
217
  </Show>
209
218
  </Show>
210
219
  </button>
@@ -35,7 +35,7 @@ function Source(props: SourceProps) {
35
35
 
36
36
  return (
37
37
  <SourceContext.Provider value={{ get href() { return props.href; }, get domain() { return domain(); } }}>
38
- <HoverCardRoot openDelay={150} closeDelay={0}>
38
+ <HoverCardRoot openDelay={150}>
39
39
  {props.children}
40
40
  </HoverCardRoot>
41
41
  </SourceContext.Provider>
@@ -61,7 +61,7 @@ function SourceTrigger(props: SourceTriggerProps) {
61
61
  target="_blank"
62
62
  rel="noopener noreferrer"
63
63
  class={cn(
64
- 'bg-muted text-muted-foreground hover:bg-muted-foreground/30 hover:text-primary inline-flex h-5 max-w-32 items-center gap-1 overflow-hidden rounded-full py-0 text-xs no-underline transition-colors duration-150',
64
+ 'bg-muted text-muted-foreground hover:bg-muted-foreground/30 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background inline-flex h-5 max-w-32 items-center gap-1 overflow-hidden rounded-full py-0 text-xs no-underline transition-colors duration-150',
65
65
  props.showFavicon ? 'pr-2 pl-1' : 'px-2',
66
66
  props.class
67
67
  )}
@@ -22,7 +22,7 @@ function ThinkingBar(props: ThinkingBarProps) {
22
22
  <Show
23
23
  when={local.onClick}
24
24
  fallback={
25
- <TextShimmer class="cursor-default font-medium">{text()}</TextShimmer>
25
+ <TextShimmer class="cursor-default text-sm font-medium">{text()}</TextShimmer>
26
26
  }
27
27
  >
28
28
  <button
@@ -30,7 +30,7 @@ function ThinkingBar(props: ThinkingBarProps) {
30
30
  onClick={local.onClick}
31
31
  class="flex items-center gap-1 text-sm transition-opacity hover:opacity-80"
32
32
  >
33
- <TextShimmer class="font-medium">{text()}</TextShimmer>
33
+ <TextShimmer class="text-sm font-medium">{text()}</TextShimmer>
34
34
  <ChevronRight class="text-muted-foreground size-4" />
35
35
  </button>
36
36
  </Show>
@@ -46,10 +46,19 @@ function ToolStateIcon(props: { state: ToolPart['state'] }) {
46
46
  );
47
47
  }
48
48
 
49
- // Status chips: a saturated hue as text over a 15% translucent fill of the same
50
- // hue. This reads on both light and dark surfaces (mirroring the inline-code chip),
51
- // so it needs no `dark:` variant which wouldn't follow a token-scoped theme anyway.
52
- const STATE_HUE: Record<ToolPart['state'], string> = {
49
+ // Status chips: a hue as text over a 15% translucent fill of the same hue
50
+ // (mirroring the inline-code chip). The TEXT color comes from a theme token
51
+ // (--color-tool-*) whose light value is darkened so it reaches WCAG AA (4.5:1)
52
+ // on the faint fill, while dark mode keeps a brighter hue for AA on the dark
53
+ // surface — both modes resolve via the token's `.dark` override. The FILL keeps
54
+ // a fixed bright hue so the chip's colored tint looks the same in both modes.
55
+ const STATE_TOKEN: Record<ToolPart['state'], string> = {
56
+ 'input-streaming': 'var(--color-tool-blue)',
57
+ 'input-available': 'var(--color-tool-amber)',
58
+ 'output-available': 'var(--color-tool-green)',
59
+ 'output-error': 'var(--color-tool-red)',
60
+ };
61
+ const STATE_FILL: Record<ToolPart['state'], string> = {
53
62
  'input-streaming': 'hsl(217 91% 60%)', // blue
54
63
  'input-available': 'hsl(38 92% 50%)', // amber
55
64
  'output-available': 'hsl(142 71% 45%)', // green
@@ -57,8 +66,10 @@ const STATE_HUE: Record<ToolPart['state'], string> = {
57
66
  };
58
67
 
59
68
  function stateChip(state: ToolPart['state']): JSX.CSSProperties {
60
- const hue = STATE_HUE[state];
61
- return { color: hue, background: `color-mix(in oklab, ${hue} 15%, transparent)` };
69
+ return {
70
+ color: STATE_TOKEN[state],
71
+ background: `color-mix(in oklab, ${STATE_FILL[state]} 15%, transparent)`,
72
+ };
62
73
  }
63
74
 
64
75
  function ToolStateBadge(props: { state: ToolPart['state'] }) {
@@ -16,6 +16,9 @@ export function VoiceInput(props: VoiceInputProps) {
16
16
  const { isRecording, start, stop } = useVoiceRecorder();
17
17
  const [isProcessing, setIsProcessing] = createSignal(false);
18
18
 
19
+ const label = () =>
20
+ isProcessing() ? 'Transcribing...' : isRecording() ? 'Stop recording' : 'Voice input';
21
+
19
22
  async function handleClick() {
20
23
  if (isRecording()) {
21
24
  stop();
@@ -76,10 +79,11 @@ export function VoiceInput(props: VoiceInputProps) {
76
79
  </For>
77
80
  </Show>
78
81
 
79
- <Tooltip content={isProcessing() ? 'Transcribing...' : isRecording() ? 'Stop recording' : 'Voice input'}>
82
+ <Tooltip content={label()}>
80
83
  <Button
81
84
  variant="ghost"
82
85
  size="icon-sm"
86
+ aria-label={label()}
83
87
  onClick={handleClick}
84
88
  disabled={local.disabled || isProcessing()}
85
89
  class={cn(