@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.
- package/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- package/tokens/color.mjs +66 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { contextLevel, formatTokens } from '@weft-js/warp-core'
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
import { Button } from '../Button'
|
|
4
|
+
import { Tooltip } from '../Tooltip'
|
|
5
|
+
import '../styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/app/context-meter.css'
|
|
7
|
+
|
|
8
|
+
/** How full the context window is, and optionally what with. */
|
|
9
|
+
export type ContextUse = { used: number; limit: number; parts?: { label: string; tokens: number }[] | undefined }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ContextMeter (07 §1.2): a ring and a number, caution at 80%, critical at 95%, where `onCompact` offers /compact. The
|
|
13
|
+
* tooltip lists what fills it. `showTokens` adds the count beside the percent.
|
|
14
|
+
*/
|
|
15
|
+
export function ContextMeter({ used, limit, parts, showTokens, onCompact }: ContextUse & { showTokens?: boolean; onCompact?: (() => void) | undefined }) {
|
|
16
|
+
const ratio = derived(() => Math.min(1, limit > 0 ? used / limit : 0))
|
|
17
|
+
const level = derived(() => contextLevel(ratio.value))
|
|
18
|
+
const pct = derived(() => Math.round(ratio.value * 100))
|
|
19
|
+
const tip = derived(() => [`${formatTokens(used)} of ${formatTokens(limit)} tokens`, ...(parts ?? []).map(p => `${p.label} ${formatTokens(p.tokens)}`)].join(' · '))
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<Tooltip content={tip.value}>
|
|
23
|
+
<span data-scope="context-meter" data-level={level.value} role="meter" tabindex="0" aria-label="Context used" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct.value} aria-valuetext={`${pct.value}%, ${formatTokens(used)} of ${formatTokens(limit)} tokens`} style={`--_v:${ratio.value}`}>
|
|
24
|
+
<svg viewBox="0 0 36 36" aria-hidden="true"><circle cx="18" cy="18" r="15.9" stroke="var(--warp-color-surface-active)" pathLength="100" /><circle cx="18" cy="18" r="15.9" pathLength="100" /></svg>
|
|
25
|
+
<span>{showTokens ? `${pct.value}% · ${formatTokens(used)}` : `${pct.value}%`}</span>
|
|
26
|
+
</span>
|
|
27
|
+
</Tooltip>
|
|
28
|
+
{level.value && onCompact && <Button emphasis="link" tone="neutral" size="sm" onClick={onCompact}>/compact</Button>}
|
|
29
|
+
</>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { DownIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { stick } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import '../styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/app/tokens.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/app/base.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/app/thread.css'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Conversation (07 §1.1, §1.3, §1.6): role=log with aria-live off (a turn announces its own end), keyed turns as
|
|
13
|
+
* `children`, an optional app bar and the Prompt below. It sticks to the bottom only within 48px of it; otherwise Jump
|
|
14
|
+
* to latest appears, and pressing it goes back to the end and focuses the log.
|
|
15
|
+
*/
|
|
16
|
+
export function Conversation({ label = 'Assistant conversation', title, bar, prompt, children }: { label?: string | undefined; title?: unknown; bar?: unknown; prompt?: unknown; children?: unknown }) {
|
|
17
|
+
const away = state(false)
|
|
18
|
+
const head = derived(() => title !== undefined || bar !== undefined)
|
|
19
|
+
let log: HTMLElement | undefined
|
|
20
|
+
use(stick, () => log, () => ({ onAway: (a: boolean) => (away.value = a) }))
|
|
21
|
+
const jump = () => {
|
|
22
|
+
log!.scrollTop = log!.scrollHeight
|
|
23
|
+
log!.focus({ preventScroll: true })
|
|
24
|
+
away.value = false
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
<section data-scope="thread" data-part="root" aria-label={label}>
|
|
28
|
+
{head.value && <div data-part="app-bar"><h2 data-part="app-title">{title}</h2><span data-part="spacer"></span>{bar}</div>}
|
|
29
|
+
<div ref={n => (log = n)} data-part="log" role="log" aria-live="off" aria-label="Messages" tabindex="0">{children}</div>
|
|
30
|
+
{away.value && <Button emphasis="soft" tone="neutral" size="sm" data-part="jump" onClick={jump}><DownIcon />Jump to latest</Button>}
|
|
31
|
+
<div style="grid-row:3">{prompt}</div>
|
|
32
|
+
</section>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { CopyIcon, EditIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { copyConfirm, installSweep } from '@weft-js/warp-core'
|
|
3
|
+
import { onMount, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { Textarea } from '../Field'
|
|
6
|
+
import { IconButton, Sweep } from './bits'
|
|
7
|
+
import '../styles'
|
|
8
|
+
import '@weft-js/warp-core/styles/app/message.css'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The user's turn (07 §1.1): a prompt bubble with edit and copy. `arrive` is a message just sent: it enters and
|
|
12
|
+
* confirms one sweep. Edit opens the bubble as a Textarea in place; saving is `onEdit` (a new branch).
|
|
13
|
+
*/
|
|
14
|
+
export function Message({ text, arrive, onEdit }: { text: string; arrive?: boolean; onEdit?: ((text: string) => void) | undefined }) {
|
|
15
|
+
const editing = state(false)
|
|
16
|
+
const draft = state('')
|
|
17
|
+
let bubble: HTMLElement | undefined
|
|
18
|
+
let root: HTMLElement | undefined
|
|
19
|
+
onMount(() => { const s = installSweep(); if (untrack(() => arrive)) requestAnimationFrame(() => s.confirm(bubble)) })
|
|
20
|
+
const open = () => {
|
|
21
|
+
draft.value = text
|
|
22
|
+
editing.value = true
|
|
23
|
+
queueMicrotask(() => root?.querySelector('textarea')?.focus())
|
|
24
|
+
}
|
|
25
|
+
const close = () => {
|
|
26
|
+
editing.value = false
|
|
27
|
+
queueMicrotask(() => root?.querySelector<HTMLElement>('[aria-label="Edit message"]')?.focus())
|
|
28
|
+
}
|
|
29
|
+
const save = () => { const t = draft.value.trim(); close(); if (t && t !== text) onEdit?.(t) }
|
|
30
|
+
return (
|
|
31
|
+
<div ref={n => (root = n)} data-scope="message" data-mine="" data-arrive={arrive ? '' : undefined}>
|
|
32
|
+
<div data-part="body">
|
|
33
|
+
{editing.value ? (
|
|
34
|
+
<>
|
|
35
|
+
<Textarea label="Edit message" value={draft.value} onInput={e => (draft.value = e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); close() } }} />
|
|
36
|
+
<div data-part="status">
|
|
37
|
+
<Button size="sm" onClick={save}>Save</Button>
|
|
38
|
+
<Button emphasis="ghost" tone="neutral" size="sm" onClick={close}>Cancel</Button>
|
|
39
|
+
</div>
|
|
40
|
+
</>
|
|
41
|
+
) : (
|
|
42
|
+
<>
|
|
43
|
+
<div ref={n => (bubble = n)} data-part="bubble" data-variant="prompt" data-sweep=""><Sweep />{text}</div>
|
|
44
|
+
<div data-part="status" style="gap:2px">
|
|
45
|
+
{onEdit && <IconButton icon={EditIcon} label="Edit message" onClick={open} />}
|
|
46
|
+
<IconButton icon={CopyIcon} label="Copy" onClick={(e: Event) => copyConfirm(e.currentTarget as Element, text, bubble)} />
|
|
47
|
+
</div>
|
|
48
|
+
</>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DownIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { announce, nextIndex, popover, uid } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, type State } from '@weft-js/core'
|
|
4
|
+
import { Badge } from '../Badge'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import { held } from './bits'
|
|
7
|
+
import '../styles'
|
|
8
|
+
import '@weft-js/warp-core/styles/components/popover.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/app/prompt.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/app/suggest.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/app/model-menu.css'
|
|
12
|
+
|
|
13
|
+
/** A model on offer. */
|
|
14
|
+
export type ModelOption = { id: string; name: string; short?: string; provider?: string | undefined; description?: string | undefined; capabilities?: string[]; context?: string; cost?: string }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ModelPicker (07 §1.3): the model chip opens a Popover holding a listbox grouped by provider. Each option shows its
|
|
18
|
+
* capabilities, context size and relative cost; the current one is checked. ↑/↓, Home/End move, Enter picks, Escape
|
|
19
|
+
* closes; focus goes back to the chip. `compact` shows the short name.
|
|
20
|
+
*/
|
|
21
|
+
export function ModelPicker({ models, value, onChange, compact }: { models: ModelOption[]; value?: string | State<string | undefined> | undefined; onChange?: ((id: string) => void) | undefined; compact?: boolean | undefined }) {
|
|
22
|
+
const id = uid('model')
|
|
23
|
+
const open = state(false)
|
|
24
|
+
const picked = held<string | undefined>(() => value, undefined, () => (v: string | undefined) => onChange?.(v!))
|
|
25
|
+
const current = derived(() => models.find(m => m.id === picked.value) ?? models[0])
|
|
26
|
+
const groups = derived(() => [...new Set(models.map(m => m.provider ?? ''))].map(name => ({ name, models: models.filter(m => (m.provider ?? '') === name) })))
|
|
27
|
+
let chip: HTMLButtonElement | undefined
|
|
28
|
+
let panel: HTMLElement | undefined
|
|
29
|
+
use(popover, () => panel, () => ({ open: open.value, trigger: chip!, side: 'top' as const, align: 'end' as const, onClose: () => (open.value = false) }))
|
|
30
|
+
const choose = (m: ModelOption) => {
|
|
31
|
+
picked.set(m.id)
|
|
32
|
+
open.value = false
|
|
33
|
+
announce(panel!.ownerDocument, `Model: ${m.name}`)
|
|
34
|
+
}
|
|
35
|
+
const onKey = (e: KeyboardEvent) => {
|
|
36
|
+
const list = [...panel!.querySelectorAll<HTMLElement>('[role="option"]')]
|
|
37
|
+
const at = list.indexOf(e.target as HTMLElement)
|
|
38
|
+
const to = nextIndex(e.key, at, list.length, { orientation: 'vertical' })
|
|
39
|
+
if (to >= 0) { e.preventDefault(); list[to]!.focus() }
|
|
40
|
+
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); list[at]?.click() }
|
|
41
|
+
}
|
|
42
|
+
return (
|
|
43
|
+
<span data-scope="prompt" style="display:contents">
|
|
44
|
+
<button ref={n => (chip = n)} type="button" data-part="model" aria-haspopup="listbox" aria-expanded={open.value ? 'true' : 'false'} aria-controls={id} onClick={() => (open.value = !open.value)}>
|
|
45
|
+
<i></i><span data-part="sr-only">Model: </span><span>{compact ? (current.value?.short ?? current.value?.name) : current.value?.name}</span><DownIcon />
|
|
46
|
+
</button>
|
|
47
|
+
<div ref={n => (panel = n)} id={id} data-scope="popover" data-part="content" popover="manual" style="padding:4px;min-inline-size:280px">
|
|
48
|
+
<div data-scope="model-menu" role="listbox" aria-label="Model" onKeyDown={onKey}>
|
|
49
|
+
{groups.value.map(g => (
|
|
50
|
+
<div key={g.name} role="group" aria-label={g.name || 'Models'}>
|
|
51
|
+
<div data-scope="suggest" style="all:unset"><div data-part="group" aria-hidden="true">{g.name || 'Models'}</div></div>
|
|
52
|
+
{g.models.map(m => (
|
|
53
|
+
<div key={m.id} role="option" tabindex="-1" aria-selected={m.id === current.value?.id ? 'true' : 'false'} data-initial={m.id === current.value?.id ? '' : undefined} onClick={() => choose(m)}>
|
|
54
|
+
<b>{m.name}</b>
|
|
55
|
+
<small>{m.description} {(m.capabilities ?? []).map(c => <Badge key={c} tone="neutral">{c}</Badge>)}</small>
|
|
56
|
+
<code>{[m.context, m.cost].filter(Boolean).join(' · ')}</code>
|
|
57
|
+
</div>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</span>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { CommandIcon, FileIcon, GlobeIcon, MicIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { announce, applySuggest, autosize, installSweep, suggestQuery, uid } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, onMount, state, type State } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import { Bar, held, IconButton, Sweep } from './bits'
|
|
7
|
+
import { ContextMeter, type ContextUse } from './ContextMeter'
|
|
8
|
+
import { ModelPicker, type ModelOption } from './ModelPicker'
|
|
9
|
+
import '../styles'
|
|
10
|
+
import '@weft-js/warp-core/styles/app/prompt.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/app/composer.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/app/suggest.css'
|
|
13
|
+
|
|
14
|
+
/** A tool the composer's row can offer. */
|
|
15
|
+
export type PromptTool = 'attach' | 'slash' | 'mic' | 'web'
|
|
16
|
+
/** A file attached to the prompt. */
|
|
17
|
+
export type PromptFile = { id: string; name: string; progress?: number }
|
|
18
|
+
/** A completion shown under the prompt. */
|
|
19
|
+
export type PromptSuggestion = { value: string; description?: string | undefined }
|
|
20
|
+
/** What the prompt hands out through `control`. */
|
|
21
|
+
export interface PromptControl { insert(text: string): void; focus(): void }
|
|
22
|
+
|
|
23
|
+
/** Props for Prompt. */
|
|
24
|
+
export interface PromptProps {
|
|
25
|
+
value?: State<string> | string
|
|
26
|
+
/** Called with the draft on every keystroke. */
|
|
27
|
+
onValueChange?: (v: string) => void
|
|
28
|
+
onSubmit?: (text: string) => void
|
|
29
|
+
/** Pressing Stop or Escape while busy. Stop keeps what arrived. */
|
|
30
|
+
onStop?: () => void
|
|
31
|
+
/** A message sent while busy goes to the queue. */
|
|
32
|
+
onQueue?: (text: string) => void
|
|
33
|
+
busy?: boolean | undefined
|
|
34
|
+
/** Why Send is off ("You're offline"): the button stays focusable and says why. */
|
|
35
|
+
blocked?: string
|
|
36
|
+
label?: string | undefined
|
|
37
|
+
placeholder?: string
|
|
38
|
+
tools?: PromptTool[]
|
|
39
|
+
/** The picked model's id: a state, or a plain value with `onModelChange`. */
|
|
40
|
+
model?: State<string> | string
|
|
41
|
+
models?: ModelOption[]
|
|
42
|
+
/** Called with the id of the model picked. */
|
|
43
|
+
onModelChange?: (id: string) => void
|
|
44
|
+
context?: ContextUse
|
|
45
|
+
/** Called from the context meter's Compact: summarise the conversation to free room. */
|
|
46
|
+
onCompact?: () => void
|
|
47
|
+
/** Attached files shown as chips above the input, with upload progress. */
|
|
48
|
+
files?: PromptFile[]
|
|
49
|
+
/** Your own attachment row, in place of `files`. */
|
|
50
|
+
attachments?: unknown
|
|
51
|
+
/** Called with the files picked or dropped. */
|
|
52
|
+
onAttach?: (files: File[]) => void
|
|
53
|
+
/** Called with a file's id when its chip is removed. */
|
|
54
|
+
onRemoveFile?: (id: string) => void
|
|
55
|
+
/** What `/` offers: one suggestion each, with a description. */
|
|
56
|
+
commands?: PromptSuggestion[]
|
|
57
|
+
/** What `@` offers: one suggestion each, with a description. */
|
|
58
|
+
mentions?: PromptSuggestion[]
|
|
59
|
+
/** Whether the Web toggle is on. */
|
|
60
|
+
web?: boolean
|
|
61
|
+
/** Called with the Web toggle's new state. */
|
|
62
|
+
onWebChange?: (on: boolean) => void
|
|
63
|
+
/** Whether dictation is on: the mic button turns critical. */
|
|
64
|
+
mic?: boolean
|
|
65
|
+
/** Called when the mic button is pressed. */
|
|
66
|
+
onMic?: () => void
|
|
67
|
+
/** Phone: fewer tools, the model's short name. */
|
|
68
|
+
compact?: boolean | undefined
|
|
69
|
+
/** Hands over insert() and focus(), for SpeechInput's Insert. */
|
|
70
|
+
control?: ((c: PromptControl) => void) | undefined
|
|
71
|
+
/** Above the composer: Suggestions. */
|
|
72
|
+
children?: unknown
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Prompt (07 §1.1, §1.6): one composer in the field's sweep. It grows to 8 lines; Enter sends, Shift+Enter adds a line,
|
|
77
|
+
* Escape stops. Send and Stop are one button, so focus never drops. `/` and `@` open a suggest listbox on the textarea
|
|
78
|
+
* (aria-activedescendant); files come by button or drop, the drag holding the sweep. Busy sends go to `onQueue`.
|
|
79
|
+
*/
|
|
80
|
+
export function Prompt(p: PromptProps) {
|
|
81
|
+
const id = uid('prompt')
|
|
82
|
+
const draft = held<string>(() => p.value, '', () => p.onValueChange)
|
|
83
|
+
const toolset = derived(() => p.tools ?? ['attach', 'slash', 'mic', 'web'])
|
|
84
|
+
const shown = (t: PromptTool) => toolset.value.includes(t)
|
|
85
|
+
const open = state<ReturnType<typeof suggestQuery>>(null)
|
|
86
|
+
const active = state(0)
|
|
87
|
+
const drag = state(false)
|
|
88
|
+
const options = derived(() => {
|
|
89
|
+
const q = open.value
|
|
90
|
+
if (!q) return []
|
|
91
|
+
const list = (q.trigger === '/' ? p.commands : p.mentions) ?? []
|
|
92
|
+
return list.filter(o => o.value.slice(1).toLowerCase().startsWith(q.query))
|
|
93
|
+
})
|
|
94
|
+
const listed = derived(() => options.value.length > 0)
|
|
95
|
+
const oid = (o: PromptSuggestion) => `${id}-o-${o.value.slice(1)}`
|
|
96
|
+
const current = derived(() => options.value[Math.min(active.value, options.value.length - 1)])
|
|
97
|
+
const activeId = derived(() => (current.value ? oid(current.value) : undefined))
|
|
98
|
+
const hasFiles = derived(() => p.attachments !== undefined || !!p.files?.length)
|
|
99
|
+
let box: HTMLTextAreaElement | undefined
|
|
100
|
+
let host: HTMLElement | undefined
|
|
101
|
+
let picker: HTMLInputElement | undefined
|
|
102
|
+
use(autosize, () => box, () => draft.value)
|
|
103
|
+
onMount(() => {
|
|
104
|
+
p.control?.({
|
|
105
|
+
insert(text) {
|
|
106
|
+
draft.set(draft.value ? `${draft.value} ${text}` : text)
|
|
107
|
+
box!.focus({ preventScroll: true })
|
|
108
|
+
installSweep().confirm(host)
|
|
109
|
+
announce(box!.ownerDocument, 'Inserted into the message box.')
|
|
110
|
+
},
|
|
111
|
+
focus: () => box!.focus(),
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
const look = () => {
|
|
115
|
+
open.value = suggestQuery(box!.value, box!.selectionStart)
|
|
116
|
+
active.value = 0
|
|
117
|
+
}
|
|
118
|
+
const pick = (value: string) => {
|
|
119
|
+
const q = open.value!
|
|
120
|
+
const next = applySuggest(box!.value, q.start, box!.selectionStart, value)
|
|
121
|
+
draft.set(next.text)
|
|
122
|
+
open.value = null
|
|
123
|
+
box!.value = next.text
|
|
124
|
+
box!.focus()
|
|
125
|
+
box!.setSelectionRange(next.caret, next.caret)
|
|
126
|
+
}
|
|
127
|
+
const send = () => {
|
|
128
|
+
const text = draft.value.trim()
|
|
129
|
+
if (!text) return
|
|
130
|
+
if (p.busy) {
|
|
131
|
+
if (!p.onQueue) return
|
|
132
|
+
p.onQueue(text)
|
|
133
|
+
announce(box!.ownerDocument, 'Queued. It will run after the current step.')
|
|
134
|
+
} else if (p.blocked) return
|
|
135
|
+
else p.onSubmit?.(text)
|
|
136
|
+
draft.set('')
|
|
137
|
+
open.value = null
|
|
138
|
+
}
|
|
139
|
+
const press = () => (p.busy ? p.onStop?.() : send())
|
|
140
|
+
const onKey = (e: KeyboardEvent) => {
|
|
141
|
+
if (listed.value) {
|
|
142
|
+
const n = options.value.length
|
|
143
|
+
if (e.key === 'Escape') { e.preventDefault(); open.value = null; return }
|
|
144
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); active.value = (active.value + (e.key === 'ArrowDown' ? 1 : n - 1)) % n; return }
|
|
145
|
+
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pick(current.value!.value); return }
|
|
146
|
+
}
|
|
147
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send() }
|
|
148
|
+
else if (e.key === 'Escape' && p.busy) { e.preventDefault(); p.onStop?.() }
|
|
149
|
+
}
|
|
150
|
+
const add = (list: FileList | null | undefined) => { const f = [...(list ?? [])]; if (f.length) p.onAttach?.(f) }
|
|
151
|
+
const onDrop = (e: DragEvent) => { e.preventDefault(); drag.value = false; add(e.dataTransfer?.files) }
|
|
152
|
+
const over = (e: DragEvent) => { e.preventDefault(); drag.value = true }
|
|
153
|
+
return (
|
|
154
|
+
<div data-scope="prompt" style="position:relative;padding:0 var(--warp-space-group) var(--warp-space-group)">
|
|
155
|
+
{p.children}
|
|
156
|
+
<div ref={n => (host = n)} data-scope="composer" data-sweep="" data-pin={drag.value ? '' : undefined} style="margin:0" onDragOver={over} onDragLeave={() => (drag.value = false)} onDrop={onDrop}>
|
|
157
|
+
<Sweep />
|
|
158
|
+
{hasFiles.value && (
|
|
159
|
+
<div data-part="files">
|
|
160
|
+
{p.attachments ?? (p.files ?? []).map(f => (
|
|
161
|
+
<span key={f.id} data-part="file">
|
|
162
|
+
<FileIcon /><span>{f.name}</span>
|
|
163
|
+
<IconButton icon={XIcon} label={`Remove ${f.name}`} onClick={() => p.onRemoveFile?.(f.id)} />
|
|
164
|
+
{f.progress !== undefined && f.progress < 1 && <Bar label={`Uploading ${f.name}`} value={f.progress} />}
|
|
165
|
+
</span>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
<label data-part="sr-only" for={`${id}-in`}>{p.label ?? 'Message the assistant'}</label>
|
|
170
|
+
<textarea ref={n => (box = n)} id={`${id}-in`} rows="1" enterkeyhint="send" placeholder={p.placeholder ?? 'Ask, or type / for commands'} aria-describedby={p.blocked ? `${id}-hint ${id}-why` : `${id}-hint`} aria-controls={listed.value ? `${id}-list` : undefined} aria-activedescendant={activeId.value} value={draft.value} onInput={e => { draft.set((e.currentTarget).value); look() }} onKeyDown={onKey} onClick={look} onBlur={() => (open.value = null)}></textarea>
|
|
171
|
+
{p.blocked && <span data-part="hint" data-state="error" id={`${id}-why`}>{p.blocked}</span>}
|
|
172
|
+
<div data-part="row">
|
|
173
|
+
{shown('attach') && <IconButton icon={PaperclipIcon} label="Attach file" onClick={() => picker?.click()} />}
|
|
174
|
+
{shown('attach') && <input ref={n => (picker = n)} type="file" multiple hidden onChange={e => { add((e.currentTarget).files); (e.currentTarget).value = '' }} />}
|
|
175
|
+
{shown('slash') && !!p.commands?.length && <IconButton icon={CommandIcon} label="Commands" onClick={() => { draft.set('/'); box!.value = '/'; box!.focus(); box!.setSelectionRange(1, 1); look() }} />}
|
|
176
|
+
{shown('mic') && <IconButton icon={MicIcon} label="Dictate" tone={p.mic ? 'critical' : 'neutral'} aria-pressed={p.mic ? 'true' : 'false'} onClick={p.onMic} />}
|
|
177
|
+
{shown('web') && !p.compact && <Button emphasis="ghost" tone="neutral" size="sm" aria-pressed={p.web ? 'true' : 'false'} onClick={() => p.onWebChange?.(!p.web)}><GlobeIcon /><span data-part="label">Web</span></Button>}
|
|
178
|
+
<span data-part="spacer"></span>
|
|
179
|
+
{p.context && <ContextMeter used={p.context.used} limit={p.context.limit} parts={p.context.parts} onCompact={p.onCompact} />}
|
|
180
|
+
{p.models && <ModelPicker models={p.models} value={p.model} onChange={p.onModelChange} compact={p.compact} />}
|
|
181
|
+
<Button data-part="send" data-icon-only="" tone={p.busy ? 'neutral' : undefined} label={p.busy ? 'Stop' : 'Send'} aria-disabled={p.blocked && !p.busy ? 'true' : undefined} onClick={press}>{p.busy ? <StopIcon /> : <SendIcon />}</Button>
|
|
182
|
+
</div>
|
|
183
|
+
<span data-part="sr-only" id={`${id}-hint`}>Enter sends, Shift+Enter adds a line, Escape stops a running answer.</span>
|
|
184
|
+
{listed.value && (
|
|
185
|
+
<div data-scope="suggest" role="listbox" id={`${id}-list`} aria-label={open.value?.trigger === '/' ? 'Commands' : 'Mentions'}>
|
|
186
|
+
<div data-part="group" aria-hidden="true">{open.value?.trigger === '/' ? 'Commands' : 'Mentions'}</div>
|
|
187
|
+
{options.value.map(o => (
|
|
188
|
+
<div key={o.value} id={oid(o)} role="option" aria-selected={o === current.value ? 'true' : 'false'} onMouseDown={e => { e.preventDefault(); pick(o.value) }}>
|
|
189
|
+
<span data-part="cmd">{o.value}</span><small>{o.description}</small><kbd>{o === current.value ? '↵' : ''}</kbd>
|
|
190
|
+
</div>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { parseInline, parseText, type AgentSource, type Inline, type TextBlock } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, untrack } from '@weft-js/core'
|
|
3
|
+
import { Cite } from './Cite'
|
|
4
|
+
import '../styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/app/assistant.css'
|
|
6
|
+
|
|
7
|
+
function Cited({ n, sources, base }: { n: number; sources: AgentSource[]; base: string }) {
|
|
8
|
+
const source = derived(() => sources[n - 1])
|
|
9
|
+
return <Cite n={n} source={source.value} href={`#${base}-s${n}`} />
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Mark = Extract<Inline, { kind: 'cite' }>
|
|
13
|
+
type Run = Exclude<Inline, Mark>
|
|
14
|
+
type Para = Extract<TextBlock, { kind: 'p' }>
|
|
15
|
+
type List = Extract<TextBlock, { kind: 'ul' }>
|
|
16
|
+
type Fence = Extract<TextBlock, { kind: 'code' }>
|
|
17
|
+
|
|
18
|
+
function Span({ part, sources, base }: { part: Inline; sources: AgentSource[]; base: string }) {
|
|
19
|
+
const kind = derived(() => part.kind)
|
|
20
|
+
const view = derived(() => {
|
|
21
|
+
const k = kind.value
|
|
22
|
+
return untrack(() => (k === 'cite' ? <Cited n={(part as Mark).n} sources={sources} base={base} /> : k === 'code' ? <code>{(part as Run).text}</code> : <span>{(part as Run).text}</span>))
|
|
23
|
+
})
|
|
24
|
+
return <>{view.value}</>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Spans({ text, sources, base }: { text: string; sources: AgentSource[]; base: string }) {
|
|
28
|
+
const parts = derived<Inline[]>(() => parseInline(text))
|
|
29
|
+
return <>{parts.value.map(p => <Span key={p.at} part={p} sources={sources} base={base} />)}</>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Block({ block, sources, base }: { block: TextBlock; sources: AgentSource[]; base: string }) {
|
|
33
|
+
const kind = derived(() => block.kind)
|
|
34
|
+
// a block that changes kind (a paragraph becoming a list) rebuilds; one that only grows is patched
|
|
35
|
+
const view = derived(() => {
|
|
36
|
+
const k = kind.value
|
|
37
|
+
return untrack(() => {
|
|
38
|
+
if (k === 'p') return <p><Spans text={(block as Para).text} sources={sources} base={base} /></p>
|
|
39
|
+
if (k === 'ul') return <ul>{(block as List).items.map(i => <li key={i.at}><Spans text={i.text} sources={sources} base={base} /></li>)}</ul>
|
|
40
|
+
return <pre role="group" tabindex="0" aria-label={(block as Fence).lang ? `Code, ${(block as Fence).lang}` : 'Code'}><code>{(block as Fence).text}</code></pre>
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
return <>{view.value}</>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Answer text, the safe default until warp has Markdown: paragraphs, `- ` lists, code fences (open ones already render
|
|
48
|
+
* as code), inline code and `[n]` cites. Text only: nothing in the model's output becomes markup. Blocks are keyed by
|
|
49
|
+
* where they start, so streaming patches the last one in place.
|
|
50
|
+
*/
|
|
51
|
+
export function Prose({ text, sources = [], base = 'src' }: { text: string; sources?: AgentSource[]; base?: string }) {
|
|
52
|
+
const blocks = derived(() => parseText(text))
|
|
53
|
+
return <>{blocks.value.map(b => <Block key={b.at} block={b} sources={sources} base={base} />)}</>
|
|
54
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CheckIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { announce, uid, type AgentQuestion } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, onMount, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { Checkbox, Radio } from '../Choice'
|
|
6
|
+
import { Input } from '../Field'
|
|
7
|
+
import '../styles'
|
|
8
|
+
import '@weft-js/warp-core/styles/app/question.css'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Question (07 §1.3): the run pauses and asks. A fieldset with the question as legend, radios or checkboxes, optional
|
|
12
|
+
* free text; Continue is the solid. Once answered it reads back the choice.
|
|
13
|
+
*/
|
|
14
|
+
export function Question({ question, onAnswer, quiet }: { question: AgentQuestion; onAnswer?: (answer: string | string[]) => void; quiet?: boolean | undefined }) {
|
|
15
|
+
const name = uid('q')
|
|
16
|
+
const type = derived(() => (question.multiple ? 'checkbox' : 'radio'))
|
|
17
|
+
const picks = state<string[]>(untrack(() => (question.multiple ? [] : question.options.slice(0, 1))))
|
|
18
|
+
const free = state('')
|
|
19
|
+
const other = '__other'
|
|
20
|
+
const options = derived(() => [...question.options.map(o => ({ value: o, label: o })), ...(question.free ? [{ value: other, label: 'Something else…' }] : [])])
|
|
21
|
+
const answered = derived(() => question.answer)
|
|
22
|
+
const said = derived(() => { const a = answered.value; return Array.isArray(a) ? a.join(', ') : a })
|
|
23
|
+
let form: HTMLFormElement | undefined
|
|
24
|
+
onMount(() => { if (!untrack(() => quiet) && form) announce(form.ownerDocument, 'The assistant has a question.', true) })
|
|
25
|
+
const toggle = (v: string, on: boolean) => (picks.value = question.multiple ? (on ? [...picks.value, v] : picks.value.filter(x => x !== v)) : [v])
|
|
26
|
+
const submit = (e: Event) => {
|
|
27
|
+
e.preventDefault()
|
|
28
|
+
const chosen = picks.value.map(p => (p === other ? free.value.trim() : p)).filter(Boolean)
|
|
29
|
+
if (chosen.length) onAnswer?.(question.multiple ? chosen : chosen[0]!)
|
|
30
|
+
}
|
|
31
|
+
// one build per phase: a list around this reads its nodes again as siblings arrive, and picks must survive that
|
|
32
|
+
const done = derived(() => answered.value !== undefined)
|
|
33
|
+
const view = derived(() => (done.value ? (
|
|
34
|
+
<p><CheckIcon /> You chose: {said.value}</p>
|
|
35
|
+
) : untrack(() => (
|
|
36
|
+
<form ref={n => (form = n)} data-scope="question" onSubmit={submit}>
|
|
37
|
+
<fieldset disabled={question.closed}>
|
|
38
|
+
<legend>{question.prompt}</legend>
|
|
39
|
+
{options.value.map(o => (
|
|
40
|
+
type.value === 'radio'
|
|
41
|
+
? <Radio key={o.value} name={name} checked={picks.value.includes(o.value)} onChange={(e: Event & { currentTarget: HTMLInputElement }) => toggle(o.value, e.currentTarget.checked)}>{o.label}</Radio>
|
|
42
|
+
: <Checkbox key={o.value} name={name} checked={picks.value.includes(o.value)} onChange={(e: Event & { currentTarget: HTMLInputElement }) => toggle(o.value, e.currentTarget.checked)}>{o.label}</Checkbox>
|
|
43
|
+
))}
|
|
44
|
+
{picks.value.includes(other) && <Input label="Your answer" value={free.value} onInput={e => (free.value = e.currentTarget.value)} />}
|
|
45
|
+
</fieldset>
|
|
46
|
+
<div>
|
|
47
|
+
<Button size="sm" type="submit" disabled={question.closed}>Continue</Button> <small>{question.closed ? 'The run stopped before this was answered.' : 'Waiting for you'}</small>
|
|
48
|
+
</div>
|
|
49
|
+
</form>
|
|
50
|
+
))))
|
|
51
|
+
return <>{view.value}</>
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RightIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
import { Shimmer } from './Shimmer'
|
|
4
|
+
import '../styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/app/reasoning.css'
|
|
6
|
+
|
|
7
|
+
/** Reasoning (07 §1.2): a native details. "Thinking…" while it streams, open; it folds itself and keeps the duration. */
|
|
8
|
+
export function Reasoning({ text, streaming, seconds }: { text: string; streaming?: boolean | undefined; seconds?: number | undefined }) {
|
|
9
|
+
const label = derived(() => (seconds !== undefined ? `Thought for ${seconds} s` : 'Thought'))
|
|
10
|
+
return (
|
|
11
|
+
<details data-scope="reasoning" data-state={streaming ? 'streaming' : 'done'} open={!!streaming}>
|
|
12
|
+
<summary>
|
|
13
|
+
<RightIcon data-part="chev" />
|
|
14
|
+
{streaming ? <Shimmer>Thinking…</Shimmer> : <span>{label.value}</span>}
|
|
15
|
+
</summary>
|
|
16
|
+
<div data-part="body">{text}</div>
|
|
17
|
+
</details>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import '../styles'
|
|
2
|
+
import '@weft-js/warp-core/styles/app/shimmer.css'
|
|
3
|
+
|
|
4
|
+
/** Placeholder text with a light passing over it (07 §1.3); static under reduced motion. */
|
|
5
|
+
export function Shimmer({ children, ...rest }: { children?: unknown; [attr: string]: unknown }) {
|
|
6
|
+
return <span data-scope="shimmer" {...rest}>{children}</span>
|
|
7
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentSource } from '@weft-js/warp-core'
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
import { Sweep, useSweep } from './bits'
|
|
4
|
+
import '../styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/app/sources.css'
|
|
6
|
+
|
|
7
|
+
/** Sources (07 §1.1): numbered cards, the same numbers as the cites. `base` ties them to their Cite hrefs. */
|
|
8
|
+
export function Sources({ items, base }: { items: AgentSource[]; base: string }) {
|
|
9
|
+
useSweep()
|
|
10
|
+
const rows = derived(() => items.map((s, i) => ({ s, n: i + 1, id: `${base}-s${i + 1}` })))
|
|
11
|
+
return (
|
|
12
|
+
<div data-scope="sources" role="group" aria-label="Sources">
|
|
13
|
+
{rows.value.map(r => (
|
|
14
|
+
<a key={r.n} id={r.id} href={r.s.url ?? `#${r.id}`} data-sweep="">
|
|
15
|
+
<Sweep />
|
|
16
|
+
<i>{r.n}</i>
|
|
17
|
+
<b>{r.s.title}</b>
|
|
18
|
+
{r.s.path !== undefined && <small>{r.s.path}</small>}
|
|
19
|
+
</a>
|
|
20
|
+
))}
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|