@weft-js/warp 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,386 @@
1
+ import { derived, DEV, effect, flush, onCleanup, onMount, state, untrack } from '@weft-js/core'
2
+ import { measureEngine, type MeasureHandle, type MeasureProvider } from '@weft-js/core/text'
3
+ import { slices, virtualizer, type VirtualAlign, type VirtualKey, type Virtualizer } from '@weft-js/warp-core'
4
+ import './styles'
5
+ import '@weft-js/warp-core/styles/data/virtual.css'
6
+
7
+ /** What a list hands out through `control`, to scroll and measure from outside. */
8
+ export interface VirtualControl {
9
+ scrollTo(key: VirtualKey, align?: VirtualAlign): void
10
+ scrollToEnd(): void
11
+ /** Render the row, wherever it is, and move focus to it. */
12
+ focus(key: VirtualKey): void
13
+ /** Move focus by rows from the focused one. */
14
+ move(by: number): void
15
+ /** The key of the focused or last-focused row. */
16
+ active(): VirtualKey | undefined
17
+ }
18
+
19
+ /** Props for VirtualList. */
20
+ export interface VirtualListProps<T> {
21
+ items: readonly T[]
22
+ rowKey: (item: T) => VirtualKey
23
+ children: (item: () => T, index: () => number) => unknown
24
+ /** The list's name. */
25
+ label?: string | undefined
26
+ /** The row's text. Rows get exact heights before they render when the app has `text.measure`, and it's measured otherwise. */
27
+ text?: (item: T) => string | null | undefined
28
+ /** Rows that look different around the same text: each gets its own measurement of everything but the text. */
29
+ variant?: ((item: T) => string) | undefined
30
+ /** A row's height before it's known. Default 48. */
31
+ estimate?: number | undefined
32
+ /** Pixels between rows. */
33
+ gap?: number | undefined
34
+ /** Pixels of rows kept rendered past each edge of the view, so a fast scroll shows no gaps. */
35
+ overscan?: number | undefined
36
+ /** Rows on the server and in the first paint. Default 20. */
37
+ initial?: number | undefined
38
+ /** Stay at the end as rows arrive while the user is within this many pixels of it. */
39
+ stick?: number | undefined
40
+ /** `end` opens at the last row and grows upward, as a chat does. */
41
+ from?: 'start' | 'end' | undefined
42
+ /** `list` by default (rows are `listitem`s; `group` with `bareRows`); `feed`, `log` and the rest bring their own row role. */
43
+ role?: string | undefined
44
+ /** The role each row takes, such as `option` or `article`. */
45
+ rowRole?: string | undefined
46
+ /** Rows take no list semantics: what's inside them says what it is, and the scroller is a labelled group. */
47
+ bareRows?: boolean | undefined
48
+ /** Keep this role off the scroller and put it on the list inside. */
49
+ inner?: boolean | undefined
50
+ /** `ol` renders an ordered list with <li> rows; `div` otherwise. */
51
+ tag?: 'div' | 'ol' | undefined
52
+ /** Row keys move focus: ↑/↓, Home/End, one tab stop on the rows. */
53
+ roving?: boolean | undefined
54
+ /** The total isn't the list's to know (a log with history to load): `aria-setsize` is -1, and an arrival writes no row. */
55
+ open?: boolean | undefined
56
+ /** On a key the list doesn't use. */
57
+ onRowKey?: ((e: KeyboardEvent, control: VirtualControl) => void) | undefined
58
+ /** Called when the view nears the first row: load older rows. */
59
+ onStart?: (() => void) | undefined
60
+ /** Called when the view nears the last row: load more. */
61
+ onEnd?: (() => void) | undefined
62
+ /** Called with `true` when the list starts holding to the end, and `false` when the user scrolls away. */
63
+ onStick?: ((stuck: boolean) => void) | undefined
64
+ /** Called when a row's measured height differs from the estimate, with both heights. For tuning. */
65
+ onDrift?: ((index: number, predicted: number, actual: number) => void) | undefined
66
+ /** Hands over a control to scroll to, focus or move between rows from outside. */
67
+ control?: ((c: VirtualControl) => void) | undefined
68
+ /** The scroller's tabindex. Default 0, none with `roving`. */
69
+ tabindex?: number | undefined
70
+ /** The scroller's `data-scope`, for styling it as another component. */
71
+ scope?: string | undefined
72
+ /** The scroller's `data-part`, when it is a part of a larger component. */
73
+ part?: string | undefined
74
+ /** The inner list's `data-scope`. */
75
+ listScope?: string | undefined
76
+ height?: string | undefined
77
+ /** `aria-busy`: more is loading. */
78
+ busy?: boolean | undefined
79
+ class?: string | undefined
80
+ }
81
+
82
+ type MeasureOptions = Parameters<MeasureProvider['prepare']>[2]
83
+ interface Metric { font: string; line: number; wrap: number; chrome: number; opts: { whiteSpace: 'normal' | 'pre-wrap'; letterSpacing?: number | undefined } }
84
+ const EDGE = 8 // rows from the end at which onStart and onEnd ask for more
85
+ const BATCH = 256 // rows prepared between index updates
86
+ const LONG = 'mmmm '.repeat(400)
87
+
88
+ function fontOf(c: CSSStyleDeclaration) {
89
+ return `${c.fontStyle} ${c.fontWeight} ${c.fontSize} ${c.fontFamily}`
90
+ }
91
+
92
+ /** One row's text and everything around it, read from a real hidden row: nothing here knows warp's padding. */
93
+ function metricOf(probe: HTMLElement): Metric | undefined {
94
+ const box = probe.querySelector<HTMLElement>('[data-measure]')
95
+ if (!box) return
96
+ const c = getComputedStyle(box)
97
+ const line = parseFloat(c.lineHeight)
98
+ if (!line) return // `normal` has no one answer
99
+ box.style.display = 'block'
100
+ box.textContent = 'x'
101
+ const chrome = probe.getBoundingClientRect().height - line
102
+ box.textContent = LONG
103
+ const wrap = box.getBoundingClientRect().width - (parseFloat(c.paddingInlineStart) || 0) - (parseFloat(c.paddingInlineEnd) || 0)
104
+ const white = c.whiteSpaceCollapse === 'preserve' || /pre/.test(c.whiteSpace)
105
+ return { font: fontOf(c), line, wrap, chrome, opts: { whiteSpace: white ? 'pre-wrap' : 'normal', letterSpacing: parseFloat(c.letterSpacing) || undefined } }
106
+ }
107
+
108
+ /**
109
+ * A long list (06 §9): one window of rows at known offsets. Give it `text` and rows have exact heights before they
110
+ * exist; rows the engine can't size are measured when they render, and what's on screen never moves when one corrects.
111
+ */
112
+ export function VirtualList<T>(props: VirtualListProps<T>) {
113
+ let el: HTMLElement | undefined, list: HTMLElement | undefined
114
+ let v: Virtualizer | undefined
115
+ let engine: MeasureProvider | null = null
116
+ let stop: (() => void) | undefined
117
+ let measuredAt = -1
118
+ let slicing = false, cursor = 0 // what the idle pass has reached: a change behind it is prepared now, ahead of it later
119
+ const waiting: [HTMLElement, VirtualKey, (undo: () => void) => void][] = []
120
+ const metrics = new Map<string, Metric>()
121
+ const prepared = new Map<VirtualKey, { text: string; handle: MeasureHandle; font: string }>()
122
+ const probes = new Map<string, HTMLElement>()
123
+ const active = state<VirtualKey | undefined>(undefined)
124
+ const pending = state<VirtualKey | undefined>(undefined)
125
+
126
+ const items = derived(() => props.items)
127
+ const keyOf = (item: T) => props.rowKey(item)
128
+ const keys = derived(() => items.value.map(keyOf))
129
+ const variantOf = (item: T) => props.variant?.(item) ?? ''
130
+ const first = untrack(() => {
131
+ const n = items.value.length, count = Math.min(n, props.initial ?? 20)
132
+ return Array.from({ length: count }, (_, i) => (props.from === 'end' ? n - count + i : i))
133
+ })
134
+ // Keys, not indices: a prepend renumbers every row, and that mustn't look like a new set of rows.
135
+ const shown = state<VirtualKey[]>(untrack(() => first.map(i => keyOf(items.value[i]!))))
136
+ const index = derived(() => new Map(keys.value.map((k, i) => [k, i])))
137
+ const rows = derived(() => {
138
+ const all = items.value, at = index.value
139
+ return shown.value.flatMap(key => { const i = at.get(key); return i === undefined ? [] : [{ item: all[i]!, i, key }] })
140
+ })
141
+
142
+ const samples = derived(() => {
143
+ if (!props.text) return []
144
+ const seen = new Map<string, T>()
145
+ for (const item of items.value) {
146
+ const name = variantOf(item)
147
+ if (!seen.has(name)) seen.set(name, item)
148
+ if (!props.variant) break
149
+ }
150
+ return [...seen]
151
+ })
152
+ const probing = state(false)
153
+
154
+ function prepare(i: number) {
155
+ const item = untrack(() => items.value[i]), m = metrics.get(variantOf(item!)), value = props.text?.(item!)
156
+ if (!engine || !m || value == null) return false
157
+ const key = keyOf(item!), had = prepared.get(key)
158
+ if (had && had.text === value && had.font === m.font) return false
159
+ // Preserved white space leaves the fast tier, so it's asked for only by text that has some to preserve.
160
+ const handle = engine.prepare(value, m.font, m.opts.whiteSpace === 'pre-wrap' && !/\n| {2}|\t/.test(value) ? { ...m.opts, whiteSpace: 'normal' } as MeasureOptions : m.opts as MeasureOptions)
161
+ prepared.set(key, { text: value, handle, font: m.font })
162
+ void handle.ready?.then(() => { const at = index.value.get(key); if (at !== undefined) v?.refresh(at, at + 1) })
163
+ return true
164
+ }
165
+
166
+ const size = (i: number) => {
167
+ if (!engine) return
168
+ const item = items.value[i]!, m = metrics.get(variantOf(item)), p = prepared.get(keyOf(item))
169
+ if (!m || !p || p.text !== props.text?.(item)) return
170
+ return m.chrome + Math.max(m.line, engine.layout(p.handle, m.wrap, m.line).height)
171
+ }
172
+
173
+ function calibrate() {
174
+ metrics.clear()
175
+ for (const [name, probe] of probes) {
176
+ const m = metricOf(probe)
177
+ if (m) metrics.set(name, m)
178
+ }
179
+ measuredAt = list?.getBoundingClientRect().width ?? -1
180
+ }
181
+
182
+ /** The window now, so it paints exact; the rest a few ms at a time, so 10,000 rows never block a frame. */
183
+ function prepareAll() {
184
+ stop?.()
185
+ slicing = true
186
+ cursor = 0
187
+ for (const key of shown.value) { const i = index.value.get(key); if (i !== undefined) prepare(i) }
188
+ v?.refresh()
189
+ let lo = 0, done = 0
190
+ stop = slices(() => {
191
+ if (cursor >= items.value.length) {
192
+ slicing = false
193
+ if (cursor > lo) v?.refresh(lo, cursor)
194
+ return false
195
+ }
196
+ if (prepare(cursor++)) done++
197
+ if (done >= BATCH) { v?.refresh(lo, cursor); lo = cursor; done = 0 }
198
+ return true
199
+ })
200
+ }
201
+
202
+ function start() {
203
+ calibrate()
204
+ prepareAll()
205
+ }
206
+
207
+ const fontsDone = () => { engine?.clear(); prepared.clear(); start() }
208
+ const root = () => el!.ownerDocument.documentElement
209
+ let dials: MutationObserver | undefined
210
+
211
+ /** Bring a row into view if it isn't. Before its key has arrived this does nothing, and the keys effect comes back. */
212
+ function reveal(key: VirtualKey) {
213
+ const i = index.value.get(key)
214
+ if (i === undefined || !v) return
215
+ const a = v.anchor(), now = v.sizes.offset(a.index) + a.within, top = v.sizes.offset(i)
216
+ if (top < now) v.scrollTo(i, 'start')
217
+ else if (top + v.sizes.get(i)! > now + el!.clientHeight) v.scrollTo(i, 'end')
218
+ }
219
+
220
+ const control: VirtualControl = {
221
+ scrollTo(key, align) {
222
+ const i = index.value.get(key)
223
+ if (i !== undefined) v?.scrollTo(i, align)
224
+ },
225
+ scrollToEnd: () => v?.scrollToEnd(),
226
+ focus(key) {
227
+ active.value = key
228
+ pending.value = key
229
+ v?.update({})
230
+ reveal(key)
231
+ },
232
+ move(by) {
233
+ const all = keys.value, at = all.indexOf(active.value as VirtualKey)
234
+ control.focus(all[Math.max(0, Math.min(all.length - 1, (at < 0 ? v!.anchor().index : at) + by))]!)
235
+ },
236
+ active: () => active.value,
237
+ }
238
+
239
+ function onKeyDown(e: KeyboardEvent) {
240
+ if (props.roving && !e.altKey && !e.ctrlKey && !e.metaKey) {
241
+ const n = keys.value.length
242
+ const to = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : e.key === 'Home' ? -n : e.key === 'End' ? n : 0
243
+ if (to && (e.target as HTMLElement).closest?.('[data-part="row"]') === e.target) {
244
+ e.preventDefault()
245
+ control.move(to)
246
+ return
247
+ }
248
+ }
249
+ props.onRowKey?.(e, control)
250
+ }
251
+
252
+ let askedStart = -1, askedEnd = -1
253
+ function edges(indices: number[]) {
254
+ const n = keys.value.length
255
+ if (!n || !indices.length) return
256
+ if (props.onEnd && indices.at(-1)! >= n - EDGE && askedEnd !== n) { askedEnd = n; props.onEnd() }
257
+ if (props.onStart && indices[0]! <= EDGE && askedStart !== n) { askedStart = n; props.onStart() }
258
+ }
259
+
260
+ onMount(() => {
261
+ queueMicrotask(() => {
262
+ if (!el) return
263
+ v = virtualizer(el, {
264
+ keys: untrack(() => keys.value), spacer: list!, estimate: untrack(() => props.estimate ?? 48), gap: untrack(() => props.gap), overscan: untrack(() => props.overscan),
265
+ stick: untrack(() => props.stick), from: untrack(() => props.from), size: (i, width) => {
266
+ if (width !== measuredAt && metrics.size) calibrate()
267
+ return size(i)
268
+ },
269
+ pinned: () => (active.value === undefined ? [] : [active.value]),
270
+ onRange: indices => { for (const i of indices) prepare(i); shown.value = indices.map(i => keys.value[i]!); edges(indices) },
271
+ onStick: s => props.onStick?.(s), onDrift: (i, p, a) => {
272
+ props.onDrift?.(i, p, a)
273
+ if (DEV && engine && Math.abs(p - a) > 1 && prepared.has(keys.value[i]!)) console.debug(`weft: X001 row ${i} predicted ${p}px and renders ${a}px`)
274
+ },
275
+ })
276
+ for (const [row, key, keep] of waiting.splice(0)) keep(v.attach(row, key))
277
+ effect(() => keys.value, k => {
278
+ v!.update({ keys: k })
279
+ const key = untrack(() => pending.value)
280
+ if (key !== undefined) reveal(key)
281
+ })
282
+ effect(() => active.value, () => v!.update({}))
283
+ untrack(() => props.control?.(control))
284
+ if (!untrack(() => props.text)) return
285
+ void measureEngine().then(e => {
286
+ if (!e || !el) return
287
+ engine = e
288
+ probing.value = true
289
+ flush()
290
+ start()
291
+ effect(() => items.value, (_, prev) => {
292
+ if (prev === undefined) return
293
+ let lo = -1, hi = 0
294
+ const to = slicing ? cursor : items.value.length
295
+ for (let i = 0; i < to; i++) if (prepare(i)) { if (lo < 0) lo = i; hi = i + 1 }
296
+ if (lo >= 0) v!.refresh(lo, hi)
297
+ })
298
+ effect(() => samples.value.length, () => { // a kind of row that wasn't here at the start
299
+ if (untrack(() => samples.value.every(([name]) => metrics.has(name)))) return
300
+ queueMicrotask(() => { calibrate(); prepareAll() })
301
+ })
302
+ document.fonts?.addEventListener?.('loadingdone', fontsDone)
303
+ dials = new MutationObserver(() => { calibrate(); prepareAll() })
304
+ dials.observe(root(), { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-expression', 'data-density'] })
305
+ })
306
+ })
307
+ onCleanup(() => {
308
+ stop?.()
309
+ dials?.disconnect()
310
+ document.fonts?.removeEventListener?.('loadingdone', fontsDone)
311
+ v?.destroy()
312
+ })
313
+ })
314
+
315
+ function Row(p: { row: { item: T; i: number; key: VirtualKey } }) {
316
+ let node: HTMLElement | undefined
317
+ const key = untrack(() => p.row.key)
318
+ onMount(() => {
319
+ let undo: (() => void) | undefined
320
+ const keep = (u: () => void) => (undo = u)
321
+ if (v) keep(v.attach(node!, key))
322
+ else waiting.push([node!, key, keep])
323
+ onCleanup(() => undo?.())
324
+ effect(() => pending.value === key, go => {
325
+ if (!go) return
326
+ pending.value = undefined
327
+ ;(node!.querySelector<HTMLElement>('[data-focus]') ?? node!).focus()
328
+ })
329
+ })
330
+ const pos = () => p.row.i
331
+ const place = untrack(() => `position:absolute;inset-inline:0;inset-block-start:0;transform:translateY(${pos() * (props.estimate ?? 48)}px)`) // the first paint; the virtualizer owns it after
332
+ const attrs = () => ({
333
+ ref: (n: HTMLElement) => (node = n),
334
+ 'data-part': 'row',
335
+ role: rowRole(),
336
+ 'aria-posinset': props.bareRows ? undefined : pos() + 1,
337
+ 'aria-setsize': props.bareRows ? undefined : props.open ? -1 : keys.value.length,
338
+ 'data-index': props.bareRows ? pos() + 1 : undefined,
339
+ tabindex: props.roving ? (active.value === key || (active.value === undefined && key === shown.value[0]) ? 0 : -1) : undefined,
340
+ style: place,
341
+ onFocusIn: () => { active.value = key },
342
+ })
343
+ const body = props.children(() => p.row.item, pos)
344
+ return props.tag === 'ol' ? <li {...attrs()}>{body}</li> : <div {...attrs()}>{body}</div>
345
+ }
346
+
347
+ const inner = untrack(() => props.inner)
348
+ const listRole = () => props.role ?? (props.bareRows ? 'group' : 'list')
349
+ /** A list's rows are its items (axe aria-required-children); `feed`, `log` and bare rows bring or need none. */
350
+ const rowRole = () => props.rowRole ?? (props.tag !== 'ol' && listRole() === 'list' ? 'listitem' : undefined)
351
+ const listAttrs = () => ({
352
+ ref: (n: HTMLElement) => (list = n),
353
+ 'data-part': 'spacer',
354
+ 'data-scope': props.listScope,
355
+ role: inner ? listRole() : props.tag === 'ol' ? undefined : rowRole() ? 'presentation' : undefined,
356
+ 'aria-label': inner ? props.label : undefined,
357
+ 'aria-busy': inner && props.busy ? 'true' : undefined,
358
+ style: 'position:relative;flex:none',
359
+ })
360
+ function Probe(p: { sample: [string, T] }) {
361
+ const [name, item] = untrack(() => p.sample)
362
+ const body = props.children(() => item, () => 0) // once: measuring overwrites the row's text
363
+ const attrs = { ref: (n: HTMLElement) => probes.set(name, n), 'data-part': 'row', 'data-probe': '', 'aria-hidden': 'true', inert: true, style: 'position:absolute;inset-inline:0;inset-block-start:0;visibility:hidden;contain:layout' }
364
+ return untrack(() => props.tag) === 'ol' ? <li {...attrs}>{body}</li> : <div {...attrs}>{body}</div>
365
+ }
366
+ return (
367
+ <div
368
+ ref={n => (el = n)}
369
+ class={props.class}
370
+ data-scope={props.scope ?? (props.part ? undefined : 'virtual')}
371
+ data-part={props.part}
372
+ data-variable={props.scope || props.part ? undefined : ''}
373
+ role={inner ? undefined : listRole()}
374
+ aria-label={inner ? undefined : props.label}
375
+ aria-live={props.role === 'log' ? 'off' : undefined}
376
+ aria-busy={!inner && props.busy ? 'true' : undefined}
377
+ tabindex={untrack(() => props.roving) ? undefined : props.tabindex ?? 0}
378
+ style={props.height ? `block-size:${props.height}` : undefined}
379
+ onKeyDown={onKeyDown}
380
+ >
381
+ {untrack(() => props.tag) === 'ol'
382
+ ? <ol {...listAttrs()}>{rows.value.map(r => <Row key={r.key} row={r} />)}{probing.value && samples.value.map(s => <Probe key={s[0]} sample={s} />)}</ol>
383
+ : <div {...listAttrs()}>{rows.value.map(r => <Row key={r.key} row={r} />)}{probing.value && samples.value.map(s => <Probe key={s[0]} sample={s} />)}</div>}
384
+ </div>
385
+ )
386
+ }
@@ -0,0 +1,136 @@
1
+ import { KeyIcon } from '@weft-js/warp-icons'
2
+ import { agoText, usedWithinDay } from '@weft-js/warp-core'
3
+ import { derived, state, type State } from '@weft-js/core'
4
+ import { Badge } from '../Badge'
5
+ import { Button } from '../Button'
6
+ import { Checkbox } from '../Choice'
7
+ import { Input } from '../Field'
8
+ import { Modal } from '../Modal'
9
+ import { Select } from '../Select'
10
+ import { Table, type Column } from '../Table'
11
+ import { toast } from '../Toast'
12
+ import { ConfirmDialog, TypeToConfirm } from './Danger'
13
+ import { SecretReveal, useReveal } from './Secret'
14
+ import { afterFlush, guarded, latch, read } from './atoms'
15
+ import '../styles'
16
+ import '@weft-js/warp-core/styles/flows/tokens.css'
17
+ import '@weft-js/warp-core/styles/flows/base.css'
18
+ import '@weft-js/warp-core/styles/rich/helpers.css'
19
+ import '@weft-js/warp-core/styles/layout/tokens.css'
20
+ import '@weft-js/warp-core/styles/layout/row.css'
21
+ import '@weft-js/warp-core/styles/layout/stack.css'
22
+ import '@weft-js/warp-core/styles/flows/api-key.css'
23
+ import '@weft-js/warp-core/styles/flows/choice.css'
24
+
25
+ /** An API key as listed: never the secret, only its prefix. */
26
+ export type ApiKey = { id: string; name: string; prefix: string; scopes: string[]; created: string; lastUsed?: string }
27
+ /** What the create form hands to `onCreate`. */
28
+ export type KeyDraft = { name: string; scopes: string[]; expires: string }
29
+
30
+ /** Props for ApiKeys. */
31
+ export interface ApiKeysProps {
32
+ keys: readonly ApiKey[] | State<readonly ApiKey[]>
33
+ kind?: 'test' | 'live' | undefined
34
+ /** Now, for "last used" and the 24-hour revoke rule. */
35
+ now: string
36
+ scopes?: { value: string; label: string }[]
37
+ expiries?: { value: string; label: string }[]
38
+ /** Fresh credentials first (09 §2.1, a 5-minute grant); false stops. */
39
+ onReauth?: (reason: string) => Promise<boolean>
40
+ /** Returns the full secret, which leaves this component only through the Copy button. */
41
+ onCreate: (draft: KeyDraft) => Promise<string>
42
+ onRevoke: (key: ApiKey) => Promise<void> | void
43
+ }
44
+
45
+ const SCOPES = [{ value: 'read', label: 'Read projects and deploys' }, { value: 'deploy', label: 'Trigger deploys' }, { value: 'admin', label: 'Manage members (admins only)' }]
46
+ const EXPIRIES = [{ value: '30', label: 'In 30 days' }, { value: '90', label: 'In 90 days' }, { value: 'never', label: 'Never' }]
47
+
48
+ /**
49
+ * API keys (09 §4.2): a DataTable of prefixes. Create asks for re-auth, then name, scopes and expiry, then shows the key
50
+ * once: it can't be dismissed before "I've saved it". Revoke is type-to-confirm when the key was used in the last 24 h.
51
+ * Live keys sit behind SignGate, which the app wraps around this component.
52
+ */
53
+ export function ApiKeys({ keys, kind = 'test', now, scopes = SCOPES, expiries = EXPIRIES, onReauth, onCreate, onRevoke }: ApiKeysProps) {
54
+ const creating = state(false)
55
+ const { secret, seen } = useReveal(creating)
56
+ const dialog = guarded(creating, () => !secret.value || seen.value)
57
+ const armed = latch(creating)
58
+ const name = state('')
59
+ const chosen = state<string[]>(['read'])
60
+ const expires = state('90')
61
+ const nameError = state('')
62
+ const scopeError = state('')
63
+ const busy = state(false)
64
+ const revoking = state<ApiKey | undefined>(undefined)
65
+ const typedOpen = state(false)
66
+ const plainOpen = state(false)
67
+ let opener: HTMLElement | undefined
68
+ let form: HTMLElement | undefined
69
+ const list = derived(() => read(keys))
70
+ const title = derived(() => (kind === 'live' ? 'live' : 'test'))
71
+
72
+ const start = async (e: MouseEvent) => {
73
+ opener = e.currentTarget as HTMLElement
74
+ if (onReauth && !(await onReauth('Creating an API key needs a fresh sign-in.'))) return
75
+ name.value = ''; chosen.value = ['read']; expires.value = '90'; nameError.value = ''; scopeError.value = ''
76
+ creating.value = true
77
+ }
78
+ const make = async () => {
79
+ if (busy.value) return
80
+ const n = name.value.trim()
81
+ nameError.value = !n ? 'Name the key so you can find it later.' : list.value.some(k => k.name.toLowerCase() === n.toLowerCase()) ? 'You already have a key with that name.' : ''
82
+ scopeError.value = chosen.value.length ? '' : 'Pick at least one scope.'
83
+ if (nameError.value || scopeError.value) return afterFlush(() => form?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus())
84
+ busy.value = true
85
+ try { secret.value = await onCreate({ name: n, scopes: chosen.value, expires: expires.value }) }
86
+ catch { toast.show({ title: 'The key wasn’t created', description: 'Try again in a moment.', tone: 'critical', action: { label: 'Retry', onClick: make } }) }
87
+ finally { busy.value = false }
88
+ if (secret.value) afterFlush(() => form?.ownerDocument.querySelector<HTMLElement>('dialog[open] input[readonly]')?.focus())
89
+ }
90
+ const toggle = (v: string, on: boolean) => { chosen.value = on ? [...chosen.value, v] : chosen.value.filter(x => x !== v); scopeError.value = '' }
91
+ const revoke = (k: ApiKey) => {
92
+ revoking.value = k
93
+ if (usedWithinDay(k.lastUsed, now)) typedOpen.value = true
94
+ else plainOpen.value = true
95
+ }
96
+ const gone = async () => {
97
+ const k = revoking.value!
98
+ await onRevoke(k)
99
+ toast.show({ title: `“${k.name}” revoked`, description: 'Requests with it now get 401.', tone: 'critical' })
100
+ afterFlush(() => opener?.focus())
101
+ }
102
+ const columns: Column<ApiKey>[] = [
103
+ { id: 'name', header: 'Name', rowHeader: true, card: 'title', cell: k => <span data-scope="row"><b>{k.name}</b>{k.scopes.map(s => <Badge key={s}>{s}</Badge>)}</span> },
104
+ { id: 'prefix', header: 'Key', cell: k => <span data-scope="api-key"><code data-part="prefix">{k.prefix}…</code></span> },
105
+ { id: 'created', header: 'Created' },
106
+ { id: 'lastUsed', header: 'Last used', value: k => (k.lastUsed ? agoText(k.lastUsed, now) : 'Never') },
107
+ { id: 'revoke', header: 'Actions', value: () => '', cell: k => <Button emphasis="soft" tone="critical" size="sm" aria-label={`Revoke ${k.name}`} onClick={(e: MouseEvent) => { opener ??= e.currentTarget as HTMLElement; revoke(k) }}>Revoke</Button> },
108
+ ]
109
+ const tools = derived(() => <Button onClick={start}>{`Create ${title.value} key`}</Button>) // one build: Table reads the prop twice
110
+ const footer = derived(() => (secret.value
111
+ ? <Button disabled={!seen.value} onClick={() => (dialog.value = false)}>Done</Button>
112
+ : <><Button emphasis="frame" data-close="">Cancel</Button><Button loading={busy.value} onClick={make}>Create key</Button></>))
113
+ return (
114
+ <div data-scope="stack">
115
+ <Table caption={`${title.value === 'live' ? 'Live' : 'Test'} API keys`} captionHidden rows={list} columns={columns} picker={false} heading={3} actions={tools.value} empty={{ title: 'No keys yet', text: 'Create one to call the API.' }} />
116
+ {armed.value && (
117
+ <Modal open={dialog} alert={!!secret.value} title={secret.value ? 'Copy your new key' : `Create a ${title.value} key`} description={secret.value ? 'You won’t see it again. Store it in your secrets manager now.' : 'Give it a name you’ll recognise in the audit log.'} footer={<>{footer.value}</>}>
118
+ {secret.value
119
+ ? <SecretReveal label={name.value} secret={secret.value} done={seen} ack="I’ve saved this key somewhere safe" />
120
+ : (
121
+ <form ref={n => (form = n)} data-scope="stack" style="padding-block-start:var(--warp-space-related)" novalidate onSubmit={(e: Event) => { e.preventDefault(); void make() }}>
122
+ <Input label="Key name" placeholder="e.g. CI deploys" autofocus value={name.value} error={nameError.value} onInput={e => { name.value = e.currentTarget.value; nameError.value = '' }} />
123
+ <fieldset data-scope="choice">
124
+ <legend>Scopes</legend>
125
+ {scopes.map(s => <Checkbox key={s.value} checked={chosen.value.includes(s.value)} error={s === scopes[0] && scopeError.value ? scopeError.value : undefined} onChange={(e: Event) => toggle(s.value, (e.currentTarget as HTMLInputElement).checked)}>{s.label}</Checkbox>)}
126
+ </fieldset>
127
+ <Select label="Expires" options={expiries} value={expires.value} onChange={(e: Event) => (expires.value = (e.currentTarget as HTMLInputElement).value)} />
128
+ </form>
129
+ )}
130
+ </Modal>
131
+ )}
132
+ <TypeToConfirm open={typedOpen} glyph={KeyIcon} title={`Revoke “${revoking.value?.name}”?`} description="This key was used in the last 24 hours. Anything using it stops working right away." confirmText={revoking.value?.name ?? ''} action="Revoke key" onConfirm={gone} />
133
+ <ConfirmDialog open={plainOpen} glyph={KeyIcon} tone="critical" title={`Revoke “${revoking.value?.name}”?`} description="Anything still using it stops working right away." action="Revoke key" onConfirm={gone} />
134
+ </div>
135
+ )
136
+ }
@@ -0,0 +1,93 @@
1
+ import { agoText, announce, plural } from '@weft-js/warp-core'
2
+ import { derived, onCleanup, state, type State } from '@weft-js/core'
3
+ import { Button } from '../Button'
4
+ import { LiveStatus, type LiveState } from '../parts'
5
+ import { Tooltip } from '../Tooltip'
6
+ import { Segmented } from '../Choice'
7
+ import { Select } from '../Select'
8
+ import { Table, type Column } from '../Table'
9
+ import { read } from './atoms'
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/rich/helpers.css'
14
+ import '@weft-js/warp-core/styles/layout/tokens.css'
15
+ import '@weft-js/warp-core/styles/layout/row.css'
16
+ import '@weft-js/warp-core/styles/layout/stack.css'
17
+
18
+ /** One line of the audit log. */
19
+ export type AuditEvent = { id: string; at: string; actor: string; category: string; verb: string; target: string; href?: string; ip: string; diff?: { field: string; from: string; to: string }[] }
20
+
21
+ /** Props for AuditLog. */
22
+ export interface AuditLogProps {
23
+ events: readonly AuditEvent[] | State<readonly AuditEvent[]>
24
+ now: string
25
+ categories?: { value: string; label: string }[]
26
+ /** The realtime pill, when the rows come from a live query. */
27
+ live?: LiveState | undefined
28
+ /** Resolve with `{ retryAfter }` (seconds) when rate limited (§5.5). */
29
+ onExport?: () => Promise<{ retryAfter?: number } | void>
30
+ }
31
+
32
+ const RANGES = [{ value: 'all', label: 'Any time', hours: Infinity }, { value: '24h', label: 'Last 24 hours', hours: 24 }, { value: '7d', label: 'Last 7 days', hours: 168 }, { value: '30d', label: 'Last 30 days', hours: 720 }]
33
+
34
+ /**
35
+ * The audit log (09 §4.5): a read-only DataTable. Time is relative, exact on hover and focus; each event opens its diff.
36
+ * Filters: actor, action, date range. New rows arrive by key, so a live query never rebuilds the table.
37
+ */
38
+ const stamp = (at: string) => at.replace('T', ' ').replace(/:\d\dZ$/, ' UTC')
39
+
40
+ /** The audit log: who did what and when, filtered by category and actor, with an export. */
41
+ export function AuditLog({ events, now, categories = [{ value: 'all', label: 'All' }], live, onExport }: AuditLogProps) {
42
+ const category = state('all')
43
+ const actor = state('all')
44
+ const range = state('all')
45
+ const wait = state(0)
46
+ const list = derived(() => read(events))
47
+ const actors = derived(() => [{ value: 'all', label: 'Everyone' }, ...[...new Set(list.value.map(e => e.actor))].map(a => ({ value: a, label: a }))])
48
+ const shown = derived(() => {
49
+ const hours = RANGES.find(r => r.value === range.value)!.hours
50
+ return list.value.filter(e => (category.value === 'all' || e.category === category.value) && (actor.value === 'all' || e.actor === actor.value) && Date.parse(now) - Date.parse(e.at) <= hours * 36e5)
51
+ })
52
+ let tick: ReturnType<typeof setInterval> | undefined
53
+ let root: HTMLElement | undefined
54
+ const say = (t: string) => { if (root) announce(root.ownerDocument, t) }
55
+ onCleanup(() => clearInterval(tick))
56
+ const refilter = () => queueMicrotask(() => say(`${plural(shown.value.length, 'event')} shown`))
57
+ const exportIt = async () => {
58
+ const r = await onExport?.()
59
+ if (!r?.retryAfter) return
60
+ wait.value = r.retryAfter
61
+ say(`Export limit reached. Try again in ${r.retryAfter} seconds.`)
62
+ tick = setInterval(() => {
63
+ wait.value -= 1
64
+ if (wait.value > 0) return
65
+ clearInterval(tick)
66
+ say('You can export again.')
67
+ }, 1000)
68
+ }
69
+ const columns: Column<AuditEvent>[] = [
70
+ { id: 'at', header: 'When', sort: r => r.at, cell: e => { const at = stamp(e.at); return <Tooltip content={at}><time datetime={e.at} tabindex="0">{agoText(e.at, now)}</time></Tooltip> } },
71
+ { id: 'actor', header: 'Who', rowHeader: true, card: 'title' },
72
+ { id: 'what', header: 'What', value: e => `${e.verb} ${e.target}`, cell: e => <>{e.verb} {e.href ? <a href={e.href}>{e.target}</a> : e.target}</> },
73
+ { id: 'ip', header: 'IP', priority: 2, cell: e => <code>{e.ip}</code> },
74
+ ]
75
+ const diff = (e: AuditEvent) => (e.diff?.length
76
+ ? <dl data-scope="desc">{e.diff.map(d => <div key={d.field}><dt>{d.field}</dt><dd>{d.from} → {d.to}</dd></div>)}</dl>
77
+ : <span class="muted">No field changes were recorded.</span>)
78
+ const hasLive = derived(() => live !== undefined)
79
+ return (
80
+ <div ref={n => (root = n)} data-scope="stack">
81
+ <div data-scope="row" data-justify="between">
82
+ <Segmented label="Filter events" size="sm" value={category.value} options={categories} onChange={v => { category.value = v; refilter() }} />
83
+ {hasLive.value && <LiveStatus state={live!} label="Audit log" />}
84
+ </div>
85
+ <div data-scope="row">
86
+ <div data-grow=""><Select label="Actor" size="sm" options={actors.value} value={actor.value} onChange={(e: Event) => { actor.value = (e.currentTarget as HTMLInputElement).value; refilter() }} /></div>
87
+ <div data-grow=""><Select label="Date range" size="sm" options={RANGES} value={range.value} onChange={(e: Event) => { range.value = (e.currentTarget as HTMLInputElement).value; refilter() }} /></div>
88
+ {onExport && <Button emphasis="frame" aria-disabled={wait.value > 0 ? 'true' : undefined} onClick={exportIt}>{wait.value > 0 ? `Try again in ${wait.value}s` : 'Export CSV'}</Button>}
89
+ </div>
90
+ <Table caption="Recent events" captionHidden rows={shown} columns={columns} picker={false} heading={3} details="sheet" expand={diff} empty={{ title: 'Nothing matches', text: 'Try a wider filter.' }} />
91
+ </div>
92
+ )
93
+ }