@weft-js/warp 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,120 @@
1
+ import { KeyIcon, LeftIcon, MailIcon } from '@weft-js/warp-icons'
2
+ import { clockLabel, emailError, passwordRules, uid } from '@weft-js/warp-core'
3
+ import { derived, onCleanup, onMount, state, untrack } from '@weft-js/core'
4
+ import { Avatar } from '../parts'
5
+ import { Badge } from '../Badge'
6
+ import { Button } from '../Button'
7
+ import { Input } from '../Field'
8
+ import { FormError, Mark, Rail, StepFrame, Submit, TextLink, Title, useCooldown, useStepForm, type FlowForm, type Heading } from './kit'
9
+ import { PasswordMeter } from './SignUp'
10
+ import '../styles'
11
+ import '@weft-js/warp-core/styles/flows/auth.css'
12
+ import '@weft-js/warp-core/styles/layout/row.css'
13
+
14
+ const Back = ({ onBack }: { onBack: () => void }) => <div><Button emphasis="ghost" tone="neutral" size="sm" icon={LeftIcon} onClick={onBack}>Back</Button></div>
15
+
16
+ /** "Check your inbox" (09 §2.1): waiting, so no solid; the address, Open mail app, resend on a countdown, change email. */
17
+ export function MagicLinkSent({ email, title, lede, heading, resendIn = 30, mailHref, onResend, onChangeEmail, onBack }: { email: string; title?: unknown; lede?: unknown; heading?: Heading; resendIn?: number; /** Shows Open email app (phones). */ mailHref?: string; onResend?: () => void; onChangeEmail?: () => void; onBack?: () => void }) {
18
+ const round = state(0)
19
+ const left = useCooldown(() => (round.value, resendIn))
20
+ return (
21
+ <div data-scope="stack">
22
+ {onBack && <Back onBack={onBack} />}
23
+ <Mark><MailIcon /></Mark>
24
+ <div><Title as={heading}>{title ?? 'Check your inbox'}</Title><p data-part="lede">{lede ?? <>We sent a sign-in link to <b>{email}</b>. It works once and expires in 15 minutes.</>}</p></div>
25
+ {mailHref && <Button emphasis="frame" full role="link" onClick={() => location.assign(mailHref)}>Open email app</Button>}
26
+ <div data-part="row">
27
+ {onResend && <TextLink disabled={left.value > 0} onClick={() => { onResend(); round.value++ }}>{left.value > 0 ? `Resend in ${clockLabel(left.value)}` : 'Resend link'}</TextLink>}
28
+ {onChangeEmail && <TextLink onClick={onChangeEmail}>Use a different email</TextLink>}
29
+ </div>
30
+ </div>
31
+ )
32
+ }
33
+
34
+ /** What the reset form collects, across its two steps. */
35
+ export interface ResetValues { email?: string; password?: string; confirm?: string }
36
+
37
+ /** Props for ResetPassword. */
38
+ export interface ResetPasswordProps extends FlowForm<ResetValues> {
39
+ /** `request` asks for the email; `password` sets the new one (after the link or the code). */
40
+ stage?: 'request' | 'password' | undefined
41
+ onBack?: () => void
42
+ title?: unknown
43
+ lede?: unknown
44
+ }
45
+
46
+ /** Reset (09 §2.1): email → link or code → new password and confirm. Never says whether the email has an account (§2.3). */
47
+ export function ResetPassword(p: ResetPasswordProps) {
48
+ const id = uid('reset')
49
+ const stage = untrack(() => p.stage ?? 'request')
50
+ const password = state('')
51
+ const ctl = useStepForm<ResetValues>(untrack(() => p), v => (stage === 'request'
52
+ ? { email: v.email ? emailError(v.email) : 'Enter the email you sign in with.' }
53
+ : {
54
+ password: passwordRules(v.password ?? '').every(r => r.met) ? undefined : 'Use at least 8 characters, with a letter and a number.',
55
+ confirm: v.confirm === v.password ? undefined : "Those passwords don't match.",
56
+ }))
57
+ return (
58
+ <StepFrame ctl={ctl} embedded={!!p.form} labelledby={`${id}-t`}>
59
+ {p.onBack && <Back onBack={p.onBack} />}
60
+ <Mark><KeyIcon /></Mark>
61
+ <div>
62
+ <Title as={p.heading} id={`${id}-t`}>{p.title ?? (stage === 'request' ? 'Reset your password' : 'Choose a new password')}</Title>
63
+ <p data-part="lede">{p.lede ?? (stage === 'request' ? "We'll email a link that lets you choose a new one. It expires in 30 minutes." : 'Then sign in with it everywhere.')}</p>
64
+ </div>
65
+ <FormError ctl={ctl} />
66
+ {stage === 'request'
67
+ ? <Input label="Work email" name="email" type="email" inputmode="email" autocomplete="email" hint="" error={ctl.errors.value.email} onBlur={(e: FocusEvent) => ctl.flag('email', emailError((e.target as HTMLInputElement).value))} />
68
+ : (
69
+ <>
70
+ <div>
71
+ <Input label="New password" name="password" type="password" autocomplete="new-password" hint="" error={ctl.errors.value.password} onInput={e => (password.value = e.currentTarget.value)} />
72
+ <PasswordMeter value={password.value} />
73
+ </div>
74
+ <Input label="Confirm password" name="confirm" type="password" autocomplete="new-password" hint="" error={ctl.errors.value.confirm} />
75
+ </>
76
+ )}
77
+ <Submit ctl={ctl}>{stage === 'request' ? 'Send reset link' : 'Continue'}</Submit>
78
+ </StepFrame>
79
+ )
80
+ }
81
+
82
+ /** Invite accept (09 §2.1): who invited you, the org and role, "You'll join as…", Join and Decline; `children` holds an inline sign-in or sign-up. */
83
+ export function InviteAccept({ inviter, org, role, email, description, heading, pending, onAccept, onDecline, onOtherAccount, onBack, children }: { inviter: string; org: string; role?: string; email?: unknown; description?: unknown; heading?: Heading; pending?: boolean | undefined; onAccept?: () => void; onDecline?: () => void; onOtherAccount?: () => void; onBack?: () => void; children?: unknown }) {
84
+ const body = derived(() => children)
85
+ return (
86
+ <div data-scope="stack">
87
+ {onBack && <Back onBack={onBack} />}
88
+ <div data-scope="row" data-wrap="none">
89
+ <Avatar name={inviter} size="lg" />
90
+ <div>
91
+ <p data-part="lede">{inviter} invited you to</p>
92
+ <b>{org}</b> {role && <Badge tone="info">{role}</Badge>}
93
+ </div>
94
+ </div>
95
+ <div><Title as={heading}>Join {org}</Title>{email !== undefined && <p data-part="lede">You'll join as <b>{email}</b>. {description}</p>}</div>
96
+ {body.value !== undefined ? body.value : <Button size="lg" full loading={pending} onClick={onAccept}>Join {org}</Button>}
97
+ <div data-part="row">
98
+ {onOtherAccount && <TextLink onClick={onOtherAccount}>Sign in with another account</TextLink>}
99
+ {onDecline && <Button emphasis="ghost" tone="neutral" size="sm" onClick={onDecline}>Decline</Button>}
100
+ </div>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ /** Done (09 §2.1): the mark strokes in, then the flow continues after `delay` (at once under reduced motion). */
106
+ export function AuthDone({ title, lede, heading, delay = 1200, onContinue }: { title?: unknown; lede?: unknown; heading?: Heading; delay?: number; onContinue?: (() => void) | undefined }) {
107
+ onMount(() => {
108
+ const reduced = matchMedia?.('(prefers-reduced-motion: reduce)')?.matches || document.documentElement.dataset.motion === 'reduce'
109
+ const t = setTimeout(() => onContinue?.(), reduced ? 0 : untrack(() => delay))
110
+ onCleanup(() => clearTimeout(t))
111
+ })
112
+ return (
113
+ <div data-scope="stack">
114
+ <Mark tone="positive" draw />
115
+ <div><Title as={heading}>{title ?? "You're in"}</Title>{lede !== undefined && <p data-part="lede">{lede}</p>}</div>
116
+ <div data-scope="row"><Rail value={100} aria-hidden="true" /></div>
117
+ {onContinue && <Button full size="lg" onClick={onContinue}>Continue</Button>}
118
+ </div>
119
+ )
120
+ }
@@ -0,0 +1,84 @@
1
+ import { XIcon } from '@weft-js/warp-icons'
2
+ import { announce, uid } from '@weft-js/warp-core'
3
+ import { derived, state, watch } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { Checkbox } from '../Choice'
6
+ import { toast } from '../Toast'
7
+ import { Mark, Rail } from './kit'
8
+ import '@weft-js/warp-core/styles/flows/checklist.css'
9
+ import '@weft-js/warp-core/styles/layout/row.css'
10
+
11
+ /** One onboarding task. */
12
+ export interface ChecklistItem {
13
+ id: string
14
+ title: unknown
15
+ description?: unknown
16
+ done?: boolean
17
+ /** The frame button on the row's end. */
18
+ action?: { label: string; onClick: () => void } | undefined
19
+ }
20
+
21
+ /** Props for Checklist. */
22
+ export interface ChecklistProps {
23
+ title?: unknown
24
+ subtitle?: unknown
25
+ /** Derived from server state, never from clicks here: doing the task anywhere checks it. */
26
+ items: ChecklistItem[]
27
+ /** A user-checkable list instead; omit it and the boxes only report. */
28
+ onToggle?: (id: string, done: boolean) => void
29
+ doneTitle?: unknown
30
+ doneDescription?: unknown
31
+ /** Shows Hide; a Toast offers Undo for 10s. */
32
+ onHide?: () => void
33
+ onUndoHide?: () => void
34
+ /** A disclosure on the title (phones: "3 of 5"). */
35
+ collapsible?: boolean
36
+ /** A row under the list: "Take the tour". */
37
+ footer?: unknown
38
+ }
39
+
40
+ /** The onboarding checklist (09 §3.1): a counter in the head, a rail that fills per item, rows, then "You're set up". */
41
+ export function Checklist({ title = 'Get set up', subtitle, items, onToggle, doneTitle = "You're set up", doneDescription, onHide, onUndoHide, collapsible, footer }: ChecklistProps) {
42
+ const id = uid('ck')
43
+ const list = derived(() => items)
44
+ const count = derived(() => list.value.filter(i => i.done).length)
45
+ const total = derived(() => list.value.length)
46
+ const hidden = state(false)
47
+ const closed = state(false)
48
+ watch(() => count.value, (n, was) => { if (was !== undefined && n > was) announce(document, n === total.value ? 'All set up.' : `${n} of ${total.value} done.`) })
49
+ const hide = () => {
50
+ hidden.value = true
51
+ onHide?.()
52
+ toast.show({ title: 'Checklist hidden', description: 'Find it again under Help.', duration: 10000, action: { label: 'Undo', onClick() { hidden.value = false; onUndoHide?.() } } })
53
+ }
54
+ return (
55
+ <section data-scope="checklist" data-state={count.value === total.value && total.value > 0 ? 'complete' : undefined} aria-labelledby={id} hidden={hidden.value || undefined}>
56
+ <div data-part="head">
57
+ <div>
58
+ <h4 data-part="title" id={id} aria-level="3">{title} <span data-part="count">{count.value} of {total.value}</span></h4>
59
+ {subtitle !== undefined && <p>{subtitle}</p>}
60
+ </div>
61
+ <div data-scope="row">
62
+ {collapsible && <Button emphasis="ghost" tone="neutral" size="sm" aria-expanded={closed.value ? 'false' : 'true'} aria-controls={`${id}-l`} onClick={() => (closed.value = !closed.value)}>{closed.value ? 'Show' : 'Collapse'}</Button>}
63
+ {onHide && <Button emphasis="ghost" tone="neutral" size="sm" icon={XIcon} label="Hide checklist" onClick={hide} />}
64
+ </div>
65
+ </div>
66
+ <div data-part="progress"><Rail value={total.value ? (count.value / total.value) * 100 : 0} role="progressbar" aria-label="Setup progress" aria-valuemin="0" aria-valuemax={total.value} aria-valuenow={count.value} /></div>
67
+ <ol id={`${id}-l`} hidden={closed.value || undefined}>
68
+ {list.value.map(it => (
69
+ <li key={it.id} data-part="item" data-state={it.done ? 'done' : undefined}>
70
+ <Checkbox checked={!!it.done} disabled={!onToggle} aria-labelledby={`${id}-${it.id}`} onChange={(e: Event) => onToggle?.(it.id, (e.currentTarget as HTMLInputElement).checked)} />
71
+ <b id={`${id}-${it.id}`}>{it.title}<span data-part="sr-only">{it.done ? ', done' : ', to do'}</span></b>
72
+ {it.action ? <Button data-part="action" emphasis="frame" size="sm" onClick={it.action.onClick}>{it.action.label}</Button> : <span></span>}
73
+ {it.description !== undefined && <p>{it.description}</p>}
74
+ </li>
75
+ ))}
76
+ </ol>
77
+ <div data-part="done">
78
+ <Mark tone="positive" draw />
79
+ <div><b>{doneTitle}</b>{doneDescription !== undefined && <p>{doneDescription}</p>}</div>
80
+ </div>
81
+ {footer}
82
+ </section>
83
+ )
84
+ }
@@ -0,0 +1,147 @@
1
+ import { ClockIcon, RefreshIcon, SparkleIcon, WifiIcon, WifiOffIcon } from '@weft-js/warp-icons'
2
+ import { announce, connView } from '@weft-js/warp-core'
3
+ import { derived, onCleanup, onMount, state, untrack, watch } from '@weft-js/core'
4
+ import { Button, type ButtonProps } from '../Button'
5
+ import { useCooldown } from './kit'
6
+ import '../styles'
7
+ import '@weft-js/warp-core/styles/flows/tokens.css'
8
+ import '@weft-js/warp-core/styles/flows/base.css'
9
+ import '@weft-js/warp-core/styles/flows/conn.css'
10
+ import '@weft-js/warp-core/styles/components/spinner.css'
11
+
12
+ type Live<T> = T | { readonly value: T } | (() => { readonly value: T } | T)
13
+
14
+ const read = <T,>(v: Live<T>): T => {
15
+ const x = typeof v === 'function' ? (v as () => { readonly value: T } | T)() : v
16
+ return x !== null && typeof x === 'object' && 'value' in x ? (x).value : x
17
+ }
18
+ /** `online` from @weft-js/core/browser, its result, or a boolean: a factory is called once. */
19
+ function liveOf<T>(get: () => Live<T>) {
20
+ const src = untrack(get)
21
+ const once = typeof src === 'function' ? untrack(() => (src as () => Live<T>)()) : undefined
22
+ return derived(() => (once !== undefined ? read<T>(once) : read<T>(get())))
23
+ }
24
+
25
+ const RECOVERED_S = 3
26
+ const LATER_MS = 30 * 60 * 1000
27
+
28
+ /** Props for ConnBanner. */
29
+ export interface ConnBannerProps {
30
+ /** `online` from `@weft-js/core/browser`, its derived, or a boolean. */
31
+ online?: Live<boolean>
32
+ /** The `.live` state (07 §4.4): `live`, `connecting`, `reconnecting`, `offline`. */
33
+ state?: Live<string | undefined>
34
+ /** Schema hash mismatch (§5.4). */
35
+ stale?: Live<boolean | undefined>
36
+ /** A mutation failed because the contract changed: the Banner turns caution. */
37
+ contractFailed?: boolean
38
+ /** Seconds to the next try: weft's backoff. */
39
+ retryIn?: number
40
+ /** Adds Retry now, to try before the backoff runs out. */
41
+ onRetry?: () => void
42
+ /** Saved changes waiting to sync. */
43
+ changes?: number
44
+ /** Save drafts, then reload. */
45
+ onReload?: () => void
46
+ /** Later hides the stale banner for 30 minutes. */
47
+ onLater?: () => void
48
+ /** "A new version of {name} is ready". */
49
+ name?: string | undefined
50
+ }
51
+
52
+ const plural = (n: number) => (n === 1 ? '1 change' : `${n} changes`)
53
+
54
+ /**
55
+ * The connection and deploy banner under the shell header (09 §5.3, §5.4): offline with its retry countdown,
56
+ * reconnecting, "Back online" for 3s, a stale deploy with Reload and Later. Takes its state as props. Offline says so
57
+ * assertively once and back online politely once; the countdown itself is never announced. Needs a mounted <Toaster />.
58
+ */
59
+ export function ConnBanner({ online = true, state: conn, stale, contractFailed, retryIn, onRetry, changes = 0, onReload, onLater, name = 'the app' }: ConnBannerProps) {
60
+ const isOn = liveOf<boolean>(() => online)
61
+ const st = liveOf<string | undefined>(() => conn)
62
+ const old = liveOf<boolean | undefined>(() => stale)
63
+ const later = state(false)
64
+ const recovered = state(false)
65
+ const view = derived(() => connView({ online: isOn.value, state: st.value, stale: old.value && !later.value, recovered: recovered.value }))
66
+ const backIn = useCooldown(() => (recovered.value ? RECOVERED_S : undefined), () => (recovered.value = false))
67
+ const retry = useCooldown(() => retryIn)
68
+ let hide: ReturnType<typeof setTimeout> | undefined
69
+ onCleanup(() => clearTimeout(hide))
70
+ onMount(() => {
71
+ watch(() => view.value, (v, was) => {
72
+ if (v === 'offline') announce(document, "You're offline. Changes are saved on this device.", true)
73
+ if (v === 'stale') announce(document, `A new version of ${untrack(() => name)} is ready.`)
74
+ if (was === 'offline' || was === 'reconnecting') {
75
+ if (v === undefined || v === 'stale') { recovered.value = true; announce(document, 'Back online.') }
76
+ }
77
+ })
78
+ })
79
+ const postpone = () => {
80
+ later.value = true
81
+ hide = setTimeout(() => (later.value = false), LATER_MS)
82
+ onLater?.()
83
+ }
84
+ return (
85
+ <>
86
+ {view.value && (
87
+ <div data-scope="conn" data-state={view.value === 'stale' && contractFailed ? 'limited' : view.value}>
88
+ {view.value === 'offline' && <>
89
+ <WifiOffIcon />
90
+ <span data-part="msg"><b>You're offline.</b> Changes are saved on this device and sync when you reconnect.</span>
91
+ {retryIn !== undefined && <span>Retrying in {retry.value}s</span>}
92
+ {onRetry && <Button emphasis="ghost" size="sm" tone="neutral" onClick={onRetry}>Retry now</Button>}
93
+ </>}
94
+ {view.value === 'reconnecting' && <>
95
+ <span data-scope="spinner" aria-hidden="true"></span>
96
+ <span data-part="msg"><b>Reconnecting…</b> {changes > 0 ? `Sending ${plural(changes)}.` : retryIn !== undefined ? `Trying again in ${retry.value}s.` : ''}</span>
97
+ </>}
98
+ {view.value === 'online' && <>
99
+ <WifiIcon />
100
+ <span data-part="msg"><b>Back online.</b> {changes > 0 ? `${plural(changes)} synced.` : ''}</span>
101
+ <span data-part="rail" data-on="" aria-hidden="true"><span data-part="rail-fill" style={`--p:${backIn.value / RECOVERED_S}`}></span></span>
102
+ </>}
103
+ {view.value === 'stale' && <>
104
+ <SparkleIcon />
105
+ <span data-part="msg"><b>A new version of {name} is ready.</b> {contractFailed ? 'Reload to finish saving.' : 'Reload to update; your draft is kept.'}</span>
106
+ <Button emphasis="ghost" size="sm" tone="neutral" onClick={postpone}>Later</Button>
107
+ <Button size="sm" tone="info" onClick={onReload}><RefreshIcon />Reload</Button>
108
+ </>}
109
+ </div>
110
+ )}
111
+ </>
112
+ )
113
+ }
114
+
115
+ /** Props for RateLimited. */
116
+ export interface RateLimitedProps {
117
+ /** Seconds, from the typed `RATE_LIMITED.retryAfter`: never a guess. */
118
+ retryAfter: number
119
+ title?: unknown
120
+ /** "You can run 5 exports a minute." */
121
+ description?: unknown
122
+ /** The action that hit the limit: disabled, labelled "Try again in 20s", and back by itself. */
123
+ trigger?: ButtonProps
124
+ /** Called when the wait is over and the action is back. */
125
+ onDone?: () => void
126
+ }
127
+
128
+ /** Rate limited (09 §5.5): a caution notice in place with a draining rail. Polite at start and end only; the numbers keep counting under reduced motion. */
129
+ export function RateLimited({ retryAfter, title = 'Limit reached.', description, trigger, onDone }: RateLimitedProps) {
130
+ const total = derived(() => retryAfter)
131
+ const left = useCooldown(() => total.value, () => { announce(document, 'You can try again now.'); onDone?.() })
132
+ const hasTrigger = derived(() => !!trigger)
133
+ const on = derived(() => left.value > 0)
134
+ onMount(() => announce(document, `${untrack(() => (typeof title === 'string' ? title : 'Limit reached.'))} Try again in ${untrack(() => total.value)}s.`))
135
+ return (
136
+ <div data-scope="stack" style="--_gap:8px">
137
+ {on.value && (
138
+ <div data-scope="conn" data-state="limited">
139
+ <ClockIcon />
140
+ <span data-part="msg"><b>{title}</b> {description} Try again in {left.value}s.</span>
141
+ <span data-part="rail" data-on="" aria-hidden="true"><span data-part="rail-fill" style={`--p:${left.value / total.value}`}></span></span>
142
+ </div>
143
+ )}
144
+ {hasTrigger.value && <div><Button {...trigger} disabled={on.value}>{on.value ? `Try again in ${left.value}s` : trigger!.children}</Button></div>}
145
+ </div>
146
+ )
147
+ }
@@ -0,0 +1,63 @@
1
+ import { stepFlow } from '@weft-js/warp-core'
2
+ import { derived, state, untrack, watch, type State } from '@weft-js/core'
3
+ import { bindable } from '../Picker'
4
+ import { use } from '@weft-js/warp-core/weft'
5
+ import '../styles'
6
+ import '@weft-js/warp-core/styles/flows/tokens.css'
7
+ import '@weft-js/warp-core/styles/flows/base.css'
8
+ import '@weft-js/warp-core/styles/flows/flow.css'
9
+
10
+ /** One step of a Flow. */
11
+ export interface FlowStep {
12
+ id: string
13
+ /** Announced on arrival ("Step 2 of 4, Shipping"), and the step's accessible name. */
14
+ label: string
15
+ /** The step's content: built when the step is first reached, then kept, so what was typed survives Back. */
16
+ render: () => unknown
17
+ }
18
+
19
+ /** Props for Flow. */
20
+ export interface FlowProps {
21
+ steps: FlowStep[]
22
+ /** The current step's id: a box, a value, or `defaultStep`. */
23
+ step?: State<string> | string
24
+ defaultStep?: string
25
+ onStepChange?: ((id: string) => void) | undefined
26
+ /** Mirror the step in the URL, one history entry a step: `true` for `?step=`, or the parameter's name. */
27
+ history?: boolean | string | undefined
28
+ /** The polite "Step 2 of 4, Shipping" (default true). */
29
+ announce?: boolean | undefined
30
+ [attr: string]: unknown
31
+ }
32
+
33
+ /** Built once, on first arrival: `open` only ever turns true, so nothing is rebuilt as other steps are reached. */
34
+ function Body({ open, render }: { open: boolean; render: () => unknown }) {
35
+ const on = derived(() => open)
36
+ return <>{on.value && render()}</>
37
+ }
38
+
39
+ /**
40
+ * The step machine (09 §1.1): steps stacked in one grid cell, so height follows the content. On a change the old step
41
+ * exits, the new one enters from the side it lies on, focus goes to its `[data-autofocus]` or title, the polite region
42
+ * says where you are, and `history` gives each step a URL. A step is live as soon as it mounts: motion never gates it.
43
+ */
44
+ export function Flow({ steps, step, defaultStep, onStepChange, history, announce: speak, ...rest }: FlowProps) {
45
+ const list = untrack(() => steps)
46
+ const ids = list.map(s => s.id)
47
+ const at = bindable<string>(() => step, () => defaultStep, ids[0]!, () => onStepChange && (v => onStepChange(v)))
48
+ const param = derived(() => (history === true ? 'step' : history || undefined))
49
+ const seen = state<ReadonlySet<string>>(new Set([untrack(() => at.value)]))
50
+ watch(() => at.value, id => { if (!seen.value.has(id)) seen.value = new Set([...seen.value, id]) })
51
+ let root: HTMLDivElement | undefined
52
+ use(stepFlow, () => root, () => ({ step: at.value, steps: ids, label: (id: string) => list.find(s => s.id === id)?.label, history: param.value, announce: speak, onStep: (id: string) => at.set(id) }))
53
+ return (
54
+ <div ref={n => (root = n)} data-scope="flow" data-dir="forward" {...rest}>
55
+ {list.map(s => (
56
+ <section key={s.id} data-part="step" data-step={s.id} data-state={at.value === s.id ? 'active' : undefined} aria-label={s.label}>
57
+ <Body open={seen.value.has(s.id)} render={s.render} />
58
+ </section>
59
+ ))}
60
+ </div>
61
+ )
62
+ }
63
+
@@ -0,0 +1,103 @@
1
+ import { DownloadIcon } from '@weft-js/warp-icons'
2
+ import { announce, copyText, mapFlowError } from '@weft-js/warp-core'
3
+ import { derived, state, type State } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { Checkbox } from '../Choice'
6
+ import { Input } from '../Field'
7
+ import { Modal } from '../Modal'
8
+ import { toast } from '../Toast'
9
+ import { Flow } from './Flow'
10
+ import { TextLink } from './kit'
11
+ import '@weft-js/warp-core/styles/layout/row.css'
12
+
13
+ /** Props for MfaSetup. */
14
+ export interface MfaSetupProps {
15
+ open: State<boolean>
16
+ /** The QR code: the app's SVG or image, with its own label. */
17
+ /** Brings its own light plate, like QRCode: a code must stay dark on light in every theme. */
18
+ qr?: unknown
19
+ /** The key for "Can't scan?". */
20
+ secret: string
21
+ /** Checks the code: throw, or resolve false, when it's wrong. */
22
+ onVerify: (code: string) => unknown
23
+ /** Backup codes, shown once verified. */
24
+ codes: string[]
25
+ /** Default: saves `backup-codes.txt`. */
26
+ onDownload?: (codes: string[]) => void
27
+ onDone?: () => void
28
+ }
29
+
30
+ const STEPS = { scan: 'Step 1 of 3 · Scan the code with 1Password, Authy or Google Authenticator.', verify: 'Step 2 of 3 · Enter the code the app shows now.', codes: 'Step 3 of 3 · Save your backup codes.' }
31
+
32
+ function save(codes: string[]) {
33
+ const a = document.createElement('a')
34
+ a.href = URL.createObjectURL(new Blob([codes.join('\n')], { type: 'text/plain' }))
35
+ a.download = 'backup-codes.txt'
36
+ a.click()
37
+ URL.revokeObjectURL(a.href)
38
+ }
39
+
40
+ /** MFA setup (09 §9, specimen `mfaSetup`): QR and key, confirm a code, backup codes; one dialog over a Flow. Finish waits for "I saved my backup codes". */
41
+ export function MfaSetup({ open, qr, secret, onVerify, codes, onDownload, onDone }: MfaSetupProps) {
42
+ const step = state<keyof typeof STEPS>('scan')
43
+ const code = state('')
44
+ const problem = state<string | undefined>(undefined)
45
+ const busy = state(false)
46
+ const saved = state(false)
47
+ const copy = async (text: string) => { await copyText(document, text); announce(document, 'Copied') }
48
+ const next = async () => {
49
+ if (step.value === 'scan') { step.value = 'verify'; return }
50
+ if (step.value === 'codes') { open.value = false; onDone?.(); return }
51
+ if (!/^\d{6}$/.test(code.value)) { problem.value = 'Enter the 6 digits from the app.'; return }
52
+ busy.value = true
53
+ try {
54
+ if ((await onVerify(code.value)) === false) problem.value = "That code didn't match. Use the newest one."
55
+ else { problem.value = undefined; step.value = 'codes' }
56
+ } catch (e) {
57
+ const m = mapFlowError(e)
58
+ if (m.surface === 'toast') toast.show({ title: m.title, description: m.description, tone: 'critical' })
59
+ else problem.value = m.fields.code ?? m.title
60
+ } finally { busy.value = false }
61
+ }
62
+ const label = derived(() => (step.value === 'codes' ? 'Finish' : 'Continue'))
63
+ return (
64
+ <Modal
65
+ open={open}
66
+ title="Set up an authenticator app"
67
+ description={STEPS[step.value]}
68
+ footer={<>
69
+ <Button emphasis="frame" data-close="">Cancel</Button>
70
+ <Button loading={busy.value} disabled={step.value === 'codes' && !saved.value} onClick={next}>{label.value}</Button>
71
+ </>}
72
+ >
73
+ <Flow
74
+ data-part="mfa"
75
+ step={step}
76
+ steps={[
77
+ { id: 'scan', label: 'scan', render: () => (
78
+ <div data-scope="stack">
79
+ {qr}
80
+ <p>Can't scan? Enter this key: <code>{secret}</code> <TextLink onClick={() => copy(secret)}>Copy key</TextLink></p>
81
+ </div>
82
+ ) },
83
+ { id: 'verify', label: 'confirm', render: () => (
84
+ <form novalidate onSubmit={(e: SubmitEvent) => { e.preventDefault(); void next() }}>
85
+ <Input label="6-digit code from the app" name="totp" inputmode="numeric" autocomplete="one-time-code" maxlength="6" hint="" error={problem.value} data-autofocus="" onInput={e => (code.value = e.currentTarget.value)} />
86
+ </form>
87
+ ) },
88
+ { id: 'codes', label: 'backup codes', render: () => (
89
+ <div data-scope="stack">
90
+ <p>Each code works once if you lose your phone. Store them somewhere safe; you won't see them again.</p>
91
+ <ol data-part="codes" aria-label="Backup codes">{codes.map(c => <li key={c}><code>{c}</code></li>)}</ol>
92
+ <div data-scope="row">
93
+ <Button emphasis="frame" size="sm" onClick={() => (onDownload ?? save)(codes)}>{() => <><DownloadIcon />Download</>}</Button>
94
+ <Button emphasis="ghost" tone="neutral" size="sm" onClick={() => copy(codes.join('\n'))}>Copy all</Button>
95
+ </div>
96
+ <Checkbox onChange={(e: Event) => (saved.value = (e.currentTarget as HTMLInputElement).checked)}>I saved my backup codes</Checkbox>
97
+ </div>
98
+ ) },
99
+ ]}
100
+ />
101
+ </Modal>
102
+ )
103
+ }
@@ -0,0 +1,114 @@
1
+ import { LeftIcon, ShieldCheckIcon } from '@weft-js/warp-icons'
2
+ import { clockLabel, otpInput, uid } from '@weft-js/warp-core'
3
+ import { derived, state, untrack, watch } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import { Mark, FormError, StepFrame, Submit, TextLink, Title, useCooldown, useStepForm, type FlowForm } from './kit'
7
+ import '../fields'
8
+ import '@weft-js/warp-core/styles/inputs/field.css'
9
+ import '@weft-js/warp-core/styles/inputs/text.css'
10
+ import '@weft-js/warp-core/styles/flows/otp.css'
11
+ import '@weft-js/warp-core/styles/flows/auth.css'
12
+
13
+ /** Props for OtpField. */
14
+ export interface OtpFieldProps {
15
+ label?: unknown
16
+ name?: string | undefined
17
+ length?: number
18
+ hint?: unknown
19
+ /** Pins every cell critical, clears them and refocuses; the message stays until the next key. */
20
+ error?: unknown
21
+ /** `checking` while the code is verified; `success` runs the cascade. */
22
+ status?: 'idle' | 'checking' | 'success' | undefined
23
+ /** Submit the form on the last digit (default true); `onComplete` runs either way. */
24
+ autoSubmit?: boolean
25
+ onComplete?: (code: string) => void
26
+ onValueChange?: (code: string) => void
27
+ /** Show dots (a PIN). */
28
+ masked?: boolean
29
+ id?: string
30
+ }
31
+
32
+ /**
33
+ * The code input (03 §3, 09 §2.3): one real input painted into cells, so one tab stop, native editing, SMS autofill and
34
+ * paste of the whole code all just work. The active cell holds its sweep.
35
+ */
36
+ export function OtpField({ label = '6-digit code', name, length = 6, hint, error, status, autoSubmit = true, onComplete, onValueChange, masked, id: given }: OtpFieldProps) {
37
+ const id = untrack(() => given) ?? uid('otp')
38
+ const n = untrack(() => length)
39
+ const half = Math.ceil(n / 2)
40
+ const cells = Array.from({ length: n }, (_, i) => [{ k: `c${i}`, i }, ...(i === half - 1 ? [{ k: 'sep', i: -1 }] : [])]).flat()
41
+ // the error shows until the next key, whatever the prop still says
42
+ const shown = state(untrack(() => error))
43
+ watch(() => error, e => { shown.value = e })
44
+ let root: HTMLDivElement | undefined
45
+ use(otpInput, () => root, () => ({
46
+ length: n, invalid: !!shown.value, success: status === 'success',
47
+ onValue: (v: string) => { if (v) shown.value = undefined; onValueChange?.(v) },
48
+ onComplete(v: string) {
49
+ onComplete?.(v)
50
+ if (autoSubmit) root?.querySelector('input')?.form?.requestSubmit()
51
+ },
52
+ }))
53
+ return (
54
+ <div data-scope="stack" style="--_gap:8px">
55
+ <label for={id} data-part="code-label">{label}</label>
56
+ <div ref={el => (root = el)} data-scope="otp" data-invalid={shown.value ? '' : undefined} data-state={status === 'checking' ? 'pending' : status === 'success' ? 'success' : undefined} data-masked={masked ? '' : undefined}>
57
+ <input id={id} name={name} inputmode="numeric" autocomplete="one-time-code" maxlength={n} pattern="[0-9]*" enterkeyhint="done" data-autofocus="" aria-describedby={`${id}-m`} aria-invalid={shown.value ? 'true' : undefined} aria-busy={status === 'checking' ? 'true' : undefined} />
58
+ {cells.map(c => (c.i < 0
59
+ ? <span key={c.k} data-part="sep" aria-hidden="true"></span>
60
+ : <span key={c.k} data-part="cell" data-sweep="" aria-hidden="true" style={`--n:${c.i}`}><span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span><b data-part="char"></b></span>))}
61
+ </div>
62
+ <p data-part="message" id={`${id}-m`} role={shown.value ? 'alert' : undefined}>{shown.value ?? hint}</p>
63
+ </div>
64
+ )
65
+ }
66
+
67
+ /** What the code step submits. */
68
+ export interface OtpValues { code: string }
69
+
70
+ /** Props for OtpStep. */
71
+ export interface OtpStepProps extends FlowForm<OtpValues> {
72
+ title?: unknown
73
+ /** "Code sent to •••• 42", or the authenticator line. */
74
+ lede?: unknown
75
+ length?: number
76
+ status?: OtpFieldProps['status'] | undefined
77
+ hint?: unknown
78
+ /** Seconds before Resend opens (default 30). */
79
+ resendIn?: number
80
+ /** Shows Resend; a press restarts the wait. */
81
+ onResend?: () => void
82
+ /** Shows "Use another method". */
83
+ onOtherMethod?: () => void
84
+ otherMethodLabel?: unknown
85
+ onBack?: () => void
86
+ }
87
+
88
+ /** The MFA step (09 §2.1, §2.3): the code, auto-submitted on the last digit, Verify, resend with its countdown, another method, Back. */
89
+ export function OtpStep(p: OtpStepProps) {
90
+ const id = uid('otpstep')
91
+ const ctl = useStepForm<OtpValues>(untrack(() => p))
92
+ const n = derived(() => p.length ?? 6)
93
+ const code = state('')
94
+ const round = state(0)
95
+ const left = useCooldown(() => (round.value, p.resendIn ?? 30))
96
+ const resend = () => { p.onResend?.(); round.value++ }
97
+ const status = derived(() => p.status ?? (ctl.pending.value ? 'checking' : 'idle'))
98
+ return (
99
+ <StepFrame ctl={ctl} embedded={!!p.form} labelledby={`${id}-t`}>
100
+ {p.onBack && <div><Button emphasis="ghost" tone="neutral" size="sm" icon={LeftIcon} onClick={p.onBack}>Back</Button></div>}
101
+ <Mark><ShieldCheckIcon /></Mark>
102
+ <div><Title as={p.heading} id={`${id}-t`}>{p.title ?? 'Enter your code'}</Title>{p.lede !== undefined && <p data-part="lede">{p.lede}</p>}</div>
103
+ <FormError ctl={ctl} />
104
+ <OtpField name="code" length={n.value} hint={p.hint ?? ''} status={status.value} error={ctl.errors.value.code} onValueChange={v => (code.value = v)} />
105
+ <Submit ctl={ctl} disabled={code.value.length !== n.value}>Verify</Submit>
106
+ {(p.onResend || p.onOtherMethod) && (
107
+ <div data-part="row">
108
+ {p.onResend && <TextLink disabled={left.value > 0} onClick={resend}>{left.value > 0 ? `Resend in ${clockLabel(left.value)}` : 'Resend code'}</TextLink>}
109
+ {p.onOtherMethod && <TextLink onClick={p.onOtherMethod}>{p.otherMethodLabel ?? 'Use another method'}</TextLink>}
110
+ </div>
111
+ )}
112
+ </StepFrame>
113
+ )
114
+ }