@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,47 @@
1
+ import { uid } from '@weft-js/warp-core'
2
+ import { derived, untrack, type State } from '@weft-js/core'
3
+ import { Button } from '../Button'
4
+ import { Input } from '../Field'
5
+ import { Modal } from '../Modal'
6
+ import { FormError, useStepForm, type FlowForm } from './kit'
7
+ import { PasskeyButton } from './Sso'
8
+
9
+ /** Props for ReauthDialog. */
10
+ export interface ReauthProps extends Omit<FlowForm<{ password: string }>, 'form' | 'onSubmit'> {
11
+ open: State<boolean>
12
+ /** Why it asks: "Deleting a project needs your password." */
13
+ reason?: unknown
14
+ /** With the password; close `open` when it's accepted. */
15
+ onConfirm?: (password: string) => void
16
+ /** Shows "Use a passkey"; WebAuthn is the app's. */
17
+ onPasskey?: () => unknown
18
+ }
19
+
20
+ /** Re-auth (09 §2.1): a Dialog before a sensitive act, with the password or a passkey. The 5-minute grant is the server's. */
21
+ export function ReauthDialog(p: ReauthProps) {
22
+ const id = uid('reauth')
23
+ const src = untrack(() => p)
24
+ const ctl = useStepForm<{ password: string }>({
25
+ get errors() { return src.errors }, get error() { return src.error }, get pending() { return src.pending },
26
+ get errorOptions() { return src.errorOptions }, get onRetry() { return src.onRetry },
27
+ onSubmit: v => src.onConfirm?.(v.password),
28
+ }, v => ({ password: v.password ? undefined : 'Enter your password.' }))
29
+ const foot = derived(() => ctl.pending.value)
30
+ return (
31
+ <Modal
32
+ open={p.open}
33
+ size="sm"
34
+ title="Confirm it's you"
35
+ description={<>{p.reason} You won't be asked again for 5 minutes.</>}
36
+ footer={<>
37
+ {p.onPasskey && <PasskeyButton full={false} onPasskey={p.onPasskey}>Use a passkey</PasskeyButton>}
38
+ <Button type="submit" form={id} loading={foot.value}>Confirm</Button>
39
+ </>}
40
+ >
41
+ <form ref={n => ctl.bind(n)} id={id} data-scope="stack" novalidate onSubmit={ctl.submit}>
42
+ <FormError ctl={ctl} />
43
+ <Input label="Password" name="password" type="password" autocomplete="current-password" hint="" autofocus error={ctl.errors.value.password} />
44
+ </form>
45
+ </Modal>
46
+ )
47
+ }
@@ -0,0 +1,60 @@
1
+ import { filterShortcuts, isApple, markParts, shortcutLabels, shortcuts as registry, type Shortcut, type Shortcuts } from '@weft-js/warp-core'
2
+ import { derived, onCleanup, onMount, state, untrack, type State } from '@weft-js/core'
3
+ import { Search } from '../Field'
4
+ import { Kbd } from '../Kbd'
5
+ import { Modal } from '../Modal'
6
+ import '../styles'
7
+ import '@weft-js/warp-core/styles/flows/shortcuts.css'
8
+
9
+ /**
10
+ * The keyboard shortcuts overlay (09 §7): `?` opens a Dialog (a bottom sheet on phones) of what's registered now,
11
+ * grouped, platform-aware, with a filter over names and keys. Never shown by itself. `shortcuts.singleKeys = false`
12
+ * turns the single-key ones off (WCAG 2.1.4); they never fire in a text input.
13
+ */
14
+ export function ShortcutsOverlay({ open, shortcuts = registry }: { open?: State<boolean>; shortcuts?: Shortcuts }) {
15
+ const own = state(false)
16
+ const on = untrack(() => open) ?? own
17
+ const reg = untrack(() => shortcuts)
18
+ const apple = state(false)
19
+ const rev = state(0)
20
+ const query = state('')
21
+ onMount(() => {
22
+ apple.value = isApple(navigator)
23
+ const quiet = reg.subscribe(() => rev.value++)
24
+ const drop = reg.register({ keys: '?', label: 'Show shortcuts', group: 'Help', run: () => (on.value = true) })
25
+ const stop = reg.listen(document)
26
+ onCleanup(() => { drop(); stop(); quiet() })
27
+ })
28
+ const groups = derived(() => {
29
+ void rev.value
30
+ const by = new Map<string, Shortcut[]>()
31
+ for (const s of filterShortcuts(reg.list(), query.value, apple.value)) {
32
+ const g = s.group ?? 'General'
33
+ by.set(g, [...(by.get(g) ?? []), s])
34
+ }
35
+ return [...by].map(([name, rows]) => ({ name, rows }))
36
+ })
37
+ return (
38
+ <Modal open={on} size="lg" title="Keyboard shortcuts" description="Press ? anywhere outside a text field.">
39
+ <div data-scope="shortcuts">
40
+ <Search label="Filter shortcuts" autofocus onInput={e => (query.value = e.currentTarget.value)} />
41
+ <div data-part="groups" aria-live="polite">
42
+ {groups.value.map(g => (
43
+ <section key={g.name}>
44
+ <h4 aria-level="3">{g.name}</h4>
45
+ <dl>
46
+ {g.rows.map(s => (
47
+ <div key={s.keys}>
48
+ <dt>{markParts(s.label, query.value).map(p => (p.hit ? <mark key={p.at}>{p.text}</mark> : <span key={p.at}>{p.text}</span>))}</dt>
49
+ <dd>{shortcutLabels(s.keys, apple.value).map((step, i) => <span key={step.join()}>{i > 0 && ' then '}{step.map(k => <Kbd key={k}>{k}</Kbd>)}</span>)}</dd>
50
+ </div>
51
+ ))}
52
+ </dl>
53
+ </section>
54
+ ))}
55
+ {groups.value.length === 0 && <p data-part="empty">No shortcut matches “{query.value}”.</p>}
56
+ </div>
57
+ </div>
58
+ </Modal>
59
+ )
60
+ }
@@ -0,0 +1,98 @@
1
+ import { CheckIcon, FileTextIcon } from '@weft-js/warp-icons'
2
+ import { announce, uid } from '@weft-js/warp-core'
3
+ import { derived, state, untrack, watch } from '@weft-js/core'
4
+ import { Badge } from '../Badge'
5
+ import { Button, type Tone } from '../Button'
6
+ import { Drawer } from '../Drawer'
7
+ import { Mark } from './kit'
8
+ import '@weft-js/warp-core/styles/flows/gate.css'
9
+ import '@weft-js/warp-core/styles/flows/gate-badge.css'
10
+ import '@weft-js/warp-core/styles/flows/pay-frame.css'
11
+ import '@weft-js/warp-core/styles/components/spinner.css'
12
+
13
+ /** Where a signature request stands. */
14
+ export type SignStatus = 'unsigned' | 'pending' | 'signed' | 'expired' | 'declined' | 'error'
15
+
16
+ const TONES: Record<SignStatus, Tone> = { unsigned: 'caution', pending: 'caution', signed: 'positive', expired: 'critical', declined: 'critical', error: 'neutral' }
17
+ const LABELS: Record<SignStatus, string> = { unsigned: 'Agreement not signed', pending: 'Signature pending', signed: 'Agreement signed', expired: 'Signing link expired', declined: 'Agreement declined', error: "Couldn't check the signature" }
18
+ const COPY: Record<SignStatus, string> = {
19
+ unsigned: 'Your legal contact can sign it instead.',
20
+ pending: 'Waiting for the signer to finish. This updates by itself.',
21
+ signed: '',
22
+ expired: 'That link has expired. Send a new one to carry on.',
23
+ declined: 'The agreement was declined. Send it again to carry on.',
24
+ error: "We couldn't reach the signing service. Try again.",
25
+ }
26
+
27
+ /** The gate's status (09 §2.4): caution, positive, critical, neutral. */
28
+ export function SignGateBadge({ status }: { status: SignStatus }) {
29
+ const s = derived(() => status)
30
+ return <Badge tone={TONES[s.value]} data-state={s.value}>{s.value === 'signed' ? <CheckIcon /> : <FileTextIcon />}{LABELS[s.value]}</Badge>
31
+ }
32
+
33
+ /** Props for SignGate. */
34
+ export interface SignGateProps {
35
+ /** From the provider: `query.live`, a poll, or a signal. */
36
+ status: SignStatus
37
+ /** The provider's embedded signer. */
38
+ src?: string
39
+ title: unknown
40
+ /** Shown while unsigned. */
41
+ description?: unknown
42
+ /** The blocker heading's level in the page's outline (the spec's h4 keeps its style). */
43
+ level?: number
44
+ /** The signer frame's accessible name. */
45
+ signerTitle?: string
46
+ onResend?: () => void
47
+ onRetry?: (() => void) | undefined
48
+ /** Once, when status turns signed. */
49
+ onSigned?: () => void
50
+ /** The gated page: rendered, inert behind the blocker until signed. */
51
+ children?: unknown
52
+ }
53
+
54
+ /**
55
+ * A page behind a signature (09 §2.4). The blocker is a region with a heading, not a dialog: assistive tech reads it as
56
+ * the page, and the page itself is inert. The signer shows while unsigned or pending, inline at 560px on wide screens and
57
+ * in a tall Sheet on a phone (it's chosen when opened, so the server's HTML doesn't guess a width); terminal states
58
+ * have one action. WebAuthn-free: the frame is the provider's.
59
+ */
60
+ export function SignGate({ status, src, title, description, signerTitle = 'Signer, provided by the e-signature service', level = 3, onResend, onRetry, onSigned, children }: SignGateProps) {
61
+ const id = uid('gate')
62
+ const s = derived(() => status)
63
+ const signing = state(false)
64
+ const sheet = state(false)
65
+ const inline = derived(() => signing.value && !sheet.value && (s.value === 'unsigned' || s.value === 'pending'))
66
+ const open = () => {
67
+ if (innerWidth < 600) sheet.value = true
68
+ else signing.value = true
69
+ }
70
+ watch(() => s.value, (now, was) => {
71
+ if (was === undefined || now === was) return
72
+ if (now === 'pending') announce(document, 'Signature pending. Checking with the provider.')
73
+ if (now === 'signed') { sheet.value = false; signing.value = false; announce(document, 'Agreement signed.'); onSigned?.() }
74
+ if (now === 'expired' || now === 'declined' || now === 'error') { sheet.value = false; signing.value = false; announce(document, COPY[now], true) }
75
+ })
76
+ return (
77
+ <div data-scope="gate" data-state={s.value}>
78
+ <div data-part="content" inert={s.value === 'signed' ? undefined : true}>{children}</div>
79
+ {s.value !== 'signed' && (
80
+ <section data-part="blocker" aria-labelledby={`${id}-t`}>
81
+ <div data-scope="stack" data-align="center">
82
+ <Mark tone={TONES[s.value] === 'critical' ? 'critical' : 'caution'}><FileTextIcon /></Mark>
83
+ <h4 id={`${id}-t`} aria-level={level}>{title}</h4>
84
+ <p>{s.value === 'unsigned' && description !== undefined ? description : COPY[s.value]}</p>
85
+ {inline.value ? <iframe data-scope="pay-frame" title={signerTitle} src={src} height="560" width="100%"></iframe>
86
+ : s.value === 'pending' ? <span data-scope="spinner" data-part="spinner" aria-hidden="true"></span>
87
+ : s.value === 'unsigned' ? <Button onClick={open}>Review and sign</Button>
88
+ : s.value === 'error' ? <Button onClick={onRetry}>Retry</Button>
89
+ : <Button onClick={onResend}>Resend</Button>}
90
+ </div>
91
+ </section>
92
+ )}
93
+ <Drawer open={sheet} side="bottom" title={untrack(() => title)}>
94
+ {sheet.value && <iframe data-scope="pay-frame" title={signerTitle} src={src} height="560" width="100%"></iframe>}
95
+ </Drawer>
96
+ </div>
97
+ )
98
+ }
@@ -0,0 +1,80 @@
1
+ import { emailError, uid } from '@weft-js/warp-core'
2
+ import { derived, untrack } from '@weft-js/core'
3
+ import { Input } from '../Field'
4
+ import { Switch } from '../Choice'
5
+ import { AuthLogo } from './AuthScreen'
6
+ import { FormError, StepFrame, Submit, TextLink, Title, useStepForm, type FlowForm } from './kit'
7
+ import { PasskeyButton, SsoButtons, type SsoProvider } from './Sso'
8
+ import '../styles'
9
+ import '@weft-js/warp-core/styles/flows/auth.css'
10
+ import '@weft-js/warp-core/styles/flows/sso.css'
11
+
12
+ /** What the sign-in form submits. */
13
+ export interface SignInValues { email: string; password: string; remember?: string }
14
+
15
+ /** Props for SignIn. */
16
+ export interface SignInProps extends FlowForm<SignInValues> {
17
+ title?: unknown
18
+ lede?: unknown
19
+ logo?: unknown
20
+ name?: unknown
21
+ submitLabel?: unknown
22
+ providers?: SsoProvider[]
23
+ onProvider?: ((id: string) => unknown) | undefined
24
+ /** Shows the passkey button; run WebAuthn here. */
25
+ onPasskey?: () => unknown
26
+ onForgot?: () => void
27
+ /** Called with the typed email; asks for one first. */
28
+ onMagicLink?: (email: string) => void
29
+ onSignUp?: () => void
30
+ onInvite?: () => void
31
+ /** The "Keep me signed in" switch (default on). */
32
+ remember?: boolean
33
+ }
34
+
35
+ /**
36
+ * Sign in (09 §2.1): passkey and SSO frames, the divider, email and password, remember and Forgot, the one solid.
37
+ * The email carries `username webauthn`, so conditional UI offers passkeys from first focus. A wrong password and an
38
+ * unknown email read the same (§2.3).
39
+ */
40
+ export function SignIn(p: SignInProps) {
41
+ const id = uid('signin')
42
+ const ctl = useStepForm<SignInValues>(untrack(() => p), v => ({ email: v.email ? emailError(v.email) : 'Enter your work email.', password: v.password ? undefined : 'Enter your password.' }))
43
+ const hasSso = derived(() => !!(p.onPasskey || p.providers?.length))
44
+ const ask = (e: MouseEvent) => {
45
+ const scope = (e.currentTarget as HTMLElement).closest('form, [role="group"]')
46
+ const email = scope?.querySelector<HTMLInputElement>('input[name="email"]')?.value ?? ''
47
+ const bad = email ? emailError(email) : "Enter your email first; we'll send the link there."
48
+ ctl.flag('email', bad)
49
+ if (!bad) p.onMagicLink?.(email)
50
+ }
51
+ return (
52
+ <StepFrame ctl={ctl} embedded={!!p.form} labelledby={`${id}-t`}>
53
+ {(p.logo !== undefined || p.name !== undefined) && <div><AuthLogo logo={p.logo} name={p.name} /></div>}
54
+ <div><Title as={p.heading} id={`${id}-t`}>{p.title ?? 'Sign in'}</Title>{p.lede !== undefined && <p data-part="lede">{p.lede}</p>}</div>
55
+ {hasSso.value && (
56
+ <div data-scope="sso">
57
+ {p.onPasskey && <PasskeyButton onPasskey={p.onPasskey} />}
58
+ {p.providers?.length && <SsoButtons providers={p.providers} onProvider={p.onProvider} />}
59
+ </div>
60
+ )}
61
+ {hasSso.value && <div data-part="divider">or with email</div>}
62
+ <FormError ctl={ctl} actions={p.onForgot && <TextLink onClick={p.onForgot}>Reset your password</TextLink>} />
63
+ <Input label="Work email" name="email" type="email" autocomplete="username webauthn" inputmode="email" value={p.form?.value?.('email') as string | undefined} hint="" error={ctl.errors.value.email} onBlur={(e: FocusEvent) => ctl.flag('email', emailError((e.target as HTMLInputElement).value))} />
64
+ <Input label="Password" name="password" type="password" autocomplete="current-password" hint="" error={ctl.errors.value.password} />
65
+ <div data-part="row">
66
+ <Switch size="sm" name="remember" checked={p.remember ?? true}>Keep me signed in</Switch>
67
+ {p.onForgot && <TextLink onClick={p.onForgot}>Forgot password?</TextLink>}
68
+ </div>
69
+ <Submit ctl={ctl}>{p.submitLabel ?? 'Sign in'}</Submit>
70
+ {p.onMagicLink && <p data-part="fine"><TextLink onClick={ask}>Email me a sign-in link instead</TextLink></p>}
71
+ {(p.onSignUp || p.onInvite) && (
72
+ <p data-part="fine">
73
+ {p.onSignUp && <TextLink onClick={p.onSignUp}>Create an account</TextLink>}
74
+ {p.onSignUp && p.onInvite && ' · '}
75
+ {p.onInvite && <TextLink onClick={p.onInvite}>Have an invite?</TextLink>}
76
+ </p>
77
+ )}
78
+ </StepFrame>
79
+ )
80
+ }
@@ -0,0 +1,71 @@
1
+ import { emailError, passwordRules, passwordStrength, uid } from '@weft-js/warp-core'
2
+ import { derived, state, untrack } from '@weft-js/core'
3
+ import { Checkbox } from '../Choice'
4
+ import { Input } from '../Field'
5
+ import { FormError, StepFrame, Submit, TextLink, Title, useStepForm, type FlowForm } from './kit'
6
+ import { SsoButtons, type SsoProvider } from './Sso'
7
+ import '../styles'
8
+ import '@weft-js/warp-core/styles/flows/auth.css'
9
+ import '@weft-js/warp-core/styles/components/meter.css'
10
+ import '@weft-js/warp-core/styles/inputs/pickers.css' // the rules list
11
+
12
+ /** Strength Meter (05) and the rules list (03) under a new password. Each rule says itself: the colour is never alone. */
13
+ export function PasswordMeter({ value }: { value: string }) {
14
+ const s = derived(() => passwordStrength(value))
15
+ const rules = derived(() => passwordRules(value))
16
+ return (
17
+ <>
18
+ <div data-scope="meter" data-level={s.value.tone === 'critical' ? 'full' : s.value.tone === 'caution' ? 'high' : undefined} role="meter" aria-label="Password strength" aria-valuemin="0" aria-valuemax="4" aria-valuenow={s.value.score} aria-valuetext={s.value.label} style={`--_v:${s.value.score / 4}`}>
19
+ <div data-part="label-row"><span>Password strength</span><span>{value ? s.value.label : ''}</span></div>
20
+ <div data-part="track"><div data-part="range"></div></div>
21
+ </div>
22
+ <ul data-scope="rules">
23
+ {rules.value.map(r => <li key={r.id} data-met={r.met ? '' : undefined}><svg viewBox="0 0 12 12" aria-hidden="true"><path pathLength="1" d="M2 6.5 5 9.5 10 3" /></svg>{r.label}<span data-part="sr-only">{r.met ? ', met' : ', not met'}</span></li>)}
24
+ </ul>
25
+ </>
26
+ )
27
+ }
28
+
29
+ /** What the sign-up form submits. */
30
+ export interface SignUpValues { name: string; email: string; password: string; terms?: string }
31
+
32
+ /** Props for SignUp. */
33
+ export interface SignUpProps extends FlowForm<SignUpValues> {
34
+ title?: unknown
35
+ lede?: unknown
36
+ submitLabel?: unknown
37
+ /** The terms checkbox's text, links included. */
38
+ terms?: unknown
39
+ providers?: SsoProvider[]
40
+ onProvider?: ((id: string) => unknown) | undefined
41
+ onSignIn?: () => void
42
+ }
43
+
44
+ /** Sign up (09 §2.1): name, email, password with strength and rules, terms; Create account is the one solid. */
45
+ export function SignUp(p: SignUpProps) {
46
+ const id = uid('signup')
47
+ const password = state('')
48
+ const ctl = useStepForm<SignUpValues>(untrack(() => p), v => ({
49
+ name: v.name ? undefined : 'Enter your name.',
50
+ email: v.email ? emailError(v.email) : 'Enter your work email.',
51
+ password: passwordRules(v.password!).every(r => r.met) ? undefined : 'Use at least 8 characters, with a letter and a number.',
52
+ terms: p.terms === undefined || v.terms ? undefined : 'Accept the terms to continue.',
53
+ }))
54
+ return (
55
+ <StepFrame ctl={ctl} embedded={!!p.form} labelledby={`${id}-t`}>
56
+ <div><Title as={p.heading} id={`${id}-t`}>{p.title ?? 'Create your account'}</Title>{p.lede !== undefined && <p data-part="lede">{p.lede}</p>}</div>
57
+ {p.providers?.length && <SsoButtons providers={p.providers} onProvider={p.onProvider} />}
58
+ {p.providers?.length && <div data-part="divider">or with email</div>}
59
+ <FormError ctl={ctl} />
60
+ <Input label="Your name" name="name" autocomplete="name" hint="" error={ctl.errors.value.name} />
61
+ <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))} />
62
+ <div>
63
+ <Input label="Password" name="password" type="password" autocomplete="new-password" hint="" error={ctl.errors.value.password} onInput={e => (password.value = e.currentTarget.value)} />
64
+ <PasswordMeter value={password.value} />
65
+ </div>
66
+ {p.terms !== undefined && <Checkbox name="terms" value="yes" hint={ctl.errors.value.terms ?? ''} error={ctl.errors.value.terms}>{p.terms}</Checkbox>}
67
+ <Submit ctl={ctl}>{p.submitLabel ?? 'Create account'}</Submit>
68
+ {p.onSignIn && <p data-part="fine"><TextLink onClick={p.onSignIn}>I already have an account</TextLink></p>}
69
+ </StepFrame>
70
+ )
71
+ }
@@ -0,0 +1,38 @@
1
+ import { KeyIcon } from '@weft-js/warp-icons'
2
+ import { isPasskeyCancel } from '@weft-js/warp-core'
3
+ import { derived, state } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { Glyph } from '../parts'
6
+ import '../styles'
7
+ import '@weft-js/warp-core/styles/flows/sso.css'
8
+ import '@weft-js/warp-core/styles/layout/grid.css'
9
+
10
+ // a frame draws its content twice: a function child builds each copy (an element would move from one to the other)
11
+
12
+ /** Sign in with a passkey (09 §2.1): a frame button. WebAuthn is the app's; a cancelled OS sheet shows nothing. */
13
+ export function PasskeyButton({ onPasskey, onError, children, ...rest }: { onPasskey?: () => unknown; onError?: (e: unknown) => void; children?: unknown; [attr: string]: unknown }) {
14
+ const busy = state(false)
15
+ const press = async () => {
16
+ busy.value = true
17
+ try { await onPasskey?.() } catch (e) { if (!isPasskeyCancel(e)) onError?.(e) } finally { busy.value = false }
18
+ }
19
+ return <Button emphasis="frame" full loading={busy.value} onClick={press} {...rest}>{() => <><KeyIcon />{children ?? 'Continue with passkey'}</>}</Button>
20
+ }
21
+
22
+ /** One identity provider on offer. */
23
+ export interface SsoProvider { id: string; label: string; /** One path on the 24 grid: monochrome, never the provider's colours. */ glyph?: string; /** More words for assistive tech, after a short label: the label stays part of the name. */ name?: string | undefined }
24
+
25
+ /** Provider buttons (09 §2.1, §2.2): frame buttons in a row; each stays pending until the app redirects. */
26
+ export function SsoButtons({ providers, onProvider }: { providers: SsoProvider[]; onProvider?: ((id: string) => unknown) | undefined }) {
27
+ const list = derived(() => providers)
28
+ const busy = state<string | undefined>(undefined)
29
+ const press = async (id: string) => {
30
+ busy.value = id
31
+ try { await onProvider?.(id) } finally { busy.value = undefined }
32
+ }
33
+ return (
34
+ <div data-scope="grid" data-min="xs" data-cols="3">
35
+ {list.value.map(p => <Button key={p.id} emphasis="frame" full loading={busy.value === p.id} onClick={() => press(p.id)}>{() => <>{p.glyph && <Glyph d={p.glyph} />}{p.label}{p.name && <span data-part="sr-only"> {p.name}</span>}</>}</Button>)}
36
+ </div>
37
+ )
38
+ }
@@ -0,0 +1,247 @@
1
+ import { ClockIcon, CopyIcon, PlusIcon, RefreshIcon } from '@weft-js/warp-icons'
2
+ import { announce, copyText, menu, uid } from '@weft-js/warp-core'
3
+ import { derived, state, untrack, watch } from '@weft-js/core'
4
+ import { Badge } from '../Badge'
5
+ import { Button, type ButtonProps } from '../Button'
6
+ import { Search, Textarea } from '../Field'
7
+ import { Avatar } from '../parts'
8
+ import { toast } from '../Toast'
9
+ import { use } from '@weft-js/warp-core/weft'
10
+ import '../styles'
11
+ import '@weft-js/warp-core/styles/flows/tokens.css'
12
+ import '@weft-js/warp-core/styles/flows/base.css'
13
+ import '@weft-js/warp-core/styles/app/tokens.css'
14
+ import '@weft-js/warp-core/styles/app/system-page.css'
15
+ import '@weft-js/warp-core/styles/flows/system-page.css'
16
+
17
+ const X = [8, 27.5, 47, 66.5, 86, 105.5, 125].map(x => ({ x }))
18
+ const Threads = ({ y2 = 84 }: { y2?: number }) => <>{X.map(t => <line key={t.x} x1={t.x} y1="0" x2={t.x} y2={y2} />)}</>
19
+
20
+ /** 404 and 500: a weft thread broken in two. */
21
+ const Broken = () => <svg data-part="weave" viewBox="0 0 132 84" preserveAspectRatio="none" aria-hidden="true"><Threads /><path pathLength="1" style="--i:0" d="M0 22H132" /><path pathLength="1" style="--i:1" d="M0 44H70" data-broken="" /><path pathLength="1" style="--i:2" d="M86 44H132" opacity=".35" /><path pathLength="1" style="--i:3" d="M0 64H132" /></svg>
22
+ /** First run: whole threads. */
23
+ const Whole = () => <svg data-part="weave" viewBox="0 0 132 84" preserveAspectRatio="none" aria-hidden="true"><Threads /><path pathLength="1" style="--i:0" d="M0 22H132" /><path pathLength="1" style="--i:1" d="M0 44H132" /><path pathLength="1" style="--i:2" d="M0 64H132" /></svg>
24
+ /** 403: a thread stops at a padlock on the crossing, in ink: nothing broke (07 §6.5). */
25
+ const Locked = () => (
26
+ <svg data-part="weave" viewBox="0 0 132 84" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
27
+ {[16, 36, 56, 76, 96, 116].map(x => <line key={x} x1={x} y1="4" x2={x} y2="80" />)}
28
+ <path pathLength="1" style="--i:0" d="M4 22 C 30 18, 40 26, 66 22 S 102 18, 128 22" />
29
+ <path pathLength="1" style="--i:1" d="M4 62 C 30 58, 40 66, 66 62 S 102 58, 128 62" />
30
+ <path pathLength="1" style="--i:2" d="M4 42 C 20 40, 34 44, 52 42" />
31
+ <path data-part="shackle" d="M58 40 v-7 a8 8 0 0 1 16 0 v7" />
32
+ <rect data-part="lock" x="54" y="39" width="24" height="19" rx="4" />
33
+ <path data-part="keyhole" d="M66 46 v5" />
34
+ </svg>
35
+ )
36
+
37
+ const focusTitle = (el: HTMLElement) => {
38
+ el.focus({ preventScroll: true })
39
+ return { update() {}, destroy() {} }
40
+ }
41
+
42
+ /** The title and code: every result page's top. */
43
+ function Head({ art, code, title, focus, as }: { art: unknown; code: unknown; title: unknown; focus?: boolean | undefined; as?: 'h2' | 'h3' | undefined }) {
44
+ const h3 = derived(() => as === 'h3')
45
+ let el: HTMLElement | undefined
46
+ if (untrack(() => focus)) use(focusTitle, () => el)
47
+ return <>
48
+ <span data-part="art">{art}</span>
49
+ <span data-part="code">{code}</span>
50
+ {h3.value ? <h3 ref={n => (el = n)} data-part="title" tabindex="-1">{title}</h3> : <h2 ref={n => (el = n)} data-part="title" tabindex="-1">{title}</h2>}
51
+ </>
52
+ }
53
+
54
+ function RequestId({ id }: { id: string }) {
55
+ return <span data-part="meta">Request ID <code>{id}</code><Button emphasis="ghost" tone="neutral" size="sm" data-icon-only="" label="Copy request ID" onClick={async () => { await copyText(document, id); announce(document, 'Copied') }}><CopyIcon /></Button></span>
56
+ }
57
+
58
+ /** An account to switch to from a no-access page. */
59
+ export interface SwitchAccount { id: string; email: string; detail?: string; hasAccess?: boolean; current?: boolean | undefined }
60
+
61
+ function AccountMenu({ accounts, onSwitch }: { accounts: SwitchAccount[]; onSwitch?: (id: string | undefined) => void }) {
62
+ const id = uid('accounts')
63
+ const open = state(false)
64
+ let list: HTMLElement | undefined
65
+ use(menu, () => list, () => ({ open: open.value, trigger: () => list?.parentElement?.querySelector<HTMLElement>('[data-switch]'), onClose: () => (open.value = false) }))
66
+ const pick = (a: string | undefined) => { open.value = false; list?.parentElement?.querySelector<HTMLElement>('[data-switch]')?.focus(); onSwitch?.(a) }
67
+ return { open, id, press: () => (open.value = !open.value), menu: () => (
68
+ <ul ref={n => (list = n)} data-part="accounts" role="menu" id={id} aria-label="Switch account" hidden={!open.value || undefined}>
69
+ {accounts.map(a => (
70
+ <li key={a.id} role="none">
71
+ <button type="button" role="menuitem" tabindex="-1" onClick={() => pick(a.id)}>
72
+ <Avatar name={a.email} size="sm" />
73
+ <span>{a.email}{a.detail && <small>{a.detail}</small>}</span>
74
+ {a.hasAccess ? <Badge tone="positive">Has access</Badge> : a.current ? <small>Current</small> : <span></span>}
75
+ </button>
76
+ </li>
77
+ ))}
78
+ <li role="none"><button type="button" role="menuitem" tabindex="-1" onClick={() => pick(undefined)}><PlusIcon /><span>Use another account</span><span></span></button></li>
79
+ </ul>
80
+ ) }
81
+ }
82
+
83
+ /** An access request, and how to send and withdraw it. */
84
+ export interface AccessRequest {
85
+ /** The workspace takes access requests (default true). */
86
+ allowed?: boolean
87
+ /** A request is out: from the server, or set here after `onSend`. */
88
+ pending?: boolean | undefined
89
+ /** Who answers: "Kofi and Ana". */
90
+ approvers?: string
91
+ onSend?: ((note: string) => unknown) | undefined
92
+ onWithdraw?: () => unknown
93
+ /** "Copy link for the owner". */
94
+ link?: string
95
+ }
96
+
97
+ /** Props for SystemPage. */
98
+ export interface SystemPageProps {
99
+ kind: '404' | '403' | '500' | 'maintenance' | 'empty'
100
+ title?: unknown
101
+ description?: unknown
102
+ /** The eyebrow, when the default won't do. */
103
+ code?: unknown
104
+ /** The primary action's label and press (or `href`). */
105
+ primary?: { label: unknown; onClick?: (() => void) | undefined; href?: string; loading?: boolean | undefined }
106
+ secondary?: { label: unknown; onClick?: (() => void) | undefined; href?: string }
107
+ /** 404: the search field. */
108
+ onSearch?: (q: string) => void
109
+ /** The 404 search field's label. Default "Search". */
110
+ searchLabel?: string
111
+ /** 500: `retry` from `<Boundary>`. */
112
+ onRetry?: (() => void) | undefined
113
+ /** The error, for the Boundary form; its message is never shown. */
114
+ error?: unknown
115
+ /** The support line. */
116
+ requestId?: string
117
+ /** Extra support text: a path, an incident. */
118
+ meta?: unknown
119
+ /** Maintenance: "Back at 14:00 UTC", and the local-time line in `description`. */
120
+ backAt?: unknown
121
+ /** 403: what was asked for and by whom. */
122
+ object?: { title: string; owner?: string }
123
+ /** 403: the signed-in account, as in "You're signed in as ana@weft.dev". */
124
+ account?: string
125
+ /** 403: the access request: who approves it, whether it is pending, and what sending or withdrawing does. */
126
+ request?: AccessRequest
127
+ /** 403: other signed-in accounts, offered in a Switch account menu. */
128
+ accounts?: SwitchAccount[]
129
+ /** Called with the account picked, or undefined to sign in as someone else. */
130
+ onSwitch?: (id: string | undefined) => void
131
+ /** 403: access came through: `children` is the object, loaded in place. */
132
+ granted?: boolean
133
+ as?: 'h2' | 'h3' | undefined
134
+ /** Focus the title on load, so the route change is heard (default: 403). */
135
+ autofocus?: boolean
136
+ children?: unknown
137
+ }
138
+
139
+ const TITLES = { 404: "We can't find that page", 403: "You don't have access", 500: 'Something went wrong on our side', maintenance: 'Back soon', empty: 'Nothing here yet' }
140
+ const CODES = { 404: '404 · Not found', 403: '403 · No access', 500: '500 · Server error', maintenance: 'Scheduled maintenance', empty: 'First run' }
141
+ const COPY = { 404: 'The link may be old, or the page has moved. Search for it, or head back to your dashboard.', 403: 'Ask an admin for access, or switch to an account that has it.', 500: "Your work is saved. We've been alerted and are looking at it now.", maintenance: "Nothing is lost, and nothing you saved is affected.", empty: undefined }
142
+
143
+ function Act({ a, emphasis, tone }: { a: { label: unknown; onClick?: (() => void) | undefined; href?: string; loading?: boolean | undefined }; emphasis?: ButtonProps['emphasis'] | undefined; tone?: ButtonProps['tone'] }) {
144
+ const href = derived(() => a.href)
145
+ return <Button emphasis={emphasis} tone={tone} loading={a.loading} role={href.value ? 'link' : undefined} onClick={() => (href.value ? location.assign(href.value) : a.onClick?.())}>{a.label}</Button>
146
+ }
147
+
148
+ /** 403's four states (07 §6.5): can request (then a note), pending, can't request, granted in place. */
149
+ function Denied(p: SystemPageProps) {
150
+ const compose = state(false)
151
+ const sent = state(false)
152
+ const note = state('')
153
+ const busy = state(false)
154
+ const name = untrack(() => p.object?.title)
155
+ const titleOf = name ? `You don't have access to ${name}` : TITLES[403]
156
+ const approvers = derived(() => p.request?.approvers ?? p.object?.owner ?? 'the owner')
157
+ const pending = derived(() => !!(p.request?.pending as unknown ?? sent.value))
158
+ const allowed = derived(() => p.request?.allowed !== false)
159
+ const view = derived(() => (p.granted ? 'granted' : pending.value ? 'pending' : !allowed.value ? 'closed' : compose.value ? 'compose' : 'request'))
160
+ let root: HTMLElement | undefined
161
+ const accounts = AccountMenu({ accounts: untrack(() => p.accounts ?? []), onSwitch: id => p.onSwitch?.(id) })
162
+ watch(() => view.value, (v, was) => {
163
+ if (was === undefined) return
164
+ queueMicrotask(() => root?.querySelector<HTMLElement>(v === 'compose' ? 'textarea' : '[data-part="title"]')?.focus({ preventScroll: true }))
165
+ if (v === 'granted') toast.show({ title: `${p.object?.owner ?? 'The owner'} gave you access`, description: `${name ?? 'It'} is open.`, tone: 'positive' })
166
+ })
167
+ const send = async () => {
168
+ busy.value = true
169
+ try {
170
+ await p.request?.onSend?.(note.value)
171
+ sent.value = true
172
+ compose.value = false
173
+ announce(document, `Request sent to ${approvers.value}. We'll email you when they answer.`)
174
+ } finally { busy.value = false }
175
+ }
176
+ const withdraw = async () => { await p.request?.onWithdraw?.(); sent.value = false; note.value = ''; announce(document, 'Request withdrawn') }
177
+ const copyLink = async () => { await copyText(document, p.request?.link ?? location.href); announce(document, `Link copied. Send it to ${approvers.value}.`) }
178
+ const Switch = () => (p.accounts?.length ? <Button emphasis="ghost" tone="neutral" data-switch="" aria-haspopup="menu" aria-expanded={accounts.open.value ? 'true' : 'false'} aria-controls={accounts.id} onClick={accounts.press}>Switch account</Button> : p.onSwitch ? <Button emphasis="ghost" tone="neutral" onClick={() => p.onSwitch!(undefined)}>Switch account</Button> : null)
179
+ return (
180
+ <>
181
+ {view.value === 'granted' ? p.children : (
182
+ <div ref={n => (root = n)} data-scope="system-page">
183
+ <div data-part="body">
184
+ <Head art={<Locked />} code={p.code ?? CODES[403]} as={p.as} focus={p.autofocus ?? true} title={view.value === 'pending' ? 'Request sent' : (p.title ?? titleOf)} />
185
+ {view.value === 'request' && <p>{p.description ?? <>{p.account ? `You're signed in as ${p.account}. ` : ''}Ask the owner for access, or switch to an account that has it.</>}</p>}
186
+ {view.value === 'compose' && <p>{approvers.value} can share it. Add a note if it helps them decide.</p>}
187
+ {view.value === 'closed' && <p>This workspace doesn't take access requests. Ask {approvers.value} to share it{p.account ? ` with ${p.account}` : ''}.</p>}
188
+ {view.value === 'pending' && <span data-part="pending" role="status"><ClockIcon /> Request sent to {approvers.value} · we'll email you when they answer</span>}
189
+ {view.value === 'compose'
190
+ ? (
191
+ <div data-part="request">
192
+ <Textarea label="Note (optional)" hint="They'll see it with your request" counter={280} maxlength="280" value={note.value} onInput={e => (note.value = e.currentTarget.value)} />
193
+ <div data-part="actions">
194
+ <Button emphasis="ghost" tone="neutral" onClick={() => (compose.value = false)}>Cancel</Button>
195
+ <Button loading={busy.value} onClick={send}>Send request</Button>
196
+ </div>
197
+ </div>
198
+ )
199
+ : (
200
+ <div data-part="actions">
201
+ {view.value === 'request' && <Button onClick={() => (compose.value = true)}>Request access</Button>}
202
+ {view.value === 'pending' && <Button emphasis="outline" tone="neutral" onClick={withdraw}>Withdraw request</Button>}
203
+ {view.value === 'closed' && <Button onClick={copyLink}>Copy link for the owner</Button>}
204
+ <Switch />
205
+ </div>
206
+ )}
207
+ {accounts.menu()}
208
+ {p.requestId && <RequestId id={p.requestId} />}
209
+ </div>
210
+ </div>
211
+ )}
212
+ </>
213
+ )
214
+ }
215
+
216
+ /**
217
+ * A result page (09 §5.1): art, code, title, one sentence, one primary, one secondary, the support line. It sits inside
218
+ * the app shell, so navigation still works. `<Boundary error={(e, retry) => <SystemPage kind="500" error={e} onRetry={retry} />}>`
219
+ * at the route level; 403 is the sharing state of 07 §6.5, and never shown to outsiders (they get the 404).
220
+ */
221
+ export function SystemPage(p: SystemPageProps) {
222
+ const kind = untrack(() => p.kind)
223
+ if (kind === '403') return <Denied {...p} />
224
+ const text = derived(() => p.description ?? COPY[kind])
225
+ const home = derived(() => p.secondary)
226
+ return (
227
+ <div data-scope="system-page">
228
+ <div data-part="body">
229
+ <Head art={kind === 'empty' ? <Whole /> : <Broken />} code={p.code ?? CODES[kind]} as={p.as} focus={p.autofocus} title={p.title ?? (kind === 'maintenance' && p.backAt ? `Back at ${p.backAt as string}` : TITLES[kind])} />
230
+ {text.value !== undefined && <p>{text.value}</p>}
231
+ {kind === '404' && p.onSearch && (
232
+ <form data-part="search" novalidate onSubmit={(e: SubmitEvent) => { e.preventDefault(); p.onSearch!(new FormData(e.currentTarget as HTMLFormElement).get('q') as string) }}>
233
+ <Search label={p.searchLabel ?? 'Search'} name="q" />
234
+ </form>
235
+ )}
236
+ <div data-part="actions">
237
+ {kind === '500' && p.onRetry && <Button onClick={p.onRetry}><RefreshIcon />Try again</Button>}
238
+ {p.primary && <Act a={p.primary} />}
239
+ {home.value && <Act a={home.value} emphasis="frame" />}
240
+ </div>
241
+ {p.meta !== undefined && <span data-part="meta">{p.meta}</span>}
242
+ {p.requestId && <RequestId id={p.requestId} />}
243
+ {p.children}
244
+ </div>
245
+ </div>
246
+ )
247
+ }