@twentyfirstmedia/ui-core 0.6.0 → 0.7.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.
@@ -0,0 +1,672 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { cx } from "../cx.js";
4
+ import { useFormat, useStrings } from "../context.js";
5
+ import { LIFE_STRINGS } from "../strings.js";
6
+ import { Icon } from "../Icon.js";
7
+ import { registerIcons } from "../icons.js";
8
+ import {
9
+ angry,
10
+ flame,
11
+ frown,
12
+ heartPulse,
13
+ laugh,
14
+ meh,
15
+ pill,
16
+ smile,
17
+ target,
18
+ } from "../icon-library.js";
19
+ import { useControlled, useId } from "../hooks.js";
20
+ import { Badge, Button, IconButton, TintIcon } from "./foundation.js";
21
+ import { Sparkline } from "./charts.js";
22
+ import { Switch } from "./inputs.js";
23
+ import type {
24
+ ActivityRingsProps,
25
+ ConsentListProps,
26
+ GoalCardProps,
27
+ HabitTrackerProps,
28
+ IconName,
29
+ InsightCardProps,
30
+ JournalEntryProps,
31
+ MedicationListProps,
32
+ MoodLevel,
33
+ MoodPickerProps,
34
+ SleepChartProps,
35
+ SleepStage,
36
+ Tint,
37
+ VitalTileProps,
38
+ } from "./types.js";
39
+
40
+ // the faces, the streak flame and the default icons come from the icon library
41
+ registerIcons({ angry, flame, frown, heartPulse, laugh, meh, pill, smile, target });
42
+
43
+ const MOOD_ICON: Record<MoodLevel, IconName> = {
44
+ 1: "angry",
45
+ 2: "frown",
46
+ 3: "meh",
47
+ 4: "smile",
48
+ 5: "laugh",
49
+ };
50
+ const MOOD_TINT: Record<MoodLevel, Tint> = {
51
+ 1: "blush",
52
+ 2: "butter",
53
+ 3: "sky",
54
+ 4: "mint",
55
+ 5: "lilac",
56
+ };
57
+
58
+ /* ── HabitTracker ── */
59
+
60
+ /** Habits with their last days as a row of marks, today's mark is a toggle. */
61
+ export function HabitTracker(p: HabitTrackerProps) {
62
+ const t = useStrings(LIFE_STRINGS);
63
+ const { formatDate } = useFormat();
64
+ const n = Math.max(0, ...p.habits.map((h) => h.days.length));
65
+ const labels =
66
+ p.dayLabels ||
67
+ Array.from({ length: n }, (_, i) => {
68
+ const d = new Date();
69
+ d.setDate(d.getDate() - (n - 1 - i));
70
+ return formatDate(d, { weekday: "narrow" });
71
+ });
72
+ return (
73
+ <div
74
+ style={p.style}
75
+ className={cx("tf-habits", p.className)}
76
+ role="list"
77
+ aria-label={p.label || t.habits}
78
+ >
79
+ <div className="tf-habit-days" aria-hidden>
80
+ {labels.map((l, i) => (
81
+ <span key={i}>{l}</span>
82
+ ))}
83
+ </div>
84
+ {p.habits.map((h) => {
85
+ const today = h.days[h.days.length - 1];
86
+ return (
87
+ <div key={h.id} className="tf-habit" role="listitem">
88
+ <TintIcon icon={h.icon || "check"} tint={h.tint || "mint"} size={32} />
89
+ <div className="tf-habit-text">
90
+ <b>{h.label}</b>
91
+ <small>
92
+ {h.streak ? (
93
+ <span className="tf-habit-streak">
94
+ <Icon name="flame" size={12} />
95
+ {t.habitStreak(h.streak)}
96
+ </span>
97
+ ) : null}
98
+ {h.goal && <span>{h.goal}</span>}
99
+ </small>
100
+ </div>
101
+ <div className="tf-habit-marks">
102
+ {h.days.map((d, i) => {
103
+ const last = i === h.days.length - 1;
104
+ const state =
105
+ d === true ? t.habitDone : d === false ? t.habitMissed : t.habitNoData;
106
+ const cls = cx("tf-habit-mark", d === true && "done", d === false && "missed");
107
+ return last && p.onToggle ? (
108
+ <button
109
+ key={i}
110
+ type="button"
111
+ className={cx(cls, "today")}
112
+ aria-pressed={today === true}
113
+ aria-label={t.markHabitDone(h.label)}
114
+ onClick={() => p.onToggle?.(h.id, today !== true)}
115
+ >
116
+ {d === true && <Icon name="check" size={12} />}
117
+ {d === false && <Icon name="x" size={10} />}
118
+ </button>
119
+ ) : (
120
+ <span
121
+ key={i}
122
+ className={cx(cls, last && "today")}
123
+ role="img"
124
+ aria-label={`${labels[i] ?? ""} ${state}`.trim()}
125
+ >
126
+ {d === true && <Icon name="check" size={12} />}
127
+ {d === false && <Icon name="x" size={10} />}
128
+ </span>
129
+ );
130
+ })}
131
+ </div>
132
+ </div>
133
+ );
134
+ })}
135
+ </div>
136
+ );
137
+ }
138
+
139
+ /* ── MoodPicker ── */
140
+
141
+ /** A five-step mood scale with faces and words (a radio group). */
142
+ export function MoodPicker(p: MoodPickerProps) {
143
+ const t = useStrings(LIFE_STRINGS);
144
+ const v = useControlled<MoodLevel | null>(p.value, p.defaultValue ?? null, (m) => {
145
+ if (m) p.onChange?.(m);
146
+ });
147
+ const name = useId("mood");
148
+ const words = p.levels || t.moodLevels;
149
+ return (
150
+ <fieldset style={p.style} className={cx("tf-mood", p.compact && "compact", p.className)}>
151
+ <legend className="tf-mood-q">{p.label || t.howAreYou}</legend>
152
+ <div className="tf-mood-scale">
153
+ {([1, 2, 3, 4, 5] as MoodLevel[]).map((m) => (
154
+ <label
155
+ key={m}
156
+ className={cx("tf-mood-opt", v[0] === m && "on", v[0] === m && "tf-t-" + MOOD_TINT[m])}
157
+ >
158
+ <input
159
+ type="radio"
160
+ name={name}
161
+ checked={v[0] === m}
162
+ onChange={() => v[1](m)}
163
+ aria-label={p.compact ? words[m - 1] : undefined}
164
+ />
165
+ <Icon name={MOOD_ICON[m]} size={22} />
166
+ {!p.compact && <span>{words[m - 1]}</span>}
167
+ </label>
168
+ ))}
169
+ </div>
170
+ </fieldset>
171
+ );
172
+ }
173
+
174
+ /* ── VitalTile ── */
175
+
176
+ /** One health value with its healthy range drawn as a band and a status in words. */
177
+ export function VitalTile(p: VitalTileProps) {
178
+ const t = useStrings(LIFE_STRINGS);
179
+ const { formatNumber } = useFormat();
180
+ const num = (n: number) =>
181
+ formatNumber(n, { minimumFractionDigits: p.digits || 0, maximumFractionDigits: p.digits || 0 });
182
+ const [lo, hi] = p.normal || [p.value, p.value];
183
+ const pad = Math.max((hi - lo) * 0.6, Math.abs(p.value) * 0.05, 1);
184
+ const [min, max] = p.scale || [Math.min(lo - pad, p.value), Math.max(hi + pad, p.value)];
185
+ const pos = (x: number) => Math.max(0, Math.min(100, ((x - min) / (max - min || 1)) * 100));
186
+ const st = !p.normal ? null : p.value < lo ? "low" : p.value > hi ? "high" : "ok";
187
+ return (
188
+ <div
189
+ style={p.style}
190
+ className={cx("tf-vital", p.surface && "tf-" + p.surface, st && "is-" + st, p.className)}
191
+ >
192
+ <div className="tf-vital-head">
193
+ <span className="tf-vital-label">
194
+ <Icon name={p.icon || "heartPulse"} size={14} />
195
+ {p.label}
196
+ </span>
197
+ {st && (
198
+ <Badge tone={st === "ok" ? "success" : "warning"}>
199
+ {st === "ok" ? t.inRange : st === "low" ? t.belowRange : t.aboveRange}
200
+ </Badge>
201
+ )}
202
+ </div>
203
+ <div className="tf-vital-main">
204
+ <span className="tf-vital-value">
205
+ {num(p.value)}
206
+ {p.unit && <small>{p.unit}</small>}
207
+ </span>
208
+ {p.trend && p.trend.length > 1 && (
209
+ <Sparkline
210
+ values={p.trend}
211
+ width={88}
212
+ height={28}
213
+ tint={st === "ok" || !st ? "mint" : "butter"}
214
+ />
215
+ )}
216
+ </div>
217
+ {p.normal && (
218
+ <div className="tf-vital-band" aria-hidden>
219
+ <span
220
+ className="tf-vital-normal"
221
+ style={{ left: pos(lo) + "%", width: pos(hi) - pos(lo) + "%" }}
222
+ />
223
+ <span className="tf-vital-dot" style={{ left: pos(p.value) + "%" }} />
224
+ </div>
225
+ )}
226
+ <div className="tf-vital-foot">
227
+ {p.normal && <span>{t.normalRange(num(lo), num(hi))}</span>}
228
+ {p.caption && <span>{p.caption}</span>}
229
+ </div>
230
+ </div>
231
+ );
232
+ }
233
+
234
+ /* ── ActivityRings ── */
235
+
236
+ /** Concentric progress rings for daily goals (move, exercise, stand …) with a legend. */
237
+ export function ActivityRings(p: ActivityRingsProps) {
238
+ const t = useStrings(LIFE_STRINGS);
239
+ const { formatNumber } = useFormat();
240
+ const size = p.size || 132,
241
+ sw = Math.max(8, Math.round(size / 11)),
242
+ gap = 3;
243
+ const tints: Tint[] = ["blush", "mint", "sky", "lilac", "butter"];
244
+ const text = (r: ActivityRingsProps["rings"][number]) =>
245
+ t.ofGoal(formatNumber(r.value), formatNumber(r.goal)) + (r.unit ? " " + r.unit : "");
246
+ return (
247
+ <div style={p.style} className={cx("tf-rings", p.className)}>
248
+ <svg
249
+ width={size}
250
+ height={size}
251
+ viewBox={`0 0 ${size} ${size}`}
252
+ role="img"
253
+ aria-label={`${p.label || t.activity}: ${p.rings.map((r) => `${r.label} ${text(r)}`).join(", ")}`}
254
+ >
255
+ {p.rings.map((r, i) => {
256
+ const rad = size / 2 - sw / 2 - i * (sw + gap);
257
+ if (rad <= sw / 2) return null;
258
+ const c = 2 * Math.PI * rad,
259
+ k = Math.max(0, Math.min(1, r.goal ? r.value / r.goal : 0));
260
+ const tint = r.tint || tints[i % tints.length];
261
+ return (
262
+ <g key={r.label}>
263
+ <circle
264
+ className="tf-arc-track"
265
+ cx={size / 2}
266
+ cy={size / 2}
267
+ r={rad}
268
+ strokeWidth={sw}
269
+ />
270
+ <circle
271
+ className={cx("tf-arc", "tf-arc-" + tint)}
272
+ cx={size / 2}
273
+ cy={size / 2}
274
+ r={rad}
275
+ strokeWidth={sw}
276
+ strokeDasharray={c}
277
+ strokeDashoffset={c * (1 - k)}
278
+ />
279
+ </g>
280
+ );
281
+ })}
282
+ </svg>
283
+ {p.legend !== false && (
284
+ <ul className="tf-rings-legend" aria-hidden>
285
+ {p.rings.map((r, i) => (
286
+ <li key={r.label}>
287
+ <span
288
+ className={cx("tf-rings-key", "tf-sf-" + (r.tint || tints[i % tints.length]))}
289
+ />
290
+ <span className="tf-rings-name">{r.label}</span>
291
+ <b>{text(r)}</b>
292
+ </li>
293
+ ))}
294
+ </ul>
295
+ )}
296
+ </div>
297
+ );
298
+ }
299
+
300
+ /* ── SleepChart ── */
301
+
302
+ const STAGES: SleepStage[] = ["awake", "rem", "light", "deep"];
303
+ const STAGE_FILL: Record<SleepStage, Tint> = {
304
+ awake: "butter",
305
+ rem: "lilac",
306
+ light: "sky",
307
+ deep: "mint",
308
+ };
309
+
310
+ /** A night as stages over time (one lane per stage) with the time asleep per stage. */
311
+ export function SleepChart(p: SleepChartProps) {
312
+ const t = useStrings(LIFE_STRINGS);
313
+ const { formatDate } = useFormat();
314
+ const ms = (d: Date | string | number) => new Date(d).getTime();
315
+ const segs = p.segments.filter((s) => ms(s.end) > ms(s.start));
316
+ const start = Math.min(...segs.map((s) => ms(s.start))),
317
+ end = Math.max(...segs.map((s) => ms(s.end))),
318
+ span = end - start || 1;
319
+ const total: Record<SleepStage, number> = { awake: 0, rem: 0, light: 0, deep: 0 };
320
+ segs.forEach((s) => (total[s.stage] += ms(s.end) - ms(s.start)));
321
+ const dur = (m: number) => {
322
+ const min = Math.round(m / 60000);
323
+ return t.durationHm(Math.floor(min / 60), min % 60);
324
+ };
325
+ const asleep = total.rem + total.light + total.deep;
326
+ const time = (x: number) => formatDate(x, { hour: "numeric", minute: "2-digit" });
327
+ return (
328
+ <div style={p.style} className={cx("tf-sleep", p.className)}>
329
+ <div
330
+ className="tf-sleep-chart"
331
+ role="img"
332
+ aria-label={`${p.label || t.sleepStages}: ${t.totalSleep(dur(asleep))}, ${STAGES.map(
333
+ (s) => `${t.sleepStageNames[s]} ${dur(total[s])}`,
334
+ ).join(", ")}`}
335
+ >
336
+ {STAGES.map((stage) => (
337
+ <div key={stage} className="tf-sleep-lane">
338
+ <span className="tf-sleep-name">{t.sleepStageNames[stage]}</span>
339
+ <span className="tf-sleep-track">
340
+ {segs
341
+ .filter((s) => s.stage === stage)
342
+ .map((s, i) => (
343
+ <span
344
+ key={i}
345
+ className={cx("tf-sleep-seg", "tf-sf-" + STAGE_FILL[stage])}
346
+ style={{
347
+ left: ((ms(s.start) - start) / span) * 100 + "%",
348
+ width: ((ms(s.end) - ms(s.start)) / span) * 100 + "%",
349
+ }}
350
+ />
351
+ ))}
352
+ </span>
353
+ </div>
354
+ ))}
355
+ <div className="tf-sleep-axis" aria-hidden>
356
+ <span>{time(start)}</span>
357
+ <span>{time(end)}</span>
358
+ </div>
359
+ </div>
360
+ {p.summary !== false && (
361
+ <div className="tf-sleep-sum">
362
+ <b>{t.totalSleep(dur(asleep))}</b>
363
+ <ul>
364
+ {STAGES.map((s) => (
365
+ <li key={s}>
366
+ <span className={cx("tf-rings-key", "tf-sf-" + STAGE_FILL[s])} />
367
+ {t.sleepStageNames[s]}
368
+ <span>{dur(total[s])}</span>
369
+ </li>
370
+ ))}
371
+ </ul>
372
+ </div>
373
+ )}
374
+ </div>
375
+ );
376
+ }
377
+
378
+ /* ── MedicationList ── */
379
+
380
+ /** Today's doses in order, each with its status in words and a "taken" button. */
381
+ export function MedicationList(p: MedicationListProps) {
382
+ const t = useStrings(LIFE_STRINGS);
383
+ const words = { taken: t.medTaken, due: t.medDue, missed: t.medMissed, upcoming: t.medUpcoming };
384
+ return (
385
+ <ul style={p.style} className={cx("tf-meds", p.className)} aria-label={p.label || t.medication}>
386
+ {p.items.map((m) => {
387
+ const st = m.status || (m.taken ? "taken" : "due");
388
+ return (
389
+ <li key={m.id} className={cx("tf-med", "is-" + st)}>
390
+ <span className="tf-med-time">{m.time}</span>
391
+ <TintIcon
392
+ icon={m.icon || "pill"}
393
+ tint={st === "missed" ? "blush" : st === "taken" ? "mint" : "sky"}
394
+ size={32}
395
+ />
396
+ <span className="tf-med-text">
397
+ <b>{m.name}</b>
398
+ {(m.dose || m.note) && <small>{[m.dose, m.note].filter(Boolean).join(" · ")}</small>}
399
+ </span>
400
+ {st === "taken" ? (
401
+ <Badge tone="success">{words.taken}</Badge>
402
+ ) : (
403
+ <span className="tf-med-act">
404
+ <Badge tone={st === "missed" ? "danger" : st === "due" ? "warning" : undefined}>
405
+ {words[st]}
406
+ </Badge>
407
+ {p.onTake && st !== "upcoming" && (
408
+ <IconButton
409
+ icon="check"
410
+ label={t.markTaken(m.name)}
411
+ size="sm"
412
+ onClick={() => p.onTake?.(m.id)}
413
+ />
414
+ )}
415
+ </span>
416
+ )}
417
+ </li>
418
+ );
419
+ })}
420
+ </ul>
421
+ );
422
+ }
423
+
424
+ /* ── JournalEntry ── */
425
+
426
+ /** A journal or diary entry: date, mood, title, text and tags. */
427
+ export function JournalEntry(p: JournalEntryProps) {
428
+ const t = useStrings(LIFE_STRINGS);
429
+ const { formatDate } = useFormat();
430
+ return (
431
+ <article
432
+ style={p.style}
433
+ className={cx("tf-journal", p.surface && "tf-" + p.surface, p.className)}
434
+ >
435
+ <header className="tf-journal-head">
436
+ <time dateTime={new Date(p.date).toISOString()}>
437
+ {formatDate(p.date, { weekday: "long", day: "numeric", month: "long" })}
438
+ </time>
439
+ {p.mood && (
440
+ <span className={cx("tf-journal-mood", "tf-t-" + MOOD_TINT[p.mood])}>
441
+ <Icon name={MOOD_ICON[p.mood]} size={14} />
442
+ {t.moodLevels[p.mood - 1]}
443
+ </span>
444
+ )}
445
+ </header>
446
+ {p.title && <h3 className="tf-journal-title">{p.title}</h3>}
447
+ {p.children && <div className="tf-journal-body">{p.children}</div>}
448
+ {(p.tags?.length || p.actions) && (
449
+ <footer className="tf-journal-foot">
450
+ <span className="tf-journal-tags">
451
+ {(p.tags || []).map((g) => (
452
+ <Badge key={g}>{g}</Badge>
453
+ ))}
454
+ </span>
455
+ {p.actions}
456
+ </footer>
457
+ )}
458
+ </article>
459
+ );
460
+ }
461
+
462
+ /* ── GoalCard ── */
463
+
464
+ /** A goal with its progress toward a target, a due date and milestones. */
465
+ export function GoalCard(p: GoalCardProps) {
466
+ const t = useStrings(LIFE_STRINGS);
467
+ const { formatNumber, formatMoney, formatPercent, formatDate } = useFormat();
468
+ const pct = Math.max(0, Math.min(100, p.target ? (p.value / p.target) * 100 : 0));
469
+ const show = (n: number) =>
470
+ p.format === "money"
471
+ ? formatMoney(n, p.currency, { maximumFractionDigits: 0 })
472
+ : p.format === "percent"
473
+ ? formatPercent(n)
474
+ : formatNumber(n) + (p.unit ? " " + p.unit : "");
475
+ return (
476
+ <div style={p.style} className={cx("tf-goal", p.surface && "tf-" + p.surface, p.className)}>
477
+ <div className="tf-goal-head">
478
+ <TintIcon icon={p.icon || "target"} tint={p.tint || "lilac"} size={36} />
479
+ <div className="tf-option-text">
480
+ <b>{p.title}</b>
481
+ {p.due != null && (
482
+ <small>{t.dueBy(formatDate(p.due, { month: "short", year: "numeric" }))}</small>
483
+ )}
484
+ </div>
485
+ <span className="tf-goal-pct">{formatPercent(Math.round(pct))}</span>
486
+ </div>
487
+ <div
488
+ className="tf-goal-bar"
489
+ role="progressbar"
490
+ aria-label={p.title}
491
+ aria-valuemin={0}
492
+ aria-valuemax={100}
493
+ aria-valuenow={Math.round(pct)}
494
+ aria-valuetext={t.ofGoal(show(p.value), show(p.target))}
495
+ >
496
+ <span className={cx("tf-sf-" + (p.tint || "lilac"))} style={{ width: pct + "%" }} />
497
+ </div>
498
+ <div className="tf-goal-meta">{t.ofGoal(show(p.value), show(p.target))}</div>
499
+ {p.milestones && p.milestones.length > 0 && (
500
+ <ul className="tf-goal-steps">
501
+ {p.milestones.map((m, i) => (
502
+ <li key={i} className={cx(m.done && "done")}>
503
+ <span className="tf-goal-step" aria-hidden>
504
+ {m.done && <Icon name="check" size={10} />}
505
+ </span>
506
+ {m.label}
507
+ </li>
508
+ ))}
509
+ </ul>
510
+ )}
511
+ {p.note && <p className="tf-goal-note">{p.note}</p>}
512
+ </div>
513
+ );
514
+ }
515
+
516
+ /* ── InsightCard ── */
517
+
518
+ const INSIGHT_TINT = {
519
+ info: "sky",
520
+ success: "mint",
521
+ warning: "butter",
522
+ highlight: "lilac",
523
+ } as const;
524
+
525
+ /**
526
+ * Something the assistant noticed, with where it comes from, why it is shown and what to do:
527
+ * accept, snooze or dismiss.
528
+ */
529
+ export function InsightCard(p: InsightCardProps) {
530
+ const t = useStrings(LIFE_STRINGS);
531
+ const [why, setWhy] = React.useState(false);
532
+ const id = useId("insight");
533
+ const tone = p.tone || "info";
534
+ return (
535
+ <section
536
+ style={p.style}
537
+ className={cx("tf-insight", p.surface && "tf-" + p.surface, "is-" + tone, p.className)}
538
+ aria-labelledby={`${id}-t`}
539
+ >
540
+ <div className="tf-insight-head">
541
+ <TintIcon icon={p.icon || "sparkle"} tint={INSIGHT_TINT[tone]} size={36} />
542
+ <div className="tf-insight-titles">
543
+ {p.meta && <small>{p.meta}</small>}
544
+ <b id={`${id}-t`}>{p.title}</b>
545
+ </div>
546
+ {p.onDismiss && (
547
+ <IconButton icon="x" label={t.dismiss} size="sm" variant="ghost" onClick={p.onDismiss} />
548
+ )}
549
+ </div>
550
+ {p.children && <div className="tf-insight-body">{p.children}</div>}
551
+ {p.sources && p.sources.length > 0 && (
552
+ <div className="tf-msg-sources">
553
+ {p.sources.map((s, i) => (
554
+ <a
555
+ key={i}
556
+ className="tf-source"
557
+ href={s.url || "#"}
558
+ title={s.title}
559
+ onClick={
560
+ s.onClick
561
+ ? (e) => {
562
+ e.preventDefault();
563
+ s.onClick?.();
564
+ }
565
+ : undefined
566
+ }
567
+ >
568
+ <span className="tf-source-n">{i + 1}</span>
569
+ <Icon name={s.icon || "book"} size={12} />
570
+ <span className="tf-source-title">{s.title}</span>
571
+ </a>
572
+ ))}
573
+ </div>
574
+ )}
575
+ {p.why && (
576
+ <div className="tf-insight-why">
577
+ <button
578
+ type="button"
579
+ aria-expanded={why}
580
+ aria-controls={`${id}-why`}
581
+ onClick={() => setWhy(!why)}
582
+ >
583
+ <Icon name="help" size={14} />
584
+ {t.whyInsight}
585
+ <Icon name="chevronDown" size={14} className="tf-insight-chev" />
586
+ </button>
587
+ {why && (
588
+ <div id={`${id}-why`} className="tf-insight-whytext">
589
+ {p.why}
590
+ </div>
591
+ )}
592
+ </div>
593
+ )}
594
+ {(p.onAccept || p.onSnooze) && (
595
+ <div className="tf-insight-actions">
596
+ {p.onSnooze && (
597
+ <Button variant="ghost" size="sm" icon="clock" onClick={p.onSnooze}>
598
+ {t.insightSnooze}
599
+ </Button>
600
+ )}
601
+ {p.onAccept && (
602
+ <Button variant="primary" size="sm" onClick={p.onAccept}>
603
+ {p.acceptLabel || t.insightAccept}
604
+ </Button>
605
+ )}
606
+ </div>
607
+ )}
608
+ </section>
609
+ );
610
+ }
611
+
612
+ /* ── ConsentList ── */
613
+
614
+ /** What each connected source lets the assistant read or do, switchable per scope, revocable. */
615
+ export function ConsentList(p: ConsentListProps) {
616
+ const t = useStrings(LIFE_STRINGS);
617
+ const { formatDate } = useFormat();
618
+ return (
619
+ <div
620
+ style={p.style}
621
+ className={cx("tf-consent", p.className)}
622
+ role="list"
623
+ aria-label={p.label || t.dataAccess}
624
+ >
625
+ {p.connectors.map((c) => (
626
+ <section
627
+ key={c.id}
628
+ className={cx("tf-consent-item", p.surface && "tf-" + p.surface)}
629
+ role="listitem"
630
+ aria-label={c.name}
631
+ >
632
+ <div className="tf-consent-head">
633
+ {c.logo || <TintIcon icon={c.icon || "plug"} tint="sky" size={36} />}
634
+ <div className="tf-option-text">
635
+ <b>{c.name}</b>
636
+ <small>
637
+ {c.lastUsed != null
638
+ ? t.lastUsed(formatDate(c.lastUsed, { day: "numeric", month: "short" }))
639
+ : t.notUsedYet}
640
+ </small>
641
+ </div>
642
+ {p.onRevoke && (
643
+ <Button variant="secondary" size="sm" onClick={() => p.onRevoke?.(c.id)}>
644
+ {t.revokeAccess}
645
+ </Button>
646
+ )}
647
+ </div>
648
+ {c.purpose && <p className="tf-consent-purpose">{c.purpose}</p>}
649
+ <ul className="tf-consent-scopes">
650
+ {c.scopes.map((s) => (
651
+ <li key={s.id}>
652
+ <span className="tf-option-text">
653
+ <b>{s.label}</b>
654
+ {s.description && <small>{s.description}</small>}
655
+ </span>
656
+ {s.required ? (
657
+ <span className="tf-consent-req">{t.requiredScope}</span>
658
+ ) : (
659
+ <Switch
660
+ checked={s.granted}
661
+ label={`${c.name}: ${s.label}`}
662
+ onChange={(e) => p.onScopeChange?.(c.id, s.id, e.currentTarget.checked)}
663
+ />
664
+ )}
665
+ </li>
666
+ ))}
667
+ </ul>
668
+ </section>
669
+ ))}
670
+ </div>
671
+ );
672
+ }