@weft-js/warp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,193 @@
1
+ import { announce, applyPrefs, gridNav, prefCell, toggleChanges, triState, uid, type PrefChange, type PrefEvent, type PrefGroup, type PrefRules, type PrefValues } from '@weft-js/warp-core'
2
+ import { derived, onMount, state, untrack, watch } from '@weft-js/core'
3
+ import { Badge } from '../Badge'
4
+ import { Button } from '../Button'
5
+ import { Checkbox } from '../Choice'
6
+ import { Select } from '../Select'
7
+ import { use } from '@weft-js/warp-core/weft'
8
+ import { G, val } from './shared'
9
+ import '../fields'
10
+ import '@weft-js/warp-core/styles/app/prefs-matrix.css'
11
+
12
+ /** A column of the preferences grid: email, push and so on. */
13
+ export interface PrefChannel { id: string; label: string }
14
+ /** Props for NotificationPreferences. */
15
+ export interface NotificationPreferencesProps {
16
+ /** The grid's name: the id of a heading, else this text is rendered in a visually hidden one. */
17
+ title?: string
18
+ groups: PrefGroup[]
19
+ channels: PrefChannel[]
20
+ value: PrefValues | { value: PrefValues } | (() => PrefValues)
21
+ /** Event id → the channels it can use. An event not listed can use all. */
22
+ offered?: Record<string, string[]> | undefined
23
+ /** Event id → the channels it must use. */
24
+ locked?: Record<string, string[]> | undefined
25
+ lockReason?: string
26
+ /** Every change saves on its own; a rejected promise flips the cells back and says why. */
27
+ onChange?: ((changes: PrefChange[]) => void | Promise<unknown>) | undefined
28
+ digest?: string
29
+ /** The channel the digest applies to. */
30
+ digestChannel?: string
31
+ /** The email delivery choices, such as instant, hourly or daily. */
32
+ digests?: { value: string; label: string }[]
33
+ /** Called with the delivery picked. A rejected promise puts the old one back. */
34
+ onDigest?: (digest: string) => void | Promise<unknown>
35
+ /** Enables Reset to defaults. */
36
+ defaults?: { value: PrefValues; digest?: string }
37
+ }
38
+
39
+ const DIGESTS = [{ value: 'instant', label: 'Instantly' }, { value: 'hourly', label: 'Hourly digest' }, { value: 'daily', label: 'Daily digest at 9:00' }, { value: 'weekly', label: 'Weekly digest, Mondays' }]
40
+ const lower = (s: string) => (s === 'SMS' ? s : s.toLowerCase())
41
+ type Save = 'idle' | 'saving' | 'saved' | 'error'
42
+ const CELL = 'input[type="checkbox"], [data-part="na"]'
43
+
44
+ /**
45
+ * NotificationPreferences (07 §4.6): event × channel, one APG grid with one tab stop. Group and channel toggles are
46
+ * tri-state; a required channel is locked and says why; a channel an event can't use is a plain "—" that still takes
47
+ * focus. Saves on every change, optimistically; a rejected save flips back. The same DOM restacks below 560px.
48
+ */
49
+ export function NotificationPreferences({ title = 'Notification preferences', groups, channels, value, offered, locked, lockReason = 'Required: this alert always goes to your account.', onChange, digest = 'instant', digestChannel = 'email', digests = DIGESTS, onDigest, defaults }: NotificationPreferencesProps) {
50
+ const id = uid('prefs')
51
+ const model = state<PrefValues>(untrack(() => val<PrefValues>(value)))
52
+ const mail = state(untrack(() => digest))
53
+ const save = state<Save>('idle')
54
+ // what a cell is (a box, locked, not offered) never depends on the values: its node stays through every change
55
+ const fixed: PrefRules = untrack(() => ({ offered, locked }))
56
+ const events = derived(() => groups.flatMap(g => g.events))
57
+ const reset = untrack(() => defaults)
58
+ let grid: HTMLDivElement | undefined
59
+ let seq = 0
60
+ onMount(() => {
61
+ watch(() => val<PrefValues>(value), v => { if (save.value !== 'saving') model.value = v })
62
+ watch(() => digest, d => { if (save.value !== 'saving') mail.value = d })
63
+ })
64
+ use(gridNav, () => grid, () => ({ targets: CELL }))
65
+
66
+ const say = (text: string, assertive = false) => announce(grid!.ownerDocument, text, assertive)
67
+ /** Apply now, run the save, roll back on rejection. */
68
+ const commit = async (apply: () => void, run: () => void | Promise<unknown>, msg: string) => {
69
+ const snap = [model.value, mail.value] as const
70
+ const mine = ++seq
71
+ apply()
72
+ save.value = 'saving'
73
+ try {
74
+ await run()
75
+ if (mine !== seq) return
76
+ save.value = 'saved'
77
+ say(`${msg}. Saved.`)
78
+ } catch {
79
+ if (mine !== seq) return
80
+ model.value = snap[0]
81
+ mail.value = snap[1]
82
+ save.value = 'error'
83
+ say(`Couldn't save: ${msg}. Change undone.`, true)
84
+ }
85
+ }
86
+ const change = (changes: PrefChange[], msg: string) => commit(() => (model.value = applyPrefs(model.value, changes)), () => onChange?.(changes), msg)
87
+ const name = (c: string) => channels.find(x => x.id === c)!.label
88
+ const cell = (e: PrefEvent, c: string, on: boolean) => change([{ event: e.id, channel: c, on }], `${e.label}, ${name(c)} ${on ? 'on' : 'off'}`)
89
+ const parent = (evs: PrefEvent[], c: string, where?: string) => {
90
+ const ch = toggleChanges(model.value, evs, fixed, c)
91
+ if (ch.length) void change(ch, `${name(c)} ${ch[0]!.on ? 'on' : 'off'} for ${ch.length} event${ch.length > 1 ? 's' : ''}${where ? ` in ${where}` : ''}`)
92
+ }
93
+ const onLocked = (e: Event) => {
94
+ e.preventDefault()
95
+ grid!.querySelector<HTMLElement>(`#${(e.currentTarget as HTMLElement).id}`)?.focus()
96
+ say(lockReason)
97
+ }
98
+ const onNa = (e: KeyboardEvent) => { if (e.key === ' ') { e.preventDefault(); say('Not offered for this event') } }
99
+
100
+ const parentBox = (evs: PrefEvent[], c: string, nameText: string, visible: string, where: string | undefined, first: boolean) => {
101
+ const tri = derived(() => triState(model.value, evs, fixed, c))
102
+ return (
103
+ <Checkbox checked={tri.value === 'on'} indeterminate={tri.value === 'mixed'} tabindex={first ? '0' : '-1'} onChange={() => parent(evs, c, where)}>
104
+ <span data-part="sr-only">{nameText}</span><span data-part="ch-name" aria-hidden="true">{visible}</span>
105
+ </Checkbox>
106
+ )
107
+ }
108
+ const na = (text: string, chName?: string) => (
109
+ <span data-part="na" tabindex="-1" onKeyDown={onNa}>
110
+ <span aria-hidden="true">—</span><span data-part="sr-only">{text}</span>
111
+ {chName && <span data-part="ch-name" aria-hidden="true">{chName}</span>}
112
+ </span>
113
+ )
114
+ const emailOn = derived(() => events.value.some(e => prefCell(model.value, fixed, e.id, digestChannel) === 'on'))
115
+ const hint = derived(() => (!emailOn.value ? 'Turn on an email event to choose a digest' : mail.value === 'instant' ? 'Each email is sent as it happens' : 'Batches email; security alerts skip the digest'))
116
+ const statusText = derived(() => ({ idle: 'All changes saved', saving: 'Saving…', saved: 'Saved', error: "Couldn't save. Your last change was undone." })[save.value])
117
+ const doReset = () => commit(() => { model.value = reset!.value; mail.value = reset!.digest ?? 'instant' }, async () => {
118
+ await onChange?.(events.value.flatMap(e => channels.flatMap(c => (prefCell(reset!.value, fixed, e.id, c.id) === 'na' || prefCell(reset!.value, fixed, e.id, c.id) === 'locked' ? [] : [{ event: e.id, channel: c.id, on: !!reset!.value[e.id]?.[c.id] }]))))
119
+ await onDigest?.(reset!.digest ?? 'instant')
120
+ }, 'Notification settings reset to defaults')
121
+ const pickDigest = (e: Event) => {
122
+ const el = e.target as HTMLSelectElement
123
+ const next = el.value
124
+ void commit(() => (mail.value = next), () => onDigest?.(next), `Email delivery: ${el.selectedOptions[0]!.textContent}`)
125
+ }
126
+
127
+ return (
128
+ <div data-scope="prefs-matrix">
129
+ <span data-part="sr-only" id={`${id}-t`}>{title}</span>
130
+ <div ref={n => (grid = n)} role="grid" data-part="grid" aria-labelledby={`${id}-t`} aria-describedby={`${id}-k`}>
131
+ <div role="row" data-part="head">
132
+ <div role="columnheader" data-part="corner">Event</div>
133
+ {channels.map((c, i) => (
134
+ <div key={c.id} role="columnheader">
135
+ <span data-part="ch-head" aria-hidden="true">{c.label}</span>
136
+ {parentBox(events.value, c.id, `${c.label}, all events`, `All ${lower(c.label)}`, undefined, i === 0)}
137
+ </div>
138
+ ))}
139
+ </div>
140
+ {groups.map(g => (
141
+ <div key={g.id} role="rowgroup" data-part="rows">
142
+ <div role="row" data-part="group">
143
+ <div role="rowheader">{g.label}</div>
144
+ {channels.map(c => (
145
+ <div key={c.id} role="gridcell">
146
+ {triState({}, g.events, fixed, c.id) === null
147
+ ? na(`${g.label}, ${lower(c.label)}, not offered`)
148
+ : parentBox(g.events, c.id, `${g.label}, all ${lower(c.label)}`, c.label, g.label, false)}
149
+ </div>
150
+ ))}
151
+ </div>
152
+ {g.events.map(e => (
153
+ <div key={e.id} role="row" data-row={e.id}>
154
+ <div role="rowheader">
155
+ <b>{e.label}</b>
156
+ {e.hint && <small>{e.hint}</small>}
157
+ {e.instant && <Badge tone="info" hidden={mail.value === 'instant' || !emailOn.value}>Always instant</Badge>}
158
+ </div>
159
+ {channels.map(c => {
160
+ const kind = prefCell({}, fixed, e.id, c.id)
161
+ return (
162
+ <div key={c.id} role="gridcell">
163
+ {kind === 'na' ? na(`${e.label}, ${lower(c.label)}, not offered`, c.label)
164
+ : kind === 'locked' ? <Checkbox checked aria-disabled="true" aria-describedby={`${id}-lock`} tabindex="-1" id={`${id}-${e.id}-${c.id}`} onClick={onLocked}>
165
+ <span data-part="sr-only">{e.label}, {lower(c.label)}</span><span data-part="ch-name" aria-hidden="true">{c.label}</span><G name="lock" data-part="lock" />
166
+ </Checkbox>
167
+ : <EventCell on={prefCell(model.value, fixed, e.id, c.id) === 'on'} text={`${e.label}, ${lower(c.label)}`} ch={c.label} onToggle={(v: boolean) => cell(e, c.id, v)} />}
168
+ </div>
169
+ )
170
+ })}
171
+ </div>
172
+ ))}
173
+ </div>
174
+ ))}
175
+ </div>
176
+ <span data-part="sr-only" id={`${id}-k`}>Arrow keys move between cells, Home and End to the row's ends, Control Home and End to the grid's corners, Space toggles.</span>
177
+ <span data-part="sr-only" id={`${id}-lock`}>{lockReason}</span>
178
+ <div data-part="footer">
179
+ <Select label="Email delivery" options={digests} value={mail.value} disabled={!emailOn.value} hint={hint.value} onChange={pickDigest} />
180
+ <span data-part="status" role="status" data-state={save.value}>{statusText.value}</span>
181
+ {reset && <Button emphasis="ghost" tone="neutral" size="sm" onClick={doReset}>Reset to defaults</Button>}
182
+ </div>
183
+ </div>
184
+ )
185
+ }
186
+
187
+ function EventCell({ on, text, ch, onToggle }: { on: boolean; text: string; ch: string; onToggle: (v: boolean) => unknown }) {
188
+ return (
189
+ <Checkbox checked={on} tabindex="-1" onChange={(e: Event) => onToggle((e.target as HTMLInputElement).checked)}>
190
+ <span data-part="sr-only">{text}</span><span data-part="ch-name" aria-hidden="true">{ch}</span>
191
+ </Checkbox>
192
+ )
193
+ }
@@ -0,0 +1,140 @@
1
+ import { announce, countdownLabel, countdownParts, levelMeter, uid } from '@weft-js/warp-core'
2
+ import { derived, onCleanup, onMount, state, untrack } from '@weft-js/core'
3
+ import { Avatar } from '../parts'
4
+ import { Button } from '../Button'
5
+ import { Card } from '../Card'
6
+ import { Input } from '../Field'
7
+ import { Select, type Option } from '../Select'
8
+ import { Spinner } from '../Spinner'
9
+ import { use } from '@weft-js/warp-core/weft'
10
+ import { avatarTint, G, held } from './shared'
11
+ import '../fields'
12
+ import '@weft-js/warp-core/styles/app/base.css'
13
+ import '@weft-js/warp-core/styles/app/tile.css'
14
+ import '@weft-js/warp-core/styles/app/prejoin.css'
15
+
16
+ const BARS = Array.from({ length: 16 }, (_, i) => i)
17
+ /** The devices to choose from before joining. */
18
+ export interface PrejoinDevices { mics?: Option[]; cameras?: Option[]; speakers?: Option[] }
19
+
20
+ /** Props for Prejoin. */
21
+ export interface PrejoinProps {
22
+ name?: string | undefined
23
+ /** The preview: your own <video>. Media is the caller's; warp never touches getUserMedia. */
24
+ preview?: () => unknown
25
+ /** 0 to 1. For a 10 Hz level, set `--level` on [data-part="meter"] yourself: nothing re-renders either way. */
26
+ level?: number
27
+ /** Whether your mic starts on. */
28
+ mic?: boolean
29
+ /** Whether your camera starts on. */
30
+ camera?: boolean
31
+ /** Called with the mic toggle's new state. */
32
+ onMic?: (on: boolean) => void
33
+ /** Called with the camera toggle's new state. */
34
+ onCamera?: (on: boolean) => void
35
+ devices?: PrejoinDevices
36
+ onDevice?: (kind: 'mic' | 'camera' | 'speaker', id: string) => void
37
+ onTestSound?: (() => void) | undefined
38
+ joining?: boolean
39
+ /** The join button's words. Default "Join now". */
40
+ joinLabel?: string
41
+ onJoin?: ((name: string) => void) | undefined
42
+ }
43
+
44
+ /** Prejoin (07 §3.2): preview, 16-bar mic meter, device Selects, Join with its loading state. */
45
+ export function Prejoin({ name = '', preview, level, mic, camera, onMic, onCamera, devices, onDevice, onTestSound, joining, joinLabel = 'Join now', onJoin }: PrejoinProps) {
46
+ const who = state(untrack(() => name))
47
+ const micOn = held<boolean>(() => mic, true, () => onMic)
48
+ const camOn = held<boolean>(() => camera, true, () => onCamera)
49
+ const list = untrack(() => devices) ?? {}
50
+ let meter: HTMLDivElement | undefined
51
+ use(levelMeter, () => meter, () => level)
52
+ const pick = (kind: 'mic' | 'camera' | 'speaker', e: Event) => onDevice?.(kind, (e.target as HTMLSelectElement).value)
53
+ return (
54
+ <div data-scope="prejoin">
55
+ <div data-part="layout">
56
+ <div data-part="stack">
57
+ <div data-part="preview">
58
+ <div data-scope="tile" data-cam={camOn.value ? undefined : 'off'}>
59
+ {camOn.value && preview ? <div data-part="video">{preview()}</div> : <Avatar name={who.value || 'You'} size="xl" style={avatarTint(who.value)} />}
60
+ <span data-part="name">You</span>
61
+ </div>
62
+ <div data-part="preview-controls">
63
+ <Button emphasis="soft" tone="neutral" data-icon-only="" aria-pressed={micOn.value ? 'true' : 'false'} aria-label="Microphone" onClick={() => micOn.set(!micOn.value)}><G name={micOn.value ? 'mic' : 'micoff'} /></Button>
64
+ <Button emphasis="soft" tone="neutral" data-icon-only="" aria-pressed={camOn.value ? 'true' : 'false'} aria-label="Camera" onClick={() => camOn.set(!camOn.value)}><G name={camOn.value ? 'cam' : 'camoff'} /></Button>
65
+ </div>
66
+ </div>
67
+ <div ref={n => (meter = n)} data-part="meter" role="meter" aria-label="Microphone level" aria-valuemin="0" aria-valuemax="16" aria-valuenow="0" style={level === undefined ? undefined : `--level:${level}`}>
68
+ {BARS.map(i => <i key={i}></i>)}
69
+ </div>
70
+ </div>
71
+ <div data-part="stack">
72
+ <Input label="Your name" value={who.value} autocomplete="name" onInput={e => (who.value = e.currentTarget.value)} />
73
+ {list.mics && <Select label="Microphone" options={list.mics} onChange={e => pick('mic', e)} />}
74
+ {list.cameras && <Select label="Camera" options={list.cameras} onChange={e => pick('camera', e)} />}
75
+ {list.speakers && <Select label="Speakers" options={list.speakers} onChange={e => pick('speaker', e)} />}
76
+ {onTestSound && <Button emphasis="link" size="sm" onClick={onTestSound}>Play test sound</Button>}
77
+ <Button size="lg" full loading={!!joining} onClick={() => onJoin?.(who.value)}>{joinLabel}</Button>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ )
82
+ }
83
+
84
+ /** Lobby (07 §3.2): waiting to be let in. A Spinner and "Asking to join…" as a status. */
85
+ export function Lobby({ title = 'Waiting for the host', children = "We'll chime when it's your turn.", onCancel }: { title?: unknown; children?: unknown; onCancel?: () => void }) {
86
+ return (
87
+ <Card eyebrow="Lobby" title={title} sweep={false}>
88
+ <p>{children}</p>
89
+ <p><Spinner size="sm" /> <span role="status">Asking to join…</span></p>
90
+ <Button emphasis="outline" tone="neutral" size="sm" onClick={() => onCancel?.()}>Cancel request</Button>
91
+ </Card>
92
+ )
93
+ }
94
+
95
+ /**
96
+ * Countdown (07 §3.2): role=timer, aria-live off, announced once at one minute. `to` and `now` are epoch ms: pass
97
+ * `now` to render on the server; without it the digits fill in after mount.
98
+ */
99
+ export function Countdown({ to, now, label = 'Starts in', onDone }: { to: number; now?: number | undefined; label?: string | undefined; onDone?: () => void }) {
100
+ const left = state<number | null>(untrack(() => (now === undefined ? null : to - now)))
101
+ const parts = derived(() => countdownParts(left.value ?? 0))
102
+ const name = derived(() => (left.value === null ? label : countdownLabel(left.value)))
103
+ let root: HTMLDivElement | undefined
104
+ onMount(() => {
105
+ let warned = untrack(() => left.value !== null && left.value <= 60000)
106
+ let done = false
107
+ const beat = () => {
108
+ const ms = untrack(() => to) - Date.now()
109
+ left.value = ms
110
+ if (!warned && ms <= 60000) { warned = true; if (root) announce(root.ownerDocument, 'Starts in 1 minute') }
111
+ if (!done && ms <= 0) { done = true; clearInterval(t); onDone?.() }
112
+ }
113
+ const t = setInterval(beat, 1000)
114
+ onCleanup(() => clearInterval(t))
115
+ queueMicrotask(beat)
116
+ })
117
+ return (
118
+ <div ref={n => (root = n)} data-part="countdown" role="timer" aria-live="off" aria-label={name.value}>
119
+ {parts.value.map(p => <span key={p.unit}><b>{p.value}</b><small>{p.unit}</small></span>)}
120
+ </div>
121
+ )
122
+ }
123
+
124
+ /** ScheduledLobby (07 §3.1): Countdown, with Join disabled until `joinable` or the countdown ends. */
125
+ export function ScheduledLobby({ title, children, to, now, joinable, opensAt, onJoin, onAddToCalendar }: { title: unknown; children?: unknown; to: number; now?: number | undefined; joinable?: boolean; opensAt?: string; onJoin?: (() => void) | undefined; onAddToCalendar?: (() => void) | undefined }) {
126
+ const ended = state(false)
127
+ const can = derived(() => !!joinable || ended.value)
128
+ const id = uid('sched')
129
+ return (
130
+ <Card eyebrow="Scheduled lobby" title={title} sweep={false} aria-labelledby={undefined}>
131
+ {children !== undefined && <p>{children}</p>}
132
+ <Countdown to={to} now={now} onDone={() => (ended.value = true)} />
133
+ <p>
134
+ <Button size="sm" disabled={!can.value} aria-describedby={!can.value && opensAt ? id : undefined} onClick={() => onJoin?.()}>Join</Button>
135
+ {onAddToCalendar && <Button emphasis="outline" tone="neutral" size="sm" onClick={onAddToCalendar}><G name="cal" />Add to calendar</Button>}
136
+ </p>
137
+ {opensAt && !can.value && <small id={id}>Opens at {opensAt}</small>}
138
+ </Card>
139
+ )
140
+ }
@@ -0,0 +1,37 @@
1
+ import { announce, uid } from '@weft-js/warp-core'
2
+ import { state } from '@weft-js/core'
3
+ import { Button } from '../Button'
4
+ import { G } from './shared'
5
+ import '../styles'
6
+ import '@weft-js/warp-core/styles/app/push-ask.css'
7
+
8
+ /**
9
+ * Push soft-ask (07 §4.2, P2): an in-app card before the browser's prompt. `onAccept` is where the caller calls
10
+ * Notification.requestPermission(), only after this yes; the caller also decides when to show it (never first visit).
11
+ */
12
+ export function PushSoftAsk({ title = 'Get a ping when someone mentions you?', children = 'Only mentions and approvals, on this device. You can change it any time.', onAccept, onDismiss, declined = "Okay. We won't ask again for 30 days." }: { title?: unknown; children?: unknown; onAccept: () => void; onDismiss?: () => void; declined?: string }) {
13
+ const id = uid('push')
14
+ const shown = state(true)
15
+ let root: HTMLElement | undefined
16
+ const no = () => {
17
+ announce(root!.ownerDocument, declined)
18
+ shown.value = false
19
+ onDismiss?.()
20
+ }
21
+ const yes = () => { shown.value = false; onAccept() }
22
+ return (
23
+ <>
24
+ {shown.value && (
25
+ <div ref={n => (root = n)} data-scope="push-ask" role="region" aria-labelledby={`${id}-t`}>
26
+ <span data-part="mark"><G name="bell" /></span>
27
+ <b id={`${id}-t`}>{title}</b>
28
+ <span>{children}</span>
29
+ <div data-part="actions">
30
+ <Button size="sm" onClick={yes}>Turn on</Button>
31
+ <Button emphasis="ghost" tone="neutral" size="sm" onClick={no}>Not now</Button>
32
+ </div>
33
+ </div>
34
+ )}
35
+ </>
36
+ )
37
+ }
@@ -0,0 +1,163 @@
1
+ import { announce, formatMs, latencyTone, liveState, percentile, transportError, transportSim, uid, whileSeen, worstLive, type LaneId, type LiveState, type TransportBus, type TransportConfig, type TransportNetwork, type TransportPreset, type TransportWire } from '@weft-js/warp-core'
2
+ import { derived, onCleanup, onMount, state, untrack } from '@weft-js/core'
3
+ import { LiveStatus } from '../parts'
4
+ import { Button } from '../Button'
5
+ import { Select } from '../Select'
6
+ import { use } from '@weft-js/warp-core/weft'
7
+ import { G, val } from './shared'
8
+ import '../fields'
9
+ import '@weft-js/warp-core/styles/inputs/choice.css'
10
+ import '@weft-js/warp-core/styles/components/alert.css'
11
+ import '@weft-js/warp-core/styles/app/transport.css'
12
+
13
+ const WIRES: [TransportWire, string, string, string][] = [['default', 'weft default', 'SSE + WebSocket', 'One SSE (.live, stream) and one WebSocket (channel) per tab'], ['tabs', 'Tabs only', 'BroadcastChannel', "tabs.channel: this browser's tabs, no server"], ['fake', 'Fake', '@weft-js/core/test', 'In-memory pair with a network model']]
14
+ const BUSES: [TransportBus, string, string][] = [['memory', '+0 ms', 'One process. The default'], ['postgres', '+4 ms', 'LISTEN / NOTIFY'], ['redis', '+2 ms', 'Pub/sub'], ['nats', '+1 ms', 'Subjects'], ['durable-object', '+8 ms', 'Cloudflare preset only']]
15
+ const PRESETS = [{ value: 'single', label: 'Single instance' }, { value: 'multi', label: 'Multi-instance' }, { value: 'cf', label: 'Cloudflare' }]
16
+ const NETS = [{ value: 'good', label: 'Good' }, { value: 'slow', label: 'Slow (×6)' }, { value: 'lossy', label: 'Lossy (spikes, drops)' }, { value: 'wsblock', label: 'WebSocket blocked' }, { value: 'offline', label: 'Offline' }]
17
+ const LANES: [LaneId, string][] = [['live', '.live'], ['stream', 'stream'], ['channel', 'channel']]
18
+ const BARS = Array.from({ length: 20 }, (_, i) => i)
19
+
20
+ type Status = string | { value: string } | (() => string)
21
+ /** Props for TransportPicker. */
22
+ export interface TransportPickerProps {
23
+ /** The real statuses to show (`.live`, `stream().status`, `channel.status`); the lanes' figures stay the sandbox's. */
24
+ lanes?: Partial<Record<LaneId, Status>>
25
+ bus?: TransportBus
26
+ wire?: TransportWire
27
+ preset?: TransportPreset
28
+ network?: TransportNetwork
29
+ onConfigChange?: (c: TransportConfig) => void
30
+ title?: string
31
+ }
32
+
33
+ /**
34
+ * TransportPicker (07 §4.7): the realtime sandbox, a dev surface, never end-user UI. It picks the wire under test and
35
+ * the server bus (validated like the build), runs the documented behaviour under a chosen network in `transportSim`,
36
+ * and reads lanes as the four states of §4.4, with p50 and p95 over the last 20 pings, cursors and an event log.
37
+ */
38
+ export function TransportPicker({ lanes, bus = 'postgres', wire = 'default', preset = 'multi', network = 'good', onConfigChange, title = 'Realtime sandbox' }: TransportPickerProps) {
39
+ const id = uid('transport')
40
+ const start = untrack(() => ({ wire, bus, preset, network }))
41
+ const sim = transportSim(start)
42
+ const rev = state(0)
43
+ const view = <T,>(fn: () => T) => derived(() => { void rev.value; return fn() })
44
+ const cfg = view(() => sim.config)
45
+ const data = view(() => sim.lanes())
46
+ const log = view(() => sim.log)
47
+ const fallback = derived(() => untrack(() => lanes))
48
+ const stateOf = (k: LaneId): LiveState => {
49
+ const given = fallback.value?.[k]
50
+ return given !== undefined ? liveState(val<string>(given)) : data.value[k].state
51
+ }
52
+ const worst = derived(() => worstLive(LANES.map(([k]) => stateOf(k))))
53
+ const error = derived(() => transportError(cfg.value))
54
+ const noServer = derived(() => cfg.value.wire !== 'default')
55
+ const busNote = derived(() => (cfg.value.wire === 'tabs' ? 'No server: tabs only stays in this browser.' : cfg.value.wire === 'fake' ? 'No server: the fake pair runs in memory.' : ''))
56
+ let root: HTMLDivElement | undefined
57
+
58
+ onMount(() => {
59
+ const off = sim.subscribe(() => { rev.value++ })
60
+ sim.start()
61
+ onCleanup(() => { off(); sim.destroy() })
62
+ })
63
+ use(whileSeen, () => root, () => ({ ms: 2000, tick: () => sim.tick() }))
64
+ // one sentence per settled change (a lane's change is polite and once, offline assertive)
65
+ onMount(() => {
66
+ let said: LiveState | undefined
67
+ let t: ReturnType<typeof setTimeout> | undefined
68
+ const off = sim.subscribe(() => {
69
+ const w = untrack(() => worst.value)
70
+ if (w === said) return
71
+ said = w
72
+ clearTimeout(t)
73
+ t = setTimeout(() => {
74
+ if (!root) return
75
+ const text = w === 'live' ? 'all lanes live' : LANES.filter(([k]) => stateOf(k) !== 'live').map(([k, n]) => `${n} ${stateOf(k)}`).join(', ')
76
+ announce(root.ownerDocument, `Realtime sandbox: ${text}`, w === 'offline')
77
+ }, 600)
78
+ })
79
+ onCleanup(() => { off(); clearTimeout(t) })
80
+ })
81
+ const set = (next: Partial<TransportConfig>) => { sim.configure(next); onConfigChange?.(sim.config) }
82
+ const reset = () => { sim.reset(start); onConfigChange?.(sim.config); announce(root!.ownerDocument, 'Realtime sandbox reset') }
83
+ const drop = () => { if (!sim.drop()) announce(root!.ownerDocument, 'Tabs only has no server connection to drop') }
84
+
85
+ return (
86
+ <div ref={n => (root = n)} data-scope="transport">
87
+ <div data-part="head">
88
+ <h4>{title}</h4>
89
+ <LiveStatus state={worst.value} label="Realtime sandbox">{worst.value === 'live' ? 'All lanes live' : undefined}</LiveStatus>
90
+ <Button emphasis="ghost" tone="neutral" size="sm" onClick={reset}><G name="refresh" />Reset</Button>
91
+ </div>
92
+ <div data-part="config">
93
+ <fieldset data-scope="group">
94
+ <legend>Wire under test</legend>
95
+ <div data-part="choices" role="radiogroup" aria-label="Wire under test">
96
+ {WIRES.map(w => (
97
+ <label key={w[0]} data-scope="choice-card" data-sweep="">
98
+ <span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span>
99
+ <input type="radio" name={`${id}-wire`} value={w[0]} checked={cfg.value.wire === w[0]} onChange={() => set({ wire: w[0] })} />
100
+ <span data-part="label">{w[1]}</span><span data-part="meta">{w[2]}</span><span data-part="hint">{w[3]}</span>
101
+ </label>
102
+ ))}
103
+ </div>
104
+ </fieldset>
105
+ <fieldset data-scope="group" disabled={noServer.value}>
106
+ <legend>Server bus <code>realtime.bus</code></legend>
107
+ <div data-part="choices" role="radiogroup" aria-label="Server bus">
108
+ {BUSES.map(b => (
109
+ <label key={b[0]} data-scope="choice-card" data-sweep="">
110
+ <span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span>
111
+ <input type="radio" name={`${id}-bus`} value={b[0]} checked={cfg.value.bus === b[0]} disabled={b[0] === 'durable-object' && cfg.value.preset !== 'cf'} aria-describedby={`${id}-h-${b[0]}`} onChange={() => set({ bus: b[0] })} />
112
+ <span data-part="label"><code>{b[0]}</code></span><span data-part="meta">{b[1]}</span>
113
+ <span data-part="hint" id={`${id}-h-${b[0]}`}>{b[0] === 'durable-object' && cfg.value.preset === 'cf' ? 'One object per channel' : b[2]}</span>
114
+ </label>
115
+ ))}
116
+ </div>
117
+ <div data-part="row"><Select label="Deploy preset" options={PRESETS} value={cfg.value.preset} onChange={(e: Event) => set({ preset: (e.target as HTMLSelectElement).value as TransportPreset })} /></div>
118
+ <span data-part="message">{busNote.value}</span>
119
+ </fieldset>
120
+ </div>
121
+ {error.value && (
122
+ <div data-scope="alert" data-tone="critical" data-part="build-error" role="alert">
123
+ <span data-part="title">{error.value.title}</span>
124
+ <span data-part="description">{error.value.description}</span>
125
+ </div>
126
+ )}
127
+ <div data-part="row">
128
+ <Select label="Network" options={NETS} value={cfg.value.network} onChange={(e: Event) => set({ network: (e.target as HTMLSelectElement).value as TransportNetwork })} />
129
+ <Button emphasis="outline" tone="neutral" size="sm" onClick={() => sim.ping()}><G name="bolt" />Ping</Button>
130
+ <Button emphasis="outline" tone="neutral" size="sm" onClick={drop}>Drop connection</Button>
131
+ <Button emphasis="outline" tone="neutral" size="sm" onClick={() => sim.fallBehind()}>Fall behind</Button>
132
+ </div>
133
+ <ul data-part="lanes" aria-label="Lanes">
134
+ {LANES.map(l => <Lane key={l[0]} name={l[1]} lane={data.value[l[0]]} status={stateOf(l[0])} />)}
135
+ </ul>
136
+ <div data-part="log" role="log" aria-live="off" aria-label="Event log" tabindex="0">
137
+ <ol>{log.value.map(e => <li key={`${e.at}-${e.text}`}><span>+{e.at.toFixed(1)}s</span><b>{e.who}</b><span>{e.text}</span></li>)}</ol>
138
+ </div>
139
+ </div>
140
+ )
141
+ }
142
+
143
+ function Lane({ name, lane, status }: { name: string; lane: { samples: number[]; cursor: string; wire: string; note: string }; status: LiveState }) {
144
+ const p50 = derived(() => percentile(lane.samples, 0.5))
145
+ const p95 = derived(() => percentile(lane.samples, 0.95))
146
+ const bars = derived(() => BARS.map(i => {
147
+ const x = lane.samples.slice(-20)[i - (20 - Math.min(20, lane.samples.length))]
148
+ return { i, h: x === undefined ? 0 : Math.min(1, Math.sqrt(x / 1000)), tone: x === undefined ? undefined : latencyTone(x) }
149
+ }))
150
+ return (
151
+ <li data-part="lane" data-state={status}>
152
+ <div data-part="lane-name"><b>{name}</b><small>{lane.wire}</small></div>
153
+ <LiveStatus state={status} />
154
+ <dl data-part="figures">
155
+ <div><dt>p50</dt><dd data-numeric="" data-tone={latencyTone(p50.value!)}>{formatMs(p50.value!)}</dd></div>
156
+ <div><dt>p95</dt><dd data-numeric="" data-tone={latencyTone(p95.value!)}>{formatMs(p95.value!)}</dd></div>
157
+ <div><dt>cursor</dt><dd data-numeric="">{lane.cursor}</dd></div>
158
+ </dl>
159
+ <div data-part="bars" aria-hidden="true">{bars.value.map(b => <i key={b.i} data-tone={b.tone} style={`--h:${b.h.toFixed(3)}`}></i>)}</div>
160
+ <span data-part="note">{lane.note}</span>
161
+ </li>
162
+ )
163
+ }
@@ -0,0 +1,12 @@
1
+ // The live pack: see docs/design/warp. One file per component family; each imports only its own CSS.
2
+ export { Bell, type BellProps } from './Bell'
3
+ export { Inbox, type InboxProps, type Note, type NoteAction, type NoteKind } from './Inbox'
4
+ export { ActivityFeed, type ActivityItem } from './ActivityFeed'
5
+ export { PushSoftAsk } from './PushSoftAsk'
6
+ export { notify } from './notify'
7
+ export { ConnectionPill, ConnectionBanner } from './Connection'
8
+ export { LiveStatus, type LiveState } from '../parts'
9
+ export { NotificationPreferences, type NotificationPreferencesProps, type PrefChannel } from './NotificationPreferences'
10
+ export { TransportPicker, type TransportPickerProps } from './TransportPicker'
11
+ export { MeetingRoom, ControlBar, Tile, type MeetingRoomProps, type ControlBarProps, type MeetingLayout, type Participant } from './MeetingRoom'
12
+ export { Prejoin, Lobby, ScheduledLobby, Countdown, type PrejoinProps, type PrejoinDevices } from './Prejoin'
@@ -0,0 +1,16 @@
1
+ import { announce, notifyPolicy, type NotifyItem } from '@weft-js/warp-core'
2
+ import { toast } from '../Toast'
3
+
4
+ /**
5
+ * The inbox's toast policy (07 §4.2) on warp's toaster: `const n = notify({ open: () => inboxOpen.value })`, then
6
+ * `n.push({ title, description, tone, urgent, action })` per arrival. Inbox open: no toast, one announcement after a
7
+ * second of quiet. Closed: a toast each, three at most; a burst collapses to "3 new notifications".
8
+ */
9
+ export function notify(opts: { open: () => boolean; burst?: number; quiet?: number | undefined; max?: number | undefined; summary?: (n: number) => string }) {
10
+ return notifyPolicy({
11
+ ...opts,
12
+ show: (t: NotifyItem) => toast.show(t),
13
+ dismiss: id => toast.dismiss(id),
14
+ announce: (text, assertive) => announce(document, text, assertive),
15
+ })
16
+ }
@@ -0,0 +1,39 @@
1
+ // What the live pack's components share: glyphs from the specimen's 24 grid, and reading a prop that may be a plain
2
+ // value, a weft State or Signal, or a getter (07 §4.5: `count={() => …}`, `items={inbox}`).
3
+ import { AlertIcon, AtIcon, BellIcon, BoardIcon, BoltIcon, CalendarIcon, CamIcon, CamOffIcon, ChatIcon, CheckIcon, CommentIcon, FlagIcon, GridIcon, HandIcon, LeaveIcon, LockIcon, MicIcon, MicOffIcon, MoreIcon, PlusIcon, PresentIcon, RefreshIcon, ScreenIcon, SignalIcon, SmileIcon, SpeakerIcon, UsersIcon, XIcon, iconSet } from '@weft-js/warp-icons'
4
+ import { hueOf } from '@weft-js/warp-core'
5
+ import { Box, state, untrack, type State } from '@weft-js/core'
6
+
7
+ const GLYPHS = { bell: BellIcon, at: AtIcon, comment: CommentIcon, flag: FlagIcon, check: CheckIcon, alert: AlertIcon, cal: CalendarIcon, more: MoreIcon, x: XIcon, plus: PlusIcon, lock: LockIcon, refresh: RefreshIcon, bolt: BoltIcon, mic: MicIcon, micoff: MicOffIcon, cam: CamIcon, camoff: CamOffIcon, screen: ScreenIcon, hand: HandIcon, smile: SmileIcon, chat: ChatIcon, users: UsersIcon, leave: LeaveIcon, signal: SignalIcon, grid: GridIcon, speaker: SpeakerIcon, present: PresentIcon, board: BoardIcon }
8
+
9
+ export type GlyphName = keyof typeof GLYPHS
10
+ export const G = iconSet(GLYPHS)
11
+
12
+ /** A prop that may be a value, a box with `.value` (State, Signal, Resource) or a getter. */
13
+ export function val<T>(v: T | { value: T } | (() => T)): T {
14
+ if (typeof v === 'function') return (v as () => T)()
15
+ if (v !== null && typeof v === 'object' && !Array.isArray(v) && 'value' in (v as object)) return (v as { value: T }).value
16
+ return v as T
17
+ }
18
+
19
+ const isState = (v: unknown): v is State<unknown> => v instanceof Box
20
+
21
+ /** One read and write over a bound State, a controlled value and a default (the Picker `bindable`, without its CSS). */
22
+ export function held<T>(value: () => State<T> | T | undefined, fallback: T, on?: () => ((v: T) => void) | undefined) {
23
+ const own = state<T>(untrack(() => { const v = value(); return isState(v) ? fallback : (v) ?? fallback }))
24
+ return {
25
+ get value(): T {
26
+ const v = value()
27
+ return isState(v) ? v.value : v !== undefined ? (v) : own.value
28
+ },
29
+ set(next: T) {
30
+ const v = untrack(value)
31
+ if (isState(v)) v.value = next
32
+ else own.value = next
33
+ untrack(() => on?.())?.(next)
34
+ },
35
+ }
36
+ }
37
+
38
+ /** The avatar and tile tint for a person: the data palette, never a status colour (07 §0). */
39
+ export const avatarTint = (id: string) => `--_bg:color-mix(in oklab,var(--warp-color-data-${hueOf(id)}) 26%,var(--warp-color-surface-raised))`