@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.
@@ -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
+ }