@twentyfirstmedia/ui-core 0.6.0 → 0.8.0

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