@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.
- package/package.json +1 -1
- package/src/components/app-shell-slotted.render.test.ts +83 -0
- package/src/components/app-shell-slotted.tsx +31 -3
- package/src/components/app-shell-types.ts +10 -0
- package/src/components/attendee-row.render.test.ts +73 -0
- package/src/components/attendee-row.stories.tsx +68 -0
- package/src/components/attendee-row.tsx +96 -0
- package/src/components/calendar-event-chip.render.test.ts +67 -0
- package/src/components/calendar-event-chip.stories.tsx +128 -0
- package/src/components/calendar-event-chip.tsx +116 -0
- package/src/components/calendar-list.render.test.ts +77 -0
- package/src/components/calendar-list.stories.tsx +130 -0
- package/src/components/calendar-list.tsx +225 -0
- package/src/components/calendar-toolbar.render.test.ts +69 -0
- package/src/components/calendar-toolbar.stories.tsx +55 -0
- package/src/components/calendar-toolbar.tsx +178 -0
- package/src/components/calendar-types.ts +135 -0
- package/src/components/custom-recurrence.stories.tsx +106 -0
- package/src/components/custom-recurrence.tsx +411 -0
- package/src/components/event-detail.render.test.ts +104 -0
- package/src/components/event-detail.stories.tsx +178 -0
- package/src/components/event-detail.tsx +188 -0
- package/src/components/event-editor-pane.tsx +54 -0
- package/src/components/event-editor.render.test.ts +83 -0
- package/src/components/event-editor.stories.tsx +99 -0
- package/src/components/event-editor.tsx +480 -0
- package/src/components/event-quick-entry.render.test.ts +40 -0
- package/src/components/event-quick-entry.stories.tsx +56 -0
- package/src/components/event-quick-entry.tsx +159 -0
- package/src/components/event-suggestion-card.render.test.ts +65 -0
- package/src/components/event-suggestion-card.stories.tsx +93 -0
- package/src/components/event-suggestion-card.tsx +116 -0
- package/src/components/flow-screen.tsx +169 -0
- package/src/components/intelligence-panel.render.test.ts +94 -0
- package/src/components/intelligence-panel.stories.tsx +10 -6
- package/src/components/intelligence-panel.tsx +7 -5
- package/src/components/message-list-pane.render.test.ts +2 -2
- package/src/components/message-list-pane.stories.tsx +1 -1
- package/src/components/mobile-reading-pane.render.test.ts +1 -1
- package/src/components/nav-sidebar.tsx +20 -0
- package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
- package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
- package/src/components/recurrence-scope-prompt.tsx +84 -0
- package/src/components/selection-wizard.tsx +19 -69
- package/src/index.ts +112 -0
- package/src/lib/calendar-color.ts +71 -0
- package/src/lib/event-phrase.test.ts +89 -0
- package/src/lib/event-phrase.ts +228 -0
- package/src/lib/recurrence.test.ts +141 -0
- package/src/lib/recurrence.ts +266 -0
- 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
|
+
});
|