@refraction-ui/astro 0.17.0 → 0.17.2

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 (41) hide show
  1. package/dist/accordion/accordion.ts +155 -0
  2. package/dist/accordion/index.ts +9 -1
  3. package/dist/analytics/analytics-manager.ts +7 -2
  4. package/dist/analytics/http-sink.ts +3 -1
  5. package/dist/analytics/types.ts +6 -0
  6. package/dist/astro-location-selector/LocationSelector.astro +56 -0
  7. package/dist/astro-location-selector/LocationSelector.stories.ts +15 -0
  8. package/dist/astro-location-selector/index.ts +1 -0
  9. package/dist/calendar/calendar.ts +11 -6
  10. package/dist/call-controls/call-controls.ts +2 -4
  11. package/dist/carousel/carousel.ts +45 -0
  12. package/dist/carousel/index.ts +9 -1
  13. package/dist/command-input/command-input.ts +5 -5
  14. package/dist/conversation/conversation.ts +9 -7
  15. package/dist/conversation/types.ts +2 -0
  16. package/dist/date-picker/date-picker.ts +6 -3
  17. package/dist/device-frame/device-frame.ts +2 -4
  18. package/dist/emoji-picker/emoji-picker.ts +2 -3
  19. package/dist/footer/footer.ts +4 -1
  20. package/dist/i18n/countries.ts +122 -0
  21. package/dist/i18n/detect-region.ts +243 -0
  22. package/dist/i18n/index.ts +47 -0
  23. package/dist/i18n/languages.ts +181 -0
  24. package/dist/i18n/locale-utils.ts +123 -0
  25. package/dist/i18n/voice-registry.ts +92 -0
  26. package/dist/index.ts +1 -0
  27. package/dist/location-selector/index.ts +1 -0
  28. package/dist/location-selector/location-selector.ts +60 -0
  29. package/dist/logger/telemetry-manager.ts +7 -4
  30. package/dist/logger/types.ts +4 -0
  31. package/dist/pagination/index.ts +14 -1
  32. package/dist/pagination/pagination.styles.ts +33 -0
  33. package/dist/pagination/pagination.ts +160 -0
  34. package/dist/presence-indicator/presence-indicator.ts +2 -4
  35. package/dist/reaction-bar/reaction-bar.ts +2 -3
  36. package/dist/slider/slider.ts +134 -2
  37. package/dist/status-indicator/status-indicator.ts +2 -4
  38. package/dist/thread-view/thread-view.ts +6 -7
  39. package/dist/toast/toast.ts +16 -5
  40. package/dist/voice-pill/voice-pill.ts +2 -4
  41. package/package.json +2 -1
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @refraction-ui/accordion — headless accordion (disclosure) state machine.
3
+ *
4
+ * Owns the *behavior* of an accordion: which item(s) are open, the single vs
5
+ * multiple expansion modes, and the collapsible toggle rule. It has NO UI
6
+ * opinion — React/Astro adapters subscribe to the store and render the parts.
7
+ */
8
+
9
+ /** Expansion mode: one item open at a time, or any number. */
10
+ export type AccordionType = 'single' | 'multiple'
11
+
12
+ /** Open item id (`single`) or ids (`multiple`). */
13
+ export type AccordionValue = string | string[]
14
+
15
+ /** Options for {@link createAccordion}. */
16
+ export interface AccordionConfig {
17
+ /** Expansion mode (default `'single'`). */
18
+ type?: AccordionType
19
+ /** When `type="single"`, allows the open item to be toggled fully closed. */
20
+ collapsible?: boolean
21
+ /** Controlled open item(s). Pair with `onValueChange`. */
22
+ value?: AccordionValue
23
+ /** Uncontrolled initial open item(s). */
24
+ defaultValue?: AccordionValue
25
+ /** Called whenever the open item(s) change. */
26
+ onValueChange?: (value: AccordionValue) => void
27
+ }
28
+
29
+ /** Snapshot of the store. Returned by `getState()`; treat as immutable. */
30
+ export interface AccordionState {
31
+ type: AccordionType
32
+ collapsible: boolean
33
+ /** Current open item id (`single`) or ids (`multiple`). */
34
+ value: AccordionValue
35
+ }
36
+
37
+ /** The framework-agnostic store. React/Astro adapters wrap this. */
38
+ export interface AccordionAPI {
39
+ /** Current immutable snapshot */
40
+ getState(): AccordionState
41
+ /** Subscribe to changes; returns an unsubscribe fn (suits useSyncExternalStore) */
42
+ subscribe(listener: () => void): () => void
43
+ /** Toggle an item open/closed per the type/collapsible rules */
44
+ toggleItem(itemValue: string): void
45
+ /** Whether an item is currently open */
46
+ isOpen(itemValue: string): boolean
47
+ /** Sync a controlled value into the store (adapters call when the prop changes) */
48
+ setValue(value: AccordionValue): void
49
+ /** Update options after creation (adapters keep props in sync) */
50
+ setOptions(options: {
51
+ type?: AccordionType
52
+ collapsible?: boolean
53
+ onValueChange?: (value: AccordionValue) => void
54
+ }): void
55
+ }
56
+
57
+ /** Pure open-state check shared by every adapter. */
58
+ export function isItemOpen(state: AccordionState, itemValue: string): boolean {
59
+ return state.type === 'single'
60
+ ? state.value === itemValue
61
+ : Array.isArray(state.value) && state.value.includes(itemValue)
62
+ }
63
+
64
+ /** Shallow value equality (arrays compared item-by-item, order-sensitive). */
65
+ function valueEquals(a: AccordionValue, b: AccordionValue): boolean {
66
+ if (Array.isArray(a) && Array.isArray(b)) {
67
+ return a.length === b.length && a.every((v, i) => v === b[i])
68
+ }
69
+ return a === b
70
+ }
71
+
72
+ /**
73
+ * createAccordion — headless accordion store.
74
+ *
75
+ * Supports controlled (`value`) and uncontrolled (`defaultValue`) usage.
76
+ * `single` mode holds one open item id (`''` when all closed); `multiple`
77
+ * holds an array of open ids. In `single` mode, re-toggling the open item
78
+ * closes it only when `collapsible` is set.
79
+ */
80
+ export function createAccordion(config: AccordionConfig = {}): AccordionAPI {
81
+ let type: AccordionType = config.type ?? 'single'
82
+ let collapsible: boolean = config.collapsible ?? false
83
+ let onValueChange = config.onValueChange
84
+
85
+ let uncontrolledValue: AccordionValue =
86
+ config.defaultValue ?? (type === 'multiple' ? [] : '')
87
+ let controlledValue: AccordionValue | undefined = config.value
88
+
89
+ const listeners = new Set<() => void>()
90
+ let snapshot: AccordionState = buildSnapshot()
91
+
92
+ function currentValue(): AccordionValue {
93
+ return controlledValue !== undefined ? controlledValue : uncontrolledValue
94
+ }
95
+
96
+ function buildSnapshot(): AccordionState {
97
+ return { type, collapsible, value: currentValue() }
98
+ }
99
+
100
+ function emit(): void {
101
+ snapshot = buildSnapshot()
102
+ for (const l of listeners) l()
103
+ }
104
+
105
+ const api: AccordionAPI = {
106
+ getState() {
107
+ return snapshot
108
+ },
109
+
110
+ subscribe(listener) {
111
+ listeners.add(listener)
112
+ return () => listeners.delete(listener)
113
+ },
114
+
115
+ toggleItem(itemValue) {
116
+ const value = currentValue()
117
+ let next: AccordionValue
118
+ if (type === 'single') {
119
+ next = value === itemValue && collapsible ? '' : itemValue
120
+ } else {
121
+ const arrValue = Array.isArray(value) ? value : []
122
+ next = arrValue.includes(itemValue)
123
+ ? arrValue.filter((v) => v !== itemValue)
124
+ : [...arrValue, itemValue]
125
+ }
126
+ if (controlledValue === undefined) {
127
+ if (valueEquals(uncontrolledValue, next)) return
128
+ uncontrolledValue = next
129
+ }
130
+ onValueChange?.(next)
131
+ emit()
132
+ },
133
+
134
+ isOpen(itemValue) {
135
+ return isItemOpen(snapshot, itemValue)
136
+ },
137
+
138
+ setValue(value) {
139
+ if (controlledValue !== undefined && valueEquals(controlledValue, value)) return
140
+ controlledValue = value
141
+ emit()
142
+ },
143
+
144
+ setOptions(options) {
145
+ const prevType = type
146
+ const prevCollapsible = collapsible
147
+ if (options.type !== undefined) type = options.type
148
+ if (options.collapsible !== undefined) collapsible = options.collapsible
149
+ if (options.onValueChange !== undefined) onValueChange = options.onValueChange
150
+ if (type !== prevType || collapsible !== prevCollapsible) emit()
151
+ },
152
+ }
153
+
154
+ return api
155
+ }
@@ -1 +1,9 @@
1
- export {}
1
+ export { createAccordion, isItemOpen } from './accordion.js'
2
+
3
+ export type {
4
+ AccordionType,
5
+ AccordionValue,
6
+ AccordionConfig,
7
+ AccordionState,
8
+ AccordionAPI,
9
+ } from './accordion.js'
@@ -64,6 +64,9 @@ export function createAnalytics(config: AnalyticsConfig): Analytics {
64
64
  const sampleRate = config.sampleRate ?? 1
65
65
  const batchSize = config.batchSize ?? 20
66
66
  const flushIntervalMs = config.flushIntervalMs ?? 10_000
67
+ // Injection boundary: ambient time/randomness default here; inject `now`/`random` for determinism.
68
+ const now = config.now ?? (() => new Date())
69
+ const random = config.random ?? (() => Math.random())
67
70
 
68
71
  const session = createSession(config.session)
69
72
  const identity = createIdentity(config.identity)
@@ -86,6 +89,8 @@ export function createAnalytics(config: AnalyticsConfig): Analytics {
86
89
  createHttpSink({
87
90
  endpoint: config.endpoint,
88
91
  writeKey: config.writeKey ?? '',
92
+ // One clock for the whole manager: the auto sink stamps sentAt with it too.
93
+ now,
89
94
  }),
90
95
  )
91
96
  }
@@ -203,7 +208,7 @@ export function createAnalytics(config: AnalyticsConfig): Analytics {
203
208
  function sampled(): boolean {
204
209
  if (sampleRate >= 1) return true
205
210
  if (sampleRate <= 0) return false
206
- return Math.random() < sampleRate
211
+ return random() < sampleRate
207
212
  }
208
213
 
209
214
  function enqueue(ev: AnalyticsEvent): void {
@@ -238,7 +243,7 @@ export function createAnalytics(config: AnalyticsConfig): Analytics {
238
243
  userId: identity.userId(),
239
244
  sessionId,
240
245
  context: buildContext(opts?.context, childCtx),
241
- timestamp: opts?.timestamp ?? new Date().toISOString(),
246
+ timestamp: opts?.timestamp ?? now().toISOString(),
242
247
  schemaVersion: SCHEMA_VERSION,
243
248
  ...fields,
244
249
  }
@@ -113,6 +113,8 @@ export function createHttpSink(options: HttpSinkOptions): AnalyticsSink {
113
113
  const base = endpoint.replace(/\/+$/, '')
114
114
  const url = `${base}/v${SCHEMA_VERSION}/batch`
115
115
  const authHeader = `Basic ${base64(`${writeKey}:`)}`
116
+ // Injection boundary: `sentAt` defaults to ambient time; inject `now` for determinism.
117
+ const now = options.now ?? (() => new Date())
116
118
 
117
119
  const resolveFetch = (): typeof fetch => {
118
120
  if (options.fetchImpl) return options.fetchImpl
@@ -137,7 +139,7 @@ export function createHttpSink(options: HttpSinkOptions): AnalyticsSink {
137
139
  function envelope(batch: AnalyticsEvent[]) {
138
140
  return {
139
141
  batch,
140
- sentAt: new Date().toISOString(),
142
+ sentAt: now().toISOString(),
141
143
  batchId: uuidv4(),
142
144
  }
143
145
  }
@@ -187,6 +187,8 @@ export interface HttpSinkOptions {
187
187
  maxBatchBytes?: number
188
188
  /** Soft per-event byte cap (Segment ≈ 32KB). Default 32_000. */
189
189
  maxEventBytes?: number
190
+ /** Clock for the batch `sentAt` stamp. Default: ambient `new Date()` — inject for deterministic tests. */
191
+ now?: () => Date
190
192
  }
191
193
 
192
194
  /** `createAnalytics` configuration. */
@@ -222,6 +224,10 @@ export interface AnalyticsConfig {
222
224
  batchSize?: number
223
225
  /** Auto-flush interval in ms (prod). Default 10_000. */
224
226
  flushIntervalMs?: number
227
+ /** Clock for default event timestamps. Default: ambient `new Date()` — inject for deterministic tests/SSR. */
228
+ now?: () => Date
229
+ /** RNG for sample-rate decisions. Default: ambient `Math.random` — inject for deterministic tests. */
230
+ random?: () => number
225
231
  }
226
232
 
227
233
  /** Options accepted by every top-level call. */
@@ -0,0 +1,56 @@
1
+ ---
2
+ import { createLocationSelector } from '../location-selector/index.ts'
3
+ import { cn } from '../shared/index.ts'
4
+
5
+ export interface Props {
6
+ defaultCountry?: string
7
+ defaultLanguage?: string
8
+ className?: string
9
+ }
10
+
11
+ const {
12
+ defaultCountry = 'US',
13
+ defaultLanguage = 'en',
14
+ className
15
+ } = Astro.props
16
+
17
+ const api = createLocationSelector({
18
+ defaultCountry,
19
+ defaultLanguage
20
+ })
21
+ ---
22
+
23
+ <div class={cn('flex flex-col gap-4 sm:flex-row', className)}>
24
+ <div class="flex flex-col gap-1.5 flex-1">
25
+ <label for={api.countryProps.id} class="text-sm font-medium">Country</label>
26
+ <select
27
+ id={api.countryProps.id}
28
+ name={api.countryProps.name}
29
+ class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
30
+ >
31
+ {api.countries.map((c: any) => {
32
+ const value = c.code || c.id || c;
33
+ const label = c.name || c.label || c;
34
+ return (
35
+ <option value={value} selected={value === defaultCountry}>{label}</option>
36
+ )
37
+ })}
38
+ </select>
39
+ </div>
40
+ <div class="flex flex-col gap-1.5 flex-1">
41
+ <label for={api.languageProps.id} class="text-sm font-medium">Language</label>
42
+ <select
43
+ id={api.languageProps.id}
44
+ name={api.languageProps.name}
45
+ class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
46
+ >
47
+ {api.languages.map((l: any) => {
48
+ const value = l.code || l.id || l;
49
+ const label = l.name || l.label || l;
50
+ return (
51
+ <option value={value} selected={value === defaultLanguage}>{label}</option>
52
+ )
53
+ })}
54
+ </select>
55
+ </div>
56
+ </div>
@@ -0,0 +1,15 @@
1
+ import Component from './LocationSelector.astro'
2
+
3
+ const meta = {
4
+ title: 'Astro/LocationSelector',
5
+ component: Component,
6
+ }
7
+
8
+ export default meta
9
+
10
+ export const Default = {
11
+ args: {
12
+ defaultCountry: 'Example defaultCountry',
13
+ defaultLanguage: 'Example defaultLanguage'
14
+ }
15
+ }
@@ -0,0 +1 @@
1
+ export { default as LocationSelector } from './LocationSelector.astro'
@@ -18,6 +18,8 @@ export interface CalendarProps {
18
18
  maxDate?: Date
19
19
  /** Specific dates to disable */
20
20
  disabledDates?: Date[]
21
+ /** Reference "today" for isToday/aria-current. Default: ambient `new Date()` — inject to avoid SSR/CSR hydration mismatch. */
22
+ today?: Date
21
23
  }
22
24
 
23
25
  export interface CalendarDay {
@@ -52,9 +54,9 @@ export interface CalendarAPI {
52
54
  /** Select a date */
53
55
  select(date: Date): void
54
56
  /** ARIA attributes for the calendar grid */
55
- ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
57
+ ariaProps: Partial<AccessibilityProps> & Record<string, string | number | boolean>
56
58
  /** Get ARIA attributes for a specific day cell */
57
- getDayAriaProps(day: CalendarDay): Partial<AccessibilityProps> & Record<string, unknown>
59
+ getDayAriaProps(day: CalendarDay): Partial<AccessibilityProps> & Record<string, string | number | boolean>
58
60
  /** Generated IDs */
59
61
  ids: {
60
62
  grid: string
@@ -89,9 +91,11 @@ export function createCalendar(props: CalendarProps = {}): CalendarAPI {
89
91
  minDate,
90
92
  maxDate,
91
93
  disabledDates = [],
94
+ today: injectedToday,
92
95
  } = props
93
96
 
94
- const today = new Date()
97
+ // Injection boundary: ambient "today" is the default; inject `today` so SSR and client agree.
98
+ const today = injectedToday ?? new Date()
95
99
 
96
100
  // Determine selected date
97
101
  const selectedDate = controlledValue ?? defaultValue
@@ -167,18 +171,19 @@ export function createCalendar(props: CalendarProps = {}): CalendarAPI {
167
171
  }
168
172
  }
169
173
 
170
- const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
174
+ const ariaProps: Partial<AccessibilityProps> & Record<string, string | number | boolean> = {
171
175
  role: 'grid',
172
176
  'aria-labelledby': labelId,
173
177
  id: gridId,
174
178
  }
175
179
 
176
- function getDayAriaProps(day: CalendarDay): Partial<AccessibilityProps> & Record<string, unknown> {
180
+ function getDayAriaProps(day: CalendarDay): Partial<AccessibilityProps> & Record<string, string | number | boolean> {
177
181
  return {
178
182
  role: 'gridcell',
179
183
  'aria-selected': day.isSelected,
180
184
  'aria-disabled': day.isDisabled,
181
- 'aria-current': day.isToday ? ('date' as const) : undefined,
185
+ // Key absent (not undefined) when not today — the ARIA record type excludes undefined.
186
+ ...(day.isToday ? { 'aria-current': 'date' as const } : {}),
182
187
  'aria-label': `${DAY_NAMES[day.date.getDay()]}, ${day.date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`,
183
188
  }
184
189
  }
@@ -1,5 +1,3 @@
1
- import type { AccessibilityProps } from '../shared/index.ts'
2
-
3
1
  /** Visual/semantic tone of a call control button. */
4
2
  export type CallControlTone = 'default' | 'active' | 'destructive'
5
3
 
@@ -33,7 +31,7 @@ export interface CallControlsProps {
33
31
 
34
32
  export interface CallControlsAPI {
35
33
  /** ARIA attributes to spread on the toolbar element (`role="toolbar"`). */
36
- ariaProps: Partial<AccessibilityProps>
34
+ ariaProps: Record<string, string | number | boolean>
37
35
  /** Data attributes for styling hooks. */
38
36
  dataAttributes: Record<string, string>
39
37
  }
@@ -66,7 +64,7 @@ export function controlTone(
66
64
  * attributes; adapters spread these onto their container element.
67
65
  */
68
66
  export function createCallControls(_props: CallControlsProps = {}): CallControlsAPI {
69
- const ariaProps: Partial<AccessibilityProps> = {
67
+ const ariaProps: Record<string, string | number | boolean> = {
70
68
  role: 'toolbar',
71
69
  'aria-label': 'Call controls',
72
70
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @refraction-ui/carousel — headless "carousel" store.
3
+ *
4
+ * Honest note: despite the name, this component is an expand/collapse panel
5
+ * set (an accordion in disguise) — that is the behavior the React adapter and
6
+ * the docs site demo (single/multiple open panels, collapsible toggles). The
7
+ * state machine is therefore shared with `@refraction-ui/accordion` rather
8
+ * than duplicated. A real slide-navigation carousel (active index, next/prev)
9
+ * would be a different core; this package keeps the shipped behavior.
10
+ */
11
+ import {
12
+ createAccordion,
13
+ isItemOpen,
14
+ type AccordionAPI,
15
+ type AccordionConfig,
16
+ type AccordionState,
17
+ type AccordionType,
18
+ type AccordionValue,
19
+ } from '../accordion/index.ts'
20
+
21
+ /** Expansion mode: one item open at a time, or any number. */
22
+ export type CarouselType = AccordionType
23
+
24
+ /** Open item id (`single`) or ids (`multiple`). */
25
+ export type CarouselValue = AccordionValue
26
+
27
+ /** Options for {@link createCarousel}. Mirrors {@link AccordionConfig}. */
28
+ export interface CarouselConfig extends AccordionConfig {}
29
+
30
+ /** Snapshot of the store. Returned by `getState()`; treat as immutable. */
31
+ export interface CarouselState extends AccordionState {}
32
+
33
+ /** The framework-agnostic store. React/Astro adapters wrap this. */
34
+ export type CarouselAPI = AccordionAPI
35
+
36
+ /**
37
+ * createCarousel — headless expand/collapse panel store. This is the
38
+ * accordion state machine under the carousel name; see the package note.
39
+ */
40
+ export function createCarousel(config: CarouselConfig = {}): CarouselAPI {
41
+ return createAccordion(config)
42
+ }
43
+
44
+ /** Pure open-state check shared by every adapter. */
45
+ export { isItemOpen }
@@ -1 +1,9 @@
1
- export {}
1
+ export { createCarousel, isItemOpen } from './carousel.js'
2
+
3
+ export type {
4
+ CarouselType,
5
+ CarouselValue,
6
+ CarouselConfig,
7
+ CarouselState,
8
+ CarouselAPI,
9
+ } from './carousel.js'
@@ -97,10 +97,10 @@ export class CommandInput {
97
97
  const matchedTrigger = this.options.triggers.find((t) => t.char === char);
98
98
 
99
99
  if (matchedTrigger) {
100
- const isStartOfWord = i === 0 || /\\s/.test(rawText[i - 1]);
100
+ const isStartOfWord = i === 0 || /\s/.test(rawText[i - 1]);
101
101
  if (isStartOfWord) {
102
102
  const commandText = rawText.slice(i + 1, cursorPosition);
103
- if (!/\\s/.test(commandText)) {
103
+ if (!/\s/.test(commandText)) {
104
104
  let isValid = true;
105
105
  if (matchedTrigger.pattern) {
106
106
  isValid = matchedTrigger.pattern.test(commandText);
@@ -118,7 +118,7 @@ export class CommandInput {
118
118
  break;
119
119
  }
120
120
 
121
- if (/\\s/.test(char)) {
121
+ if (/\s/.test(char)) {
122
122
  break;
123
123
  }
124
124
  }
@@ -130,8 +130,8 @@ export class CommandInput {
130
130
  return;
131
131
  }
132
132
 
133
- const triggerChars = this.options.triggers.map((t) => t.char.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'));
134
- const patternStr = `(^|\\\\s)(${triggerChars.join('|')})([^\\\\s]*)`;
133
+ const triggerChars = this.options.triggers.map((t) => t.char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
134
+ const patternStr = `(^|\\s)(${triggerChars.join('|')})([^\\s]*)`;
135
135
  const regex = new RegExp(patternStr, 'g');
136
136
 
137
137
  let currentIndex = 0;
@@ -33,6 +33,8 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
33
33
  const assistant = config.assistant ?? DEFAULT_ASSISTANT
34
34
  const currentUser = config.currentUser ?? DEFAULT_USER
35
35
  const generateTitle = config.generateTitle ?? defaultGenerateTitle
36
+ // Injection boundary: ambient time is the default; inject `now` for deterministic timestamps.
37
+ const now = config.now ?? (() => new Date())
36
38
 
37
39
  let transport = config.transport
38
40
  let threadingMode: ThreadingMode = config.threadingMode ?? 'inline'
@@ -87,7 +89,7 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
87
89
 
88
90
  function touch(conversationId: string): void {
89
91
  const c = conversations.get(conversationId)
90
- if (c) conversations.set(conversationId, { ...c, updatedAt: new Date() })
92
+ if (c) conversations.set(conversationId, { ...c, updatedAt: now() })
91
93
  }
92
94
 
93
95
  function ensureActiveConversation(opts?: SendOptions): string {
@@ -103,12 +105,12 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
103
105
  opts: { title?: string; metadata?: Record<string, unknown> },
104
106
  id?: string,
105
107
  ): Conversation {
106
- const now = new Date()
108
+ const timestamp = now()
107
109
  const conversation: Conversation = {
108
110
  id: id ?? generateId('rfr-conv'),
109
111
  title: opts.title ?? DEFAULT_TITLE,
110
- createdAt: now,
111
- updatedAt: now,
112
+ createdAt: timestamp,
113
+ updatedAt: timestamp,
112
114
  metadata: opts.metadata,
113
115
  }
114
116
  conversations.set(conversation.id, conversation)
@@ -294,7 +296,7 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
294
296
  role: 'user',
295
297
  author: currentUser,
296
298
  content: trimmed,
297
- timestamp: new Date(),
299
+ timestamp: now(),
298
300
  status: 'sent',
299
301
  parentId,
300
302
  replyToId,
@@ -323,7 +325,7 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
323
325
  role: 'assistant',
324
326
  author: assistant,
325
327
  content: '',
326
- timestamp: new Date(),
328
+ timestamp: now(),
327
329
  status: 'streaming',
328
330
  parentId,
329
331
  }
@@ -350,7 +352,7 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
350
352
  role: 'assistant',
351
353
  author: assistant,
352
354
  content: '',
353
- timestamp: new Date(),
355
+ timestamp: now(),
354
356
  status: 'streaming',
355
357
  parentId,
356
358
  }
@@ -170,6 +170,8 @@ export interface ConversationConfig {
170
170
  generateTitle?: (firstMessage: string) => string
171
171
  /** Threading mode (default 'inline') */
172
172
  threadingMode?: ThreadingMode
173
+ /** Clock for conversation/message timestamps. Default: ambient `new Date()` — inject for deterministic SSR/tests. */
174
+ now?: () => Date
173
175
  }
174
176
 
175
177
  /** Options for a single `sendMessage`. */
@@ -26,6 +26,8 @@ export interface DatePickerProps {
26
26
  onOpenChange?: (open: boolean) => void
27
27
  /** The currently displayed month (controlled) */
28
28
  currentMonth?: Date
29
+ /** Reference "today" for isToday/aria-current and time defaults. Default: ambient `new Date()` — inject to avoid SSR/CSR hydration mismatch. */
30
+ today?: Date
29
31
  }
30
32
 
31
33
  export interface DatePickerState {
@@ -149,7 +151,8 @@ export function createDatePicker(props: DatePickerProps = {}): DatePickerAPI {
149
151
  onOpenChange,
150
152
  } = props
151
153
 
152
- const today = new Date()
154
+ // Injection boundary: ambient "today" is the default; inject `today` so SSR and client agree.
155
+ const today = props.today ?? new Date()
153
156
  let internalOpen = controlledOpen ?? defaultOpen
154
157
  let currentMonth = props.currentMonth ?? (value ? startOfMonth(value) : startOfMonth(today))
155
158
  let view: DatePickerView = 'calendar'
@@ -230,14 +233,14 @@ export function createDatePicker(props: DatePickerProps = {}): DatePickerAPI {
230
233
 
231
234
  function setHours(h: number): void {
232
235
  if (h < 0 || h > 23) return
233
- const newDate = value ? new Date(value) : new Date()
236
+ const newDate = value ? new Date(value) : new Date(today.getTime())
234
237
  newDate.setHours(h)
235
238
  onChange?.(newDate)
236
239
  }
237
240
 
238
241
  function setMinutes(m: number): void {
239
242
  if (m < 0 || m > 59) return
240
- const newDate = value ? new Date(value) : new Date()
243
+ const newDate = value ? new Date(value) : new Date(today.getTime())
241
244
  newDate.setMinutes(m)
242
245
  onChange?.(newDate)
243
246
  }
@@ -1,5 +1,3 @@
1
- import type { AccessibilityProps } from '../shared/index.ts'
2
-
3
1
  export type DeviceType = 'iphone' | 'ipad' | 'android-phone' | 'android-tablet'
4
2
  export type DeviceOrientation = 'portrait' | 'landscape'
5
3
 
@@ -27,7 +25,7 @@ export interface DeviceFrameAPI {
27
25
  /** Computed dimensions for the device */
28
26
  dimensions: DeviceDimensions
29
27
  /** ARIA attributes for the device frame */
30
- ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
28
+ ariaProps: Record<string, string | number | boolean>
31
29
  /** Data attributes for styling hooks */
32
30
  dataAttributes: Record<string, string>
33
31
  }
@@ -92,7 +90,7 @@ export function createDeviceFrame(props: DeviceFrameProps): DeviceFrameAPI {
92
90
  homeIndicator: spec.homeIndicator,
93
91
  }
94
92
 
95
- const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
93
+ const ariaProps: Record<string, string | number | boolean> = {
96
94
  role: 'img',
97
95
  'aria-label': `${DEVICE_LABELS[device]} device frame in ${orientation} orientation`,
98
96
  }
@@ -1,4 +1,3 @@
1
- import type { AccessibilityProps } from '../shared/index.ts'
2
1
  import { generateId } from '../shared/index.ts'
3
2
  import {
4
3
  EMOJI_DATA,
@@ -39,7 +38,7 @@ export interface EmojiPickerAPI {
39
38
  /** Category tabs with labels */
40
39
  categoryTabs: { category: EmojiCategory; label: string; emoji: string }[]
41
40
  /** ARIA props for the picker container */
42
- ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
41
+ ariaProps: Record<string, string | number | boolean>
43
42
  /** ARIA props for the search input */
44
43
  searchInputProps: Record<string, unknown>
45
44
  /** Get ARIA props for an emoji button */
@@ -118,7 +117,7 @@ export function createEmojiPicker(props: EmojiPickerProps = {}): EmojiPickerAPI
118
117
  emoji: CATEGORY_ICONS[cat],
119
118
  }))
120
119
 
121
- const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
120
+ const ariaProps: Record<string, string | number | boolean> = {
122
121
  role: 'dialog',
123
122
  'aria-labelledby': labelId,
124
123
  id: containerId,
@@ -13,6 +13,8 @@ export interface FooterProps {
13
13
  copyright?: string
14
14
  socialLinks?: SocialLink[]
15
15
  columns?: FooterColumn[]
16
+ /** Year for the default copyrightText. Default: ambient current year — inject to avoid year-boundary SSR/CSR mismatch. */
17
+ year?: number
16
18
  }
17
19
 
18
20
  export interface FooterAPI {
@@ -22,7 +24,8 @@ export interface FooterAPI {
22
24
 
23
25
  export function createFooter(props: FooterProps = {}): FooterAPI {
24
26
  const { copyright } = props
25
- const year = new Date().getFullYear()
27
+ // Injection boundary: ambient year is the default; inject `year` so SSR and client agree.
28
+ const year = props.year ?? new Date().getFullYear()
26
29
  const copyrightText = copyright ?? `© ${year} All rights reserved.`
27
30
 
28
31
  return {