@actioneer/ads 1.0.9 → 1.0.10

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 (51) hide show
  1. package/DESIGN.md +98 -57
  2. package/README.md +19 -7
  3. package/components/answer.js +1 -1
  4. package/components/assistant-ui/composer-harness.js +13 -10
  5. package/components/body-page-header.d.ts +9 -4
  6. package/components/body-page-header.js +10 -10
  7. package/components/breadcrumbs.js +22 -22
  8. package/components/card.js +18 -18
  9. package/components/chart.d.ts +9 -0
  10. package/components/chart.js +738 -717
  11. package/components/chip.js +12 -12
  12. package/components/collection-header.js +4 -4
  13. package/components/connector-card.js +30 -30
  14. package/components/date-range-picker.js +1 -1
  15. package/components/detail-list.d.ts +28 -0
  16. package/components/detail-list.js +52 -0
  17. package/components/drawer.d.ts +38 -15
  18. package/components/drawer.js +150 -86
  19. package/components/empty-state.d.ts +3 -2
  20. package/components/empty-state.js +22 -22
  21. package/components/field.d.ts +16 -0
  22. package/components/field.js +63 -42
  23. package/components/file-dropzone.d.ts +15 -4
  24. package/components/file-dropzone.js +100 -64
  25. package/components/filter-bar.d.ts +44 -0
  26. package/components/filter-bar.js +89 -0
  27. package/components/index.d.ts +3 -0
  28. package/components/index.js +526 -513
  29. package/components/input-field.d.ts +1 -1
  30. package/components/input-field.js +24 -22
  31. package/components/modal.js +56 -53
  32. package/components/panel.js +26 -27
  33. package/components/sidebar.js +54 -54
  34. package/components/sparkline.d.ts +30 -0
  35. package/components/sparkline.js +69 -0
  36. package/demo/components/add-to-artifact.js +5 -2
  37. package/examples/README.md +4 -2
  38. package/examples/call-logs-screen.tsx +381 -0
  39. package/examples/campaign-analysis-screen.tsx +114 -72
  40. package/examples/campaigns-screen.tsx +218 -160
  41. package/examples/guardrails-settings-screen.tsx +123 -0
  42. package/fonts/geist-variable.woff2 +0 -0
  43. package/fonts.css +12 -17
  44. package/lib/icons.js +3 -3
  45. package/llms.txt +107 -39
  46. package/manifest.json +3100 -892
  47. package/package.json +1 -1
  48. package/styles.css +102 -59
  49. package/theme.css +81 -37
  50. package/fonts/aribau-grotesk-medium.woff2 +0 -0
  51. package/fonts/aribau-grotesk-regular.woff2 +0 -0
@@ -4,19 +4,25 @@
4
4
  * Follows principles `collection-screens`, `record-detail-panels`,
5
5
  * `status-language` and `tabs-search-opposed` (see `get_principles`):
6
6
  *
7
- * - One white panel: BodyPageHeader (title + one primary action), then the
8
- * control row — Tabs with counts leading, SearchDisclosure then Filters as
7
+ * - The app window (rounded-window, WG4 hairline, shadow-window on the WG3
8
+ * backdrop) holds one white page: BodyPageHeader (a 64px bar with the title,
9
+ * one primary action and a full-width hairline), then the control row — Tabs with counts leading, SearchDisclosure then Filters as
9
10
  * icon triggers trailing — then the Table.
10
11
  * - The rows fill the panel: the page size is derived from the height that is
11
12
  * left, so Pagination always sits on the panel's bottom edge.
12
13
  * - Row checkboxes appear on hover (the header checkbox is always visible).
13
14
  * - Clicking a row opens its detail panel beside the list and marks the row
14
- * with `isActive` — no radio, no modal.
15
- * - The detail panel: kicker, title, status Chip + context under the title,
16
- * then Performance (stat cards + chart card) and Details (label–value rows).
17
- * - New campaign opens as a Modal over the list (never a separate screen): one
18
- * scrolling form — Campaign name, Caller numbers, Audience — with Cancel and
19
- * Create campaign together in the footer.
15
+ * with `isActive` — no radio, no modal. The panel is attached to the page:
16
+ * flush beside the list, split off by one full-height hairline, no gap.
17
+ * - The detail panel: a title-only 64px header (title and close centred on
18
+ * one row), then Performance (stat cards + chart card) and Details
19
+ * (label–value rows that start with the status Chip and the finish date).
20
+ * - New campaign opens as a fixed-height Modal over the list (never a separate
21
+ * screen): one scrolling form — Campaign name, Caller numbers, Audience —
22
+ * with Cancel and Create campaign together in the footer (principle
23
+ * `create-modals`). Optional guidance lives in a Field `info` icon, the
24
+ * source switch sits in the Field `actions` slot, uploads use the inline
25
+ * FileDropzone, and picking one saved record is a single-select Table.
20
26
  *
21
27
  * In a full app this screen sits inside the shell, next to the Sidebar
22
28
  * (put the customer's logo in its brand slot with SidebarWorkspace).
@@ -24,16 +30,19 @@
24
30
  */
25
31
  import * as React from "react";
26
32
  import {
27
- Avatar,
28
33
  BodyPageHeader,
29
34
  Button,
30
35
  Checkbox,
31
36
  Chip,
32
37
  Drawer,
38
+ Field,
33
39
  DrawerBody,
34
40
  DrawerFooter,
35
41
  DrawerHeader,
42
+ DrawerSection,
36
43
  DrawerTitle,
44
+ DetailItem,
45
+ DetailList,
37
46
  FileDropzone,
38
47
  Filters,
39
48
  InputField,
@@ -44,8 +53,10 @@ import {
44
53
  ModalHeader,
45
54
  ModalTitle,
46
55
  Pagination,
56
+ Progress,
47
57
  SearchDisclosure,
48
58
  SegmentedControl,
59
+ Sparkline,
49
60
  Tab,
50
61
  TabList,
51
62
  Table,
@@ -168,6 +179,10 @@ function findCampaign(id: string | null): CampaignRecord | undefined {
168
179
  }
169
180
 
170
181
  function CampaignCells({ campaign }: { campaign: CampaignRecord }) {
182
+ // Metric cells (principle `collection-screens`): a rate gets a small meter
183
+ // before the number; the 7-day trend is a dotless Sparkline.
184
+ const rate = campaign.connectRate === "—" ? null : Number.parseFloat(campaign.connectRate);
185
+ const hasCalls = campaign.week.some((value) => value > 0);
171
186
  return (
172
187
  <>
173
188
  <TableCell>{campaign.name}</TableCell>
@@ -175,12 +190,30 @@ function CampaignCells({ campaign }: { campaign: CampaignRecord }) {
175
190
  <Chip color={STATUS_COLOR[campaign.status]}>{campaign.status}</Chip>
176
191
  </TableCell>
177
192
  <TableCell align="right" className="tabular-nums">{campaign.leads.toLocaleString("en-IN")}</TableCell>
178
- <TableCell>
179
- <span className="flex items-center gap-2">
180
- <Avatar name={campaign.owner} size="sm" />
181
- {campaign.owner}
182
- </span>
193
+ <TableCell align="right">
194
+ {rate === null ? (
195
+ <span className="text-on-white-secondary">—</span>
196
+ ) : (
197
+ <span className="inline-flex items-center justify-end gap-2.5">
198
+ <Progress value={rate} size="sm" label={`${campaign.name} connect rate`} className="w-12" />
199
+ <span className="tabular-nums">{campaign.connectRate}</span>
200
+ </span>
201
+ )}
202
+ </TableCell>
203
+ <TableCell align="right">
204
+ {hasCalls ? (
205
+ <Sparkline
206
+ data={campaign.week}
207
+ height={28}
208
+ dot={false}
209
+ label={`${campaign.name}: connected calls, last 7 days`}
210
+ className="ml-auto w-24"
211
+ />
212
+ ) : (
213
+ <span className="text-on-white-secondary">No calls yet</span>
214
+ )}
183
215
  </TableCell>
216
+ <TableCell>{campaign.owner}</TableCell>
184
217
  </>
185
218
  );
186
219
  }
@@ -188,34 +221,34 @@ function CampaignCells({ campaign }: { campaign: CampaignRecord }) {
188
221
  /** Selection-driven detail panel (principle `record-detail-panels`). */
189
222
  export function CampaignDetail({ campaign, onClose }: { campaign: CampaignRecord | undefined; onClose: () => void }) {
190
223
  return (
191
- <Drawer open={Boolean(campaign)} onOpenChange={(open) => !open && onClose()} aria-label="Campaign details">
224
+ <Drawer
225
+ open={Boolean(campaign)}
226
+ onOpenChange={(open) => !open && onClose()}
227
+ aria-label="Campaign details"
228
+ // Centre the built-in close button on the 64px title row.
229
+ className="[&>button[aria-label=Close]]:top-4"
230
+ >
192
231
  {campaign && (
193
232
  <>
194
- <DrawerHeader>
195
- <p className="text-xs uppercase tracking-wide text-secondary">Campaign</p>
196
- <DrawerTitle>{campaign.name}</DrawerTitle>
197
- {/* Status lives with the title, so it never floats on its own. */}
198
- <div className="mt-1 flex items-center gap-2">
199
- <Chip color={STATUS_COLOR[campaign.status]}>{campaign.status}</Chip>
200
- <span className="text-s text-secondary">{campaign.context}</span>
201
- </div>
233
+ {/* Title-only header bar: the title and the close button share one centred row. */}
234
+ <DrawerHeader className="h-16 justify-center py-0">
235
+ <DrawerTitle className="truncate">{campaign.name}</DrawerTitle>
202
236
  </DrawerHeader>
203
- <DrawerBody className="flex flex-col gap-8 overflow-y-auto">
204
- <section aria-label="Performance" className="flex flex-col gap-2">
205
- <h3 className="mb-1 text-m font-medium text-foreground">Performance</h3>
237
+ <DrawerBody className="flex flex-col gap-8">
238
+ <DrawerSection title="Performance">
206
239
  <dl className="grid grid-cols-3 gap-2">
207
240
  {[
208
241
  { label: "Leads", value: campaign.leads.toLocaleString("en-IN") },
209
242
  { label: "Connect rate", value: campaign.connectRate },
210
243
  { label: "Booked", value: campaign.booked.toLocaleString("en-IN") },
211
244
  ].map((stat) => (
212
- <div key={stat.label} className="min-w-0 rounded-l border border-tertiary p-3">
245
+ <div key={stat.label} className="min-w-0 rounded-card border border-border bg-wg1 p-3">
213
246
  <dt className="truncate text-s text-secondary">{stat.label}</dt>
214
247
  <dd className="mt-0.5 text-xl tabular-nums text-foreground">{stat.value}</dd>
215
248
  </div>
216
249
  ))}
217
250
  </dl>
218
- <div className="rounded-l border border-tertiary p-4">
251
+ <div className="rounded-card border border-border p-4">
219
252
  <LineChart
220
253
  title={<span className="text-s font-normal text-secondary">Connected calls · last 7 days</span>}
221
254
  data={campaign.week.map((calls, index) => ({ day: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][index], calls }))}
@@ -227,22 +260,22 @@ export function CampaignDetail({ campaign, onClose }: { campaign: CampaignRecord
227
260
  height={160}
228
261
  />
229
262
  </div>
230
- </section>
231
- <section aria-label="Details" className="flex flex-col gap-1">
232
- <h3 className="text-m font-medium text-foreground">Details</h3>
233
- <dl>
234
- {[
235
- { label: "Owner", value: campaign.owner },
236
- { label: "Started", value: campaign.started },
237
- { label: "Calling windows", value: String(campaign.callingWindows) },
238
- ].map((row) => (
239
- <div key={row.label} className="flex min-h-10 items-center justify-between gap-3 border-b border-tertiary last:border-0">
240
- <dt className="text-m text-secondary">{row.label}</dt>
241
- <dd className="text-m text-foreground">{row.value}</dd>
242
- </div>
243
- ))}
244
- </dl>
245
- </section>
263
+ </DrawerSection>
264
+ <DrawerSection title="Details">
265
+ <DetailList>
266
+ <DetailItem label="Status">
267
+ <Chip color={STATUS_COLOR[campaign.status]}>{campaign.status}</Chip>
268
+ </DetailItem>
269
+ <DetailItem label="Owner">{campaign.owner}</DetailItem>
270
+ <DetailItem label="Started">{campaign.started}</DetailItem>
271
+ {campaign.status === "Completed" ? (
272
+ <DetailItem label="Finished">{campaign.context.replace(/^Finished /, "")}</DetailItem>
273
+ ) : (
274
+ <DetailItem label="Activity">{campaign.context}</DetailItem>
275
+ )}
276
+ <DetailItem label="Calling windows">{campaign.callingWindows}</DetailItem>
277
+ </DetailList>
278
+ </DrawerSection>
246
279
  </DrawerBody>
247
280
  <DrawerFooter>
248
281
  <Button variant="outline">Duplicate</Button>
@@ -255,59 +288,79 @@ export function CampaignDetail({ campaign, onClose }: { campaign: CampaignRecord
255
288
  }
256
289
 
257
290
  const SAVED_AUDIENCES = [
258
- { id: "0019b431", name: "CSV audience 0019b431", size: 6 },
259
- { id: "b190b191", name: "CSV audience b190b191", size: 868 },
260
- { id: "5ee46ae6", name: "CSV audience 5ee46ae6", size: 162 },
261
- { id: "9dc3d0b1", name: "CSV audience 9dc3d0b1", size: 188 },
291
+ { id: "0019b431", name: "CSV audience 0019b431", uploaded: "Oct 24", size: 6 },
292
+ { id: "b190b191", name: "CSV audience b190b191", uploaded: "Oct 21", size: 868 },
293
+ { id: "5ee46ae6", name: "CSV audience 5ee46ae6", uploaded: "Oct 18", size: 162 },
294
+ { id: "9dc3d0b1", name: "CSV audience 9dc3d0b1", uploaded: "Oct 12", size: 188 },
262
295
  ];
263
296
 
264
- /** Create task launched from the collection: a Modal over the list (principle `minimal-information`). */
297
+ /** Create task launched from the collection: a fixed-height Modal over the list (principle `create-modals`). */
265
298
  export function NewCampaignModal({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
266
299
  const [name, setName] = React.useState("");
267
300
  const [useNumber, setUseNumber] = React.useState(false);
268
301
  const [audience, setAudience] = React.useState("csv");
269
302
  const [query, setQuery] = React.useState("");
303
+ const [picked, setPicked] = React.useState<Set<string>>(new Set(["b190b191"]));
270
304
  const audiences = SAVED_AUDIENCES.filter((item) => item.name.toLowerCase().includes(query.trim().toLowerCase()));
271
305
 
272
306
  return (
273
- <Modal open={open} onOpenChange={onOpenChange} height="fixed">
307
+ <Modal open={open} onOpenChange={onOpenChange}>
274
308
  <ModalHeader>
275
309
  <ModalTitle>New campaign</ModalTitle>
276
310
  </ModalHeader>
277
311
  <ModalBody className="flex flex-col gap-6">
278
312
  <InputField label="Campaign name" value={name} onChange={(event) => setName(event.target.value)} placeholder="First-EMI onboarding" />
279
- <div className="flex flex-col gap-2">
280
- <p className="text-m font-medium text-foreground">Caller numbers</p>
313
+
314
+ {/* Optional guidance goes in the label's info icon, not a helper line. */}
315
+ <Field
316
+ label="Caller numbers"
317
+ info="Optional. Tick none to call from any enabled number. You can change this later, even while the campaign is calling."
318
+ >
281
319
  <div className="flex min-h-12 items-center gap-3 rounded-m border border-input px-3">
282
320
  <Checkbox aria-label="+91 22 6987 0900" checked={useNumber} onChange={(event) => setUseNumber(event.target.checked)} />
283
321
  <span className="flex-1 text-m tabular-nums text-foreground">+91 22 6987 0900</span>
284
322
  <Chip>Plivo</Chip>
285
323
  </div>
286
- <p className="text-s text-secondary">Optional. Tick none to call from any enabled number; you can change this later, even while it is calling.</p>
287
- </div>
288
- <div className="flex flex-col gap-3">
289
- <p className="text-m font-medium text-foreground">Audience</p>
290
- <SegmentedControl
291
- aria-label="Audience source"
292
- options={[{ value: "csv", label: "CSV upload" }, { value: "empty", label: "Empty audience" }]}
293
- value={audience}
294
- onChange={setAudience}
295
- />
296
- {audience === "csv" && (
297
- <>
298
- <InputField aria-label="Search audiences" type="search" placeholder="Search audiences" value={query} onChange={(event) => setQuery(event.target.value)} />
299
- <FileDropzone accept=".csv,.xlsx" hint="CSV or first-sheet .xlsx with a phone column; name and other columns are optional" />
300
- <ul aria-label="Saved audiences" className="rounded-m border border-input px-3">
301
- {audiences.map((item) => (
302
- <li key={item.id} className="flex min-h-10 items-center justify-between gap-3 border-b border-tertiary text-m last:border-0">
303
- <span className="text-foreground">{item.name}</span>
304
- <span className="tabular-nums text-secondary">{item.size.toLocaleString("en-IN")}</span>
305
- </li>
306
- ))}
307
- </ul>
308
- </>
309
- )}
310
- </div>
324
+ </Field>
325
+
326
+ {/* The source switch sits on the label row; the body changes with it. */}
327
+ <Field
328
+ label="Audience"
329
+ actions={
330
+ <SegmentedControl
331
+ size="sm"
332
+ aria-label="Audience source"
333
+ options={[{ value: "csv", label: "CSV upload" }, { value: "empty", label: "Empty audience" }]}
334
+ value={audience}
335
+ onChange={setAudience}
336
+ />
337
+ }
338
+ >
339
+ {audience === "csv" ? (
340
+ <div className="flex flex-col gap-4">
341
+ <FileDropzone layout="inline" accept=".csv,.xlsx" label="Upload a new list" hint="Drop a CSV or .xlsx with a phone column" />
342
+ <InputField aria-label="Search saved audiences" type="search" placeholder="Search saved audiences" value={query} onChange={(event) => setQuery(event.target.value)} />
343
+ <Table aria-label="Saved audiences" selectionMode="single" selectedKeys={picked} onSelectionChange={setPicked}>
344
+ <TableHeader>
345
+ <TableRow>
346
+ <TableColumn>Name</TableColumn>
347
+ <TableColumn>Uploaded</TableColumn>
348
+ <TableColumn align="right">Contacts</TableColumn>
349
+ </TableRow>
350
+ </TableHeader>
351
+ <TableBody>
352
+ {audiences.map((item) => (
353
+ <TableRow key={item.id} rowKey={item.id} aria-label={`Use ${item.name}`}>
354
+ <TableCell>{item.name}</TableCell>
355
+ <TableCell className="text-secondary">{item.uploaded}</TableCell>
356
+ <TableCell align="right" className="tabular-nums">{item.size.toLocaleString("en-IN")}</TableCell>
357
+ </TableRow>
358
+ ))}
359
+ </TableBody>
360
+ </Table>
361
+ </div>
362
+ ) : null}
363
+ </Field>
311
364
  </ModalBody>
312
365
  <ModalFooter>
313
366
  <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
@@ -346,94 +399,99 @@ export function CampaignsScreen() {
346
399
  value === "all" ? CAMPAIGNS.length : CAMPAIGNS.filter((c) => c.status === value).length;
347
400
 
348
401
  return (
349
- <div className="flex h-screen gap-3 bg-background p-3 text-foreground">
350
- <section aria-label="Voice campaigns" className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-l bg-card">
351
- <BodyPageHeader title="Voice campaigns" actions={<Button onClick={() => setCreating(true)}>New campaign</Button>} />
402
+ <div className="flex h-screen bg-surface p-3 text-foreground">
403
+ {/* The app window: one rounded surface on the WG3 backdrop. */}
404
+ <div className="flex min-w-0 flex-1 overflow-hidden rounded-window border border-border bg-card shadow-window">
405
+ <section aria-label="Voice campaigns" className="flex min-w-0 flex-1 flex-col overflow-hidden">
406
+ <BodyPageHeader title="Voice campaigns" actions={<Button onClick={() => setCreating(true)}>New campaign</Button>} />
352
407
 
353
- {/* Tabs lead; search and filters trail as icon triggers. */}
354
- <div className="flex items-center justify-between gap-4 px-6">
355
- <Tabs className="min-w-0 flex-1" value={tab} onValueChange={(value) => { setTab(value as typeof tab); setPage(1); }}>
356
- <TabList aria-label="Filter campaigns by status">
357
- {TABS.map((item) => (
358
- <Tab key={item.value} value={item.value} count={countFor(item.value)}>
359
- {item.label}
360
- </Tab>
361
- ))}
362
- </TabList>
363
- </Tabs>
364
- <div className="flex shrink-0 items-center gap-2">
365
- <SearchDisclosure
366
- value={search}
367
- onValueChange={(value) => { setSearch(value); setPage(1); }}
368
- open={searchOpen}
369
- onOpenChange={setSearchOpen}
370
- placeholder="Search campaigns"
371
- triggerLabel="Search campaigns"
372
- />
373
- <Filters
374
- triggerLabel="Filter campaigns"
375
- groups={filters}
376
- onChange={(id, value) => { setFilters((groups) => groups.map((group) => (group.id === id ? { ...group, value } : group))); setPage(1); }}
377
- />
408
+ {/* Tabs lead; search and filters trail as icon triggers. */}
409
+ <div className="flex items-center justify-between gap-4 px-6">
410
+ <Tabs className="min-w-0 flex-1" value={tab} onValueChange={(value) => { setTab(value as typeof tab); setPage(1); }}>
411
+ <TabList aria-label="Filter campaigns by status">
412
+ {TABS.map((item) => (
413
+ <Tab key={item.value} value={item.value} count={countFor(item.value)}>
414
+ {item.label}
415
+ </Tab>
416
+ ))}
417
+ </TabList>
418
+ </Tabs>
419
+ <div className="flex shrink-0 items-center gap-2">
420
+ <SearchDisclosure
421
+ value={search}
422
+ onValueChange={(value) => { setSearch(value); setPage(1); }}
423
+ open={searchOpen}
424
+ onOpenChange={setSearchOpen}
425
+ placeholder="Search campaigns"
426
+ triggerLabel="Search campaigns"
427
+ />
428
+ <Filters
429
+ triggerLabel="Filter campaigns"
430
+ groups={filters}
431
+ onChange={(id, value) => { setFilters((groups) => groups.map((group) => (group.id === id ? { ...group, value } : group))); setPage(1); }}
432
+ />
433
+ </div>
378
434
  </div>
379
- </div>
380
435
 
381
- {/* The rows fill what is left of the panel; pagination sits at the bottom. */}
382
- <div ref={rowsRef} className="min-h-0 flex-1 px-6 pt-4 pb-6">
383
- <Table
384
- aria-label="Voice campaigns"
385
- selectionMode="multiple"
386
- selectedKeys={selectedKeys}
387
- onSelectionChange={setSelectedKeys}
388
- footer={
389
- <Pagination
390
- className="w-full"
391
- total={pages}
392
- page={current}
393
- onChange={setPage}
394
- summary={`${visible.length === 0 ? 0 : (current - 1) * pageSize + 1}–${Math.min(current * pageSize, visible.length)} of ${visible.length} campaigns`}
395
- />
396
- }
397
- >
398
- <TableHeader>
399
- <TableRow>
400
- <TableColumn>Campaign</TableColumn>
401
- <TableColumn>Status</TableColumn>
402
- <TableColumn align="right">Leads</TableColumn>
403
- <TableColumn>Owner</TableColumn>
404
- </TableRow>
405
- </TableHeader>
406
- <TableBody>
407
- {pageRows.map((campaign) => (
408
- <TableRow
409
- key={campaign.id}
410
- rowKey={campaign.id}
411
- aria-label={`Open ${campaign.name}`}
412
- isActive={openId === campaign.id}
413
- onClick={() => setOpenId((id) => (id === campaign.id ? null : campaign.id))}
414
- childRows={campaign.cohorts?.map((cohort) => (
415
- <TableRow
416
- key={cohort.id}
417
- rowKey={cohort.id}
418
- aria-label={`Open ${cohort.name}`}
419
- isActive={openId === cohort.id}
420
- onClick={() => setOpenId((id) => (id === cohort.id ? null : cohort.id))}
421
- >
422
- <CampaignCells campaign={cohort} />
423
- </TableRow>
424
- ))}
425
- defaultExpanded={Boolean(campaign.cohorts)}
426
- >
427
- <CampaignCells campaign={campaign} />
436
+ {/* The rows fill what is left of the panel; pagination sits at the bottom. */}
437
+ <div ref={rowsRef} className="min-h-0 flex-1 px-6 pt-4 pb-6">
438
+ <Table
439
+ aria-label="Voice campaigns"
440
+ selectionMode="multiple"
441
+ selectedKeys={selectedKeys}
442
+ onSelectionChange={setSelectedKeys}
443
+ footer={
444
+ <Pagination
445
+ className="w-full"
446
+ total={pages}
447
+ page={current}
448
+ onChange={setPage}
449
+ summary={`${visible.length === 0 ? 0 : (current - 1) * pageSize + 1}–${Math.min(current * pageSize, visible.length)} of ${visible.length} campaigns`}
450
+ />
451
+ }
452
+ >
453
+ <TableHeader>
454
+ <TableRow>
455
+ <TableColumn>Campaign</TableColumn>
456
+ <TableColumn>Status</TableColumn>
457
+ <TableColumn align="right">Leads</TableColumn>
458
+ <TableColumn align="right">Connect rate</TableColumn>
459
+ <TableColumn align="right">Last 7 days</TableColumn>
460
+ <TableColumn>Owner</TableColumn>
428
461
  </TableRow>
429
- ))}
430
- </TableBody>
431
- </Table>
432
- </div>
433
- </section>
462
+ </TableHeader>
463
+ <TableBody>
464
+ {pageRows.map((campaign) => (
465
+ <TableRow
466
+ key={campaign.id}
467
+ rowKey={campaign.id}
468
+ aria-label={`Open ${campaign.name}`}
469
+ isActive={openId === campaign.id}
470
+ onClick={() => setOpenId((id) => (id === campaign.id ? null : campaign.id))}
471
+ childRows={campaign.cohorts?.map((cohort) => (
472
+ <TableRow
473
+ key={cohort.id}
474
+ rowKey={cohort.id}
475
+ aria-label={`Open ${cohort.name}`}
476
+ isActive={openId === cohort.id}
477
+ onClick={() => setOpenId((id) => (id === cohort.id ? null : cohort.id))}
478
+ >
479
+ <CampaignCells campaign={cohort} />
480
+ </TableRow>
481
+ ))}
482
+ defaultExpanded={Boolean(campaign.cohorts)}
483
+ >
484
+ <CampaignCells campaign={campaign} />
485
+ </TableRow>
486
+ ))}
487
+ </TableBody>
488
+ </Table>
489
+ </div>
490
+ </section>
434
491
 
435
- <CampaignDetail campaign={findCampaign(openId)} onClose={() => setOpenId(null)} />
436
- <NewCampaignModal open={creating} onOpenChange={setCreating} />
492
+ <CampaignDetail campaign={findCampaign(openId)} onClose={() => setOpenId(null)} />
493
+ <NewCampaignModal open={creating} onOpenChange={setCreating} />
494
+ </div>
437
495
  </div>
438
496
  );
439
497
  }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Guardrails settings — the ADS settings-screen recipe, built only from ADS
3
+ * exports.
4
+ *
5
+ * Follows principle `settings-screens` (see `get_principles`):
6
+ *
7
+ * - One white page in the app window: BodyPageHeader (a 64px bar with the
8
+ * title, the one save action and a full-width hairline), then the setting
9
+ * groups 32px apart.
10
+ * - Every group FILLS the panel's width between its 24px side insets — never a
11
+ * fixed reading-width column — so each row's control lines up on the panel's
12
+ * right edge under the save action.
13
+ * - A group is a 16px Medium title over a SettingList; each SettingRow reads
14
+ * bare glyph → label + one-line description → the control that changes it
15
+ * (Switch, a Button showing the current value, or a Badge).
16
+ *
17
+ * Copy it and replace GROUPS with your own settings.
18
+ */
19
+ import * as React from "react";
20
+ import {
21
+ Badge,
22
+ BodyPageHeader,
23
+ Button,
24
+ SettingList,
25
+ SettingRow,
26
+ Switch,
27
+ } from "@actioneer/ads";
28
+ import { Call, CheckCircle, FileText, Info, Refresh } from "@actioneer/ads/lib/icons";
29
+
30
+ type Control =
31
+ | { kind: "switch"; on: boolean }
32
+ | { kind: "button"; label: string }
33
+ | { kind: "badge"; label: string };
34
+
35
+ interface Setting {
36
+ id: string;
37
+ icon: React.ReactNode;
38
+ label: string;
39
+ description: string;
40
+ control: Control;
41
+ }
42
+
43
+ export const GROUPS: { title: string; settings: Setting[] }[] = [
44
+ {
45
+ title: "Calling window",
46
+ settings: [
47
+ { id: "hours", icon: <Call />, label: "Calling hours", description: "Leads are dialled only inside this window", control: { kind: "button", label: "10:00 – 19:00" } },
48
+ { id: "retry", icon: <Refresh />, label: "Retry unanswered calls", description: "Up to 3 attempts, 2 hours apart", control: { kind: "switch", on: true } },
49
+ { id: "dnd", icon: <CheckCircle />, label: "Respect the DND registry", description: "Skip numbers on the national do-not-call list", control: { kind: "switch", on: true } },
50
+ ],
51
+ },
52
+ {
53
+ title: "Compliance",
54
+ settings: [
55
+ { id: "rates", icon: <Info />, label: "Block rate quotes", description: "The agent never quotes a rate outside the approved sheet", control: { kind: "switch", on: true } },
56
+ { id: "disclosure", icon: <FileText />, label: "Disclosure script", description: "Read at the start of every call", control: { kind: "button", label: "Edit" } },
57
+ { id: "talk-over", icon: <Info />, label: "Talk-over limit", description: "Flag calls where the agent talks over the lead for 2s or more", control: { kind: "badge", label: "2s" } },
58
+ ],
59
+ },
60
+ {
61
+ title: "Escalation",
62
+ settings: [
63
+ { id: "handoff", icon: <Call />, label: "Hand off to a person", description: "When the lead asks for a person twice", control: { kind: "switch", on: false } },
64
+ ],
65
+ },
66
+ ];
67
+
68
+ export function GuardrailsSettingsScreen() {
69
+ const [switches, setSwitches] = React.useState<Record<string, boolean>>(() =>
70
+ Object.fromEntries(
71
+ GROUPS.flatMap((group) => group.settings)
72
+ .filter((setting) => setting.control.kind === "switch")
73
+ .map((setting) => [setting.id, (setting.control as { on: boolean }).on]),
74
+ ),
75
+ );
76
+
77
+ const control = (setting: Setting) => {
78
+ switch (setting.control.kind) {
79
+ case "switch":
80
+ return (
81
+ <Switch
82
+ aria-label={setting.label}
83
+ checked={switches[setting.id]}
84
+ onCheckedChange={(on) => setSwitches((current) => ({ ...current, [setting.id]: on }))}
85
+ />
86
+ );
87
+ case "button":
88
+ return <Button variant="outline">{setting.control.label}</Button>;
89
+ case "badge":
90
+ return <Badge color="success">{setting.control.label}</Badge>;
91
+ }
92
+ };
93
+
94
+ return (
95
+ <div className="flex h-screen bg-surface p-3 text-foreground">
96
+ {/* The app window: one rounded surface on the WG3 backdrop. */}
97
+ <div className="flex min-w-0 flex-1 overflow-hidden rounded-window border border-border bg-card shadow-window">
98
+ <section aria-label="Guardrails" className="flex min-w-0 flex-1 flex-col overflow-hidden">
99
+ <BodyPageHeader title="Guardrails" actions={<Button>Save changes</Button>} />
100
+ <div className="flex min-h-0 flex-1 flex-col gap-8 overflow-y-auto px-6 pb-6">
101
+ {GROUPS.map((group) => (
102
+ // w-full: the group fills the panel — no max-width column.
103
+ <section key={group.title} aria-label={group.title} className="flex w-full flex-col gap-2">
104
+ <h2 className="m-0 text-l font-medium text-on-white-primary">{group.title}</h2>
105
+ <SettingList>
106
+ {group.settings.map((setting) => (
107
+ <SettingRow
108
+ key={setting.id}
109
+ icon={setting.icon}
110
+ label={setting.label}
111
+ description={setting.description}
112
+ action={control(setting)}
113
+ />
114
+ ))}
115
+ </SettingList>
116
+ </section>
117
+ ))}
118
+ </div>
119
+ </section>
120
+ </div>
121
+ </div>
122
+ );
123
+ }
Binary file