@thewhileloop/whileui 4.0.0 → 4.1.1

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 (51) hide show
  1. package/README.md +186 -20
  2. package/dist/blocks/chat/chat-conversation.d.ts +8 -0
  3. package/dist/blocks/chat/chat-conversation.js +36 -0
  4. package/dist/blocks/chat/chat.js +5 -11
  5. package/dist/blocks/chat/index.d.ts +1 -0
  6. package/dist/blocks/chat/index.js +1 -0
  7. package/dist/blocks/layout/smart-input.js +2 -1
  8. package/dist/blocks/media/smart-image.js +5 -7
  9. package/dist/code.d.ts +1 -0
  10. package/dist/code.js +1 -0
  11. package/dist/components/avatar/avatar.d.ts +3 -2
  12. package/dist/components/avatar/avatar.js +19 -13
  13. package/dist/components/avatar/index.d.ts +1 -1
  14. package/dist/components/card/card.d.ts +1 -1
  15. package/dist/components/card/card.js +4 -25
  16. package/dist/components/code-block/code-block.d.ts +12 -0
  17. package/dist/components/code-block/code-block.js +13 -0
  18. package/dist/components/code-block/index.d.ts +1 -0
  19. package/dist/components/code-block/index.js +1 -0
  20. package/dist/components/form-field/form-field.d.ts +8 -0
  21. package/dist/components/form-field/form-field.js +3 -0
  22. package/dist/components/form-field/index.d.ts +1 -1
  23. package/dist/components/form-field/index.js +1 -1
  24. package/dist/components/input/input.d.ts +1 -0
  25. package/dist/components/input/input.js +11 -6
  26. package/dist/components/markdown/index.d.ts +1 -0
  27. package/dist/components/markdown/index.js +1 -0
  28. package/dist/components/markdown/markdown.d.ts +14 -0
  29. package/dist/components/markdown/markdown.js +94 -0
  30. package/dist/components/numeric-input/numeric-input.d.ts +1 -0
  31. package/dist/components/numeric-input/numeric-input.js +11 -6
  32. package/dist/components/select/select.d.ts +2 -1
  33. package/dist/components/select/select.js +44 -24
  34. package/dist/components/surface/index.d.ts +1 -0
  35. package/dist/components/surface/index.js +1 -0
  36. package/dist/components/surface/surface.d.ts +14 -0
  37. package/dist/components/surface/surface.js +35 -0
  38. package/dist/components/text/text.d.ts +42 -42
  39. package/dist/components/text/text.js +39 -17
  40. package/dist/components/textarea/textarea.d.ts +1 -0
  41. package/dist/components/textarea/textarea.js +7 -3
  42. package/dist/components/toast/toast.js +5 -1
  43. package/dist/index.d.ts +3 -2
  44. package/dist/index.js +2 -1
  45. package/dist/lib/theme-bridge.d.ts +1 -1
  46. package/dist/lib/theme-bridge.js +52 -18
  47. package/dist/lib/web-autofill.d.ts +9 -0
  48. package/dist/lib/web-autofill.js +9 -0
  49. package/dist/markdown.d.ts +1 -0
  50. package/dist/markdown.js +1 -0
  51. package/package.json +16 -2
package/README.md CHANGED
@@ -11,6 +11,18 @@ production targets — iOS and Android set the interaction baseline. See
11
11
  [ROADMAP.md](./ROADMAP.md) for the product boundary and extraction rules, and
12
12
  [PLAN.md](./PLAN.md) for the supported stack and production contracts.
13
13
 
14
+ ## See it in action
15
+
16
+ One React Native component contract across native and web, with semantic typography,
17
+ token-driven light and dark themes, accessible controls, and application-owned chat
18
+ composition. The same component tree can feel airy and atmospheric or compact and
19
+ editorial by changing tokens alone.
20
+
21
+ <p align="center">
22
+ <img src="./docs/assets/whileui-showcase.gif" width="300" alt="WhileUI Cosmic Aura mobile theme demonstrating light and dark modes, controls, Select, and composable chat" />
23
+ <img src="./docs/assets/whileui-showcase-atelier.gif" width="300" alt="WhileUI Atelier mobile theme demonstrating compact controls, authentication forms, Select, and composable chat" />
24
+ </p>
25
+
14
26
  ## Installation
15
27
 
16
28
  ```bash
@@ -18,6 +30,8 @@ bun add @thewhileloop/whileui
18
30
  bun add react@^19.3.0 react-native@^0.88.0 uniwind@^1.12.2 tailwindcss@^4.0.0 react-native-reanimated react-native-safe-area-context react-native-screens
19
31
  # Only if you use Select, Popover, Tooltip, or HoverCard:
20
32
  bun add @rn-primitives/portal @rn-primitives/hooks @rn-primitives/slot @rn-primitives/select @rn-primitives/popover @rn-primitives/tooltip @rn-primitives/hover-card
33
+ # Only if you import @thewhileloop/whileui/markdown:
34
+ bun add markdown-it
21
35
  ```
22
36
 
23
37
  Use `npm install` instead of `bun add` if you prefer npm.
@@ -230,17 +244,33 @@ function MyScreen() {
230
244
  `@source './node_modules/@thewhileloop/whileui/dist';` or every component renders unstyled.
231
245
  - **Reference:** Block props in `packages/ui/src/blocks` (core) and `apps/showcase/templates/` (auth, profile); flow patterns in README "Flow Patterns" section.
232
246
 
247
+ ### Composition grammar
248
+
249
+ - Start with one page canvas and flat sections. Add a `Surface` only when grouping or
250
+ elevation communicates something; do not wrap every section in a card.
251
+ - Use `level="base"` for grouped content, `raised` for selectable or floating regions,
252
+ and `overlay` for content above the page. Avoid nesting more than one surfaced level.
253
+ - Express hierarchy through `Text` variants before custom font-size classes. Keep one
254
+ primary action per region and constrain long-form content with `Container`.
255
+ - Forms use `FormField`; its disabled and invalid state flows into `Input`, `Textarea`,
256
+ `NumericInput`, and `Select`. Pass a control prop only to override that inherited state.
257
+ - Chat mechanics belong to `ChatConversation` + `SmartInput`. Message payloads, tools,
258
+ reasoning, approvals, sources, and action rows are application-owned compositions.
259
+ - Import Markdown and code rendering from their optional subpaths. Do not make the root
260
+ package pay for content rendering when the screen does not use it.
261
+
233
262
  ## Components
234
263
 
235
264
  ### Primitives
236
265
 
237
- | Component | Notes |
238
- | ------------- | -------------------------------- |
239
- | **Text** | Themed text with variant support |
240
- | **View** | Themed view wrapper |
241
- | **Pressable** | Themed pressable wrapper |
242
- | **Stack** | Vertical flex layout with gap |
243
- | **Row** | Horizontal flex layout with gap |
266
+ | Component | Notes |
267
+ | ------------- | ------------------------------------------------ |
268
+ | **Text** | Themed text with variant support |
269
+ | **Surface** | Token-backed base, raised, and overlay container |
270
+ | **View** | Themed view wrapper |
271
+ | **Pressable** | Themed pressable wrapper |
272
+ | **Stack** | Vertical flex layout with gap |
273
+ | **Row** | Horizontal flex layout with gap |
244
274
 
245
275
  ### Root Providers
246
276
 
@@ -273,6 +303,7 @@ function MyScreen() {
273
303
  | Component | Variants | Notes |
274
304
  | --------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
275
305
  | **Card** | padding (none, sm, default, lg), unstyled | Compound: Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter |
306
+ | **Surface** | level (base, raised, overlay) | Shared radius, padding, border, shadow, and optional frosted treatment |
276
307
  | **Badge** | default, secondary, destructive, outline, success | BadgeText sub-component |
277
308
  | **FAB** | default, secondary, destructive, surface; sm/default/lg | Floating action button. `label` required; pass your own icon as children |
278
309
  | **Alert** | default, destructive, success, warning | AlertTitle & AlertDescription |
@@ -364,11 +395,12 @@ Copy from `apps/showcase/templates/auth/`:
364
395
 
365
396
  ### Chat
366
397
 
367
- | Block | Description |
368
- | --------------------- | ------------------------------------------------ |
369
- | **Chat** | AI-style chat: messages, suggestions, SmartInput |
370
- | **ChatMessageBubble** | Message bubble (user/assistant, big/small text) |
371
- | **ChatSuggestions** | Suggestion chips when empty |
398
+ | Block | Description |
399
+ | --------------------- | --------------------------------------------------------------- |
400
+ | **Chat** | Opinionated messages, suggestions, and SmartInput composition |
401
+ | **ChatConversation** | Constrained conversation viewport that follows streaming output |
402
+ | **ChatMessageBubble** | Message bubble (user/assistant, big/small text) |
403
+ | **ChatSuggestions** | Suggestion chips when empty |
372
404
 
373
405
  ### Profile & Settings (Copy from showcase templates)
374
406
 
@@ -405,9 +437,9 @@ Copy from `apps/showcase/templates/profile/`:
405
437
 
406
438
  ### Media
407
439
 
408
- | Block | Description |
409
- | -------------- | ----------------------------------- |
410
- | **SmartImage** | Image with aspect ratio and loading |
440
+ | Block | Description |
441
+ | -------------- | ------------------------------------------------------------------ |
442
+ | **SmartImage** | Image with explicit loading, loaded, fallback, and error lifecycle |
411
443
 
412
444
  ### Date Picker
413
445
 
@@ -447,6 +479,11 @@ soft variant, overlay, surface, interaction state and shadow. Those are exactly
447
479
  `deriveThemeColors` computes, so hand-writing them produces a second derivation that
448
480
  drifts from the library's. Override a derived value by assigning to the result.
449
481
 
482
+ `WHILEUI_PRESET_SOFT` is the airy default. `WHILEUI_PRESET_ATELIER` is the stronger
483
+ product preset: tighter geometry, opaque surfaces, firmer borders, real shadows, and no
484
+ blur. Use it as the acceptance check that a screen still has enough visual weight when
485
+ the soft treatment is removed.
486
+
450
487
  ### FAB
451
488
 
452
489
  One primary action, raised above the content. It owns no icon dependency — pass the
@@ -558,7 +595,7 @@ import { AppShell, Header, BottomNav, ScrollView } from '@thewhileloop/whileui';
558
595
  | Forms | FormField + Input/NumericInput/LabeledField + DatePickerModal + FormModalScreen |
559
596
  | Settings | ProfileHeader + SettingsSection + SettingsItem (+ FormModalScreen for edits) |
560
597
  | E-commerce | ProductCard list → CheckoutSummary + ActionBar |
561
- | Chat | Chat + ChatSuggestions + SmartInput (attach, send). Extensible for images/tags |
598
+ | Chat | Chat, or ChatConversation + SmartInput for custom message/tool/source compositions |
562
599
  | App shell | AppShell + Header + BottomNav + content |
563
600
  | Loading | AppShell (`loading`) + PageSkeleton/ScreenSkeleton (keep header mounted) |
564
601
 
@@ -901,6 +938,9 @@ const adapter: ThemeBridgeAdapter = {
901
938
  const { mode, resolvedTheme, setMode, cycleMode } = useThemeBridge({ adapter });
902
939
  ```
903
940
 
941
+ The bridge loads a new adapter before saving into it. If a user changes the mode while
942
+ storage is still loading, that explicit choice wins over the late persisted value.
943
+
904
944
  When another store already owns the mode, mount the controlled bridge once near the
905
945
  application root. Change `mode` in the owning event handler; the bridge applies and
906
946
  persists it without mirroring state back through a callback effect.
@@ -1000,6 +1040,17 @@ import { Button, ButtonText, ButtonIcon } from '@thewhileloop/whileui';
1000
1040
  | loading | `boolean` | `false` | Show a spinner, expose busy state, and block input |
1001
1041
  | className | `string` | — | Additional Tailwind classes |
1002
1042
 
1043
+ ## Text
1044
+
1045
+ `Text` maps semantic roles to the active visual typography tokens; custom font-size
1046
+ classes are unnecessary for normal hierarchy. `heading` also sets
1047
+ `accessibilityRole="header"` unless the caller supplies another role.
1048
+
1049
+ | Prop | Type | Default |
1050
+ | ------- | --------------------------------------------------------------------------------------------- | ----------- |
1051
+ | variant | `'default' \| 'heading' \| 'title' \| 'subtitle' \| 'body' \| 'caption' \| 'label' \| 'code'` | `'default'` |
1052
+ | size | `'xs' \| 'sm' \| 'base' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | by variant |
1053
+
1003
1054
  ## Input
1004
1055
 
1005
1056
  ```tsx
@@ -1008,6 +1059,9 @@ import { Input } from '@thewhileloop/whileui';
1008
1059
  <Input placeholder="Email" variant="default" value={value} onChangeText={setValue} />;
1009
1060
  ```
1010
1061
 
1062
+ On web, filled Input, Textarea, NumericInput, and SmartInput surfaces preserve their
1063
+ themed background, foreground, and caret when the browser autofills them.
1064
+
1011
1065
  | Prop | Type | Default | Description |
1012
1066
  | -------------------- | --------------------------------- | ----------- | --------------------------------------------------------------------- |
1013
1067
  | variant | `'default' \| 'error' \| 'plain'` | `'default'` | Input style; `plain` drops border and fill for a large display figure |
@@ -1088,6 +1142,30 @@ import {
1088
1142
  </FormField>;
1089
1143
  ```
1090
1144
 
1145
+ `invalid` and `disabled` are inherited by `Input`, `Textarea`, `NumericInput`, and
1146
+ `Select`. An explicit control prop wins. `useFormFieldState()` exposes the same state for
1147
+ custom controls; labels, hints, messages, density, and required indicators remain part
1148
+ of the compound field rather than each input.
1149
+
1150
+ ## Surface
1151
+
1152
+ ```tsx
1153
+ import { Surface } from '@thewhileloop/whileui';
1154
+
1155
+ <Surface level="raised" padding="default">
1156
+ {/* grouped content */}
1157
+ </Surface>;
1158
+ ```
1159
+
1160
+ | Prop | Type | Default | Description |
1161
+ | -------- | ------------------------------------- | ----------- | ----------------------------------- |
1162
+ | level | `'base' \| 'raised' \| 'overlay'` | `'base'` | Semantic background and depth tier |
1163
+ | padding | `'none' \| 'sm' \| 'default' \| 'lg'` | `'default'` | Token-backed interior spacing |
1164
+ | radius | `'sm' \| 'md' \| 'lg' \| 'xl'` | `'xl'` | Token-backed corner radius |
1165
+ | bordered | `boolean` | `true` | Draw the theme hairline border |
1166
+ | shadow | `'none' \| 'sm' \| 'md' \| 'lg'` | by level | Override the level's depth |
1167
+ | frosted | `boolean` | `false` | Enable the optional frosted overlay |
1168
+
1091
1169
  ## LabeledField
1092
1170
 
1093
1171
  ```tsx
@@ -1380,6 +1458,12 @@ import {
1380
1458
  </Select>;
1381
1459
  ```
1382
1460
 
1461
+ Focused and pressed options use the subtle interaction-state tokens; the primary token
1462
+ marks the selected indicator without replacing the option's foreground colour. Long
1463
+ selected values stay on one line and truncate without displacing the trigger chevron.
1464
+ For popover presentation on web, the options panel matches the trigger width, caps
1465
+ itself to the collision-available viewport, and wraps long option labels inside it.
1466
+
1383
1467
  `SelectContent` supports optional frosted props:
1384
1468
 
1385
1469
  | Prop | Type | Default |
@@ -1440,7 +1524,7 @@ import {
1440
1524
  import { Avatar, AvatarImage, AvatarFallback } from '@thewhileloop/whileui';
1441
1525
 
1442
1526
  <Avatar size="default">
1443
- <AvatarImage src="https://..." />
1527
+ <AvatarImage source={{ uri: 'https://...' }} />
1444
1528
  <AvatarFallback>JD</AvatarFallback>
1445
1529
  </Avatar>;
1446
1530
  ```
@@ -1449,6 +1533,10 @@ import { Avatar, AvatarImage, AvatarFallback } from '@thewhileloop/whileui';
1449
1533
  | ---- | --------------------------- | ----------- |
1450
1534
  | size | `'sm' \| 'default' \| 'lg'` | `'default'` |
1451
1535
 
1536
+ `AvatarImage` keeps `AvatarFallback` visible while loading or after an error. Its
1537
+ `onLoadingStatusChange` callback reports `'loading'`, `'loaded'`, and `'error'`, including
1538
+ when a changed source begins loading, while the standard image event callbacks still run.
1539
+
1452
1540
  ## Progress
1453
1541
 
1454
1542
  ```tsx
@@ -1485,6 +1573,9 @@ toast({ title: 'Success', description: 'Saved!', variant: 'success' });
1485
1573
  | variant | `'default' \| 'success' \| 'destructive'` |
1486
1574
  | duration | `number` (ms) |
1487
1575
 
1576
+ Each toast owns its duration from the moment it appears; adding or dismissing another
1577
+ toast does not restart timers already in progress.
1578
+
1488
1579
  ## Popover
1489
1580
 
1490
1581
  ```tsx
@@ -1738,9 +1829,34 @@ import { SmartInput, Button, ButtonText } from '@thewhileloop/whileui';
1738
1829
  />;
1739
1830
  ```
1740
1831
 
1741
- ## Chat
1832
+ ## ChatConversation / Chat
1742
1833
 
1743
- AI-style chat: message list, suggestion chips when empty, SmartInput with attach/send slots. Uses semantic tokens; theme via `global.css`. Copy-paste block — edit directly. `renderMessage` for markdown/code, `loadingIndicator` for typing state.
1834
+ `ChatConversation` is the low-level viewport for custom conversation screens. Its content stays constrained on wide windows, follows new or streaming output while the reader is near the bottom, and stops following after the reader scrolls upward. Compose it with `SmartInput`; the application owns message data, streaming, tools, approvals, and sources.
1835
+
1836
+ It is a `ScrollView` for a bounded active transcript. Paginate or window historical messages; use a virtualized list when the full history can grow without a bound.
1837
+
1838
+ ```tsx
1839
+ import { ChatConversation, SmartInput, View } from '@thewhileloop/whileui';
1840
+
1841
+ <View className="flex-1">
1842
+ <ChatConversation contentContainerClassName="gap-4 px-4 py-6">
1843
+ {messages.map((message) => (
1844
+ <ProductMessage key={message.id} message={message} />
1845
+ ))}
1846
+ </ChatConversation>
1847
+ <SmartInput
1848
+ value={value}
1849
+ onChangeText={setValue}
1850
+ onSubmitEditing={send}
1851
+ submitBehavior="submit"
1852
+ leftSlot={<AttachButton />}
1853
+ centerSlot={<ModelSelect />}
1854
+ rightSlot={<SendButton />}
1855
+ />
1856
+ </View>;
1857
+ ```
1858
+
1859
+ `Chat` is the opinionated convenience composition: message bubbles, suggestion chips, and `SmartInput` with attach/send slots. It uses semantic tokens; theme via `global.css`. Use `renderMessage` for markdown, code, tool calls, reasoning, or sources, and `loadingIndicator` for streaming feedback.
1744
1860
 
1745
1861
  ```tsx
1746
1862
  import { Chat, type ChatMessage } from '@thewhileloop/whileui';
@@ -1777,6 +1893,56 @@ const [value, setValue] = useState('');
1777
1893
  | `inputSafeArea` | `boolean` | SmartInput safe-area (default `true`) |
1778
1894
  | `keyboardVerticalOffset` | `number` | For header offset when keyboard opens |
1779
1895
 
1896
+ `ChatConversation` extends React Native `ScrollViewProps`, forwards its ref, and adds `contentContainerClassName` to style the constrained inner column. Caller `onScroll` and `onContentSizeChange` callbacks are preserved.
1897
+
1898
+ ### Optional Markdown and code
1899
+
1900
+ These are explicit subpaths so the root package remains independent of a Markdown
1901
+ parser. Install `markdown-it` only in applications that render Markdown.
1902
+
1903
+ ```tsx
1904
+ import { Markdown } from '@thewhileloop/whileui/markdown';
1905
+ import { CodeBlock } from '@thewhileloop/whileui/code';
1906
+
1907
+ <Markdown onLinkPress={openLink}>{assistantText}</Markdown>
1908
+ <CodeBlock code={source} language="TypeScript" actions={<CopyButton />} />
1909
+ ```
1910
+
1911
+ `Markdown` supports headings, paragraphs, emphasis, links, lists, quotes, rules, inline
1912
+ code, and fenced code. It never opens links or writes to the clipboard itself:
1913
+ `onLinkPress` and `codeActions` keep those capabilities in the application. `CodeBlock`
1914
+ is dependency-free and accepts an application-owned `actions` slot.
1915
+
1916
+ ## SmartImage
1917
+
1918
+ ```tsx
1919
+ import { SmartImage } from '@thewhileloop/whileui';
1920
+
1921
+ <SmartImage
1922
+ source={{ uri: imageUrl }}
1923
+ fallbackSource={{ uri: fallbackUrl }}
1924
+ alt="Product preview"
1925
+ className="h-48 w-full"
1926
+ />;
1927
+ ```
1928
+
1929
+ | Prop | Type | Default |
1930
+ | ------------------- | -------------------------------------------------- | --------- |
1931
+ | `source` | `ImageProps['source']` | required |
1932
+ | `alt` | `string` | required |
1933
+ | `fallbackSource` | `ImageProps['source']` | — |
1934
+ | `skeleton` | `boolean` | `true` |
1935
+ | `skeletonClassName` | `string` | — |
1936
+ | `transition` | `number` | `200` |
1937
+ | `radius` | `'none' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'` | `'md'` |
1938
+ | `objectFit` | `'cover' \| 'contain' \| 'fill' \| 'scale-down'` | `'cover'` |
1939
+ | `onLoad` | `() => void` | — |
1940
+ | `onError` | `() => void` | — |
1941
+
1942
+ The skeleton returns whenever the primary image starts loading again. A failed primary
1943
+ source switches to `fallbackSource` when supplied without re-entering a primary/fallback
1944
+ loop.
1945
+
1780
1946
  ## DatePickerModal / DatePickerInline / DateRangePickerModal / TimePickerModal
1781
1947
 
1782
1948
  Date selection blocks using react-native-calendars. Theme-aware via `useCalendarTheme` (Uniwind light/dark). Optional `theme` prop for custom RN color strings (hex/rgb/hsl/named). `CalendarTheme` maps to react-native-calendars Theme and supports `arrowColor`, `disabledArrowColor`, and optional font keys.
@@ -2298,7 +2464,7 @@ Tracked work items for future releases.
2298
2464
  ### Documentation Gaps
2299
2465
 
2300
2466
  - [ ] Add `## API` sections for: Textarea, Toggle, ToggleGroup, Label, Separator, Skeleton, AspectRatio, Collapsible, Text, View
2301
- - [ ] Add `## API` sections for blocks: FloatingBottomNav, TabBar, FormModalScreen, ErrorState, LoadingScreen, ListItem, NotificationItem, MetricCard, SmartImage, TimelineFeed
2467
+ - [ ] Add `## API` sections for blocks: FloatingBottomNav, TabBar, FormModalScreen, ErrorState, LoadingScreen, ListItem, NotificationItem, MetricCard, TimelineFeed
2302
2468
 
2303
2469
  ### Infrastructure
2304
2470
 
@@ -0,0 +1,8 @@
1
+ import { type ScrollViewInstance, type ScrollViewProps } from 'react-native';
2
+ import type { ForwardRefComponent } from '../../lib/ref-types';
3
+ /** ScrollView viewport for a bounded active transcript; virtualize unbounded history. */
4
+ export interface ChatConversationProps extends ScrollViewProps {
5
+ contentContainerClassName?: string;
6
+ }
7
+ declare const ChatConversation: ForwardRefComponent<ScrollViewInstance, ChatConversationProps>;
8
+ export { ChatConversation };
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React, { useCallback, useRef } from 'react';
3
+ import { ScrollView, View, } from 'react-native';
4
+ import { cn } from '../../lib/cn';
5
+ const BOTTOM_THRESHOLD = 48;
6
+ function assignRef(ref, value) {
7
+ if (typeof ref === 'function') {
8
+ ref(value);
9
+ }
10
+ else if (ref) {
11
+ ref.current = value;
12
+ }
13
+ }
14
+ const ChatConversation = React.forwardRef(({ className, contentContainerClassName, contentContainerStyle, keyboardShouldPersistTaps = 'handled', onContentSizeChange, onScroll, scrollEventThrottle = 64, children, ...props }, forwardedRef) => {
15
+ const scrollRef = useRef(null);
16
+ const followsOutputRef = useRef(true);
17
+ const setRef = useCallback((instance) => {
18
+ scrollRef.current = instance;
19
+ assignRef(forwardedRef, instance);
20
+ }, [forwardedRef]);
21
+ const handleScroll = useCallback((event) => {
22
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
23
+ const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y;
24
+ followsOutputRef.current = distanceFromBottom <= BOTTOM_THRESHOLD;
25
+ onScroll?.(event);
26
+ }, [onScroll]);
27
+ const handleContentSizeChange = useCallback((width, height) => {
28
+ onContentSizeChange?.(width, height);
29
+ if (followsOutputRef.current) {
30
+ scrollRef.current?.scrollToEnd({ animated: true });
31
+ }
32
+ }, [onContentSizeChange]);
33
+ return (_jsx(ScrollView, { ref: setRef, className: cn('flex-1', className), contentContainerStyle: { flexGrow: 1 }, keyboardShouldPersistTaps: keyboardShouldPersistTaps, onContentSizeChange: handleContentSizeChange, onScroll: handleScroll, scrollEventThrottle: scrollEventThrottle, ...props, children: _jsx(View, { className: cn('w-full max-w-3xl flex-1 self-center', contentContainerClassName), style: contentContainerStyle, children: children }) }));
34
+ });
35
+ ChatConversation.displayName = 'ChatConversation';
36
+ export { ChatConversation };
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useRef, useEffect } from 'react';
3
- import { View, ScrollView, Pressable, KeyboardAvoidingView, Platform, useWindowDimensions, } from 'react-native';
2
+ import React from 'react';
3
+ import { View, Pressable, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native';
4
4
  import { Text } from '../../components/text';
5
5
  import { SmartInput } from '../layout/smart-input';
6
+ import { ChatConversation } from './chat-conversation';
6
7
  import { cn } from '../../lib/cn';
7
8
  import { useInteractionTokens, withInteractivePressableStyle } from '../../lib/interaction-tokens';
8
9
  import { useVisualTokens } from '../../lib/visual-tokens';
@@ -49,23 +50,16 @@ export function ChatSuggestions({ suggestions, onSelect, className }) {
49
50
  }
50
51
  const EMPTY_SUGGESTIONS = [];
51
52
  export function Chat({ messages, value, onChangeText, onSend, placeholder = 'Message...', suggestions = EMPTY_SUGGESTIONS, onSuggestionPress, leftSlot, rightSlot, loading = false, header, emptyTitle, emptyDescription, exampleMessage, renderMessage, loadingIndicator, inputSafeArea = true, keyboardVerticalOffset = 0, className, messagesClassName, }) {
52
- const scrollRef = useRef(null);
53
53
  const visual = useVisualTokens();
54
54
  const emptyTitleTypography = typographyStyle(visual, 'emphasis');
55
55
  const emptyDescriptionTypography = typographyStyle(visual, 'label');
56
56
  const isEmpty = messages.length === 0;
57
57
  const showSuggestions = isEmpty && suggestions.length > 0;
58
- useEffect(() => {
59
- if (!showSuggestions && messages.length > 0) {
60
- scrollRef.current?.scrollToEnd({ animated: true });
61
- }
62
- }, [messages.length, showSuggestions]);
63
58
  const defaultRightSlot = rightSlot ?? (_jsx(ChatSendButton, { onPress: onSend, disabled: loading || !value.trim() }));
64
- return (_jsxs(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, keyboardVerticalOffset: keyboardVerticalOffset, style: { flex: 1 }, className: className, children: [header, _jsx(ScrollView, { ref: scrollRef, className: cn('flex-1', messagesClassName), contentContainerStyle: {
65
- flexGrow: 1,
59
+ return (_jsxs(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, keyboardVerticalOffset: keyboardVerticalOffset, style: { flex: 1 }, className: className, children: [header, _jsx(ChatConversation, { className: cn('flex-1', messagesClassName), contentContainerStyle: {
66
60
  paddingBottom: 8,
67
61
  justifyContent: showSuggestions ? 'center' : 'flex-end',
68
- }, keyboardShouldPersistTaps: "handled", children: showSuggestions ? (_jsxs(View, { className: "flex-1 justify-center", children: [exampleMessage && (_jsx(View, { className: "mb-6 px-4", children: renderMessage ? (renderMessage(exampleMessage)) : (_jsx(ChatMessageBubble, { message: exampleMessage })) })), emptyTitle && (_jsx(Text, { className: "mb-1 text-center font-medium text-foreground", style: emptyTitleTypography, children: emptyTitle })), emptyDescription && (_jsx(Text, { className: "mb-4 text-center text-muted-foreground", style: emptyDescriptionTypography, children: emptyDescription })), _jsx(ChatSuggestions, { suggestions: suggestions, onSelect: onSuggestionPress ?? (() => { }) })] })) : (_jsxs(View, { className: "gap-3 px-4 pt-4", children: [messages.map((msg) => renderMessage ? (_jsx(React.Fragment, { children: renderMessage(msg) }, msg.id)) : (_jsx(ChatMessageBubble, { message: msg }, msg.id))), loading && loadingIndicator && _jsx(View, { className: "px-4", children: loadingIndicator })] })) }), _jsx(SmartInput, { value: value, onChangeText: onChangeText, placeholder: placeholder, leftSlot: leftSlot, rightSlot: defaultRightSlot, editable: !loading, safeArea: inputSafeArea })] }));
62
+ }, children: showSuggestions ? (_jsxs(View, { className: "flex-1 justify-center", children: [exampleMessage && (_jsx(View, { className: "mb-6 px-4", children: renderMessage ? (renderMessage(exampleMessage)) : (_jsx(ChatMessageBubble, { message: exampleMessage })) })), emptyTitle && (_jsx(Text, { className: "mb-1 text-center font-medium text-foreground", style: emptyTitleTypography, children: emptyTitle })), emptyDescription && (_jsx(Text, { className: "mb-4 text-center text-muted-foreground", style: emptyDescriptionTypography, children: emptyDescription })), _jsx(ChatSuggestions, { suggestions: suggestions, onSelect: onSuggestionPress ?? (() => { }) })] })) : (_jsxs(View, { className: "gap-3 px-4 pt-4", children: [messages.map((msg) => renderMessage ? (_jsx(React.Fragment, { children: renderMessage(msg) }, msg.id)) : (_jsx(ChatMessageBubble, { message: msg }, msg.id))), loading && loadingIndicator && _jsx(View, { className: "px-4", children: loadingIndicator })] })) }), _jsx(SmartInput, { value: value, onChangeText: onChangeText, placeholder: placeholder, leftSlot: leftSlot, rightSlot: defaultRightSlot, editable: !loading, safeArea: inputSafeArea })] }));
69
63
  }
70
64
  function ChatSendButton({ onPress, disabled }) {
71
65
  const interaction = useInteractionTokens();
@@ -1 +1,2 @@
1
1
  export { Chat, ChatMessageBubble, ChatSuggestions, type ChatMessage, type ChatProps, type ChatSuggestionsProps, } from './chat';
2
+ export { ChatConversation, type ChatConversationProps } from './chat-conversation';
@@ -1 +1,2 @@
1
1
  export { Chat, ChatMessageBubble, ChatSuggestions, } from './chat';
2
+ export { ChatConversation } from './chat-conversation';
@@ -7,6 +7,7 @@ import { focusRing } from '../../lib/focus-ring';
7
7
  import { useThemeColors } from '../../lib/theme-colors';
8
8
  import { useInteractionTokens } from '../../lib/interaction-tokens';
9
9
  import { useVisualTokens } from '../../lib/visual-tokens';
10
+ import { webAutofillField } from '../../lib/web-autofill';
10
11
  import { controlRecipe, shadowStyle, surfaceRadius, typographyStyle } from '../../lib/recipes';
11
12
  // The examples list is joined into one string so it can be an effect dependency without
12
13
  // re-running on every render. A NUL byte cannot appear in a placeholder.
@@ -143,5 +144,5 @@ export const SmartInput = forwardRef(function SmartInput({ leftSlot, centerSlot,
143
144
  { maxHeight: visual.smartInputMaxHeight },
144
145
  !editable ? { opacity: interaction.disabledOpacity } : null,
145
146
  style,
146
- ], className: cn('flex-1 text-foreground placeholder:text-muted-foreground', focusRing, variant === 'bar' && 'border-border bg-muted', variant === 'card' && 'bg-transparent', inputClassName), multiline: true, submitBehavior: submitBehavior, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, placeholder: placeholderState.placeholder, value: value, defaultValue: defaultValue, onChangeText: placeholderState.onChangeText, editable: editable, ...props }), _jsx(SmartInputSlot, { children: rightSlot })] }));
147
+ ], className: cn('flex-1 text-foreground placeholder:text-muted-foreground', focusRing, variant === 'bar' && 'border-border bg-muted', variant === 'bar' && webAutofillField, variant === 'card' && 'bg-transparent', inputClassName), multiline: true, submitBehavior: submitBehavior, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, placeholder: placeholderState.placeholder, value: value, defaultValue: defaultValue, onChangeText: placeholderState.onChangeText, editable: editable, ...props }), _jsx(SmartInputSlot, { children: rightSlot })] }));
147
148
  });
@@ -44,21 +44,19 @@ function resolveRadius(visual, radius) {
44
44
  return visual.radiusMd;
45
45
  }
46
46
  const SmartImage = React.forwardRef(({ className, source, alt, skeleton = true, skeletonClassName, transition = 200, radius, objectFit, onLoad, onError, fallbackSource, style, ...props }, ref) => {
47
- const [loading, setLoading] = useState(true);
48
- const [error, setError] = useState(false);
47
+ const [phase, setPhase] = useState('loading');
49
48
  const visual = useVisualTokens();
50
49
  const borderRadius = resolveRadius(visual, radius ?? 'md');
51
50
  const handleLoad = () => {
52
- setLoading(false);
51
+ setPhase((current) => (current === 'fallback' ? 'fallback' : 'loaded'));
53
52
  onLoad?.();
54
53
  };
55
54
  const handleError = () => {
56
- setLoading(false);
57
- setError(true);
55
+ setPhase((current) => current === 'fallback' ? 'fallback' : fallbackSource ? 'fallback' : 'error');
58
56
  onError?.();
59
57
  };
60
- const imageSource = error && fallbackSource ? fallbackSource : source;
61
- return (_jsxs(View, { ref: ref, className: cn(smartImageVariants({ radius, objectFit }), className), style: [{ borderRadius }, style], ...props, children: [skeleton && loading && (_jsx(Skeleton, { className: cn('absolute inset-0', skeletonClassName) })), _jsx(Image, { source: imageSource, alt: alt, transition: transition, onLoad: handleLoad, onError: handleError, className: cn('w-full h-full', objectFit === 'cover' && 'object-cover', objectFit === 'contain' && 'object-contain', objectFit === 'fill' && 'object-fill', objectFit === 'scale-down' && 'object-scale-down'), style: { opacity: loading && !error ? 0 : 1 } })] }));
58
+ const imageSource = phase === 'fallback' ? fallbackSource : source;
59
+ return (_jsxs(View, { ref: ref, className: cn(smartImageVariants({ radius, objectFit }), className), style: [{ borderRadius }, style], ...props, children: [skeleton && phase === 'loading' && (_jsx(Skeleton, { className: cn('absolute inset-0', skeletonClassName) })), _jsx(Image, { source: imageSource, alt: alt, transition: transition, onLoadStart: () => setPhase((current) => (current === 'fallback' ? current : 'loading')), onLoad: handleLoad, onError: handleError, className: cn('w-full h-full', objectFit === 'cover' && 'object-cover', objectFit === 'contain' && 'object-contain', objectFit === 'fill' && 'object-fill', objectFit === 'scale-down' && 'object-scale-down'), style: { opacity: phase === 'loading' ? 0 : 1 } })] }));
62
60
  });
63
61
  SmartImage.displayName = 'SmartImage';
64
62
  export { SmartImage, smartImageVariants };
package/dist/code.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { CodeBlock, type CodeBlockProps } from './components/code-block';
package/dist/code.js ADDED
@@ -0,0 +1 @@
1
+ export { CodeBlock } from './components/code-block';
@@ -27,15 +27,16 @@ declare const avatarVariants: import("tailwind-variants").TVReturnType<{
27
27
  export interface AvatarProps extends ViewProps, VariantProps<typeof avatarVariants> {
28
28
  className?: string;
29
29
  }
30
+ export type AvatarImageStatus = 'loading' | 'loaded' | 'error';
30
31
  export interface AvatarImageProps extends Omit<ImageProps, 'style'> {
31
32
  className?: string;
32
- onLoadingStatusChange?: (status: 'loading' | 'loaded' | 'error') => void;
33
+ onLoadingStatusChange?: (status: AvatarImageStatus) => void;
33
34
  }
34
35
  export interface AvatarFallbackProps extends ViewProps {
35
36
  className?: string;
36
37
  }
37
38
  declare const Avatar: ForwardRefComponent<ViewInstance, AvatarProps>;
38
- declare function AvatarImage({ className, onLoadingStatusChange, ...props }: AvatarImageProps): React.JSX.Element;
39
+ declare function AvatarImage({ className, onLoadingStatusChange, onLoadStart, onLoad, onError, ...props }: AvatarImageProps): React.JSX.Element;
39
40
  declare namespace AvatarImage {
40
41
  var displayName: string;
41
42
  }
@@ -20,30 +20,36 @@ const avatarVariants = tv({
20
20
  },
21
21
  });
22
22
  const AvatarContext = React.createContext({
23
- imageLoaded: false,
24
- setImageLoaded: () => { },
23
+ imageStatus: 'loading',
24
+ setImageStatus: () => { },
25
25
  });
26
26
  const Avatar = React.forwardRef(({ className, size, ...props }, ref) => {
27
- const [imageLoaded, setImageLoaded] = useState(false);
28
- const contextValue = useMemo(() => ({ imageLoaded, setImageLoaded }), [imageLoaded]);
27
+ const [imageStatus, setImageStatus] = useState('loading');
28
+ const contextValue = useMemo(() => ({ imageStatus, setImageStatus }), [imageStatus]);
29
29
  return (_jsx(AvatarContext.Provider, { value: contextValue, children: _jsx(View, { ref: ref, className: cn(avatarVariants({ size }), className), ...props }) }));
30
30
  });
31
31
  Avatar.displayName = 'Avatar';
32
- function AvatarImage({ className, onLoadingStatusChange, ...props }) {
33
- const { setImageLoaded } = React.useContext(AvatarContext);
34
- return (_jsx(Image, { className: cn('aspect-square h-full w-full rounded-full', className), onLoad: () => {
35
- setImageLoaded(true);
32
+ function AvatarImage({ className, onLoadingStatusChange, onLoadStart, onLoad, onError, ...props }) {
33
+ const { setImageStatus } = React.useContext(AvatarContext);
34
+ return (_jsx(Image, { ...props, className: cn('aspect-square h-full w-full rounded-full', className), onLoadStart: () => {
35
+ setImageStatus('loading');
36
+ onLoadingStatusChange?.('loading');
37
+ onLoadStart?.();
38
+ }, onLoad: (event) => {
39
+ setImageStatus('loaded');
36
40
  onLoadingStatusChange?.('loaded');
37
- }, onError: () => {
38
- setImageLoaded(false);
41
+ onLoad?.(event);
42
+ }, onError: (event) => {
43
+ setImageStatus('error');
39
44
  onLoadingStatusChange?.('error');
40
- }, ...props }));
45
+ onError?.(event);
46
+ } }));
41
47
  }
42
48
  AvatarImage.displayName = 'AvatarImage';
43
49
  function AvatarFallback({ className, children, ...props }) {
44
- const { imageLoaded } = React.useContext(AvatarContext);
50
+ const { imageStatus } = React.useContext(AvatarContext);
45
51
  const visual = useVisualTokens();
46
- if (imageLoaded)
52
+ if (imageStatus === 'loaded')
47
53
  return null;
48
54
  return (_jsx(View, { className: cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className), ...props, children: typeof children === 'string' ? (_jsx(Text, { className: "font-medium text-muted-foreground", style: typographyStyle(visual, 'label'), children: children })) : (children) }));
49
55
  }
@@ -1 +1 @@
1
- export { Avatar, AvatarImage, AvatarFallback, avatarVariants, type AvatarProps, type AvatarImageProps, type AvatarFallbackProps, } from './avatar';
1
+ export { Avatar, AvatarImage, AvatarFallback, avatarVariants, type AvatarProps, type AvatarImageProps, type AvatarImageStatus, type AvatarFallbackProps, } from './avatar';
@@ -1,6 +1,6 @@
1
1
  import { type ViewInstance, type ViewProps, type TextInstance, type TextProps } from 'react-native';
2
2
  import { type VariantProps } from '../../lib/tv';
3
- import { type FrostedSurfaceProps } from '../../lib/frosted-surface';
3
+ import type { FrostedSurfaceProps } from '../../lib/frosted-surface';
4
4
  import { type ShadowTier } from '../../lib/recipes';
5
5
  import type { ForwardRefComponent } from '../../lib/ref-types';
6
6
  declare const cardVariants: import("tailwind-variants").TVReturnType<{
@@ -1,13 +1,12 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { View, } from 'react-native';
4
4
  import { Text } from '../text';
5
+ import { Surface } from '../surface';
5
6
  import { cn } from '../../lib/cn';
6
7
  import { tv } from '../../lib/tv';
7
- import { useFrostedSurface } from '../../lib/frosted-surface';
8
8
  import { useVisualTokens } from '../../lib/visual-tokens';
9
- import { useThemeColors } from '../../lib/theme-colors';
10
- import { shadowStyle, surfacePadding, typographyStyle } from '../../lib/recipes';
9
+ import { typographyStyle } from '../../lib/recipes';
11
10
  const cardVariants = tv({
12
11
  base: 'border border-border bg-card',
13
12
  variants: {
@@ -28,27 +27,7 @@ const cardVariants = tv({
28
27
  },
29
28
  });
30
29
  const Card = React.forwardRef(({ className, padding, unstyled, shadow = 'sm', frosted = false, blurIntensity, blurTintToken, style, children, ...props }, ref) => {
31
- const visual = useVisualTokens();
32
- const colors = useThemeColors();
33
- const frostedSurface = useFrostedSurface({
34
- frosted,
35
- blurIntensity,
36
- blurTintToken,
37
- defaultTintToken: 'card',
38
- defaultBlurPreset: 'subtle',
39
- });
40
- const effectivePadding = surfacePadding(visual, padding ?? 'default');
41
- const cardStyle = [
42
- {
43
- borderRadius: unstyled ? 0 : visual.radiusXl,
44
- padding: unstyled ? 0 : effectivePadding,
45
- borderWidth: unstyled ? 0 : visual.borderWidthHairline,
46
- },
47
- unstyled ? null : shadowStyle(visual, colors, shadow),
48
- frostedSurface.surfaceStyle,
49
- style,
50
- ];
51
- return (_jsxs(View, { ref: ref, className: cn(cardVariants({ padding, unstyled }), frosted && 'relative overflow-hidden bg-transparent', className), style: cardStyle, ...props, children: [frostedSurface.overlay, children] }));
30
+ return (_jsx(Surface, { ref: ref, level: "base", padding: unstyled ? 'none' : (padding ?? 'default'), bordered: !unstyled, shadow: unstyled ? 'none' : shadow, frosted: frosted, blurIntensity: blurIntensity, blurTintToken: blurTintToken, className: cn(cardVariants({ padding, unstyled }), className), style: [unstyled ? { borderRadius: 0 } : null, style], ...props, children: children }));
52
31
  });
53
32
  Card.displayName = 'Card';
54
33
  const CardHeader = React.forwardRef(({ className, ...props }, ref) => {