@schlenkr/quassel 0.6.0 → 0.7.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.
@@ -1,7 +1,7 @@
1
1
  import { ReactNode, Ref } from "react";
2
2
  import { ChatTexts } from "./texts";
3
3
  import type { ChatAttachmentInput } from "./events";
4
- import type { SendShortcut } from "./options";
4
+ import type { ChatMicrophone, SendShortcut } from "./options";
5
5
  /** A toolbar button, declarative: icon, text or both - plus click handler. */
6
6
  export interface ToolbarAction {
7
7
  icon?: ReactNode;
@@ -21,20 +21,14 @@ export interface ChatInputHandle {
21
21
  allowEmpty?: boolean;
22
22
  }) => Promise<void>;
23
23
  }
24
- /**
25
- * The input card: borderless textarea on top, toolbar below a hairline. Custom buttons come
26
- * declaratively through `actions` or freely through the left/right slots; while work is running
27
- * the stop button stays visible, and typing shows the send button next to it, which then
28
- * interjects. `rows` sets the initial height; with `maxRows` the textarea grows with its content
29
- * up to that number of rows and scrolls after that. `inline` starts with one row and grows the same way.
30
- */
31
- export declare function ChatInputToolbar({ onSend, onStop, running, disabled, sendDisabled, layout, detailsContainer, onErrorChange, inputAriaControls, rows, maxRows, actions, toolbarLeft, toolbarRight, texts, handleRef, attachments: attachmentsEnabled, attachmentCapabilities, attachmentCapabilitiesError, onAttachmentsChange, initialValue, onDraftChange, sendShortcut, autoFocus, onAutoFocusSettled, }: {
24
+ /** A composer with attachments, host slots and steering into a running turn. */
25
+ export declare function ChatInputToolbar({ onSend, onStop, running, disabled, sendDisabled, layout, detailsContainer, onErrorChange, inputAriaControls, rows, maxRows, actions, toolbarLeft, toolbarRight, microphone, replaceInput, texts, handleRef, attachments: attachmentsEnabled, attachmentCapabilities, attachmentCapabilitiesError, onAttachmentsChange, initialValue, onDraftChange, sendShortcut, autoFocus, onAutoFocusSettled, }: {
32
26
  onSend: (text: string, attachments?: ChatAttachmentInput[]) => void | Promise<void>;
33
27
  onStop?: () => void;
34
28
  running?: boolean;
35
29
  disabled?: boolean;
36
30
  sendDisabled?: boolean;
37
- layout?: "card" | "toolbar" | "inline";
31
+ layout?: "card" | "toolbar" | "inline" | "pill";
38
32
  detailsContainer?: HTMLElement | null;
39
33
  onErrorChange?: (error: string | undefined) => void;
40
34
  inputAriaControls?: string;
@@ -43,6 +37,8 @@ export declare function ChatInputToolbar({ onSend, onStop, running, disabled, se
43
37
  actions?: ToolbarAction[];
44
38
  toolbarLeft?: ReactNode;
45
39
  toolbarRight?: ReactNode;
40
+ microphone?: ChatMicrophone;
41
+ replaceInput?: ReactNode;
46
42
  texts?: Partial<ChatTexts>;
47
43
  handleRef?: Ref<ChatInputHandle>;
48
44
  /** false = no attaching: neither button nor dropping or pasting files. */
@@ -0,0 +1,13 @@
1
+ import type { ChatMicrophone } from "./options";
2
+ import type { ChatTexts } from "./texts";
3
+ export declare function ComposerPrimaryAction({ hasContent, running, sending, disabled, sendDisabled, microphone, texts, onSend, onStop }: {
4
+ hasContent: boolean;
5
+ running: boolean;
6
+ sending: boolean;
7
+ disabled: boolean;
8
+ sendDisabled: boolean;
9
+ microphone?: ChatMicrophone;
10
+ texts: Required<ChatTexts>;
11
+ onSend: () => void;
12
+ onStop?: () => void;
13
+ }): import("react").JSX.Element;
@@ -18,6 +18,7 @@ export interface QuasselToggleProps {
18
18
  className?: string;
19
19
  disabled?: boolean;
20
20
  title?: string;
21
+ "aria-label"?: string;
21
22
  pressed?: boolean;
22
23
  onPressedChange?: (pressed: boolean) => void;
23
24
  children?: ReactNode;
package/README.md CHANGED
@@ -43,6 +43,7 @@ variables too.
43
43
  - Streaming-safe Markdown (tables, code with copy button, lists) via Streamdown.
44
44
  - Steering: typing while a turn runs sends into it; stop, retry and failed-send recovery built in.
45
45
  - Attachments by button, drag and drop or paste, with previews and size limits.
46
+ - Optional pill composer: one animated action for microphone, send, steering and stop, with host-controlled recording and transcription.
46
47
  - Condensed conversation: all user inputs and the latest answer between them, independently of tool/thinking detail.
47
48
  - Timestamps, day separators, message actions, configurable bubbles and appearance.
48
49
  - Screen reader friendly: the transcript is not a live region; finished replies and new
@@ -91,11 +92,38 @@ export function Chat({ send, stop }: { send: (text: string) => Promise<void>; st
91
92
 
92
93
  Any backend that emits the `@schlenkr/quassel/events` stream works with the same components.
93
94
 
95
+ ## Pill composer
96
+
97
+ Use `layout="pill"` for a larger, rounded composer with one 48px primary action. Host buttons
98
+ go into `toolbarLeft` and `toolbarRight`; attachments stay just before the primary action.
99
+
100
+ ```tsx
101
+ <ChatInputToolbar
102
+ layout="pill"
103
+ maxRows={5}
104
+ running={running}
105
+ onSend={send}
106
+ onStop={stop}
107
+ microphone={microphone}
108
+ replaceInput={microphone.state === "recording" ? <LiveWaveform /> : undefined}
109
+ toolbarLeft={<ConversationControls />}
110
+ />
111
+ ```
112
+
113
+ Empty input shows the microphone, text or attachments show send, and an empty running turn
114
+ shows stop. Typing while running sends into that turn. Without `microphone`, empty input shows
115
+ a disabled send button. The host owns audio, press-and-hold or click-to-latch behavior, and
116
+ transcription through the typed `ChatMicrophone` API. Recording has a live color and an aura
117
+ driven by `level` (0-1); preparing and transcribing show a spinner. The aura renders outside
118
+ clipping ancestors and animations respect reduced motion. Card, inline and toolbar keep their defaults.
119
+ See the [API, state table and microphone integration](https://github.com/SchlenkR/quassel/blob/main/docs/guide.md#pill-composer) and the gallery's Pill composer demo.
120
+
94
121
  ## Text size
95
122
 
96
123
  quassel defaults to 13px. Set `--qsl-font-size` on a wrapper, or `1em` to inherit the host's
97
124
  font size. Messages, the composer, action cards and text buttons use that base with line height
98
- 1.5. All small UI text, including tools and timestamps, uses 85% with line height 1.4.
125
+ 1.5, the pill input included. All small UI text, including tools and timestamps, uses 85% with
126
+ line height 1.4.
99
127
  `--qsl-font` and `--qsl-mono` select the font families. There are no other typography inputs.
100
128
  Sizes derive from an absolute base, so nested small elements never compound.
101
129
 
@@ -0,0 +1,4 @@
1
+ export declare function RecordingAura({ anchor, level }: {
2
+ anchor: HTMLButtonElement;
3
+ level?: number;
4
+ }): import("react").ReactPortal;
package/chat.css CHANGED
@@ -21,12 +21,57 @@
21
21
  container-type: inline-size;
22
22
  container-name: chat;
23
23
  }
24
+ .qsl\:recording-aura {
25
+ position: fixed;
26
+ visibility: hidden;
27
+ pointer-events: none;
28
+ border: 2px solid var(--qsl-pill-aura-ring-color);
29
+ @supports (color: color-mix(in lab, red, red)) {
30
+ border: 2px solid color-mix(in srgb, var(--qsl-pill-aura-ring-color) 55%, transparent);
31
+ }
32
+ border-radius: 50%;
33
+ background: radial-gradient(circle, transparent 42%, var(--qsl-pill-aura-color) 70%, transparent);
34
+ @supports (color: color-mix(in lab, red, red)) {
35
+ background: radial-gradient(circle, transparent 42%, color-mix(in srgb, var(--qsl-pill-aura-color) 30%, transparent) 70%, transparent);
36
+ }
37
+ box-shadow: 0 0 18px 5px var(--qsl-pill-aura-color);
38
+ @supports (color: color-mix(in lab, red, red)) {
39
+ box-shadow: 0 0 18px 5px color-mix(in srgb, var(--qsl-pill-aura-color) 25%, transparent);
40
+ }
41
+ transition: opacity 80ms linear, transform 80ms linear;
42
+ }
43
+ .qsl\:primary-icon {
44
+ position: absolute;
45
+ display: grid;
46
+ place-items: center;
47
+ opacity: 0;
48
+ transform: scale(0.6) rotate(-30deg);
49
+ transition: opacity 180ms ease, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
50
+ pointer-events: none;
51
+ &[data-active="true"] {
52
+ opacity: 1;
53
+ transform: scale(1) rotate(0);
54
+ }
55
+ & svg {
56
+ width: 22px;
57
+ height: 22px;
58
+ }
59
+ }
24
60
  .qsl\:pointer-events-auto {
25
61
  pointer-events: auto;
26
62
  }
27
63
  .qsl\:pointer-events-none {
28
64
  pointer-events: none;
29
65
  }
66
+ .qsl\:recording-dot {
67
+ position: absolute;
68
+ right: 7px;
69
+ top: 7px;
70
+ width: 6px;
71
+ height: 6px;
72
+ border-radius: 50%;
73
+ background: currentColor;
74
+ }
30
75
  .qsl\:sr-only {
31
76
  position: absolute;
32
77
  width: 1px;
@@ -38,6 +83,48 @@
38
83
  white-space: nowrap;
39
84
  border-width: 0;
40
85
  }
86
+ .qsl\:primary-action {
87
+ position: relative;
88
+ display: grid;
89
+ flex: none;
90
+ place-items: center;
91
+ width: 48px;
92
+ height: 48px;
93
+ border-radius: 50%;
94
+ background: var(--qsl-pill-primary-background, var(--qsl-primary));
95
+ color: var(--qsl-pill-primary-foreground, var(--qsl-primary-foreground));
96
+ box-shadow: var(--qsl-pill-primary-shadow, 0 5px 20px -5px var(--qsl-primary));
97
+ @supports (color: color-mix(in lab, red, red)) {
98
+ box-shadow: var(--qsl-pill-primary-shadow, 0 5px 20px -5px color-mix(in srgb, var(--qsl-primary) 50%, transparent));
99
+ }
100
+ cursor: pointer;
101
+ touch-action: none;
102
+ user-select: none;
103
+ -webkit-touch-callout: none;
104
+ transition: transform 180ms ease, background-color 180ms ease, color 180ms ease, box-shadow 180ms ease;
105
+ &:hover:not(:disabled) {
106
+ filter: brightness(1.08);
107
+ }
108
+ &:active:not(:disabled) {
109
+ transform: scale(0.94);
110
+ }
111
+ &:focus-visible {
112
+ outline: 3px solid var(--qsl-primary);
113
+ outline-offset: 3px;
114
+ }
115
+ &:disabled {
116
+ opacity: 0.45;
117
+ cursor: default;
118
+ }
119
+ &[data-primary-action="recording"] {
120
+ background: var(--qsl-pill-recording-background, var(--qsl-destructive));
121
+ color: var(--qsl-pill-recording-foreground, var(--qsl-background));
122
+ box-shadow: 0 0 0 4px var(--qsl-pill-aura-color, var(--qsl-destructive));
123
+ @supports (color: color-mix(in lab, red, red)) {
124
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--qsl-pill-aura-color, var(--qsl-destructive)) 22%, transparent);
125
+ }
126
+ }
127
+ }
41
128
  .qsl\:absolute {
42
129
  position: absolute;
43
130
  }
@@ -131,6 +218,45 @@
131
218
  .qsl\:ml-1\.5 {
132
219
  margin-left: calc(var(--qsl-spacing) * 1.5);
133
220
  }
221
+ .qsl\:pill-slot {
222
+ display: flex;
223
+ flex: none;
224
+ align-items: center;
225
+ align-self: flex-end;
226
+ gap: 2px;
227
+ min-height: 48px;
228
+ & > button {
229
+ min-width: 36px;
230
+ min-height: 36px;
231
+ border-radius: 999px;
232
+ }
233
+ }
234
+ .qsl\:pill-replacement {
235
+ display: flex;
236
+ flex: 1;
237
+ align-items: center;
238
+ align-self: center;
239
+ min-width: 0;
240
+ min-height: 48px;
241
+ padding-inline: 8px;
242
+ font-size: var(--qsl-base);
243
+ }
244
+ .qsl\:pill-row {
245
+ display: flex;
246
+ align-items: flex-end;
247
+ gap: 4px;
248
+ min-height: 64px;
249
+ min-width: 0;
250
+ padding: 7px;
251
+ border: 1px solid var(--qsl-pill-border, var(--qsl-border));
252
+ border-radius: 32px;
253
+ background: var(--qsl-pill-background, var(--qsl-background));
254
+ box-shadow: var(--qsl-pill-shadow, var(--qsl-bar-shadow));
255
+ transition: border-color 180ms ease, box-shadow 180ms ease;
256
+ &:focus-within {
257
+ border-color: var(--qsl-primary);
258
+ }
259
+ }
134
260
  .qsl\:block {
135
261
  display: block;
136
262
  }
@@ -170,6 +296,10 @@
170
296
  width: calc(var(--qsl-spacing) * 9);
171
297
  height: calc(var(--qsl-spacing) * 9);
172
298
  }
299
+ .qsl\:size-24 {
300
+ width: calc(var(--qsl-spacing) * 24);
301
+ height: calc(var(--qsl-spacing) * 24);
302
+ }
173
303
  .qsl\:h-5 {
174
304
  height: calc(var(--qsl-spacing) * 5);
175
305
  }
@@ -209,6 +339,20 @@
209
339
  .qsl\:max-h-\[min\(70dvh\,620px\,var\(--available-height\)\)\] {
210
340
  max-height: min(70dvh, 620px, var(--available-height));
211
341
  }
342
+ .qsl\:pill-input {
343
+ flex: 1;
344
+ align-self: center;
345
+ min-width: 0;
346
+ min-height: 48px;
347
+ padding: 12px 8px;
348
+ border-radius: 24px;
349
+ font-size: var(--qsl-base);
350
+ line-height: 1.5;
351
+ &:focus-visible {
352
+ outline: 2px solid var(--qsl-primary);
353
+ outline-offset: -2px;
354
+ }
355
+ }
212
356
  .qsl\:min-h-0 {
213
357
  min-height: 0px;
214
358
  }
@@ -230,9 +374,6 @@
230
374
  .qsl\:w-\[190px\] {
231
375
  width: 190px;
232
376
  }
233
- .qsl\:w-\[320px\] {
234
- width: 320px;
235
- }
236
377
  .qsl\:w-\[calc\(100\%_-_2_\*_var\(--qsl-chat-horizontal-padding\,24px\)\)\] {
237
378
  width: calc(100% - 2 * var(--qsl-chat-horizontal-padding,24px));
238
379
  }
@@ -565,6 +706,9 @@
565
706
  .qsl\:object-contain {
566
707
  object-fit: contain;
567
708
  }
709
+ .qsl\:object-cover {
710
+ object-fit: cover;
711
+ }
568
712
  .qsl\:p-0 {
569
713
  padding: 0px;
570
714
  }
@@ -1194,6 +1338,11 @@
1194
1338
  .qsl\:data-\[working\=true\]\:border-primary[data-working="true"] {
1195
1339
  border-color: var(--qsl-primary);
1196
1340
  }
1341
+ @media (prefers-reduced-motion: no-preference) {
1342
+ .qsl\:motion-safe\:animate-spin {
1343
+ animation: var(--qsl-animate-spin);
1344
+ }
1345
+ }
1197
1346
  @media (prefers-reduced-motion: reduce) {
1198
1347
  .qsl\:motion-reduce\:animate-none {
1199
1348
  animation: none;
@@ -1543,6 +1692,16 @@
1543
1692
  inherits: true;
1544
1693
  initial-value: 13px;
1545
1694
  }
1695
+ @media (prefers-reduced-motion: reduce) {
1696
+ [data-layout="pill"] *, [data-recording-aura] {
1697
+ transition: none !important;
1698
+ animation: none !important;
1699
+ }
1700
+ [data-recording-aura] {
1701
+ transform: scale(1.3) !important;
1702
+ opacity: 0.6 !important;
1703
+ }
1704
+ }
1546
1705
  @layer components {
1547
1706
  [data-quassel-markdown] {
1548
1707
  overflow-wrap: anywhere;
package/index.d.ts CHANGED
@@ -20,6 +20,6 @@ export { ChatSendContext, createChatSendScope, type ChatSendScope } from "./Chat
20
20
  export { messageDate, timestampDay, timestampLabel } from "./timestamps";
21
21
  export { formatElapsed, useElapsed } from "./elapsed";
22
22
  export { attachmentCapabilityError, attachmentDownloadUrl, attachmentMediaType, encodeAttachment, formatAttachmentSize, validateAttachmentSelection, MAX_CHAT_ATTACHMENTS, MAX_CHAT_ATTACHMENT_BYTES, } from "./attachments";
23
- export type { ChatAppearance, TimestampOptions, CodeBlockOptions, BubbleOptions, MessageAction, MessageActionsOptions, SendShortcut, } from "./options";
23
+ export type { ChatAppearance, TimestampOptions, CodeBlockOptions, BubbleOptions, MessageAction, MessageActionsOptions, SendShortcut, ChatMicrophone, MicrophonePress, } from "./options";
24
24
  export { appearanceStyle } from "./options";
25
25
  export { QuasselProvider, defaultComponents, useQuasselComponents, useQuasselPortalContainer, type QuasselComponents, type QuasselButtonProps, type QuasselButtonVariant, type QuasselButtonSize, type QuasselToggleProps, type QuasselCardProps, type QuasselStopButtonProps, type QuasselPopoverProps, type QuasselPopoverContentProps, type QuasselPopoverAnchor, type QuasselPortalContainer, type QuasselPortalTarget, } from "./QuasselProvider";