@refraction-ui/astro 0.17.1 → 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.
@@ -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. */
@@ -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'
@@ -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 {
@@ -34,6 +34,9 @@ export function createTelemetry(config: TelemetryConfig): Telemetry {
34
34
  const preset = resolvePreset(config.env)
35
35
  const sampleRate = config.sampleRate ?? preset.sampleRate
36
36
  const redactKeys = config.redactKeys ?? []
37
+ // Injection boundary: ambient time/randomness default here; inject `now`/`random` for determinism.
38
+ const now = config.now ?? (() => Date.now())
39
+ const random = config.random ?? (() => Math.random())
37
40
 
38
41
  const sinks = new Map<string, TelemetrySink>()
39
42
  /** Insertion-ordered sink names. */
@@ -71,7 +74,7 @@ export function createTelemetry(config: TelemetryConfig): Telemetry {
71
74
  function shouldSample(): boolean {
72
75
  if (sampleRate >= 1) return true
73
76
  if (sampleRate <= 0) return false
74
- return Math.random() < sampleRate
77
+ return random() < sampleRate
75
78
  }
76
79
 
77
80
  function dispatch(
@@ -134,7 +137,7 @@ export function createTelemetry(config: TelemetryConfig): Telemetry {
134
137
  const record: LogRecord = {
135
138
  level,
136
139
  message,
137
- timestamp: Date.now(),
140
+ timestamp: now(),
138
141
  app: config.app,
139
142
  env: config.env,
140
143
  context: merged,
@@ -164,13 +167,13 @@ export function createTelemetry(config: TelemetryConfig): Telemetry {
164
167
  },
165
168
 
166
169
  startSpan(name: string, attributes?: LogContext): Span {
167
- const startTime = Date.now()
170
+ const startTime = now()
168
171
  let ended = false
169
172
  return {
170
173
  end(opts?: { error?: unknown; attributes?: LogContext }): void {
171
174
  if (ended) return
172
175
  ended = true
173
- const endTime = Date.now()
176
+ const endTime = now()
174
177
  const merged = redact(
175
178
  { ...boundContext, ...attributes, ...opts?.attributes },
176
179
  redactKeys,
@@ -84,6 +84,10 @@ export interface TelemetryConfig {
84
84
  * PII / secrets, e.g. `['password', 'token', 'authorization']`.
85
85
  */
86
86
  redactKeys?: string[]
87
+ /** Clock for record timestamps and span timing (ms epoch). Default: ambient `Date.now()` — inject for deterministic tests. */
88
+ now?: () => number
89
+ /** RNG for sample-rate decisions. Default: ambient `Math.random` — inject for deterministic tests. */
90
+ random?: () => number
87
91
  }
88
92
 
89
93
  /** A logger bound to a context. Child loggers inherit + extend that context. */
@@ -1 +1,14 @@
1
- export {}
1
+ export {
2
+ createPagination,
3
+ clampPage,
4
+ getPaginationItems,
5
+ type PaginationItem,
6
+ type PaginationConfig,
7
+ type PaginationAPI,
8
+ } from './pagination.js'
9
+
10
+ export {
11
+ paginationVariants,
12
+ paginationItemVariants,
13
+ paginationEllipsisClass,
14
+ } from './pagination.styles.js'
@@ -0,0 +1,33 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /** Row of pagination controls. */
4
+ export const paginationVariants = cva({
5
+ base: 'flex items-center gap-1',
6
+ })
7
+
8
+ /**
9
+ * A pagination button (prev / next / numbered page). The `state` variant
10
+ * carries the current-page treatment so adapters never inline these classes.
11
+ */
12
+ export const paginationItemVariants = cva({
13
+ base: [
14
+ 'inline-flex h-9 min-w-9 items-center justify-center rounded-md border px-3',
15
+ 'text-sm font-medium transition-colors',
16
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
17
+ 'focus-visible:ring-offset-2 focus-visible:ring-offset-background',
18
+ 'disabled:pointer-events-none disabled:opacity-50',
19
+ ].join(' '),
20
+ variants: {
21
+ state: {
22
+ current: 'border-primary bg-primary text-primary-foreground',
23
+ default: 'border-border bg-background text-foreground hover:bg-muted',
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ state: 'default',
28
+ },
29
+ })
30
+
31
+ /** Ellipsis gap between page numbers (not interactive). */
32
+ export const paginationEllipsisClass =
33
+ 'inline-flex h-9 min-w-9 items-center justify-center text-sm text-muted-foreground'
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @refraction-ui/pagination — headless pagination.
3
+ *
4
+ * Owns the *behavior* of page navigation: the windowed page range with
5
+ * ellipses, current-page clamping, and prev/next edge semantics. It has NO UI
6
+ * opinion — adapters render the controls and spread the ARIA objects.
7
+ */
8
+
9
+ /** One entry of the computed page range: a page number or an ellipsis gap. */
10
+ export type PaginationItem =
11
+ | { type: 'page'; page: number }
12
+ | { type: 'ellipsis'; id: 'start' | 'end' }
13
+
14
+ /** Options for {@link createPagination}. */
15
+ export interface PaginationConfig {
16
+ /** Current page (1-based). Clamped into `[1, totalPages]`. */
17
+ page?: number
18
+ /** Total number of pages. Values < 1 behave as 1. */
19
+ totalPages?: number
20
+ /** Pages shown on each side of the current page (default 1). */
21
+ siblingCount?: number
22
+ /** Accessible label for the nav landmark (default `'Pagination'`). */
23
+ ariaLabel?: string
24
+ }
25
+
26
+ /** The computed view of a pagination control. */
27
+ export interface PaginationAPI {
28
+ /** ARIA attributes for the root element (`role="navigation"`). */
29
+ ariaProps: Record<string, string | number | boolean>
30
+ /** Data attributes for styling hooks. */
31
+ dataAttributes: Record<string, string>
32
+ /** Current page after clamping (1-based). */
33
+ page: number
34
+ /** Total number of pages (>= 1). */
35
+ totalPages: number
36
+ /** Windowed range of pages and ellipses to render. */
37
+ items: PaginationItem[]
38
+ /** Whether "previous" navigation is possible. */
39
+ canGoPrevious: boolean
40
+ /** Whether "next" navigation is possible. */
41
+ canGoNext: boolean
42
+ /** Target page for the previous control (clamped). */
43
+ previousPage: number
44
+ /** Target page for the next control (clamped). */
45
+ nextPage: number
46
+ /** ARIA attributes for a numbered page button (`aria-current` on the current page). */
47
+ getPageAria(page: number): Record<string, string | number | boolean>
48
+ /** ARIA attributes for the previous control. */
49
+ getPreviousAria(): Record<string, string | number | boolean>
50
+ /** ARIA attributes for the next control. */
51
+ getNextAria(): Record<string, string | number | boolean>
52
+ }
53
+
54
+ /** Clamp a 1-based page number into `[1, max(totalPages, 1)]`. */
55
+ export function clampPage(page: number, totalPages: number): number {
56
+ const total = Math.max(1, Math.floor(totalPages))
57
+ if (Number.isNaN(page)) return 1
58
+ return Math.min(Math.max(1, Math.floor(page)), total)
59
+ }
60
+
61
+ function pageItem(page: number): PaginationItem {
62
+ return { type: 'page', page }
63
+ }
64
+
65
+ function pageRange(start: number, end: number): PaginationItem[] {
66
+ const items: PaginationItem[] = []
67
+ for (let p = start; p <= end; p++) items.push(pageItem(p))
68
+ return items
69
+ }
70
+
71
+ /**
72
+ * Compute the windowed page range with ellipses.
73
+ *
74
+ * Always shows the first and last page plus `siblingCount` pages on each side
75
+ * of the current page; gaps of more than one page collapse into an ellipsis.
76
+ * Small totals render every page (no ellipses).
77
+ */
78
+ export function getPaginationItems(
79
+ current: number,
80
+ totalPages: number,
81
+ siblingCount = 1,
82
+ ): PaginationItem[] {
83
+ const total = Math.max(0, Math.floor(totalPages))
84
+ if (total === 0) return []
85
+
86
+ // first + last + current + 2 siblings + 2 ellipses
87
+ const maxVisibleWithoutEllipsis = siblingCount * 2 + 5
88
+ if (total <= maxVisibleWithoutEllipsis) return pageRange(1, total)
89
+
90
+ const currentPage = clampPage(current, total)
91
+ const leftSibling = Math.max(currentPage - siblingCount, 1)
92
+ const rightSibling = Math.min(currentPage + siblingCount, total)
93
+
94
+ const showLeftEllipsis = leftSibling > 2
95
+ const showRightEllipsis = rightSibling < total - 1
96
+
97
+ if (!showLeftEllipsis && showRightEllipsis) {
98
+ const leftCount = 3 + 2 * siblingCount
99
+ return [...pageRange(1, leftCount), { type: 'ellipsis', id: 'end' }, pageItem(total)]
100
+ }
101
+
102
+ if (showLeftEllipsis && !showRightEllipsis) {
103
+ const rightCount = 3 + 2 * siblingCount
104
+ return [
105
+ pageItem(1),
106
+ { type: 'ellipsis', id: 'start' },
107
+ ...pageRange(total - rightCount + 1, total),
108
+ ]
109
+ }
110
+
111
+ return [
112
+ pageItem(1),
113
+ { type: 'ellipsis', id: 'start' },
114
+ ...pageRange(leftSibling, rightSibling),
115
+ { type: 'ellipsis', id: 'end' },
116
+ pageItem(total),
117
+ ]
118
+ }
119
+
120
+ /**
121
+ * createPagination — the framework-agnostic view of a pagination control:
122
+ * ARIA objects for the nav landmark and controls, plus the clamped
123
+ * current/total and prev/next edge semantics. Adapters own the page state
124
+ * (controlled/uncontrolled) and call this per render.
125
+ */
126
+ export function createPagination(config: PaginationConfig = {}): PaginationAPI {
127
+ const totalPages = Math.max(1, Math.floor(config.totalPages ?? 1))
128
+ const page = clampPage(config.page ?? 1, totalPages)
129
+ const siblingCount = Math.max(0, Math.floor(config.siblingCount ?? 1))
130
+
131
+ const canGoPrevious = page > 1
132
+ const canGoNext = page < totalPages
133
+
134
+ return {
135
+ ariaProps: {
136
+ role: 'navigation',
137
+ 'aria-label': config.ariaLabel ?? 'Pagination',
138
+ },
139
+ dataAttributes: {
140
+ 'data-page': String(page),
141
+ 'data-total-pages': String(totalPages),
142
+ },
143
+ page,
144
+ totalPages,
145
+ items: getPaginationItems(page, totalPages, siblingCount),
146
+ canGoPrevious,
147
+ canGoNext,
148
+ previousPage: canGoPrevious ? page - 1 : page,
149
+ nextPage: canGoNext ? page + 1 : page,
150
+ getPageAria(p): Record<string, string | number | boolean> {
151
+ return p === page ? { 'aria-current': 'page' } : {}
152
+ },
153
+ getPreviousAria(): Record<string, string | number | boolean> {
154
+ return { 'aria-label': 'Previous page' }
155
+ },
156
+ getNextAria(): Record<string, string | number | boolean> {
157
+ return { 'aria-label': 'Next page' }
158
+ },
159
+ }
160
+ }
@@ -1,5 +1,3 @@
1
- import type { AccessibilityProps } from '../shared/index.ts'
2
-
3
1
  export type PresenceStatus = 'online' | 'offline' | 'away' | 'busy' | 'dnd'
4
2
 
5
3
  export interface PresenceProps {
@@ -13,7 +11,7 @@ export interface PresenceProps {
13
11
 
14
12
  export interface PresenceAPI {
15
13
  /** ARIA attributes for the indicator */
16
- ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
14
+ ariaProps: Record<string, string | number | boolean>
17
15
  /** CSS color for the status dot */
18
16
  color: string
19
17
  /** Human-readable label for the status */
@@ -48,7 +46,7 @@ export function createPresence(props: PresenceProps): PresenceAPI {
48
46
  const color = STATUS_COLORS[status]
49
47
  const label = labelOverride ?? STATUS_LABELS[status]
50
48
 
51
- const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
49
+ const ariaProps: Record<string, string | number | boolean> = {
52
50
  role: 'status',
53
51
  'aria-label': label,
54
52
  }
@@ -1,4 +1,3 @@
1
- import type { AccessibilityProps } from '../shared/index.ts'
2
1
  import { generateId } from '../shared/index.ts'
3
2
 
4
3
  export interface Reaction {
@@ -27,7 +26,7 @@ export interface ReactionBarAPI {
27
26
  /** Add a new reaction */
28
27
  add(emoji: string): void
29
28
  /** ARIA props for the reaction bar container */
30
- ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
29
+ ariaProps: Record<string, string | number | boolean>
31
30
  /** Get ARIA props for an individual reaction pill */
32
31
  getReactionAriaProps(reaction: Reaction): Record<string, unknown>
33
32
  /** ARIA props for the "add reaction" button */
@@ -53,7 +52,7 @@ export function createReactionBar(props: ReactionBarProps): ReactionBarAPI {
53
52
  onAdd?.(emoji)
54
53
  }
55
54
 
56
- const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
55
+ const ariaProps: Record<string, string | number | boolean> = {
57
56
  role: 'group',
58
57
  'aria-label': 'Reactions',
59
58
  id: containerId,
@@ -1,2 +1,134 @@
1
- // Core slider logic
2
- export interface SliderProps {}
1
+ /**
2
+ * @refraction-ui/slider — headless slider.
3
+ *
4
+ * Owns the *behavior* of a single-value range slider: min/max clamping,
5
+ * step rounding, and keyboard increment/decrement semantics. It has NO UI
6
+ * opinion — adapters render the control (a native `<input type="range">` in
7
+ * React, which already applies these same keyboard rules natively) and spread
8
+ * the ARIA object.
9
+ */
10
+
11
+ /** Options for {@link createSlider}. */
12
+ export interface SliderProps {
13
+ /** Current value. Clamped into `[min, max]` and rounded to `step`. */
14
+ value?: number
15
+ /** Minimum selectable value (default 0). */
16
+ min?: number
17
+ /** Maximum selectable value (default 100). */
18
+ max?: number
19
+ /** Granularity between selectable values (default 1). */
20
+ step?: number
21
+ /** Disables the control. */
22
+ disabled?: boolean
23
+ /** Accessible label attached to the slider, when provided. */
24
+ 'aria-label'?: string
25
+ }
26
+
27
+ /** The computed view of a slider control. */
28
+ export interface SliderAPI {
29
+ /** Normalized value (clamped + step-rounded). */
30
+ value: number
31
+ min: number
32
+ max: number
33
+ step: number
34
+ /** Value position within the range, 0–100. */
35
+ percentage: number
36
+ /** ARIA attributes for the slider element (`role="slider"` + value attrs). */
37
+ ariaProps: Record<string, string | number | boolean>
38
+ /** Data attributes for styling hooks. */
39
+ dataAttributes: Record<string, string>
40
+ }
41
+
42
+ /** Clamp a value into `[min, max]`. */
43
+ export function clampSliderValue(value: number, min = 0, max = 100): number {
44
+ if (Number.isNaN(value)) return min
45
+ return Math.min(Math.max(value, min), max)
46
+ }
47
+
48
+ /**
49
+ * Round a value to the nearest multiple of `step`, offset from `min`.
50
+ * Non-positive steps leave the value unchanged. The result is re-rounded to
51
+ * the step's decimal precision to avoid float noise (e.g. 0.30000000000000004).
52
+ */
53
+ export function roundToStep(value: number, min = 0, step = 1): number {
54
+ if (!step || step <= 0 || Number.isNaN(value)) return value
55
+ const snapped = min + Math.round((value - min) / step) * step
56
+ const decimals = (String(step).split('.')[1] ?? '').length
57
+ return Number(snapped.toFixed(decimals))
58
+ }
59
+
60
+ /**
61
+ * Pure keyboard rule for a slider (WAI-ARIA slider pattern), shared by every
62
+ * adapter that does NOT render a native range input (native inputs already
63
+ * implement these keys):
64
+ *
65
+ * - ArrowRight / ArrowUp → +step
66
+ * - ArrowLeft / ArrowDown → -step
67
+ * - PageUp / PageDown → ±10×step
68
+ * - Home / End → min / max
69
+ *
70
+ * The result is clamped; unhandled keys return `current` unchanged.
71
+ */
72
+ export function getSliderValueFromKey(
73
+ current: number,
74
+ key: string,
75
+ options: { min?: number; max?: number; step?: number } = {},
76
+ ): number {
77
+ const { min = 0, max = 100, step = 1 } = options
78
+ switch (key) {
79
+ case 'ArrowRight':
80
+ case 'ArrowUp':
81
+ return clampSliderValue(current + step, min, max)
82
+ case 'ArrowLeft':
83
+ case 'ArrowDown':
84
+ return clampSliderValue(current - step, min, max)
85
+ case 'PageUp':
86
+ return clampSliderValue(current + step * 10, min, max)
87
+ case 'PageDown':
88
+ return clampSliderValue(current - step * 10, min, max)
89
+ case 'Home':
90
+ return min
91
+ case 'End':
92
+ return max
93
+ default:
94
+ return current
95
+ }
96
+ }
97
+
98
+ /**
99
+ * createSlider — the framework-agnostic view of a slider: normalized value,
100
+ * percentage position, and the ARIA object (`role="slider"`,
101
+ * `aria-valuemin/max/now`). Adapters own the value state
102
+ * (controlled/uncontrolled) and call this per render.
103
+ */
104
+ export function createSlider(props: SliderProps = {}): SliderAPI {
105
+ const min = props.min ?? 0
106
+ const max = props.max ?? 100
107
+ const step = props.step ?? 1
108
+ const value = clampSliderValue(roundToStep(props.value ?? min, min, step), min, max)
109
+ const percentage = max === min ? 0 : ((value - min) / (max - min)) * 100
110
+
111
+ const ariaProps: Record<string, string | number | boolean> = {
112
+ role: 'slider',
113
+ 'aria-valuemin': min,
114
+ 'aria-valuemax': max,
115
+ 'aria-valuenow': value,
116
+ 'aria-orientation': 'horizontal',
117
+ }
118
+ if (props.disabled) ariaProps['aria-disabled'] = true
119
+ if (props['aria-label']) ariaProps['aria-label'] = props['aria-label']
120
+
121
+ return {
122
+ value,
123
+ min,
124
+ max,
125
+ step,
126
+ percentage,
127
+ ariaProps,
128
+ dataAttributes: {
129
+ 'data-value': String(value),
130
+ 'data-min': String(min),
131
+ 'data-max': String(max),
132
+ },
133
+ }
134
+ }
@@ -1,5 +1,3 @@
1
- import type { AccessibilityProps } from '../shared/index.ts'
2
-
3
1
  export type StatusType = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'neutral'
4
2
 
5
3
  export interface StatusProps {
@@ -13,7 +11,7 @@ export interface StatusProps {
13
11
 
14
12
  export interface StatusAPI {
15
13
  /** ARIA attributes for the status indicator */
16
- ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
14
+ ariaProps: Record<string, string | number | boolean>
17
15
  /** The status type */
18
16
  type: StatusType
19
17
  /** The display label */
@@ -54,7 +52,7 @@ export function createStatusIndicator(props: StatusProps): StatusAPI {
54
52
  const label = labelOverride ?? STATUS_INDICATOR_LABELS[type]
55
53
  const pulse = pulseOverride ?? (type === 'pending')
56
54
 
57
- const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
55
+ const ariaProps: Record<string, string | number | boolean> = {
58
56
  role: 'status',
59
57
  'aria-label': label,
60
58
  }
@@ -1,4 +1,3 @@
1
- import type { AccessibilityProps } from '../shared/index.ts'
2
1
  import { generateId } from '../shared/index.ts'
3
2
 
4
3
  export interface MessageReaction {
@@ -70,11 +69,11 @@ export interface ThreadViewAPI {
70
69
  /** Format relative time (e.g., "2 minutes ago") */
71
70
  formatRelativeTime(date: Date): string
72
71
  /** ARIA props for the thread container */
73
- ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
72
+ ariaProps: Record<string, string | number | boolean>
74
73
  /** Get ARIA props for a message */
75
- getMessageAriaProps(message: MessageData): Record<string, unknown>
74
+ getMessageAriaProps(message: MessageData): Record<string, string | number | boolean>
76
75
  /** Get ARIA props for a reply button */
77
- getReplyButtonAriaProps(messageId: string): Record<string, unknown>
76
+ getReplyButtonAriaProps(messageId: string): Record<string, string | number | boolean>
78
77
  /** Generated IDs */
79
78
  ids: {
80
79
  thread: string
@@ -132,14 +131,14 @@ export function createThreadView(props: ThreadViewProps): ThreadViewAPI {
132
131
  onReact?.(messageId, emoji)
133
132
  }
134
133
 
135
- const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
134
+ const ariaProps: Record<string, string | number | boolean> = {
136
135
  role: 'log',
137
136
  'aria-label': 'Message thread',
138
137
  'aria-live': 'polite',
139
138
  id: threadId,
140
139
  }
141
140
 
142
- function getMessageAriaProps(message: MessageData): Record<string, unknown> {
141
+ function getMessageAriaProps(message: MessageData): Record<string, string | number | boolean> {
143
142
  const isOwn = currentUserId && message.author.id === currentUserId
144
143
  return {
145
144
  role: 'article',
@@ -147,7 +146,7 @@ export function createThreadView(props: ThreadViewProps): ThreadViewAPI {
147
146
  }
148
147
  }
149
148
 
150
- function getReplyButtonAriaProps(_messageId: string): Record<string, unknown> {
149
+ function getReplyButtonAriaProps(_messageId: string): Record<string, string | number | boolean> {
151
150
  return {
152
151
  role: 'button',
153
152
  'aria-label': `Reply to message`,
@@ -25,6 +25,8 @@ export interface ToastProps {
25
25
  open?: boolean
26
26
  /** Callback when open state changes */
27
27
  onOpenChange?: (open: boolean) => void
28
+ /** Elapsed-time clock for timer pause/resume math (ms). Default: ambient `Date.now()` — inject for deterministic tests. */
29
+ now?: () => number
28
30
  }
29
31
 
30
32
  export interface ToastState {
@@ -55,6 +57,8 @@ export function createToast(props: ToastProps = {}): ToastAPI {
55
57
  onOpenChange,
56
58
  } = props
57
59
 
60
+ // Injection boundary: ambient time is the default; inject `now` for deterministic timer math.
61
+ const now = props.now ?? (() => Date.now())
58
62
  let isOpen = controlledOpen ?? true
59
63
  let timerId: ReturnType<typeof setTimeout> | null = null
60
64
  let remaining = duration
@@ -77,21 +81,21 @@ export function createToast(props: ToastProps = {}): ToastAPI {
77
81
  if (duration <= 0) return
78
82
  clearTimer()
79
83
  remaining = duration
80
- startedAt = Date.now()
84
+ startedAt = now()
81
85
  timerId = setTimeout(dismiss, remaining)
82
86
  }
83
87
 
84
88
  function pauseTimer(): void {
85
89
  if (timerId === null) return
86
90
  clearTimer()
87
- remaining = remaining - (Date.now() - startedAt)
91
+ remaining = remaining - (now() - startedAt)
88
92
  if (remaining < 0) remaining = 0
89
93
  }
90
94
 
91
95
  function resumeTimer(): void {
92
96
  if (duration <= 0 || remaining <= 0) return
93
97
  clearTimer()
94
- startedAt = Date.now()
98
+ startedAt = now()
95
99
  timerId = setTimeout(dismiss, remaining)
96
100
  }
97
101
 
@@ -136,7 +140,14 @@ export interface ToastManagerAPI {
136
140
  subscribe(fn: (toasts: ReadonlyArray<ToastEntry>) => void): () => void
137
141
  }
138
142
 
139
- export function createToastManager(): ToastManagerAPI {
143
+ export interface ToastManagerConfig {
144
+ /** Clock for entry `createdAt` stamps (ms). Default: ambient `Date.now()` — inject for deterministic tests. */
145
+ now?: () => number
146
+ }
147
+
148
+ export function createToastManager(config: ToastManagerConfig = {}): ToastManagerAPI {
149
+ // Injection boundary: ambient time is the default; inject `now` for deterministic createdAt.
150
+ const now = config.now ?? (() => Date.now())
140
151
  let toasts: ToastEntry[] = []
141
152
  const listeners = new Set<(toasts: ReadonlyArray<ToastEntry>) => void>()
142
153
  const timers = new Map<string, ReturnType<typeof setTimeout>>()
@@ -171,7 +182,7 @@ export function createToastManager(): ToastManagerAPI {
171
182
  message,
172
183
  variant,
173
184
  duration,
174
- createdAt: Date.now(),
185
+ createdAt: now(),
175
186
  }
176
187
 
177
188
  toasts = [...toasts, entry]
@@ -1,5 +1,3 @@
1
- import type { AccessibilityProps } from '../shared/index.ts'
2
-
3
1
  export type VoicePillSpeaker = 'ai' | 'user' | (string & {})
4
2
 
5
3
  export type VoicePillPosition =
@@ -60,7 +58,7 @@ export interface VoicePillAPI {
60
58
  /** Invoke the mute callback when provided */
61
59
  toggleMute(): void
62
60
  /** ARIA props for the live status pill */
63
- ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
61
+ ariaProps: Record<string, string | number | boolean>
64
62
  /** ARIA props for the mute toggle */
65
63
  toggleMuteAriaProps: Record<string, unknown>
66
64
  /** Data attributes reflecting speaker/state */
@@ -196,7 +194,7 @@ export function createVoicePill(props: VoicePillProps): VoicePillAPI {
196
194
  onToggleMute?.()
197
195
  }
198
196
 
199
- const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
197
+ const ariaProps: Record<string, string | number | boolean> = {
200
198
  role: 'status',
201
199
  'aria-live': 'polite',
202
200
  'aria-atomic': true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@refraction-ui/astro",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "description": "All Refraction UI Astro components in one package",
5
5
  "type": "module",
6
6
  "exports": {