@atelic-action/ui 0.1.0 → 0.3.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.
- package/README.md +133 -5
- package/package.json +7 -1
- package/src/components/NotFound.tsx +87 -0
- package/src/components/index.ts +1 -0
- package/src/email/atoms.tsx +465 -0
- package/src/email/frame.tsx +230 -0
- package/src/email/index.ts +98 -0
- package/src/email/render.tsx +116 -0
- package/src/email/scoreboard.tsx +479 -0
- package/src/email/text.ts +116 -0
- package/src/email/theme.tsx +77 -0
- package/src/routing/index.ts +31 -0
- package/src/styles/components.css +20 -0
- package/src/tokens/css.ts +27 -0
- package/src/tokens/index.ts +2 -0
- package/src/tokens/palettes.ts +54 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The atoms of the runner email, ported one to one from the atoms section of
|
|
6
|
+
* homebase `runners/lib/email.jq`. Every layout is a table and every style is
|
|
7
|
+
* inline, because Gmail strips a style block and ignores media queries on some
|
|
8
|
+
* accounts. Where the jq takes a pre rendered html string the React prop is
|
|
9
|
+
* `children` or a ReactNode; where the jq escapes a string argument the prop
|
|
10
|
+
* is a plain string and React does the escaping.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type EyebrowProps = { text: string };
|
|
14
|
+
|
|
15
|
+
/** A section label between cards. */
|
|
16
|
+
export function Eyebrow({ text }: EyebrowProps) {
|
|
17
|
+
const { palette, fonts } = useEmailTheme();
|
|
18
|
+
return (
|
|
19
|
+
<tr>
|
|
20
|
+
<td style={{ padding: "36px 8px 12px", ...eyebrowStyle(fonts), color: palette.faint }}>
|
|
21
|
+
{text}
|
|
22
|
+
</td>
|
|
23
|
+
</tr>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type CardProps = { children?: ReactNode };
|
|
28
|
+
|
|
29
|
+
/** A card: paper on a hairline, rows inside. */
|
|
30
|
+
export function Card({ children }: CardProps) {
|
|
31
|
+
const { palette } = useEmailTheme();
|
|
32
|
+
return (
|
|
33
|
+
<tr>
|
|
34
|
+
<td
|
|
35
|
+
style={{
|
|
36
|
+
background: palette.paper,
|
|
37
|
+
border: `1px solid ${palette.line}`,
|
|
38
|
+
borderRadius: "14px",
|
|
39
|
+
padding: "0",
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<table {...tableReset} width="100%">
|
|
43
|
+
<tbody>{children}</tbody>
|
|
44
|
+
</table>
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type FoldProps = { summary: string; children?: ReactNode };
|
|
51
|
+
|
|
52
|
+
/** A small disclosure inside a row: "+ what they do". */
|
|
53
|
+
export function Fold({ summary, children }: FoldProps) {
|
|
54
|
+
const { palette, fonts } = useEmailTheme();
|
|
55
|
+
return (
|
|
56
|
+
<details style={{ marginTop: "12px" }}>
|
|
57
|
+
<summary
|
|
58
|
+
style={{
|
|
59
|
+
fontFamily: fonts.mono,
|
|
60
|
+
fontSize: "12px",
|
|
61
|
+
color: palette.faint,
|
|
62
|
+
cursor: "pointer",
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{"+ "}
|
|
66
|
+
{summary}
|
|
67
|
+
</summary>
|
|
68
|
+
<div
|
|
69
|
+
style={{
|
|
70
|
+
fontSize: "14px",
|
|
71
|
+
lineHeight: "1.55",
|
|
72
|
+
color: palette.dim,
|
|
73
|
+
marginTop: "8px",
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{children}
|
|
77
|
+
</div>
|
|
78
|
+
</details>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type BigFoldProps = { summary: string; count?: ReactNode; children?: ReactNode };
|
|
83
|
+
|
|
84
|
+
/** A card level disclosure: a bold summary with an optional faint count beside it. */
|
|
85
|
+
export function BigFold({ summary, count, children }: BigFoldProps) {
|
|
86
|
+
const { palette, fonts } = useEmailTheme();
|
|
87
|
+
const hasCount = count !== undefined && count !== null && count !== "";
|
|
88
|
+
return (
|
|
89
|
+
<details>
|
|
90
|
+
<summary
|
|
91
|
+
style={{
|
|
92
|
+
fontSize: "15px",
|
|
93
|
+
fontWeight: "600",
|
|
94
|
+
color: palette.ink,
|
|
95
|
+
cursor: "pointer",
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<span style={{ fontFamily: fonts.mono, color: palette.faint }}>+</span> {summary}
|
|
99
|
+
{hasCount ? (
|
|
100
|
+
<>
|
|
101
|
+
{" "}
|
|
102
|
+
<span
|
|
103
|
+
style={{
|
|
104
|
+
fontFamily: fonts.mono,
|
|
105
|
+
fontSize: "12px",
|
|
106
|
+
fontWeight: "400",
|
|
107
|
+
color: palette.faint,
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{count}
|
|
111
|
+
</span>
|
|
112
|
+
</>
|
|
113
|
+
) : null}
|
|
114
|
+
</summary>
|
|
115
|
+
{children}
|
|
116
|
+
</details>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type TitleLineProps = { name: string; right: string };
|
|
121
|
+
|
|
122
|
+
/** A name on the left and the one value that matters on the right, in the accent. */
|
|
123
|
+
export function TitleLine({ name, right }: TitleLineProps) {
|
|
124
|
+
const { palette, fonts } = useEmailTheme();
|
|
125
|
+
return (
|
|
126
|
+
<table {...tableReset} width="100%">
|
|
127
|
+
<tbody>
|
|
128
|
+
<tr>
|
|
129
|
+
<td
|
|
130
|
+
style={{
|
|
131
|
+
fontSize: "17px",
|
|
132
|
+
fontWeight: "600",
|
|
133
|
+
letterSpacing: "-0.02em",
|
|
134
|
+
color: palette.ink,
|
|
135
|
+
lineHeight: "1.2",
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{name}
|
|
139
|
+
</td>
|
|
140
|
+
<td
|
|
141
|
+
align="right"
|
|
142
|
+
style={{
|
|
143
|
+
fontFamily: fonts.mono,
|
|
144
|
+
fontSize: "12px",
|
|
145
|
+
color: palette.accent,
|
|
146
|
+
whiteSpace: "nowrap",
|
|
147
|
+
paddingLeft: "12px",
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
{right}
|
|
151
|
+
</td>
|
|
152
|
+
</tr>
|
|
153
|
+
</tbody>
|
|
154
|
+
</table>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type ItemProps = {
|
|
159
|
+
name: string;
|
|
160
|
+
right: string;
|
|
161
|
+
/** Facts joined by a middle dot. Null and empty parts drop, an empty list still renders the row. */
|
|
162
|
+
subparts: (string | null)[];
|
|
163
|
+
body?: string;
|
|
164
|
+
foldLabel?: string;
|
|
165
|
+
foldBody?: string;
|
|
166
|
+
linkText?: string;
|
|
167
|
+
url?: string;
|
|
168
|
+
/** Drops the bottom hairline so the card closes clean. */
|
|
169
|
+
last: boolean;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The workhorse row: title line, a dim subline of facts joined by middots, a
|
|
174
|
+
* body sentence or two, an optional fold, an optional link.
|
|
175
|
+
*/
|
|
176
|
+
export function Item({
|
|
177
|
+
name,
|
|
178
|
+
right,
|
|
179
|
+
subparts,
|
|
180
|
+
body,
|
|
181
|
+
foldLabel,
|
|
182
|
+
foldBody,
|
|
183
|
+
linkText,
|
|
184
|
+
url,
|
|
185
|
+
last,
|
|
186
|
+
}: ItemProps) {
|
|
187
|
+
const { palette, fonts } = useEmailTheme();
|
|
188
|
+
const parts = subparts.filter((p) => p !== null && p !== "");
|
|
189
|
+
return (
|
|
190
|
+
<tr>
|
|
191
|
+
<td
|
|
192
|
+
style={{
|
|
193
|
+
padding: `22px 24px ${last ? "22px" : "20px"}`,
|
|
194
|
+
fontFamily: fonts.sans,
|
|
195
|
+
...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
<TitleLine name={name} right={right} />
|
|
199
|
+
{subparts.length > 0 ? (
|
|
200
|
+
<div style={{ fontSize: "14px", color: palette.dim, marginTop: "3px" }}>
|
|
201
|
+
{parts.join(" · ")}
|
|
202
|
+
</div>
|
|
203
|
+
) : null}
|
|
204
|
+
{body ? (
|
|
205
|
+
<div
|
|
206
|
+
style={{
|
|
207
|
+
fontSize: "14px",
|
|
208
|
+
lineHeight: "1.55",
|
|
209
|
+
color: palette.ink,
|
|
210
|
+
marginTop: "12px",
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
{body}
|
|
214
|
+
</div>
|
|
215
|
+
) : null}
|
|
216
|
+
{foldBody ? <Fold summary={foldLabel ?? ""}>{foldBody}</Fold> : null}
|
|
217
|
+
{url ? (
|
|
218
|
+
<div style={{ marginTop: "14px" }}>
|
|
219
|
+
<a
|
|
220
|
+
href={url}
|
|
221
|
+
style={{
|
|
222
|
+
fontSize: "14px",
|
|
223
|
+
fontWeight: "500",
|
|
224
|
+
color: palette.ink,
|
|
225
|
+
textDecoration: "none",
|
|
226
|
+
borderBottom: `1px solid ${palette.accent}`,
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
{linkText ?? ""}
|
|
230
|
+
{" →"}
|
|
231
|
+
</a>
|
|
232
|
+
</div>
|
|
233
|
+
) : null}
|
|
234
|
+
</td>
|
|
235
|
+
</tr>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type NoteProps = { eyebrowText: string; text: string; accented: boolean; last: boolean };
|
|
240
|
+
|
|
241
|
+
/** A short note inside a card under a small eyebrow; accented puts the eyebrow in orange. */
|
|
242
|
+
export function Note({ eyebrowText, text, accented, last }: NoteProps) {
|
|
243
|
+
const { palette, fonts } = useEmailTheme();
|
|
244
|
+
return (
|
|
245
|
+
<tr>
|
|
246
|
+
<td
|
|
247
|
+
style={{
|
|
248
|
+
padding: `18px 24px ${last ? "20px" : "4px"}`,
|
|
249
|
+
fontFamily: fonts.sans,
|
|
250
|
+
fontSize: "14px",
|
|
251
|
+
lineHeight: "1.55",
|
|
252
|
+
color: accented ? palette.ink : palette.dim,
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<span style={{ ...eyebrowStyle(fonts), color: accented ? palette.accent : palette.faint }}>
|
|
256
|
+
{eyebrowText}
|
|
257
|
+
</span>
|
|
258
|
+
<br />
|
|
259
|
+
{text}
|
|
260
|
+
</td>
|
|
261
|
+
</tr>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export type EmptyRowProps = { text: string };
|
|
266
|
+
|
|
267
|
+
/** One line of dim text filling a card row: the empty state. */
|
|
268
|
+
export function EmptyRow({ text }: EmptyRowProps) {
|
|
269
|
+
const { palette, fonts } = useEmailTheme();
|
|
270
|
+
return (
|
|
271
|
+
<tr>
|
|
272
|
+
<td
|
|
273
|
+
style={{
|
|
274
|
+
padding: "22px 24px",
|
|
275
|
+
fontFamily: fonts.sans,
|
|
276
|
+
fontSize: "14px",
|
|
277
|
+
color: palette.dim,
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
{text}
|
|
281
|
+
</td>
|
|
282
|
+
</tr>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export type StatProps = { n: string | number; caption?: ReactNode };
|
|
287
|
+
|
|
288
|
+
/** A big number over a small caption; three of these sit in a title card. */
|
|
289
|
+
export function Stat({ n, caption }: StatProps) {
|
|
290
|
+
const { palette, fonts } = useEmailTheme();
|
|
291
|
+
return (
|
|
292
|
+
<td style={{ padding: "10px 0", borderTop: `1px solid ${palette.line}` }}>
|
|
293
|
+
<span
|
|
294
|
+
style={{
|
|
295
|
+
fontFamily: fonts.sans,
|
|
296
|
+
fontSize: "22px",
|
|
297
|
+
fontWeight: "600",
|
|
298
|
+
letterSpacing: "-0.02em",
|
|
299
|
+
color: palette.ink,
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
{String(n)}
|
|
303
|
+
</span>
|
|
304
|
+
<br />
|
|
305
|
+
{caption}
|
|
306
|
+
</td>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type StatsRowProps = { children?: ReactNode };
|
|
311
|
+
|
|
312
|
+
/** The row of Stat cells inside a title card. */
|
|
313
|
+
export function StatsRow({ children }: StatsRowProps) {
|
|
314
|
+
const { palette, fonts } = useEmailTheme();
|
|
315
|
+
return (
|
|
316
|
+
<tr>
|
|
317
|
+
<td style={{ padding: "20px 26px 24px" }}>
|
|
318
|
+
<table
|
|
319
|
+
{...tableReset}
|
|
320
|
+
width="100%"
|
|
321
|
+
style={{ fontFamily: fonts.mono, fontSize: "12px", color: palette.faint }}
|
|
322
|
+
>
|
|
323
|
+
<tbody>
|
|
324
|
+
<tr>{children}</tr>
|
|
325
|
+
</tbody>
|
|
326
|
+
</table>
|
|
327
|
+
</td>
|
|
328
|
+
</tr>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export type ListProps = { fontSize: string; children?: ReactNode };
|
|
333
|
+
|
|
334
|
+
/** A hairline list inside a fold: one ListRow per line. */
|
|
335
|
+
export function List({ fontSize, children }: ListProps) {
|
|
336
|
+
const { palette } = useEmailTheme();
|
|
337
|
+
return (
|
|
338
|
+
<table
|
|
339
|
+
{...tableReset}
|
|
340
|
+
width="100%"
|
|
341
|
+
style={{
|
|
342
|
+
marginTop: "12px",
|
|
343
|
+
fontSize,
|
|
344
|
+
lineHeight: "1.5",
|
|
345
|
+
color: palette.dim,
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
<tbody>{children}</tbody>
|
|
349
|
+
</table>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export type ListRowProps = { children?: ReactNode };
|
|
354
|
+
|
|
355
|
+
export function ListRow({ children }: ListRowProps) {
|
|
356
|
+
const { palette } = useEmailTheme();
|
|
357
|
+
return (
|
|
358
|
+
<tr>
|
|
359
|
+
<td style={{ padding: "6px 0", borderTop: `1px solid ${palette.hair}` }}>{children}</td>
|
|
360
|
+
</tr>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export type LeadRowProps = { lead: string; rest: string };
|
|
365
|
+
|
|
366
|
+
/** A list row that opens on a bolded lead. */
|
|
367
|
+
export function LeadRow({ lead, rest }: LeadRowProps) {
|
|
368
|
+
const { palette } = useEmailTheme();
|
|
369
|
+
return (
|
|
370
|
+
<ListRow>
|
|
371
|
+
<b style={{ color: palette.ink, fontWeight: "500" }}>{lead}</b> {rest}
|
|
372
|
+
</ListRow>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export type RowProps = { last: boolean; children?: ReactNode };
|
|
377
|
+
|
|
378
|
+
/** A card row holding any block the pieces above built, with the card's own padding and hairline. */
|
|
379
|
+
export function Row({ last, children }: RowProps) {
|
|
380
|
+
const { palette, fonts } = useEmailTheme();
|
|
381
|
+
return (
|
|
382
|
+
<tr>
|
|
383
|
+
<td
|
|
384
|
+
style={{
|
|
385
|
+
padding: `18px 24px ${last ? "22px" : "18px"}`,
|
|
386
|
+
fontFamily: fonts.sans,
|
|
387
|
+
...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
|
|
388
|
+
}}
|
|
389
|
+
>
|
|
390
|
+
{children}
|
|
391
|
+
</td>
|
|
392
|
+
</tr>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export type FoldRowProps = { last: boolean; children?: ReactNode };
|
|
397
|
+
|
|
398
|
+
/** A card row that holds a card level fold. */
|
|
399
|
+
export function FoldRow({ last, children }: FoldRowProps) {
|
|
400
|
+
const { palette, fonts } = useEmailTheme();
|
|
401
|
+
return (
|
|
402
|
+
<tr>
|
|
403
|
+
<td
|
|
404
|
+
style={{
|
|
405
|
+
padding: last ? "18px 24px 20px" : "18px 24px",
|
|
406
|
+
fontFamily: fonts.sans,
|
|
407
|
+
...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
{children}
|
|
411
|
+
</td>
|
|
412
|
+
</tr>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export type MonoTableProps = { headers: string[]; rows: string[][] };
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* A compact monospace table for data the reader copies rather than reads.
|
|
420
|
+
*/
|
|
421
|
+
export function MonoTable({ headers, rows }: MonoTableProps) {
|
|
422
|
+
const { palette, fonts } = useEmailTheme();
|
|
423
|
+
return (
|
|
424
|
+
<table
|
|
425
|
+
{...tableReset}
|
|
426
|
+
width="100%"
|
|
427
|
+
style={{
|
|
428
|
+
marginTop: "12px",
|
|
429
|
+
fontFamily: fonts.mono,
|
|
430
|
+
fontSize: "11px",
|
|
431
|
+
lineHeight: "1.5",
|
|
432
|
+
color: palette.dim,
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
<tbody>
|
|
436
|
+
<tr>
|
|
437
|
+
{headers.map((label, i) => (
|
|
438
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a header's position is its identity
|
|
439
|
+
<td key={i} style={{ padding: "4px 6px", color: palette.faint }}>
|
|
440
|
+
{label}
|
|
441
|
+
</td>
|
|
442
|
+
))}
|
|
443
|
+
</tr>
|
|
444
|
+
{rows.map((cells, r) => (
|
|
445
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a row's position is its identity
|
|
446
|
+
<tr key={r}>
|
|
447
|
+
{cells.map((cell, c) => (
|
|
448
|
+
<td
|
|
449
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a cell's position is its identity
|
|
450
|
+
key={c}
|
|
451
|
+
style={{
|
|
452
|
+
padding: "4px 6px",
|
|
453
|
+
borderTop: `1px solid ${palette.hair}`,
|
|
454
|
+
verticalAlign: "top",
|
|
455
|
+
}}
|
|
456
|
+
>
|
|
457
|
+
{cell}
|
|
458
|
+
</td>
|
|
459
|
+
))}
|
|
460
|
+
</tr>
|
|
461
|
+
))}
|
|
462
|
+
</tbody>
|
|
463
|
+
</table>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
2
|
+
import { Card } from "./atoms";
|
|
3
|
+
import { asciiDowncase } from "./text";
|
|
4
|
+
import { eyebrowStyle, fadeStop, tableReset, useEmailTheme } from "./theme";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The frame, ported one to one from the frame section of homebase
|
|
8
|
+
* `runners/lib/email.jq`. The page shell itself is not a component: it is a
|
|
9
|
+
* template string in render.tsx, because React emits no doctype and hoists
|
|
10
|
+
* head tags.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type MastheadProps = {
|
|
14
|
+
title: string;
|
|
15
|
+
/** The wordmark at the top left. */
|
|
16
|
+
wordmark?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The wordmark and one orange rule at the top left, the email's title beside
|
|
21
|
+
* them: the runner's name, not the week.
|
|
22
|
+
*/
|
|
23
|
+
export function Masthead({ title, wordmark = "atelic" }: MastheadProps) {
|
|
24
|
+
const { palette, fonts } = useEmailTheme();
|
|
25
|
+
return (
|
|
26
|
+
<tr>
|
|
27
|
+
<td style={{ padding: "8px 8px 22px", fontFamily: fonts.sans }}>
|
|
28
|
+
<table {...tableReset}>
|
|
29
|
+
<tbody>
|
|
30
|
+
<tr>
|
|
31
|
+
<td
|
|
32
|
+
style={{
|
|
33
|
+
fontSize: "20px",
|
|
34
|
+
fontWeight: "600",
|
|
35
|
+
letterSpacing: "-0.045em",
|
|
36
|
+
color: palette.ink,
|
|
37
|
+
paddingRight: "8px",
|
|
38
|
+
lineHeight: "1",
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
{wordmark}
|
|
42
|
+
</td>
|
|
43
|
+
<td
|
|
44
|
+
width="34"
|
|
45
|
+
style={{ width: "34px", verticalAlign: "middle", paddingRight: "12px" }}
|
|
46
|
+
>
|
|
47
|
+
<div
|
|
48
|
+
style={{
|
|
49
|
+
width: "34px",
|
|
50
|
+
height: "2px",
|
|
51
|
+
fontSize: "0",
|
|
52
|
+
lineHeight: "0",
|
|
53
|
+
backgroundColor: palette.accent,
|
|
54
|
+
background: `linear-gradient(90deg,${palette.accent},${fadeStop(palette.accent)})`,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{"\u00a0"}
|
|
58
|
+
</div>
|
|
59
|
+
</td>
|
|
60
|
+
<td
|
|
61
|
+
style={{
|
|
62
|
+
...eyebrowStyle(fonts),
|
|
63
|
+
color: palette.faint,
|
|
64
|
+
verticalAlign: "middle",
|
|
65
|
+
lineHeight: "1",
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{title}
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|
|
71
|
+
</tbody>
|
|
72
|
+
</table>
|
|
73
|
+
</td>
|
|
74
|
+
</tr>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type TitleCardProps = {
|
|
79
|
+
eyebrowText: string;
|
|
80
|
+
/** One to three short lines, broken between them. */
|
|
81
|
+
headlineLines: string[];
|
|
82
|
+
lede?: string;
|
|
83
|
+
/** Whatever rows the runner hands in under the lede: a StatsRow, a Scoreboard. */
|
|
84
|
+
stats?: ReactNode;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The title card: an eyebrow, a headline, a lede, and the runner's own rows. */
|
|
88
|
+
export function TitleCard({ eyebrowText, headlineLines, lede, stats }: TitleCardProps) {
|
|
89
|
+
const { palette, fonts } = useEmailTheme();
|
|
90
|
+
return (
|
|
91
|
+
<Card>
|
|
92
|
+
<tr>
|
|
93
|
+
<td style={{ padding: "26px 26px 6px", ...eyebrowStyle(fonts), color: palette.faint }}>
|
|
94
|
+
{eyebrowText}
|
|
95
|
+
</td>
|
|
96
|
+
</tr>
|
|
97
|
+
<tr>
|
|
98
|
+
<td
|
|
99
|
+
style={{
|
|
100
|
+
padding: "0 26px",
|
|
101
|
+
fontFamily: fonts.sans,
|
|
102
|
+
fontSize: "30px",
|
|
103
|
+
fontWeight: "600",
|
|
104
|
+
letterSpacing: "-0.03em",
|
|
105
|
+
lineHeight: "1.1",
|
|
106
|
+
color: palette.ink,
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{headlineLines.map((line, i) => (
|
|
110
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a line's position is its identity
|
|
111
|
+
<Fragment key={i}>
|
|
112
|
+
{i > 0 ? <br /> : null}
|
|
113
|
+
{line}
|
|
114
|
+
</Fragment>
|
|
115
|
+
))}
|
|
116
|
+
</td>
|
|
117
|
+
</tr>
|
|
118
|
+
{lede ? (
|
|
119
|
+
<tr>
|
|
120
|
+
<td
|
|
121
|
+
style={{
|
|
122
|
+
padding: `14px 26px ${stats ? "0" : "26px"}`,
|
|
123
|
+
fontFamily: fonts.sans,
|
|
124
|
+
fontSize: "15px",
|
|
125
|
+
lineHeight: "1.6",
|
|
126
|
+
color: palette.dim,
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{lede}
|
|
130
|
+
</td>
|
|
131
|
+
</tr>
|
|
132
|
+
) : null}
|
|
133
|
+
{stats}
|
|
134
|
+
</Card>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type FooterProps = { meta: string };
|
|
139
|
+
|
|
140
|
+
/** One quiet line at the foot: the run's facts. */
|
|
141
|
+
export function Footer({ meta }: FooterProps) {
|
|
142
|
+
const { palette, fonts } = useEmailTheme();
|
|
143
|
+
return (
|
|
144
|
+
<tr>
|
|
145
|
+
<td
|
|
146
|
+
style={{
|
|
147
|
+
padding: "36px 8px 0",
|
|
148
|
+
fontFamily: fonts.mono,
|
|
149
|
+
fontSize: "11px",
|
|
150
|
+
lineHeight: "1.8",
|
|
151
|
+
color: palette.faint,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{meta}
|
|
155
|
+
</td>
|
|
156
|
+
</tr>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type FailurePageProps = {
|
|
161
|
+
runnerTitle: string;
|
|
162
|
+
eyebrowText: string;
|
|
163
|
+
reason: string;
|
|
164
|
+
logTail: string;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The body rows of the failure page, in the same cream, so a bad week reads
|
|
169
|
+
* like a good one with the reason on top and the log's tail beneath.
|
|
170
|
+
*/
|
|
171
|
+
export function FailurePage({ runnerTitle, eyebrowText, reason, logTail }: FailurePageProps) {
|
|
172
|
+
const { palette, fonts } = useEmailTheme();
|
|
173
|
+
return (
|
|
174
|
+
<>
|
|
175
|
+
<Masthead title={runnerTitle} />
|
|
176
|
+
<Card>
|
|
177
|
+
<tr>
|
|
178
|
+
<td style={{ padding: "26px 26px 6px", ...eyebrowStyle(fonts), color: palette.faint }}>
|
|
179
|
+
{eyebrowText}
|
|
180
|
+
</td>
|
|
181
|
+
</tr>
|
|
182
|
+
<tr>
|
|
183
|
+
<td
|
|
184
|
+
style={{
|
|
185
|
+
padding: "0 26px",
|
|
186
|
+
fontFamily: fonts.sans,
|
|
187
|
+
fontSize: "30px",
|
|
188
|
+
fontWeight: "600",
|
|
189
|
+
letterSpacing: "-0.03em",
|
|
190
|
+
lineHeight: "1.1",
|
|
191
|
+
color: palette.ink,
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
{`The ${asciiDowncase(runnerTitle)} did not run.`}
|
|
195
|
+
</td>
|
|
196
|
+
</tr>
|
|
197
|
+
<tr>
|
|
198
|
+
<td
|
|
199
|
+
style={{
|
|
200
|
+
padding: "14px 26px 0",
|
|
201
|
+
fontFamily: fonts.sans,
|
|
202
|
+
fontSize: "15px",
|
|
203
|
+
lineHeight: "1.6",
|
|
204
|
+
color: palette.dim,
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
{reason}
|
|
208
|
+
</td>
|
|
209
|
+
</tr>
|
|
210
|
+
<tr>
|
|
211
|
+
<td style={{ padding: "20px 26px 26px" }}>
|
|
212
|
+
<pre
|
|
213
|
+
style={{
|
|
214
|
+
fontFamily: fonts.mono,
|
|
215
|
+
fontSize: "12px",
|
|
216
|
+
whiteSpace: "pre-wrap",
|
|
217
|
+
color: palette.dim,
|
|
218
|
+
borderTop: `1px solid ${palette.line}`,
|
|
219
|
+
paddingTop: "14px",
|
|
220
|
+
margin: "0",
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
{logTail}
|
|
224
|
+
</pre>
|
|
225
|
+
</td>
|
|
226
|
+
</tr>
|
|
227
|
+
</Card>
|
|
228
|
+
</>
|
|
229
|
+
);
|
|
230
|
+
}
|