@remit/ui 0.0.112 → 0.0.114

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/package.json +1 -1
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/app-shell-types.ts +10 -0
  5. package/src/components/attendee-row.render.test.ts +73 -0
  6. package/src/components/attendee-row.stories.tsx +68 -0
  7. package/src/components/attendee-row.tsx +96 -0
  8. package/src/components/calendar-event-chip.render.test.ts +67 -0
  9. package/src/components/calendar-event-chip.stories.tsx +128 -0
  10. package/src/components/calendar-event-chip.tsx +116 -0
  11. package/src/components/calendar-list.render.test.ts +77 -0
  12. package/src/components/calendar-list.stories.tsx +130 -0
  13. package/src/components/calendar-list.tsx +225 -0
  14. package/src/components/calendar-toolbar.render.test.ts +69 -0
  15. package/src/components/calendar-toolbar.stories.tsx +55 -0
  16. package/src/components/calendar-toolbar.tsx +178 -0
  17. package/src/components/calendar-types.ts +135 -0
  18. package/src/components/custom-recurrence.stories.tsx +106 -0
  19. package/src/components/custom-recurrence.tsx +411 -0
  20. package/src/components/event-detail.render.test.ts +104 -0
  21. package/src/components/event-detail.stories.tsx +178 -0
  22. package/src/components/event-detail.tsx +188 -0
  23. package/src/components/event-editor-pane.tsx +54 -0
  24. package/src/components/event-editor.render.test.ts +83 -0
  25. package/src/components/event-editor.stories.tsx +99 -0
  26. package/src/components/event-editor.tsx +480 -0
  27. package/src/components/event-quick-entry.render.test.ts +40 -0
  28. package/src/components/event-quick-entry.stories.tsx +56 -0
  29. package/src/components/event-quick-entry.tsx +159 -0
  30. package/src/components/event-suggestion-card.render.test.ts +65 -0
  31. package/src/components/event-suggestion-card.stories.tsx +93 -0
  32. package/src/components/event-suggestion-card.tsx +116 -0
  33. package/src/components/flow-screen.tsx +169 -0
  34. package/src/components/intelligence-panel.render.test.ts +94 -0
  35. package/src/components/intelligence-panel.stories.tsx +10 -6
  36. package/src/components/intelligence-panel.tsx +7 -5
  37. package/src/components/message-list-pane.render.test.ts +2 -2
  38. package/src/components/message-list-pane.stories.tsx +1 -1
  39. package/src/components/mobile-reading-pane.render.test.ts +1 -1
  40. package/src/components/nav-sidebar.tsx +20 -0
  41. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  42. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  43. package/src/components/recurrence-scope-prompt.tsx +84 -0
  44. package/src/components/selection-wizard.tsx +19 -69
  45. package/src/index.ts +112 -0
  46. package/src/lib/calendar-color.ts +71 -0
  47. package/src/lib/event-phrase.test.ts +89 -0
  48. package/src/lib/event-phrase.ts +228 -0
  49. package/src/lib/recurrence.test.ts +141 -0
  50. package/src/lib/recurrence.ts +266 -0
  51. package/src/tokens.css +63 -0
@@ -0,0 +1,159 @@
1
+ import { AlertTriangle, Info, Wand2 } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import type { PhraseParse } from "../lib/event-phrase.js";
4
+
5
+ export interface EventQuickEntryProps {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ /** The reading of `value`, recomputed by the caller on every keystroke. */
9
+ parse: PhraseParse;
10
+ /** Commits the reading to the form. */
11
+ onCommit: () => void;
12
+ placeholder?: string;
13
+ touch?: boolean;
14
+ className?: string;
15
+ }
16
+
17
+ interface ReadingProps {
18
+ label: string;
19
+ value: string;
20
+ source: string;
21
+ }
22
+
23
+ function Reading({ label, value, source }: ReadingProps) {
24
+ return (
25
+ <div className="flex min-w-0 items-baseline gap-1.5">
26
+ <span className="shrink-0 text-2xs uppercase tracking-wider text-fg-subtle">
27
+ {label}
28
+ </span>
29
+ <span className="truncate text-xs font-medium text-fg">{value}</span>
30
+ {source !== "" && (
31
+ <span className="shrink-0 rounded-xs bg-accent-2-soft px-1 text-2xs text-accent-2">
32
+ {source}
33
+ </span>
34
+ )}
35
+ </div>
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Typing is the fastest way to make an event, so the field takes a sentence.
41
+ * What it does with the sentence is on screen while you type: every reading is
42
+ * shown next to the words it was taken from, and anything the reader filled in
43
+ * or could not settle is said out loud. Correcting the machine happens before
44
+ * the event exists, not after it is on the grid.
45
+ */
46
+ export function EventQuickEntry({
47
+ value,
48
+ onChange,
49
+ parse,
50
+ onCommit,
51
+ placeholder = "lunch with Jane friday 1pm",
52
+ touch,
53
+ className,
54
+ }: EventQuickEntryProps) {
55
+ const hasReading = value.trim() !== "";
56
+ const endTime =
57
+ parse.startTime === ""
58
+ ? ""
59
+ : ` – ${clockAfter(parse.startTime, parse.durationMinutes)}`;
60
+
61
+ return (
62
+ <div className={cn("flex flex-col gap-2", className)}>
63
+ <div
64
+ className={cn(
65
+ "flex items-center gap-2 rounded-md border border-line bg-surface-sunken px-3 focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30",
66
+ touch ? "min-h-11" : "h-9",
67
+ )}
68
+ >
69
+ <Wand2 className="size-4 shrink-0 text-fg-subtle" aria-hidden />
70
+ <input
71
+ value={value}
72
+ aria-label="Describe the event"
73
+ placeholder={placeholder}
74
+ onChange={(e) => onChange(e.target.value)}
75
+ onKeyDown={(e) => {
76
+ if (e.key === "Enter") onCommit();
77
+ }}
78
+ className="min-w-0 flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-subtle"
79
+ />
80
+ </div>
81
+
82
+ {hasReading && (
83
+ <div className="flex flex-col gap-1 rounded-md border border-line bg-surface px-2.5 py-2">
84
+ <Reading
85
+ label="What"
86
+ value={parse.title === "" ? "—" : parse.title}
87
+ source=""
88
+ />
89
+ <Reading
90
+ label="When"
91
+ value={`${formatDay(parse.date)}${
92
+ parse.startTime === "" ? "" : ` ${parse.startTime}${endTime}`
93
+ }`}
94
+ source={[parse.dateText, parse.startTimeText]
95
+ .filter((part) => part !== "")
96
+ .join(" ")}
97
+ />
98
+ {parse.durationText !== "" && (
99
+ <Reading
100
+ label="Long"
101
+ value={formatDuration(parse.durationMinutes)}
102
+ source={parse.durationText}
103
+ />
104
+ )}
105
+ {parse.attendees.length > 0 && (
106
+ <Reading
107
+ label="Who"
108
+ value={parse.attendees.join(", ")}
109
+ source={parse.attendeesText}
110
+ />
111
+ )}
112
+ {parse.assumptions.map((note) => (
113
+ <p
114
+ key={note}
115
+ className="flex items-center gap-1.5 text-2xs text-fg-subtle"
116
+ >
117
+ <Info className="size-3 shrink-0" aria-hidden />
118
+ {note}
119
+ </p>
120
+ ))}
121
+ {parse.unresolved.map((note) => (
122
+ <p
123
+ key={note}
124
+ className="flex items-center gap-1.5 text-2xs text-warning"
125
+ >
126
+ <AlertTriangle className="size-3 shrink-0" aria-hidden />
127
+ {note}
128
+ </p>
129
+ ))}
130
+ </div>
131
+ )}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ function clockAfter(clock: string, minutes: number): string {
137
+ const [hours, mins] = clock.split(":").map(Number);
138
+ const total = (hours * 60 + mins + minutes) % 1440;
139
+ return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
140
+ total % 60,
141
+ ).padStart(2, "0")}`;
142
+ }
143
+
144
+ function formatDay(iso: string): string {
145
+ if (iso === "") return "—";
146
+ const [year, month, day] = iso.split("-").map(Number);
147
+ return new Date(year, month - 1, day).toLocaleDateString("en-GB", {
148
+ weekday: "short",
149
+ day: "numeric",
150
+ month: "short",
151
+ });
152
+ }
153
+
154
+ function formatDuration(minutes: number): string {
155
+ if (minutes === 0) return "—";
156
+ if (minutes % 60 === 0) return `${minutes / 60}h`;
157
+ if (minutes < 60) return `${minutes}m`;
158
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
159
+ }
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import type { EventSuggestion } from "./calendar-types.js";
6
+ import { EventSuggestionCard } from "./event-suggestion-card.js";
7
+
8
+ const suggestion: EventSuggestion = {
9
+ id: "s1",
10
+ title: "Stay in Lisbon",
11
+ start: "2026-06-19",
12
+ end: "2026-06-23",
13
+ allDay: true,
14
+ location: "Alfama, Lisbon",
15
+ threadId: "thr_airbnb",
16
+ threadSubject: "Your reservation in Lisbon is confirmed",
17
+ sender: "Airbnb",
18
+ confidence: 0.94,
19
+ ambiguity: "",
20
+ suggestedCalendarId: "c5",
21
+ timeZone: "Europe/Lisbon",
22
+ zoneCertainty: "explicit",
23
+ };
24
+
25
+ const render = (overrides: Partial<EventSuggestion>) =>
26
+ renderToString(
27
+ createElement(EventSuggestionCard, {
28
+ suggestion: { ...suggestion, ...overrides },
29
+ whenText: "Friday 19 June – Monday 22 June",
30
+ onAdd: () => undefined,
31
+ onReview: () => undefined,
32
+ onDismiss: () => undefined,
33
+ onOpenThread: () => undefined,
34
+ }),
35
+ );
36
+
37
+ describe("EventSuggestionCard", () => {
38
+ it("says it is a suggestion and not an event", () => {
39
+ const html = render({});
40
+ assert.match(html, /Suggested from mail/);
41
+ assert.match(html, /border-dashed/);
42
+ });
43
+
44
+ it("puts the reading into words rather than a percentage", () => {
45
+ assert.match(render({}), /Read cleanly/);
46
+ assert.match(render({ confidence: 0.7 }), /Read with gaps/);
47
+ assert.match(render({ confidence: 0.2 }), /Barely read/);
48
+ });
49
+
50
+ it("names what it could not settle", () => {
51
+ assert.match(
52
+ render({ ambiguity: "Two Tuesdays fit." }),
53
+ /Two Tuesdays fit\./,
54
+ );
55
+ });
56
+
57
+ it("credits the mail it was read out of", () => {
58
+ assert.match(render({}), /Airbnb/);
59
+ assert.match(render({}), /Your reservation in Lisbon is confirmed/);
60
+ });
61
+
62
+ it("needs a person to press Add", () => {
63
+ assert.match(render({}), />Add</);
64
+ });
65
+ });
@@ -0,0 +1,93 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import type { EventSuggestion } from "./calendar-types.js";
3
+ import { EventSuggestionCard } from "./event-suggestion-card.js";
4
+
5
+ /**
6
+ * What the reader found in a mail, waiting for a person. It sits on a dashed
7
+ * card off the grid: a suggestion is never a provisional event that someone has
8
+ * to notice and take back off the calendar.
9
+ */
10
+ const meta: Meta<typeof EventSuggestionCard> = {
11
+ title: "Calendar/Suggestion card",
12
+ component: EventSuggestionCard,
13
+ parameters: { layout: "padded" },
14
+ decorators: [
15
+ (Story) => (
16
+ <div className="max-w-sm">
17
+ <Story />
18
+ </div>
19
+ ),
20
+ ],
21
+ };
22
+ export default meta;
23
+
24
+ type Story = StoryObj<typeof EventSuggestionCard>;
25
+
26
+ const base: EventSuggestion = {
27
+ id: "s1",
28
+ title: "Stay in Lisbon",
29
+ start: "2026-06-19",
30
+ end: "2026-06-23",
31
+ allDay: true,
32
+ location: "Alfama, Lisbon",
33
+ threadId: "thr_airbnb",
34
+ threadSubject: "Your reservation in Lisbon is confirmed",
35
+ sender: "Airbnb",
36
+ confidence: 0.94,
37
+ ambiguity: "",
38
+ suggestedCalendarId: "c5",
39
+ timeZone: "Europe/Lisbon",
40
+ zoneCertainty: "explicit",
41
+ };
42
+
43
+ const handlers = {
44
+ onAdd: () => {},
45
+ onReview: () => {},
46
+ onDismiss: () => {},
47
+ onOpenThread: () => {},
48
+ };
49
+
50
+ /** A booking mail that gave every field it needed to. */
51
+ export const ReadCleanly: Story = {
52
+ render: () => (
53
+ <EventSuggestionCard
54
+ suggestion={base}
55
+ whenText="Friday 19 June – Monday 22 June"
56
+ {...handlers}
57
+ />
58
+ ),
59
+ };
60
+
61
+ /** What it could not settle is named, not smoothed over. */
62
+ export const WithAmbiguity: Story = {
63
+ render: () => (
64
+ <EventSuggestionCard
65
+ suggestion={{
66
+ ...base,
67
+ id: "s2",
68
+ title: "Analytics pilot — first call",
69
+ allDay: false,
70
+ location: "",
71
+ threadSubject: "Following up: analytics pilot proposal",
72
+ sender: "Erik Wahlberg",
73
+ confidence: 0.38,
74
+ ambiguity:
75
+ 'Asked for "some time Tuesday" and named no hour. Two Tuesdays fit.',
76
+ }}
77
+ whenText="Tuesday 16 June · 09:00 – 10:00"
78
+ {...handlers}
79
+ />
80
+ ),
81
+ };
82
+
83
+ /** The same card sized for a phone sheet. */
84
+ export const Touch: Story = {
85
+ render: () => (
86
+ <EventSuggestionCard
87
+ suggestion={base}
88
+ whenText="Friday 19 June – Monday 22 June"
89
+ touch
90
+ {...handlers}
91
+ />
92
+ ),
93
+ };
@@ -0,0 +1,116 @@
1
+ import { AlertTriangle, Mail, Plus, SlidersHorizontal, X } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import { Button } from "./button.js";
4
+ import type { EventSuggestion } from "./calendar-types.js";
5
+
6
+ export interface EventSuggestionCardProps {
7
+ suggestion: EventSuggestion;
8
+ /** Already formatted by the caller. */
9
+ whenText: string;
10
+ onAdd: () => void;
11
+ onReview: () => void;
12
+ onDismiss: () => void;
13
+ onOpenThread: () => void;
14
+ touch?: boolean;
15
+ className?: string;
16
+ }
17
+
18
+ /** Words for a number, so the card never shows a false 87%. */
19
+ function confidenceText(confidence: number): string {
20
+ if (confidence >= 0.85) return "Read cleanly";
21
+ if (confidence >= 0.6) return "Read with gaps";
22
+ return "Barely read";
23
+ }
24
+
25
+ /**
26
+ * A candidate, and unmistakably not an event. It lives off the grid, on a
27
+ * dashed card, and only a person pressing Add puts anything on the calendar —
28
+ * a machine reading of a mail never becomes a provisional block that someone
29
+ * has to notice and remove.
30
+ */
31
+ export function EventSuggestionCard({
32
+ suggestion,
33
+ whenText,
34
+ onAdd,
35
+ onReview,
36
+ onDismiss,
37
+ onOpenThread,
38
+ touch,
39
+ className,
40
+ }: EventSuggestionCardProps) {
41
+ return (
42
+ <div
43
+ className={cn(
44
+ "flex flex-col gap-2 rounded-lg border border-dashed border-line-strong bg-surface-sunken p-3",
45
+ className,
46
+ )}
47
+ >
48
+ <div className="flex items-start gap-2">
49
+ <span className="min-w-0 flex-1">
50
+ <span className="block text-2xs uppercase tracking-wider text-fg-subtle">
51
+ Suggested from mail · {confidenceText(suggestion.confidence)}
52
+ </span>
53
+ <span className="block truncate text-sm font-medium text-fg">
54
+ {suggestion.title}
55
+ </span>
56
+ <span className="block text-xs text-fg-muted">{whenText}</span>
57
+ {suggestion.location !== "" && (
58
+ <span className="block truncate text-xs text-fg-muted">
59
+ {suggestion.location}
60
+ </span>
61
+ )}
62
+ </span>
63
+ <button
64
+ type="button"
65
+ aria-label="Dismiss suggestion"
66
+ onClick={onDismiss}
67
+ className={cn(
68
+ "flex shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none hover:bg-surface hover:text-fg focus-visible:ring-2 focus-visible:ring-ring",
69
+ touch ? "size-11" : "size-7",
70
+ )}
71
+ >
72
+ <X className="size-4" />
73
+ </button>
74
+ </div>
75
+
76
+ {suggestion.ambiguity !== "" && (
77
+ <p className="flex items-start gap-1.5 text-2xs text-warning">
78
+ <AlertTriangle className="mt-0.5 size-3 shrink-0" aria-hidden />
79
+ {suggestion.ambiguity}
80
+ </p>
81
+ )}
82
+
83
+ <button
84
+ type="button"
85
+ onClick={onOpenThread}
86
+ className="flex w-full min-w-0 items-center gap-1.5 rounded-sm text-left text-2xs text-accent-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
87
+ >
88
+ <Mail className="size-3 shrink-0" />
89
+ <span className="truncate">
90
+ {suggestion.sender} — {suggestion.threadSubject}
91
+ </span>
92
+ </button>
93
+
94
+ <div className="flex items-center gap-2">
95
+ <Button
96
+ variant="primary"
97
+ size={touch ? "md" : "sm"}
98
+ icon={<Plus className="size-3.5" />}
99
+ onClick={onAdd}
100
+ className={touch ? "min-h-11 flex-1" : ""}
101
+ >
102
+ Add
103
+ </Button>
104
+ <Button
105
+ variant="secondary"
106
+ size={touch ? "md" : "sm"}
107
+ icon={<SlidersHorizontal className="size-3.5" />}
108
+ onClick={onReview}
109
+ className={touch ? "min-h-11" : ""}
110
+ >
111
+ Change first
112
+ </Button>
113
+ </div>
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The full-screen flow chrome: a fixed header carrying back and cancel, one
3
+ * scrolling body, and a fixed footer in the thumb zone. Every multi-decision
4
+ * surface on a phone is this shape, so there is one implementation of it.
5
+ */
6
+
7
+ import { ArrowLeft, X } from "lucide-react";
8
+ import type { ReactNode } from "react";
9
+ import { cn } from "../lib/cn.js";
10
+
11
+ export interface FlowStepRailProps {
12
+ /** How many steps the flow walks. */
13
+ count: number;
14
+ /** Zero-based index of the step on screen. */
15
+ activeStep: number;
16
+ }
17
+
18
+ export function FlowStepRail({ count, activeStep }: FlowStepRailProps) {
19
+ return (
20
+ <ol className="flex items-center gap-1.5" aria-label="Progress">
21
+ {Array.from({ length: count }, (_, i) => (
22
+ <li
23
+ // biome-ignore lint/suspicious/noArrayIndexKey: a rail segment is its position
24
+ key={i}
25
+ className="flex flex-1 items-center gap-1.5"
26
+ >
27
+ <span
28
+ className={cn(
29
+ "h-1 flex-1 rounded-full transition-colors",
30
+ i <= activeStep ? "bg-accent" : "bg-surface-sunken",
31
+ )}
32
+ aria-current={i === activeStep ? "step" : undefined}
33
+ />
34
+ </li>
35
+ ))}
36
+ </ol>
37
+ );
38
+ }
39
+
40
+ export interface FlowScreenProps {
41
+ title: string;
42
+ subtitle?: string;
43
+ /** The step labels in order. One label means one page and no rail. */
44
+ steps: readonly string[];
45
+ activeStep: number;
46
+ onBack: () => void;
47
+ onExit: () => void;
48
+ /** Omitted where the screen is read rather than answered. */
49
+ footer?: ReactNode;
50
+ children: ReactNode;
51
+ /**
52
+ * `scroll` pads the body and scrolls it. `fill` hands the whole region to a
53
+ * child that carries its own header and scrolling.
54
+ */
55
+ bodyFit?: "scroll" | "fill";
56
+ /**
57
+ * `viewport` covers the window and becomes a centred modal from 768px up.
58
+ * `container` fills the nearest positioned ancestor and stays full-bleed,
59
+ * for a surface that is already sized to a phone.
60
+ */
61
+ anchor?: "viewport" | "container";
62
+ }
63
+
64
+ /**
65
+ * The body is the only scrolling region, so back and the footer's controls
66
+ * never leave the screen. The screen covers what opened it, which is why it
67
+ * takes the accessibility tree with it.
68
+ */
69
+ export function FlowScreen({
70
+ title,
71
+ subtitle,
72
+ steps,
73
+ activeStep,
74
+ onBack,
75
+ onExit,
76
+ footer,
77
+ children,
78
+ anchor = "viewport",
79
+ bodyFit = "scroll",
80
+ }: FlowScreenProps) {
81
+ const contained = anchor === "container";
82
+ return (
83
+ <div
84
+ role="dialog"
85
+ aria-modal="true"
86
+ aria-label={title}
87
+ className={cn(
88
+ "z-50 flex flex-col font-sans text-fg",
89
+ contained
90
+ ? "absolute inset-0"
91
+ : "fixed inset-0 md:items-center md:justify-center md:bg-black/40 md:p-6",
92
+ )}
93
+ >
94
+ <div
95
+ className={cn(
96
+ "flex min-h-0 w-full flex-1 flex-col bg-canvas",
97
+ !contained &&
98
+ "md:h-[45rem] md:max-h-[calc(100dvh-3rem)] md:w-[35rem] md:max-w-[calc(100vw-3rem)] md:flex-none md:overflow-hidden md:rounded-xl md:border md:border-line md:shadow-lg",
99
+ )}
100
+ >
101
+ <header
102
+ className={cn(
103
+ "shrink-0 border-b border-line px-3 pb-2",
104
+ contained
105
+ ? "pt-3"
106
+ : "pt-[calc(0.75rem+env(safe-area-inset-top,0px))] md:pt-3",
107
+ )}
108
+ >
109
+ <div className="flex items-center gap-1">
110
+ <button
111
+ type="button"
112
+ onClick={onBack}
113
+ aria-label="Back"
114
+ className="flex size-11 items-center justify-center rounded-md text-fg-muted"
115
+ >
116
+ <ArrowLeft className="size-5" />
117
+ </button>
118
+ <div className="min-w-0 flex-1 text-center">
119
+ <h1 className="truncate text-sm font-semibold">{title}</h1>
120
+ {subtitle && (
121
+ <p className="truncate text-2xs text-fg-muted">{subtitle}</p>
122
+ )}
123
+ </div>
124
+ <button
125
+ type="button"
126
+ onClick={onExit}
127
+ aria-label="Cancel"
128
+ className="flex size-11 items-center justify-center rounded-md text-fg-muted"
129
+ >
130
+ <X className="size-5" />
131
+ </button>
132
+ </div>
133
+ {steps.length > 1 && (
134
+ <div className="space-y-1 px-1 pt-2">
135
+ <FlowStepRail count={steps.length} activeStep={activeStep} />
136
+ <p className="text-2xs text-fg-subtle">
137
+ Step {activeStep + 1} of {steps.length} · {steps[activeStep]}
138
+ </p>
139
+ </div>
140
+ )}
141
+ </header>
142
+
143
+ <div
144
+ className={cn(
145
+ "min-h-0 flex-1",
146
+ bodyFit === "fill"
147
+ ? "overflow-hidden"
148
+ : "overflow-y-auto px-4 py-4",
149
+ )}
150
+ >
151
+ {children}
152
+ </div>
153
+
154
+ {footer && (
155
+ <footer
156
+ className={cn(
157
+ "shrink-0 border-t border-line px-4 py-3",
158
+ contained
159
+ ? ""
160
+ : "pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3",
161
+ )}
162
+ >
163
+ {footer}
164
+ </footer>
165
+ )}
166
+ </div>
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { categoryTone, isThreadCategory } from "./app-shell-types.js";
6
+ import type { IntelligenceData } from "./intelligence-panel.js";
7
+ import { IntelligencePanel } from "./intelligence-panel.js";
8
+
9
+ const vipWithFailingAuthenticity: IntelligenceData = {
10
+ sender: {
11
+ name: "Sabrina Basten",
12
+ email: "sabrina@sabrinabasten.example",
13
+ trust: "vip",
14
+ firstSeenLabel: "Mar 2023",
15
+ inboundCount: 214,
16
+ replyCount: 188,
17
+ },
18
+ authenticity: {
19
+ verdict: "mismatch",
20
+ fromDomain: "sabrinabasten.example",
21
+ dkimDomain: "custmx.one.example",
22
+ summary: "Signed by custmx.one.example instead.",
23
+ },
24
+ category: { value: "personal" },
25
+ flags: { vip: true },
26
+ similar: [],
27
+ };
28
+
29
+ /** The rendered chip in the Category section. */
30
+ function categoryChip(data: IntelligenceData): string {
31
+ const html = renderToString(createElement(IntelligencePanel, { data }));
32
+ const marker = `>${data.category.value}</span>`;
33
+ const end = html.indexOf(marker);
34
+ assert.notEqual(end, -1, `expected a chip reading '${data.category.value}'`);
35
+ const start = html.lastIndexOf("<span", end);
36
+ return html.slice(start, end + marker.length);
37
+ }
38
+
39
+ describe("IntelligencePanel category chip", () => {
40
+ it("takes its colour from the category, not the authenticity verdict", () => {
41
+ const chip = categoryChip(vipWithFailingAuthenticity);
42
+ assert.doesNotMatch(chip, /danger/);
43
+ assert.match(chip, /text-accent-2/);
44
+ });
45
+
46
+ it("renders the same chip for a category whatever the verdict said", () => {
47
+ const failing = categoryChip(vipWithFailingAuthenticity);
48
+ const aligned = categoryChip({
49
+ ...vipWithFailingAuthenticity,
50
+ authenticity: {
51
+ verdict: "aligned",
52
+ fromDomain: "sabrinabasten.example",
53
+ summary: "Nothing looks unusual about this sender.",
54
+ },
55
+ });
56
+ assert.equal(failing, aligned);
57
+ });
58
+
59
+ it("gives each category its own tone", () => {
60
+ const receipt = categoryChip({
61
+ ...vipWithFailingAuthenticity,
62
+ category: { value: "transactional" },
63
+ });
64
+ assert.match(receipt, /text-positive/);
65
+ const newsletter = categoryChip({
66
+ ...vipWithFailingAuthenticity,
67
+ category: { value: "newsletter" },
68
+ });
69
+ assert.match(newsletter, /text-fg-muted/);
70
+ });
71
+
72
+ it("renders a readable chip for a message the classifier placed nowhere", () => {
73
+ const chip = categoryChip({
74
+ ...vipWithFailingAuthenticity,
75
+ category: { value: "uncategorized" },
76
+ });
77
+ assert.match(chip, /text-fg-muted/);
78
+ assert.doesNotMatch(chip, /danger/);
79
+ });
80
+ });
81
+
82
+ describe("isThreadCategory", () => {
83
+ it("accepts every category the tone map covers", () => {
84
+ for (const category of Object.keys(categoryTone)) {
85
+ assert.equal(isThreadCategory(category), true, category);
86
+ }
87
+ });
88
+
89
+ it("rejects a category this build has no tone for", () => {
90
+ assert.equal(isThreadCategory("invoice"), false);
91
+ assert.equal(isThreadCategory("Personal"), false);
92
+ assert.equal(isThreadCategory("toString"), false);
93
+ });
94
+ });