@djangocfg/ui-tools 2.1.465 → 2.1.468

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.465",
3
+ "version": "2.1.468",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -334,8 +334,8 @@
334
334
  "test:watch": "vitest"
335
335
  },
336
336
  "peerDependencies": {
337
- "@djangocfg/i18n": "^2.1.465",
338
- "@djangocfg/ui-core": "^2.1.465",
337
+ "@djangocfg/i18n": "^2.1.468",
338
+ "@djangocfg/ui-core": "^2.1.468",
339
339
  "consola": "^3.4.2",
340
340
  "lodash-es": "^4.18.1",
341
341
  "lucide-react": "^0.545.0",
@@ -418,9 +418,9 @@
418
418
  "@maplibre/maplibre-gl-geocoder": "^1.9.4"
419
419
  },
420
420
  "devDependencies": {
421
- "@djangocfg/i18n": "^2.1.465",
422
- "@djangocfg/typescript-config": "^2.1.465",
423
- "@djangocfg/ui-core": "^2.1.465",
421
+ "@djangocfg/i18n": "^2.1.468",
422
+ "@djangocfg/typescript-config": "^2.1.468",
423
+ "@djangocfg/ui-core": "^2.1.468",
424
424
  "@types/lodash-es": "^4.17.12",
425
425
  "@types/mapbox__mapbox-gl-draw": "^1.4.9",
426
426
  "@types/node": "^25.9.5",
@@ -1002,6 +1002,7 @@ const PROMPTS: readonly SuggestedPromptItem[] = [
1002
1002
  onPick={(item) => { setValue(item.prompt); focus(); }}
1003
1003
  hero={<Sparkles className="size-6 text-primary" />}
1004
1004
  title="How can I help?"
1005
+ titleLevel={2}
1005
1006
  description="Pick a starter, or just start typing."
1006
1007
  />
1007
1008
  ),
@@ -1009,7 +1010,10 @@ const PROMPTS: readonly SuggestedPromptItem[] = [
1009
1010
  />
1010
1011
  ```
1011
1012
 
1012
- `renderItem` is an escape hatch for one-off chip styling without forking the component.
1013
+ The prompt collection uses native `ul`/`li` semantics while each prompt remains
1014
+ a button. `titleLevel` defaults to `2`; set it to `3` or `4` when the empty state
1015
+ is nested below an existing section heading. `renderItem` is an escape hatch for
1016
+ one-off chip styling without forking the component.
1013
1017
 
1014
1018
  ### 3. Headless
1015
1019
 
@@ -41,6 +41,7 @@ import type { ComposerAppearance } from '../composer/types';
41
41
  export interface MessageBubbleProps {
42
42
  message: ChatMessage;
43
43
  isUser?: boolean;
44
+ /** Show the sender avatar. Defaults to `false` for the flat transcript layout. */
44
45
  showAvatar?: boolean;
45
46
  /** Override avatar URL (skips persona resolution). */
46
47
  avatarSrc?: string;
@@ -158,9 +159,8 @@ const APPEARANCE_CLASSES: Record<ComposerAppearance, {
158
159
  avatar: 'size-10',
159
160
  avatarFallback: 'text-sm',
160
161
  content: 'max-w-[44rem]',
161
- // Roomy ChatGPT-style bubble: larger text with generous line-height
162
- // and padding so a full-page chat reads spacious, not just bigger.
163
- bubble: 'rounded-3xl px-5 py-3.5 text-base leading-relaxed',
162
+ // Both roles share roomy geometry; their surfaces differ by intensity.
163
+ bubble: 'text-base leading-relaxed',
164
164
  meta: 'text-xs',
165
165
  },
166
166
  };
@@ -168,7 +168,7 @@ const APPEARANCE_CLASSES: Record<ComposerAppearance, {
168
168
  const MessageBubbleInner = ({
169
169
  message,
170
170
  isUser: isUserProp,
171
- showAvatar = true,
171
+ showAvatar = false,
172
172
  avatarSrc,
173
173
  avatarFallback,
174
174
  user,
@@ -346,6 +346,7 @@ const MessageBubbleInner = ({
346
346
  // `select-text` on the prose itself: the text is the only
347
347
  // selection island, bounded to this one message.
348
348
  'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm',
349
+ appearance === 'full' && 'rounded-3xl px-5 py-3.5',
349
350
  ap.bubble,
350
351
  bubbleSurface,
351
352
  )}
@@ -0,0 +1,36 @@
1
+ import { memo } from 'react';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ import { formatMessageDateLabel } from './messageDate';
6
+
7
+ export interface MessageDateSeparatorProps {
8
+ timestamp: number;
9
+ timeZone: string;
10
+ locale?: string;
11
+ now?: number;
12
+ className?: string;
13
+ }
14
+
15
+ export const MessageDateSeparator = memo(function MessageDateSeparator({
16
+ timestamp,
17
+ timeZone,
18
+ locale,
19
+ now,
20
+ className,
21
+ }: MessageDateSeparatorProps) {
22
+ const label = formatMessageDateLabel(timestamp, { locale, timeZone, now });
23
+
24
+ return (
25
+ <div
26
+ role="separator"
27
+ aria-label={label}
28
+ className={cn(
29
+ 'flex items-center justify-center px-3 pb-2 pt-5 text-xs font-medium text-muted-foreground',
30
+ className,
31
+ )}
32
+ >
33
+ <time dateTime={new Date(timestamp).toISOString()}>{label}</time>
34
+ </div>
35
+ );
36
+ });
@@ -19,6 +19,8 @@ import { useCopy } from '@djangocfg/ui-core/hooks';
19
19
  import type { ChatMessage } from '../types';
20
20
  import { useChatContextOptional } from '../context';
21
21
  import { MessageBubble } from './MessageBubble';
22
+ import { MessageDateSeparator } from './MessageDateSeparator';
23
+ import { formatMessageDateLabel, getCalendarDayKey } from './messageDate';
22
24
  import type { ComposerAppearance } from '../composer/types';
23
25
 
24
26
  export interface MessageListProps {
@@ -69,6 +71,18 @@ export interface MessageListProps {
69
71
  * whole bubble up for full-page chat. Default `compact` (no change).
70
72
  * Ignored when `renderItem` is set (the host owns bubble rendering). */
71
73
  appearance?: ComposerAppearance;
74
+ /** Show a divider when adjacent messages fall on different calendar days. */
75
+ showDateSeparators?: boolean;
76
+ /** Locale used for divider labels. Defaults to the viewer's locale. */
77
+ dateSeparatorLocale?: string;
78
+ /**
79
+ * IANA timezone used for calendar grouping (for example `UTC` or
80
+ * `Asia/Makassar`). Defaults to the viewer's browser timezone after mount.
81
+ * Message timestamps remain absolute UTC epoch milliseconds.
82
+ */
83
+ dateSeparatorTimeZone?: string;
84
+ /** Replace the default date divider while retaining day-grouping behavior. */
85
+ renderDateSeparator?: (timestamp: number, label: string) => ReactNode;
72
86
  }
73
87
 
74
88
  export interface MessageListHandle {
@@ -103,6 +117,10 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
103
117
  atBottomThreshold = 120,
104
118
  scrollAnchorId,
105
119
  appearance = 'compact',
120
+ showDateSeparators = true,
121
+ dateSeparatorLocale,
122
+ dateSeparatorTimeZone,
123
+ renderDateSeparator,
106
124
  },
107
125
  ref,
108
126
  ) {
@@ -110,6 +128,22 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
110
128
  const messages = messagesProp ?? ctx?.messages ?? [];
111
129
  const isLoadingMore = isLoadingMoreProp ?? ctx?.isLoadingMore ?? false;
112
130
  const { copyToClipboard } = useCopy();
131
+ // Do not guess the server timezone during SSR. When the host does not pass
132
+ // one explicitly, resolve it in the browser after mount; this avoids a
133
+ // hydration mismatch around midnight while still grouping for the viewer.
134
+ const [browserTimeZone, setBrowserTimeZone] = useState<string | undefined>(dateSeparatorTimeZone);
135
+ useEffect(() => {
136
+ if (dateSeparatorTimeZone) {
137
+ setBrowserTimeZone(dateSeparatorTimeZone);
138
+ return;
139
+ }
140
+ setBrowserTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
141
+ }, [dateSeparatorTimeZone]);
142
+ const resolvedDateSeparatorTimeZone = dateSeparatorTimeZone ?? browserTimeZone;
143
+ // Keep relative labels stable for the lifetime of this list. Normal chat
144
+ // activity remounts/re-renders the shell often enough; more importantly,
145
+ // one render cannot label adjacent separators against different instants.
146
+ const dateLabelNow = useMemo(() => Date.now(), []);
113
147
 
114
148
  const virtuosoRef = useRef<VirtuosoHandle | null>(null);
115
149
  // Virtuoso's `atBottomStateChange` only fires when the list is
@@ -210,6 +244,50 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
210
244
  );
211
245
 
212
246
  const itemRenderer = renderItem ?? defaultRenderItem;
247
+ const renderTranscriptItem = useCallback(
248
+ (m: ChatMessage, index: number) => {
249
+ const item = itemRenderer(m, index);
250
+ if (!showDateSeparators || !resolvedDateSeparatorTimeZone) return item;
251
+
252
+ const previous = messages[index - 1];
253
+ const startsNewDay =
254
+ !previous ||
255
+ getCalendarDayKey(previous.createdAt, resolvedDateSeparatorTimeZone) !==
256
+ getCalendarDayKey(m.createdAt, resolvedDateSeparatorTimeZone);
257
+
258
+ if (!startsNewDay) return item;
259
+
260
+ const separator = (
261
+ <MessageDateSeparator
262
+ timestamp={m.createdAt}
263
+ timeZone={resolvedDateSeparatorTimeZone}
264
+ locale={dateSeparatorLocale}
265
+ now={dateLabelNow}
266
+ />
267
+ );
268
+ const label = formatMessageDateLabel(m.createdAt, {
269
+ locale: dateSeparatorLocale,
270
+ timeZone: resolvedDateSeparatorTimeZone,
271
+ now: dateLabelNow,
272
+ });
273
+
274
+ return (
275
+ <>
276
+ {renderDateSeparator ? renderDateSeparator(m.createdAt, label) : separator}
277
+ {item}
278
+ </>
279
+ );
280
+ },
281
+ [
282
+ dateLabelNow,
283
+ dateSeparatorLocale,
284
+ itemRenderer,
285
+ messages,
286
+ renderDateSeparator,
287
+ resolvedDateSeparatorTimeZone,
288
+ showDateSeparators,
289
+ ],
290
+ );
213
291
  // Jump to bottom on the first non-empty messages batch. A SINGLE rAF +
214
292
  // scrollToIndex is not enough when the list mounts with a long transcript
215
293
  // (opening / switching a contact): virtuoso measures the dynamic bubble
@@ -242,9 +320,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
242
320
  // self-cancel.
243
321
  const scroller = scrollerRef.current;
244
322
  const scrollerEl =
245
- scroller != null && scroller !== window && scroller instanceof HTMLElement
246
- ? scroller
247
- : null;
323
+ scroller != null && scroller !== window && scroller instanceof HTMLElement ? scroller : null;
248
324
  scrollerEl?.addEventListener('wheel', cancelOnUserScroll, { passive: true });
249
325
  scrollerEl?.addEventListener('touchstart', cancelOnUserScroll, { passive: true });
250
326
  scrollerEl?.addEventListener('keydown', cancelOnUserScroll);
@@ -454,7 +530,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
454
530
  </div>
455
531
  ) : null}
456
532
  {messages.map((m, i) => (
457
- <div key={m.id ?? i}>{itemRenderer(m, i)}</div>
533
+ <div key={m.id ?? i}>{renderTranscriptItem(m, i)}</div>
458
534
  ))}
459
535
  {/* Mirror the virtualized Footer spacer so the last bubble keeps
460
536
  its breathing room above the composer in the non-virtual path. */}
@@ -474,7 +550,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
474
550
  computeItemKey={computeItemKey}
475
551
  itemContent={(index, m) => {
476
552
  if (!m) return null;
477
- const node = itemRenderer(m, index);
553
+ const node = renderTranscriptItem(m, index);
478
554
  // Wrap only the last bubble in a size-observed shell so we can
479
555
  // re-pin to the absolute bottom as the streaming reply grows
480
556
  // (corrects virtuoso's slight final-chunk under-scroll). The
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { formatMessageDateLabel, getCalendarDayKey } from '../messageDate';
4
+
5
+ describe('messageDate', () => {
6
+ it('groups an absolute timestamp by the requested timezone', () => {
7
+ const timestamp = Date.UTC(2026, 6, 12, 23, 30);
8
+
9
+ expect(getCalendarDayKey(timestamp, 'UTC')).toBe('2026-07-12');
10
+ expect(getCalendarDayKey(timestamp, 'Asia/Makassar')).toBe('2026-07-13');
11
+ });
12
+
13
+ it('formats today and yesterday relative to the display timezone', () => {
14
+ const now = Date.UTC(2026, 6, 13, 12);
15
+
16
+ expect(
17
+ formatMessageDateLabel(Date.UTC(2026, 6, 13, 1), {
18
+ locale: 'en-US',
19
+ timeZone: 'UTC',
20
+ now,
21
+ }),
22
+ ).toBe('Today');
23
+ expect(
24
+ formatMessageDateLabel(Date.UTC(2026, 6, 12, 23), {
25
+ locale: 'en-US',
26
+ timeZone: 'UTC',
27
+ now,
28
+ }),
29
+ ).toBe('Yesterday');
30
+ });
31
+
32
+ it('includes the year only when the message is outside the current year', () => {
33
+ const now = Date.UTC(2026, 6, 13, 12);
34
+
35
+ expect(
36
+ formatMessageDateLabel(Date.UTC(2026, 6, 4, 12), {
37
+ locale: 'en-US',
38
+ timeZone: 'UTC',
39
+ now,
40
+ }),
41
+ ).toBe('Saturday, July 4');
42
+ expect(
43
+ formatMessageDateLabel(Date.UTC(2025, 6, 4, 12), {
44
+ locale: 'en-US',
45
+ timeZone: 'UTC',
46
+ now,
47
+ }),
48
+ ).toBe('Friday, July 4, 2025');
49
+ });
50
+ });
@@ -8,6 +8,13 @@ export {
8
8
  type MessageListHandle,
9
9
  } from './MessageList';
10
10
  export { MessageBubble, type MessageBubbleProps } from './MessageBubble';
11
+ export { MessageDateSeparator, type MessageDateSeparatorProps } from './MessageDateSeparator';
12
+ export {
13
+ formatMessageDateLabel,
14
+ getCalendarDateParts,
15
+ getCalendarDayKey,
16
+ type CalendarDateParts,
17
+ } from './messageDate';
11
18
  // The auto-detect link-preview slot — shared by MessageBubble and any forked
12
19
  // bubble (e.g. cmdop's mention bubble) so previews never silently drift.
13
20
  export { AutoLinkPreview, type AutoLinkPreviewProps } from './AutoLinkPreview';
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Calendar helpers for chat timestamps.
3
+ *
4
+ * `ChatMessage.createdAt` is an epoch-millisecond value, so it represents an
5
+ * absolute UTC instant. Calendar grouping is deliberately performed in the
6
+ * viewer's display timezone: the same instant may belong to Monday in UTC and
7
+ * Tuesday in Asia/Makassar.
8
+ */
9
+
10
+ export interface CalendarDateParts {
11
+ year: number;
12
+ month: number;
13
+ day: number;
14
+ }
15
+
16
+ export function getCalendarDateParts(timestamp: number, timeZone: string): CalendarDateParts {
17
+ const parts = new Intl.DateTimeFormat('en-US', {
18
+ timeZone,
19
+ year: 'numeric',
20
+ month: '2-digit',
21
+ day: '2-digit',
22
+ }).formatToParts(new Date(timestamp));
23
+
24
+ const values = Object.fromEntries(
25
+ parts
26
+ .filter((part) => part.type === 'year' || part.type === 'month' || part.type === 'day')
27
+ .map((part) => [part.type, Number(part.value)]),
28
+ );
29
+
30
+ return {
31
+ year: values.year,
32
+ month: values.month,
33
+ day: values.day,
34
+ };
35
+ }
36
+
37
+ export function getCalendarDayKey(timestamp: number, timeZone: string): string {
38
+ const { year, month, day } = getCalendarDateParts(timestamp, timeZone);
39
+ return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
40
+ }
41
+
42
+ function calendarDayNumber(timestamp: number, timeZone: string): number {
43
+ const { year, month, day } = getCalendarDateParts(timestamp, timeZone);
44
+ return Math.floor(Date.UTC(year, month - 1, day) / 86_400_000);
45
+ }
46
+
47
+ function capitalizeLabel(label: string, locale?: string): string {
48
+ if (!label) return label;
49
+ return label[0].toLocaleUpperCase(locale) + label.slice(1);
50
+ }
51
+
52
+ export function formatMessageDateLabel(
53
+ timestamp: number,
54
+ options: {
55
+ locale?: string;
56
+ timeZone: string;
57
+ now?: number;
58
+ },
59
+ ): string {
60
+ const { locale, timeZone, now = Date.now() } = options;
61
+ const dayDelta = calendarDayNumber(timestamp, timeZone) - calendarDayNumber(now, timeZone);
62
+
63
+ if (dayDelta === 0 || dayDelta === -1) {
64
+ return capitalizeLabel(
65
+ new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(dayDelta, 'day'),
66
+ locale,
67
+ );
68
+ }
69
+
70
+ const messageYear = getCalendarDateParts(timestamp, timeZone).year;
71
+ const currentYear = getCalendarDateParts(now, timeZone).year;
72
+ return new Intl.DateTimeFormat(locale, {
73
+ timeZone,
74
+ weekday: 'long',
75
+ month: 'long',
76
+ day: 'numeric',
77
+ ...(messageYear === currentYear ? {} : { year: 'numeric' as const }),
78
+ }).format(new Date(timestamp));
79
+ }
@@ -30,12 +30,15 @@ export type SuggestedPromptItem = {
30
30
  };
31
31
 
32
32
  export type SuggestedPromptsLayout = 'chips' | 'grid';
33
+ export type SuggestedPromptsTitleLevel = 2 | 3 | 4;
33
34
 
34
35
  export interface SuggestedPromptsProps {
35
36
  items: readonly SuggestedPromptItem[];
36
37
  onPick: (item: SuggestedPromptItem) => void;
37
38
  /** Optional title above the chips. */
38
39
  title?: ReactNode;
40
+ /** Semantic title level. Defaults to `2`; set it to match the host page outline. */
41
+ titleLevel?: SuggestedPromptsTitleLevel;
39
42
  /** Optional subtitle/description above the chips. */
40
43
  description?: ReactNode;
41
44
  /** Optional hero element above the title (icon, logo, …). */
@@ -54,6 +57,7 @@ export function SuggestedPrompts({
54
57
  items,
55
58
  onPick,
56
59
  title,
60
+ titleLevel = 2,
57
61
  description,
58
62
  hero,
59
63
  layout = 'chips',
@@ -62,6 +66,7 @@ export function SuggestedPrompts({
62
66
  renderItem,
63
67
  }: SuggestedPromptsProps) {
64
68
  const hasHeader = hero != null || title != null || description != null;
69
+ const Title = `h${titleLevel}` as 'h2' | 'h3' | 'h4';
65
70
 
66
71
  return (
67
72
  <section
@@ -78,9 +83,9 @@ export function SuggestedPrompts({
78
83
  <div className="text-foreground/90">{hero}</div>
79
84
  ) : null}
80
85
  {title != null ? (
81
- <h3 className="text-base font-semibold text-foreground">
86
+ <Title className="text-base font-semibold text-foreground">
82
87
  {title}
83
- </h3>
88
+ </Title>
84
89
  ) : null}
85
90
  {description != null ? (
86
91
  <p className="max-w-md text-sm leading-snug text-muted-foreground">
@@ -91,35 +96,25 @@ export function SuggestedPrompts({
91
96
  ) : null}
92
97
 
93
98
  {layout === 'grid' ? (
94
- <div
95
- className="grid w-full max-w-xl grid-cols-1 gap-2 sm:grid-cols-2"
96
- role="list"
97
- >
98
- {items.map((item, idx) =>
99
- renderItem ? (
100
- <span key={item.id} role="listitem">
101
- {renderItem(item, idx)}
102
- </span>
103
- ) : (
104
- <GridCard key={item.id} item={item} onPick={onPick} />
105
- ),
106
- )}
107
- </div>
99
+ <ul className="m-0 grid w-full max-w-xl list-none grid-cols-1 gap-2 p-0 sm:grid-cols-2">
100
+ {items.map((item, idx) => (
101
+ <li key={item.id} className="min-w-0">
102
+ {renderItem
103
+ ? renderItem(item, idx)
104
+ : <GridCard item={item} onPick={onPick} />}
105
+ </li>
106
+ ))}
107
+ </ul>
108
108
  ) : (
109
- <div
110
- className="flex w-full max-w-xl flex-wrap justify-center gap-2"
111
- role="list"
112
- >
113
- {items.map((item, idx) =>
114
- renderItem ? (
115
- <span key={item.id} role="listitem">
116
- {renderItem(item, idx)}
117
- </span>
118
- ) : (
119
- <Chip key={item.id} item={item} onPick={onPick} />
120
- ),
121
- )}
122
- </div>
109
+ <ul className="m-0 flex w-full max-w-xl list-none flex-wrap justify-center gap-2 p-0">
110
+ {items.map((item, idx) => (
111
+ <li key={item.id}>
112
+ {renderItem
113
+ ? renderItem(item, idx)
114
+ : <Chip item={item} onPick={onPick} />}
115
+ </li>
116
+ ))}
117
+ </ul>
123
118
  )}
124
119
  </section>
125
120
  );
@@ -135,7 +130,6 @@ function Chip({
135
130
  return (
136
131
  <button
137
132
  type="button"
138
- role="listitem"
139
133
  onClick={() => onPick(item)}
140
134
  className={cn(
141
135
  'inline-flex items-center gap-1.5 rounded-full border border-border',
@@ -166,10 +160,9 @@ function GridCard({
166
160
  return (
167
161
  <button
168
162
  type="button"
169
- role="listitem"
170
163
  onClick={() => onPick(item)}
171
164
  className={cn(
172
- 'group flex flex-col gap-1 rounded-md border border-border',
165
+ 'group flex h-full w-full flex-col gap-1 rounded-md border border-border',
173
166
  'bg-card/60 p-3 text-left',
174
167
  'transition-colors duration-150',
175
168
  'hover:border-primary/40 hover:bg-card',
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, createElement, type ComponentProps } from 'react';
4
+ import { createRoot } from 'react-dom/client';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import { SuggestedPrompts, type SuggestedPromptItem } from '../SuggestedPrompts';
8
+
9
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const items: readonly SuggestedPromptItem[] = [
12
+ { id: 'one', label: 'First prompt', prompt: 'First prompt' },
13
+ { id: 'two', label: 'Second prompt', prompt: 'Second prompt' },
14
+ ];
15
+
16
+ describe('SuggestedPrompts', () => {
17
+ const mounted: Array<ReturnType<typeof createRoot>> = [];
18
+
19
+ afterEach(() => {
20
+ act(() => mounted.splice(0).forEach((root) => root.unmount()));
21
+ });
22
+
23
+ function render(props: Partial<ComponentProps<typeof SuggestedPrompts>> = {}) {
24
+ const container = document.createElement('div');
25
+ const root = createRoot(container);
26
+ mounted.push(root);
27
+ act(() => {
28
+ root.render(createElement(SuggestedPrompts, {
29
+ items,
30
+ onPick: vi.fn(),
31
+ title: 'Start here',
32
+ ...props,
33
+ }));
34
+ });
35
+ return container;
36
+ }
37
+
38
+ it('uses native list semantics without overriding button roles', () => {
39
+ const container = render();
40
+ const list = container.querySelector('ul');
41
+
42
+ expect(list?.children).toHaveLength(2);
43
+ expect(Array.from(list?.children ?? []).every((item) => item.tagName === 'LI')).toBe(true);
44
+ expect(container.querySelectorAll('button[role]')).toHaveLength(0);
45
+ });
46
+
47
+ it('defaults to h2 and lets the host select the outline level', () => {
48
+ expect(render().querySelector('h2')?.textContent).toBe('Start here');
49
+ expect(render({ titleLevel: 4 }).querySelector('h4')?.textContent).toBe('Start here');
50
+ });
51
+ });
@@ -15,10 +15,10 @@
15
15
 
16
16
  /** Bubble surface classes (background + text), keyed by message state. */
17
17
  export const BUBBLE_SURFACE = {
18
- /** User-authored bubble solid brand color. */
19
- user: 'bg-primary text-primary-foreground rounded-tr-md',
20
- /** Assistant bubble in normal state neutral muted surface. */
21
- assistant: 'bg-muted text-foreground rounded-tl-md',
18
+ /** User-authored bubble - a quiet neutral surface aligned to the right. */
19
+ user: 'bg-muted text-foreground',
20
+ /** Assistant replies use a lighter neutral surface than user messages. */
21
+ assistant: 'bg-muted/40 text-foreground',
22
22
  /** Assistant bubble when the turn failed — destructive tint. */
23
23
  error: 'bg-destructive/10 text-destructive rounded-tl-md border border-destructive/30',
24
24
  } as const;
@@ -44,23 +44,22 @@ export const BLOCK_SURFACE =
44
44
  /**
45
45
  * Anchor (link) classes for markdown content rendered inside a bubble.
46
46
  *
47
- * On `bg-primary` the link MUST stay legible against the cyan/brand fill
48
- * `text-primary-foreground` matches the bubble's foreground token, so
49
- * contrast tracks the design system automatically.
47
+ * User and assistant prose now share neutral surfaces, so both roles use the
48
+ * theme's primary link color with a restrained underline.
50
49
  *
51
50
  * On the neutral assistant bubble we keep the brand-primary color so links
52
51
  * still pop without competing with the body text.
53
52
  */
54
53
  export const ANCHOR = {
55
54
  user:
56
- 'text-primary-foreground underline decoration-primary-foreground/60 underline-offset-2 ' +
57
- 'hover:decoration-primary-foreground transition-colors break-all',
55
+ 'text-primary underline decoration-primary/50 underline-offset-2 ' +
56
+ 'hover:text-primary/80 hover:decoration-primary transition-colors break-all',
58
57
  assistant: 'text-primary underline hover:text-primary/80 transition-colors break-all',
59
58
  } as const;
60
59
 
61
60
  /** Inline secondary action (e.g. "Show more / less"). Same logic as anchors. */
62
61
  export const TOGGLE = {
63
- user: 'text-primary-foreground/80 hover:text-primary-foreground',
62
+ user: 'text-primary hover:text-primary/80',
64
63
  assistant: 'text-primary hover:text-primary/80',
65
64
  } as const;
66
65
 
@@ -20,6 +20,9 @@ We tried popular headless tree engines first. They all leak React-integration bu
20
20
  5. **CRUD = adapter, not props.** The host app describes how to delete / rename / move / etc. through a single `TreeAdapter<T>` object. Tree owns the UX (dialogs, hotkeys, menus) and calls back into the adapter. No `onDelete`/`onRename`/... prop sprawl.
21
21
  6. **Dialogs come from `<DialogProvider>`.** Tree never re-implements its own dialogs. Built-in CRUD flows resolve `window.dialog` (installed by `@djangocfg/ui-core/lib/dialog-service`). If the host app hasn't mounted it, CRUD flows silently no-op with a dev-mode warning — Tree itself still renders.
22
22
  7. **CSS-variable theming.** Density, sizes, gaps, indent — all exposed as `--tree-*` variables on the root. Override in any consumer without re-implementing components.
23
+ 8. **Honest empty semantics.** An empty collection is announced as a status and
24
+ does not expose an invalid, focusable ARIA tree until at least one tree item
25
+ exists.
23
26
 
24
27
  ## Layered architecture
25
28