@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,76 @@
1
+ import { untrack } from '@weft-js/core'
2
+ import { uid } from '@weft-js/warp-core'
3
+ import { Glyph } from '../parts'
4
+ import '../styles'
5
+ import '@weft-js/warp-core/styles/flows/tokens.css'
6
+ import '@weft-js/warp-core/styles/flows/base.css'
7
+ import '@weft-js/warp-core/styles/rich/helpers.css'
8
+ import '@weft-js/warp-core/styles/layout/tokens.css'
9
+ import '@weft-js/warp-core/styles/layout/row.css'
10
+ import '@weft-js/warp-core/styles/layout/stack.css'
11
+ import '@weft-js/warp-core/styles/flows/settings.css'
12
+ import '@weft-js/warp-core/styles/flows/settings-section.css'
13
+ import '@weft-js/warp-core/styles/flows/setting-row.css'
14
+
15
+ /** One link of the settings nav. */
16
+ export type SettingsNavItem = { id: string; label: string; glyph?: string; tone?: 'critical' | undefined }
17
+
18
+ /**
19
+ * The settings frame (09 §4.1): a TabNav-like strip that becomes a sticky column at 760px of its own width, and a form
20
+ * holding the sections. A nav press scrolls to the section and moves focus to its heading.
21
+ */
22
+ export function SettingsLayout({ label = 'Settings sections', sections, current, onNavigate, children }: { label?: string | undefined; sections: SettingsNavItem[]; current: string; onNavigate?: (id: string) => void; children: unknown }) {
23
+ const go = (e: MouseEvent, id: string) => {
24
+ e.preventDefault()
25
+ onNavigate?.(id)
26
+ const doc = (e.currentTarget as Element).ownerDocument
27
+ const sec = doc.getElementById(id)
28
+ const reduce = doc.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)').matches
29
+ sec?.scrollIntoView?.({ behavior: reduce ? 'auto' : 'smooth', block: 'start' })
30
+ sec?.querySelector<HTMLElement>('header :is(h3, h4)')?.focus({ preventScroll: true })
31
+ }
32
+ return (
33
+ <div data-scope="settings">
34
+ <div data-part="layout">
35
+ <nav data-part="nav" aria-label={label}>
36
+ {sections.map(s => (
37
+ <a key={s.id} href={`#${s.id}`} data-tone={s.tone} aria-current={s.id === current ? 'true' : undefined} onClick={(e: MouseEvent) => go(e, s.id)}>
38
+ {s.glyph && <Glyph d={s.glyph} />}{s.label}
39
+ </a>
40
+ ))}
41
+ </nav>
42
+ <form data-part="content" novalidate>{children}</form>
43
+ </div>
44
+ </div>
45
+ )
46
+ }
47
+
48
+ /** A heading, one line, then one bordered group of rows (09 §4.1). `tone="critical"` is the danger zone's border. */
49
+ export function SettingsSection({ id, title, description, tone, as: heading = 'h3', children }: { id: string; title: unknown; description?: unknown; tone?: 'critical' | undefined; as?: 'h3' | 'h4' | undefined; children: unknown }) {
50
+ return (
51
+ <section data-scope="settings-section" id={id} data-tone={tone} aria-labelledby={`${id}-h`}>
52
+ <header>
53
+ {heading === 'h4' ? <h4 id={`${id}-h`} tabindex="-1">{title}</h4> : <h3 id={`${id}-h`} tabindex="-1">{title}</h3>}
54
+ {description !== undefined && <p>{description}</p>}
55
+ </header>
56
+ <div data-part="group">{children}</div>
57
+ </section>
58
+ )
59
+ }
60
+
61
+ /**
62
+ * Label and help at the inline-start, the control at the inline-end, stacking under 520px (09 §4.1). `children` may be
63
+ * a function of the label's and help's ids, so a Switch can be labelled by them instead of repeating the words.
64
+ */
65
+ export function SettingRow({ title, description, layout, children }: { title?: unknown; description?: unknown; layout?: 'stack'; children: unknown }) {
66
+ const id = uid('row')
67
+ const body = () => (typeof children === 'function' ? children({ label: `${id}-l`, description: `${id}-d` }) : children)
68
+ const stacked = untrack(() => title === undefined)
69
+ if (stacked) return <div data-scope="setting-row" data-layout={layout}>{body()}</div>
70
+ return (
71
+ <div data-scope="setting-row" data-layout={layout}>
72
+ <div><b id={`${id}-l`}>{title}</b><p id={`${id}-d`}>{description}</p></div>
73
+ <div data-part="control">{body()}</div>
74
+ </div>
75
+ )
76
+ }
@@ -0,0 +1,35 @@
1
+ import { CheckIcon } from '@weft-js/warp-icons'
2
+ import { derived } from '@weft-js/core'
3
+ import '../parts'
4
+ import '@weft-js/warp-core/styles/rich/helpers.css'
5
+ import '@weft-js/warp-core/styles/layout/tokens.css'
6
+ import '@weft-js/warp-core/styles/layout/row.css'
7
+ import '@weft-js/warp-core/styles/layout/stack.css'
8
+ import '@weft-js/warp-core/styles/components/progress.css'
9
+
10
+ /**
11
+ * Steps (05 §4) with the compact form 09 §6.2 asks for: steps.css hides the list under 520px and shows
12
+ * `[data-part="compact"]`, "Payment, step 4 of 5" over a bar, which parts' Steps doesn't render.
13
+ */
14
+ export function StepBar({ label, steps, current, error }: { label: string; steps: { id: string; title: string }[]; current: number; error?: number }) {
15
+ const at = derived(() => Math.min(current, steps.length - 1))
16
+ return (
17
+ <nav data-scope="steps" aria-label={label}>
18
+ <ol>
19
+ {steps.map((s, i) => (
20
+ <li key={s.id} data-part="item" data-state={i === error ? 'error' : i < current ? 'complete' : i === current ? 'current' : undefined} aria-current={i === current ? 'step' : undefined}>
21
+ <span data-part="indicator"><CheckIcon /></span>
22
+ <span data-part="title">{s.title}</span>
23
+ </li>
24
+ ))}
25
+ </ol>
26
+ <div data-part="compact">
27
+ <div data-scope="row" data-justify="between">
28
+ <b data-part="compact-title">{steps[at.value]!.title}</b>
29
+ <span class="muted">Step {at.value + 1} of {steps.length}</span>
30
+ </div>
31
+ <div data-scope="progress" data-part="root" style={`--_v:${(at.value + 1) / steps.length}`}><div data-part="track"><div data-part="range"></div></div></div>
32
+ </div>
33
+ </nav>
34
+ )
35
+ }
@@ -0,0 +1,79 @@
1
+ import { addTags, announce } from '@weft-js/warp-core'
2
+ import { derived, state, type State } from '@weft-js/core'
3
+ import { Field } from '../Field'
4
+ import { Tag } from '../Tag'
5
+ import { afterFlush } from './atoms'
6
+ import '@weft-js/warp-core/styles/inputs/choice.css'
7
+
8
+ /** Props for TagsInput. */
9
+ export interface TagsInputProps {
10
+ label: string
11
+ hint?: unknown
12
+ error?: unknown
13
+ value: State<string[]>
14
+ /** A false tag stays, critical: the field's error says why, once for all of them. */
15
+ valid?: (tag: string) => boolean
16
+ /** Called when the tags change, to clear a stale error. */
17
+ onEdit?: (() => void) | undefined
18
+ /** Enter with nothing typed: the form's submit. */
19
+ onSubmit?: () => void
20
+ /** Focuses the field when it mounts. */
21
+ autofocus?: boolean
22
+ [attr: string]: unknown
23
+ }
24
+
25
+ /**
26
+ * TagsInput (09 §4.2): Enter, comma or a pasted list adds tags; an invalid one stays as a critical tag; Backspace
27
+ * highlights the last tag, then removes it. warp had none, so it lives here. Changes are announced politely.
28
+ */
29
+ export function TagsInput({ label, hint, error, value, valid, onEdit, onSubmit, ...rest }: TagsInputProps) {
30
+ const draft = state('')
31
+ const armed = state(false)
32
+ let host: HTMLElement | undefined
33
+ const say = (text: string) => { if (host) announce(host.ownerDocument, text) }
34
+ const add = (raw: string) => {
35
+ const { list, added } = addTags(value.value, raw)
36
+ draft.value = ''
37
+ if (!added.length) return
38
+ value.value = list
39
+ armed.value = false
40
+ say(`Added ${added.join(', ')}. ${list.length} in total.`)
41
+ onEdit?.()
42
+ }
43
+ const drop = (tag: string) => {
44
+ value.value = value.value.filter(t => t !== tag)
45
+ armed.value = false
46
+ say(`Removed ${tag}.`)
47
+ onEdit?.()
48
+ afterFlush(() => host?.focus())
49
+ }
50
+ const key = (e: KeyboardEvent) => {
51
+ const text = draft.value.trim()
52
+ if ((e.key === 'Enter' || e.key === ',') && text) { e.preventDefault(); add(text) }
53
+ else if (e.key === 'Enter') { e.preventDefault(); onSubmit?.() }
54
+ else if (e.key === 'Backspace' && !draft.value && value.value.length) {
55
+ if (armed.value) drop(value.value[value.value.length - 1]!)
56
+ else armed.value = true
57
+ } else if (e.key !== 'Backspace') armed.value = false
58
+ }
59
+ const paste = (e: ClipboardEvent) => {
60
+ const t = e.clipboardData?.getData('text') ?? ''
61
+ if (/[\s,;]/.test(t.trim())) { e.preventDefault(); add(`${draft.value} ${t}`) }
62
+ }
63
+ const last = derived(() => value.value[value.value.length - 1])
64
+ return (
65
+ <Field
66
+ wrap
67
+ label={label}
68
+ hint={hint}
69
+ error={error ?? ''}
70
+ filled={value.value.length > 0 || !!draft.value}
71
+ control={p => (
72
+ <>
73
+ {value.value.map(t => <Tag key={t} label={t} tone={valid && !valid(t) ? 'critical' : undefined} data-highlighted={armed.value && t === last.value ? '' : undefined} onRemove={() => drop(t)}>{t}</Tag>)}
74
+ <input ref={n => (host = n)} data-part="input" id={p.id} placeholder=" " autocomplete="off" enterkeyhint="enter" aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} value={draft.value} onInput={(e: Event) => { draft.value = (e.currentTarget as HTMLInputElement).value; armed.value = false }} onKeyDown={key} onPaste={paste} onBlur={() => { if (draft.value.trim()) add(draft.value) }} {...rest} />
75
+ </>
76
+ )}
77
+ />
78
+ )
79
+ }
@@ -0,0 +1,33 @@
1
+ // Small pieces the admin pack shares. Internal: not exported from the barrel.
2
+ import { flush, type State, state, untrack, watch } from '@weft-js/core'
3
+ import { CompactSelect } from '../Select'
4
+ import '../fields'
5
+ import '@weft-js/warp-core/styles/inputs/choice.css'
6
+ import '@weft-js/warp-core/styles/inputs/text.css'
7
+
8
+ /** A value given plainly or as a State box. */
9
+ export const read = <T,>(v: T | State<T>): T => (v !== null && typeof v === 'object' && !Array.isArray(v) && 'value' in (v as object) ? (v as State<T>).value : v as T)
10
+
11
+ /** The sweep's two spans, in every swept host. */
12
+ export const Sweep = () => <><span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span></>
13
+
14
+ /** Runs after the DOM has caught up: a focus move onto something this handler just rendered. */
15
+ export const afterFlush = (fn: () => void) => queueMicrotask(() => { flush(); fn() })
16
+
17
+ /** Goes true with `open` and stays: a closed overlay carries nothing until first opened, and keeps its close animation. */
18
+ export function latch(open: State<boolean>) {
19
+ const on = state(untrack(() => open.value))
20
+ watch(() => open.value, v => { if (v) on.value = true })
21
+ return on
22
+ }
23
+
24
+ /** A close that can refuse, for dialogs that mustn't go yet. Modal only reads and writes `.value`. */
25
+ export const guarded = (open: State<boolean>, may: () => boolean) => ({
26
+ get value() { return open.value },
27
+ set value(v: boolean) { if (v || may()) open.value = v },
28
+ }) as State<boolean>
29
+
30
+ /** The compact select in a table cell (specimen `roleSel`). */
31
+ export const InlineSelect = ({ label, value, options, describedby, onValueChange }: { label: string; value: string; options: { value: string; label: string }[]; describedby?: string | undefined; onValueChange: (value: string) => void }) => (
32
+ <CompactSelect label={label} value={value} options={options} describedby={describedby} onValueChange={onValueChange} />
33
+ )
@@ -0,0 +1,16 @@
1
+ // The admin pack: see docs/design/warp. One file per component family; each imports only its own CSS.
2
+ export { SettingsLayout, SettingsSection, SettingRow, type SettingsNavItem } from './Settings'
3
+ export { SaveBar, type SaveBarProps, type FieldErrors } from './SaveBar'
4
+ export { DangerZone, ConfirmDialog, TypeToConfirm, type ConfirmDialogProps, type TypeToConfirmProps } from './Danger'
5
+ export { MembersTable, SeatsMeter, RolesMatrix, type Member, type Invite, type Role, type Permission, type MembersTableProps } from './Members'
6
+ export { InviteDialog, type InviteDialogProps } from './InviteDialog'
7
+ export { TagsInput, type TagsInputProps } from './TagsInput'
8
+ export { ApiKeys, type ApiKey, type KeyDraft, type ApiKeysProps } from './ApiKeys'
9
+ export { PlanPicker, UsageMeter, UsageMeters, type BillingPlan, type PlanPickerProps, type UsageMeterProps } from './Plans'
10
+ export { Invoices, PaymentMethods, PayFrame, type Invoice, type InvoiceStatus, type PaymentMethod, type PayFrameProps } from './Billing'
11
+ export { AuditLog, type AuditEvent, type AuditLogProps } from './Audit'
12
+ export { IntegrationsGrid, Webhooks, type Integration, type IntegrationState, type IntegrationsGridProps, type Webhook, type WebhooksProps } from './Integrations'
13
+ export { CsvImport, type CsvImportProps } from './CsvImport'
14
+ export { CartDrawer, CartButton, LineItem, type CartDrawerProps } from './Cart'
15
+ export { Checkout, type CheckoutProps, type CheckoutStep, type ShippingOption, type Coupon, type OrderDraft, type PlaceOrderResult } from './Checkout'
16
+ export { OrderConfirmation, OrderTracker, type OrderConfirmationProps, type OrderStage } from './Order'
@@ -0,0 +1,85 @@
1
+ import { BranchIcon, CopyIcon, LeftIcon, RefreshIcon, RightIcon, ThumbsDownIcon, ThumbsUpIcon } from '@weft-js/warp-icons'
2
+ import { announce, popover } from '@weft-js/warp-core'
3
+ import { derived, state } from '@weft-js/core'
4
+ import { Badge } from '../Badge'
5
+ import { Button } from '../Button'
6
+ import { use } from '@weft-js/warp-core/weft'
7
+ import { IconButton } from './bits'
8
+ import '../styles'
9
+ import '@weft-js/warp-core/styles/components/popover.css'
10
+ import '@weft-js/warp-core/styles/app/branch.css'
11
+
12
+ const say = (e: Event, text: string) => announce((e.currentTarget as Element).ownerDocument, text)
13
+
14
+ /** BranchSwitcher (07 §1.3): `‹ 2/2 ›`, shown once a turn has more than one version. `index` counts from 0. */
15
+ export function BranchSwitcher({ index, count, onChange }: { index: number; count: number; onChange: (index: number) => void }) {
16
+ const go = (e: Event, to: number) => { onChange(to); say(e, `Version ${to + 1} of ${count}`) }
17
+ return (
18
+ <>
19
+ {count > 1 && (
20
+ <span data-scope="branch" role="group" aria-label="Versions">
21
+ <button type="button" aria-label="Previous version" disabled={index <= 0} onClick={e => go(e, index - 1)}><LeftIcon /></button>
22
+ <span data-numeric="">{index + 1}/{count}</span>
23
+ <button type="button" aria-label="Next version" disabled={index >= count - 1} onClick={e => go(e, index + 1)}><RightIcon /></button>
24
+ </span>
25
+ )}
26
+ </>
27
+ )
28
+ }
29
+
30
+ /** A reader's verdict on an answer; null until they give one. */
31
+ export type Feedback = 'up' | 'down' | null
32
+
33
+ /** Props for MessageActions. */
34
+ export interface MessageActionsProps {
35
+ /** Adds Copy, and is called with the pressed button. Copy the message text here. */
36
+ onCopy?: ((el: HTMLElement) => void) | undefined
37
+ /** Called with `up`, `down` or null when thumbs change, and the reason picked for a thumbs down. */
38
+ onFeedback?: ((value: Feedback, reason?: string) => void) | undefined
39
+ reasons?: string[] | undefined
40
+ /** Adds Regenerate: ask for a new answer. */
41
+ onRegenerate?: (() => void) | undefined
42
+ /** Adds Branch: start a new conversation from this message. */
43
+ onBranch?: (() => void) | undefined
44
+ stopped?: boolean | undefined
45
+ /** Which of several answers is shown, with arrows between them. */
46
+ branch?: ({ index: number; count: number; onChange: (index: number) => void }) | undefined
47
+ }
48
+
49
+ /**
50
+ * Message actions (07 §1.3): copy, good and bad answer (toggles; bad opens a short reason popover), regenerate, branch
51
+ * and the switcher. Regenerate, branch and thumbs render only when they have a handler.
52
+ */
53
+ export function MessageActions({ onCopy, onFeedback, reasons = ['Wrong', 'Too long', 'Missed the point'], onRegenerate, onBranch, stopped, branch }: MessageActionsProps) {
54
+ const value = state<Feedback>(null)
55
+ const asking = state(false)
56
+ const has = derived(() => branch)
57
+ let bad: HTMLElement | undefined
58
+ let panel: HTMLElement | undefined
59
+ use(popover, () => panel, () => ({ open: asking.value, trigger: bad!, side: 'top' as const, onClose: () => (asking.value = false) }))
60
+ const rate = (e: Event, to: 'up' | 'down') => {
61
+ const next = value.value === to ? null : to
62
+ value.value = next
63
+ onFeedback?.(next)
64
+ if (next === 'down') asking.value = true
65
+ else if (next) say(e, 'Thanks for the feedback.')
66
+ }
67
+ const because = (e: Event, reason: string) => { onFeedback?.('down', reason); say(e, 'Thanks for the feedback.') }
68
+ return (
69
+ <div data-part="actions">
70
+ {stopped && <Badge tone="neutral">Stopped · kept what arrived</Badge>}
71
+ {onCopy && <IconButton icon={CopyIcon} label="Copy" onClick={(e: Event) => onCopy(e.currentTarget as HTMLElement)} />}
72
+ {onFeedback && <IconButton icon={ThumbsUpIcon} label="Good answer" aria-pressed={value.value === 'up' ? 'true' : 'false'} onClick={(e: Event) => rate(e, 'up')} />}
73
+ {onFeedback && <IconButton icon={ThumbsDownIcon} label="Bad answer" aria-pressed={value.value === 'down' ? 'true' : 'false'} aria-haspopup="dialog" ref={(n: HTMLElement) => (bad = n)} onClick={(e: Event) => rate(e, 'down')} />}
74
+ {onFeedback && (
75
+ <div ref={n => (panel = n)} data-scope="popover" data-part="content" role="dialog" popover="manual" aria-label="What went wrong?">
76
+ <p data-part="title">What went wrong?</p>
77
+ <div>{reasons.map(r => <Button key={r} emphasis="ghost" tone="neutral" size="sm" data-close-popover="" onClick={(e: Event) => because(e, r)}>{r}</Button>)}</div>
78
+ </div>
79
+ )}
80
+ {onRegenerate && <IconButton icon={RefreshIcon} label="Regenerate" onClick={onRegenerate} />}
81
+ {onBranch && <IconButton icon={BranchIcon} label="Branch from here" onClick={onBranch} />}
82
+ {has.value && <BranchSwitcher index={has.value.index} count={has.value.count} onChange={has.value.onChange} />}
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,49 @@
1
+ import { CheckIcon, ClockIcon, HandIcon, XIcon } from '@weft-js/warp-icons'
2
+ import type { PlanStep } from '@weft-js/warp-core'
3
+ import { derived } from '@weft-js/core'
4
+ import { Bar, IconButton } from './bits'
5
+ import '../styles'
6
+ import '@weft-js/warp-core/styles/components/spinner.css'
7
+ import '@weft-js/warp-core/styles/app/plan.css'
8
+
9
+ /** A message waiting for the agent to finish. */
10
+ export type QueuedMessage = { id: string; text: string }
11
+
12
+ /**
13
+ * Plan: the task list and the Queue (07 §1.1, §1.2). Steps are pending, running, done or blocked; messages sent during
14
+ * a run wait in order under them and can be removed. Named AgentPlan so it can't be mistaken for a billing plan.
15
+ */
16
+ export function AgentPlan({ steps, queue, title = 'Plan', onRemoveQueued }: { steps: PlanStep[]; queue?: QueuedMessage[]; title?: unknown; onRemoveQueued?: (id: string) => void }) {
17
+ const done = derived(() => steps.filter(s => s.state === 'done').length)
18
+ const waiting = derived(() => queue ?? [])
19
+ const note = (s: PlanStep) => s.note ?? (s.state === 'blocked' ? 'Waiting for your approval' : s.state === 'running' ? 'In progress' : undefined)
20
+ return (
21
+ <div data-scope="plan">
22
+ <div data-part="header"><h4>{title}</h4><small data-numeric="">{done.value}/{steps.length}</small></div>
23
+ <Bar label="Plan progress" value={steps.length ? done.value / steps.length : 0} />
24
+ <ol aria-label="Plan steps">
25
+ {steps.map(s => (
26
+ <li key={s.id} data-state={s.state}>
27
+ <span data-part="box">
28
+ {s.state === 'done' && <CheckIcon style="inline-size:12px;block-size:12px;stroke-width:2.2" />}
29
+ {s.state === 'running' && <span data-scope="spinner" style="--_size:10px;color:var(--warp-color-accent-text)"></span>}
30
+ {s.state === 'blocked' && <HandIcon style="inline-size:11px;block-size:11px" />}
31
+ </span>
32
+ <span>{s.title}{note(s) !== undefined && <small>{note(s)}</small>}</span>
33
+ </li>
34
+ ))}
35
+ </ol>
36
+ <div data-part="queue" role="group" aria-label="Queued messages">
37
+ <span data-part="eyebrow">Queue · {waiting.value.length}</span>
38
+ {waiting.value.length === 0 && <small>Messages you send while a run is busy wait here, in order.</small>}
39
+ {waiting.value.map(q => (
40
+ <div key={q.id}>
41
+ <ClockIcon style="inline-size:14px;block-size:14px;color:var(--warp-color-text-subtle)" />
42
+ <span>{q.text}</span>
43
+ <IconButton icon={XIcon} label={`Remove from queue: ${q.text}`} onClick={() => onRemoveQueued?.(q.id)} />
44
+ </div>
45
+ ))}
46
+ </div>
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,80 @@
1
+ import { CopyIcon, ExternalIcon } from '@weft-js/warp-icons'
2
+ import { announce, copyConfirm, indicator, nextIndex, uid } from '@weft-js/warp-core'
3
+ import { derived, state, untrack, type State } from '@weft-js/core'
4
+ import { Badge } from '../Badge'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import { IconButton, held, Sweep, useSweep } from './bits'
7
+ import '../styles'
8
+ import '@weft-js/warp-core/styles/components/tabs.css'
9
+ import '@weft-js/warp-core/styles/app/artifact.css'
10
+ import '@weft-js/warp-core/styles/app/versions.css'
11
+
12
+ /** One saved version of an artifact. */
13
+ export type ArtifactVersion = { id: string; label: string; meta?: string | undefined; code?: string; named?: boolean }
14
+ const VIEWS = ['Preview', 'Code', 'Versions'] as const
15
+
16
+ /**
17
+ * ArtifactPanel (07 §6.4): title, version badge, Copy; APG tabs Preview, Code, Versions. Versions is a listbox with a
18
+ * roving tab stop, selection following focus; picking one is `onVersion`, and Code shows that version's code. Copy
19
+ * confirms the panel's sweep. It fills its container, so a rail, a Sheet or a full route all hold it.
20
+ */
21
+ export function ArtifactPanel({ title, versions, version, onVersion, code, onOpen, children }: { title: unknown; versions: ArtifactVersion[]; version?: string | State<string | undefined> | undefined; onVersion?: (id: string) => void; code?: string; onOpen?: () => void; children?: unknown }) {
22
+ const id = uid('art')
23
+ const view = state<(typeof VIEWS)[number]>('Preview')
24
+ const picked = held<string | undefined>(() => version, untrack(() => versions[0]?.id), () => (v: string | undefined) => onVersion?.(v!))
25
+ const current = derived(() => versions.find(v => v.id === picked.value) ?? versions[0])
26
+ const source = derived(() => current.value?.code ?? code ?? '')
27
+ const badge = derived(() => current.value?.label.split(' ·')[0])
28
+ let root: HTMLElement | undefined
29
+ let list: HTMLElement | undefined
30
+ useSweep()
31
+ use(indicator, () => list, () => ({ selected: '[aria-selected="true"]', x: '--_ix', w: '--_iw', at: view.value }))
32
+ const go = (v: (typeof VIEWS)[number]) => (view.value = v)
33
+ const onTab = (e: KeyboardEvent) => {
34
+ const to = nextIndex(e.key, VIEWS.indexOf(view.value), VIEWS.length, { rtl: getComputedStyle(e.currentTarget as Element).direction === 'rtl' })
35
+ if (to < 0) return
36
+ e.preventDefault()
37
+ go(VIEWS[to]!)
38
+ list!.querySelectorAll<HTMLElement>('[role="tab"]')[to]?.focus()
39
+ }
40
+ const choose = (v: ArtifactVersion) => {
41
+ picked.set(v.id)
42
+ announce(root!.ownerDocument, `Previewing ${v.label}`)
43
+ queueMicrotask(() => root!.querySelectorAll<HTMLElement>('[role="option"]')[versions.findIndex(x => x.id === v.id)]?.focus())
44
+ }
45
+ const onList = (e: KeyboardEvent) => {
46
+ const at = versions.findIndex(v => v.id === current.value?.id)
47
+ const to = nextIndex(e.key, at, versions.length, { orientation: 'vertical' })
48
+ if (to < 0) return
49
+ e.preventDefault()
50
+ choose(versions[to]!)
51
+ }
52
+ return (
53
+ <section ref={n => (root = n)} data-scope="artifact" data-sweep="" aria-labelledby={`${id}-n`}>
54
+ <Sweep />
55
+ <div data-part="head">
56
+ <b id={`${id}-n`}>{title}</b>
57
+ {badge.value !== undefined && <Badge tone="neutral">{badge.value}</Badge>}
58
+ <IconButton icon={CopyIcon} label="Copy" onClick={(e: Event) => copyConfirm(e.currentTarget as Element, source.value, root)} />
59
+ {onOpen && <IconButton icon={ExternalIcon} label="Open in editor" onClick={onOpen} />}
60
+ </div>
61
+ <div data-scope="tabs" data-part="root" style="padding:8px 12px 0;gap:0">
62
+ <div ref={n => (list = n)} data-part="list" role="tablist" aria-label="Artifact views" onKeyDown={onTab}>
63
+ {VIEWS.map((v, i) => <button key={v} data-part="trigger" role="tab" type="button" id={`${id}-t${i}`} aria-controls={`${id}-p${i}`} aria-selected={v === view.value ? 'true' : 'false'} tabindex={v === view.value ? undefined : '-1'} onClick={() => go(v)}>{v}</button>)}
64
+ <span data-part="indicator" aria-hidden="true" data-instant=""></span>
65
+ </div>
66
+ </div>
67
+ <div data-part="panel" role="tabpanel" id={`${id}-p0`} aria-labelledby={`${id}-t0`} tabindex="0" hidden={view.value !== 'Preview'}><div data-part="preview">{children}</div></div>
68
+ <div data-part="panel" role="tabpanel" id={`${id}-p1`} aria-labelledby={`${id}-t1`} tabindex="0" hidden={view.value !== 'Code'}><pre>{source.value}</pre></div>
69
+ <div data-part="panel" role="tabpanel" id={`${id}-p2`} aria-labelledby={`${id}-t2`} hidden={view.value !== 'Versions'}>
70
+ <ol data-scope="versions" role="listbox" aria-label="Versions" onKeyDown={onList}>
71
+ {versions.map(v => (
72
+ <li key={v.id} id={`${id}-v-${v.id}`} role="option" aria-selected={v.id === current.value?.id ? 'true' : 'false'} aria-current={v.id === current.value?.id ? 'true' : undefined} tabindex={v.id === current.value?.id ? '0' : '-1'} data-named={v.named ?? v.id === current.value?.id ? '' : undefined} onClick={() => choose(v)}>
73
+ <i></i><span>{v.label}{v.meta !== undefined && <small>{v.meta}</small>}</span>
74
+ </li>
75
+ ))}
76
+ </ol>
77
+ </div>
78
+ </section>
79
+ )
80
+ }
@@ -0,0 +1,96 @@
1
+ import { SparkleIcon } from '@weft-js/warp-icons'
2
+ import { announce, answerText, copyConfirm, uid, type AgentTool, type Answer, type AnswerBlock } from '@weft-js/warp-core'
3
+ import { derived, onMount, untrack, watch } from '@weft-js/core'
4
+ import { Alert } from '../Alert'
5
+ import { Button } from '../Button'
6
+ import { MessageActions, type Feedback } from './Actions'
7
+ import { Prose } from './Prose'
8
+ import { Question } from './Question'
9
+ import { Reasoning } from './Reasoning'
10
+ import { Sources } from './Sources'
11
+ import { ToolCall } from './ToolCall'
12
+ import '../styles'
13
+ import '@weft-js/warp-core/styles/app/assistant.css'
14
+
15
+ /** Props for Assistant. */
16
+ export interface AssistantProps {
17
+ /** The folded value: `foldAnswer(events)`, or what a stream folds to. */
18
+ answer: Answer
19
+ /** The turn number, for its name: "Assistant answer 2". */
20
+ n?: number
21
+ arrive?: boolean
22
+ /** Was there on load: approvals and questions take no focus and announce nothing. */
23
+ quiet?: boolean | undefined
24
+ /** Render a block of answer text yourself (Markdown, say); the default is `Prose`. */
25
+ render?: (text: string, answer: Answer) => unknown
26
+ /** Called when a tool call is approved, with its arguments as edited and whether to always allow it. */
27
+ onApprove?: (tool: AgentTool, args: string, always: boolean) => void
28
+ /** Called when a tool call is denied. */
29
+ onDeny?: (tool: AgentTool) => void
30
+ alwaysAllow?: (tool: AgentTool) => string | undefined
31
+ /** Called with a question's id and the answer picked or typed. */
32
+ onAnswer?: (id: string, answer: string | string[]) => void
33
+ /** Called with `up`, `down` or null when thumbs change, and the reason picked for a thumbs down. */
34
+ onFeedback?: (value: Feedback, reason?: string) => void
35
+ /** Adds Regenerate: ask for a new answer. */
36
+ onRegenerate?: () => void
37
+ /** Adds Branch: start a new conversation from this answer. */
38
+ onBranch?: () => void
39
+ /** Which of several answers is shown, with arrows between them. */
40
+ branch?: { index: number; count: number; onChange: (index: number) => void }
41
+ }
42
+
43
+ type Thought = Extract<AnswerBlock, { kind: 'reasoning' }>
44
+ type Said = Extract<AnswerBlock, { kind: 'text' }>
45
+ type Asked = Extract<AnswerBlock, { kind: 'question' }>
46
+
47
+ function Block({ block, base, p }: { block: AnswerBlock; base: string; p: AssistantProps }) {
48
+ const tool = derived(() => p.answer.tools.find(t => t.id === block.id))
49
+ const kind = derived(() => block.kind)
50
+ // once per kind: a row's nodes are read again whenever the list changes
51
+ const view = derived(() => {
52
+ const k = kind.value
53
+ return untrack(() => {
54
+ if (k === 'reasoning') return <Reasoning text={(block as Thought).text} streaming={(block as Thought).streaming} seconds={(block as Thought).seconds} />
55
+ if (k === 'text') return p.render ? <>{p.render((block as Said).text, p.answer)}</> : <Prose text={(block as Said).text} sources={p.answer.sources} base={base} />
56
+ if (k === 'question') return <Question question={(block as Asked).question} quiet={p.quiet} onAnswer={a => p.onAnswer?.(block.id, a)} />
57
+ return <ToolCall call={tool.value!} quiet={p.quiet} alwaysAllow={p.alwaysAllow?.(tool.value!)} onApprove={(a, al) => p.onApprove?.(tool.value!, a, al)} onDeny={() => p.onDeny?.(tool.value!)} />
58
+ })
59
+ })
60
+ return <>{view.value}</>
61
+ }
62
+
63
+ /**
64
+ * The assistant's turn (07 §1.1, §1.2): renders the folded Answer only, so replay and hydration take the same path.
65
+ * Caret while streaming, aria-busy until done; the end of a turn is announced once, never each token.
66
+ */
67
+ export function Assistant(p: AssistantProps) {
68
+ const base = uid('ans')
69
+ const st = derived(() => p.answer.state)
70
+ const busy = derived(() => st.value === 'waiting' || st.value === 'streaming' || st.value === 'approval')
71
+ const empty = derived(() => p.answer.blocks.length === 0 && st.value !== 'error')
72
+ const blocks = derived(() => p.answer.blocks)
73
+ const many = derived(() => p.answer.sources.length > 0)
74
+ let root: HTMLElement | undefined
75
+ onMount(() => {
76
+ watch(() => st.value, (s, prev) => {
77
+ if (prev === undefined || !root) return
78
+ const doc = root.ownerDocument
79
+ if (s === 'done') announce(doc, 'Answer ready')
80
+ if (s === 'stopped') announce(doc, 'Stopped')
81
+ if (s === 'error') announce(doc, 'The answer failed', true)
82
+ })
83
+ })
84
+ return (
85
+ <article ref={n => (root = n)} data-scope="assistant" data-state={st.value} aria-label={p.n !== undefined ? `Assistant answer ${p.n}` : 'Assistant answer'} aria-busy={busy.value ? 'true' : undefined} data-arrive={p.arrive ? '' : undefined}>
86
+ <span data-part="mark"><SparkleIcon /></span>
87
+ <div data-part="content">
88
+ {empty.value && <p></p>}
89
+ {blocks.value.map(b => <Block key={b.id} block={b} base={base} p={p} />)}
90
+ {st.value === 'error' && <Alert tone="critical" title="The answer failed">{p.answer.error}{p.onRegenerate && <> <Button emphasis="link" tone="neutral" onClick={p.onRegenerate}>Retry</Button></>}</Alert>}
91
+ {many.value && <Sources items={p.answer.sources} base={base} />}
92
+ </div>
93
+ {!busy.value && <MessageActions stopped={st.value === 'stopped'} onCopy={el => void copyConfirm(el, answerText(untrack(() => p.answer)))} onFeedback={p.onFeedback} onRegenerate={p.onRegenerate} onBranch={p.onBranch} branch={p.branch} />}
94
+ </article>
95
+ )
96
+ }
@@ -0,0 +1,14 @@
1
+ import { UndoIcon } from '@weft-js/warp-icons'
2
+ import { Button } from '../Button'
3
+ import '../styles'
4
+ import '@weft-js/warp-core/styles/app/checkpoint.css'
5
+
6
+ /** Checkpoint (07 §1.3): a hairline with a label and Restore. Restoring forks a branch; `onRestore` decides how. */
7
+ export function Checkpoint({ label, onRestore }: { label: unknown; onRestore?: () => void }) {
8
+ return (
9
+ <div data-scope="checkpoint">
10
+ <span>{label}</span>
11
+ {onRestore && <Button emphasis="ghost" tone="neutral" size="sm" onClick={onRestore}><UndoIcon />Restore</Button>}
12
+ </div>
13
+ )
14
+ }
@@ -0,0 +1,39 @@
1
+ import { citePreview, installSweep, type AgentSource } from '@weft-js/warp-core'
2
+ import { state } from '@weft-js/core'
3
+ import { use } from '@weft-js/warp-core/weft'
4
+ import '../styles'
5
+ import '@weft-js/warp-core/styles/components/popover.css'
6
+ import '@weft-js/warp-core/styles/app/cite.css'
7
+
8
+ /**
9
+ * A citation (07 §1.3, §1.6): a link to source n named "Source n", with a preview on hover and focus. The preview sits
10
+ * next in the DOM, so Tab reaches its link. Pressing moves focus to the source card and confirms it.
11
+ */
12
+ export function Cite({ n, source, href }: { n: number; source?: AgentSource | undefined; href: string }) {
13
+ const open = state(false)
14
+ let link: HTMLAnchorElement | undefined
15
+ let panel: HTMLElement | undefined
16
+ use(citePreview, () => (source ? link : undefined), () => ({ panel: panel!, onOpen: (o: boolean) => (open.value = o) }))
17
+ const jump = (e: MouseEvent) => {
18
+ const a = e.currentTarget as Element
19
+ const doc = a.ownerDocument
20
+ const card = doc.getElementById(a.getAttribute('href')!.slice(1)) // the page's id: a hydrated server id isn't this side's
21
+ if (!card) return
22
+ e.preventDefault()
23
+ card.focus()
24
+ installSweep(doc).confirm(card)
25
+ }
26
+ return (
27
+ <span style="display:contents">
28
+ <a ref={el => (link = el)} href={href} data-scope="cite" aria-label={`Source ${n}`} aria-expanded={source ? (open.value ? 'true' : 'false') : undefined} onClick={jump}>{n}</a>
29
+ {source && (
30
+ <span ref={el => (panel = el)} data-scope="popover" data-part="content" popover="manual">
31
+ <span data-part="title" style="display:block">{source.title}</span>
32
+ {source.path !== undefined && <span data-part="description" style="display:block">{source.path}</span>}
33
+ {source.snippet !== undefined && <span>{source.snippet}</span>}
34
+ {source.url !== undefined && <a href={source.url} data-part="open">Open source</a>}
35
+ </span>
36
+ )}
37
+ </span>
38
+ )
39
+ }