@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,98 @@
|
|
|
1
|
+
export { atelicFonts, atelicPalette, type Fonts, type Palette } from "../tokens";
|
|
2
|
+
export {
|
|
3
|
+
BigFold,
|
|
4
|
+
type BigFoldProps,
|
|
5
|
+
Card,
|
|
6
|
+
type CardProps,
|
|
7
|
+
EmptyRow,
|
|
8
|
+
type EmptyRowProps,
|
|
9
|
+
Eyebrow,
|
|
10
|
+
type EyebrowProps,
|
|
11
|
+
Fold,
|
|
12
|
+
type FoldProps,
|
|
13
|
+
FoldRow,
|
|
14
|
+
type FoldRowProps,
|
|
15
|
+
Item,
|
|
16
|
+
type ItemProps,
|
|
17
|
+
LeadRow,
|
|
18
|
+
type LeadRowProps,
|
|
19
|
+
List,
|
|
20
|
+
type ListProps,
|
|
21
|
+
ListRow,
|
|
22
|
+
type ListRowProps,
|
|
23
|
+
MonoTable,
|
|
24
|
+
type MonoTableProps,
|
|
25
|
+
Note,
|
|
26
|
+
type NoteProps,
|
|
27
|
+
Row,
|
|
28
|
+
type RowProps,
|
|
29
|
+
Stat,
|
|
30
|
+
type StatProps,
|
|
31
|
+
StatsRow,
|
|
32
|
+
type StatsRowProps,
|
|
33
|
+
TitleLine,
|
|
34
|
+
type TitleLineProps,
|
|
35
|
+
} from "./atoms";
|
|
36
|
+
export {
|
|
37
|
+
FailurePage,
|
|
38
|
+
type FailurePageProps,
|
|
39
|
+
Footer,
|
|
40
|
+
type FooterProps,
|
|
41
|
+
Masthead,
|
|
42
|
+
type MastheadProps,
|
|
43
|
+
TitleCard,
|
|
44
|
+
type TitleCardProps,
|
|
45
|
+
} from "./frame";
|
|
46
|
+
export {
|
|
47
|
+
Badge,
|
|
48
|
+
type BadgeProps,
|
|
49
|
+
Bar,
|
|
50
|
+
type BarProps,
|
|
51
|
+
type Day,
|
|
52
|
+
type DayEntry,
|
|
53
|
+
DayStrip,
|
|
54
|
+
type DayStripProps,
|
|
55
|
+
GroupRow,
|
|
56
|
+
type GroupRowProps,
|
|
57
|
+
ReadBlock,
|
|
58
|
+
type ReadBlockProps,
|
|
59
|
+
Records,
|
|
60
|
+
type RecordsCell,
|
|
61
|
+
type RecordsColumn,
|
|
62
|
+
type RecordsProps,
|
|
63
|
+
Scoreboard,
|
|
64
|
+
type ScoreboardProps,
|
|
65
|
+
StatStrip,
|
|
66
|
+
type StatStripEntry,
|
|
67
|
+
type StatStripProps,
|
|
68
|
+
SubEyebrow,
|
|
69
|
+
type SubEyebrowProps,
|
|
70
|
+
TargetRow,
|
|
71
|
+
type TargetRowProps,
|
|
72
|
+
WhatMoved,
|
|
73
|
+
type WhatMovedItem,
|
|
74
|
+
type WhatMovedProps,
|
|
75
|
+
} from "./scoreboard";
|
|
76
|
+
export {
|
|
77
|
+
asciiDowncase,
|
|
78
|
+
asciiUpcase,
|
|
79
|
+
lpad,
|
|
80
|
+
rpad,
|
|
81
|
+
spaces,
|
|
82
|
+
type TextTarget,
|
|
83
|
+
textBar,
|
|
84
|
+
textRead,
|
|
85
|
+
textRule,
|
|
86
|
+
textSection,
|
|
87
|
+
textTable,
|
|
88
|
+
textTableGrid,
|
|
89
|
+
textTarget,
|
|
90
|
+
wrap,
|
|
91
|
+
} from "./text";
|
|
92
|
+
export {
|
|
93
|
+
type EmailTheme,
|
|
94
|
+
EmailThemeProvider,
|
|
95
|
+
type EmailThemeProviderProps,
|
|
96
|
+
eyebrowStyle,
|
|
97
|
+
useEmailTheme,
|
|
98
|
+
} from "./theme";
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { atelicFonts, atelicPalette, type Fonts, type Palette } from "../tokens";
|
|
4
|
+
import { FailurePage } from "./frame";
|
|
5
|
+
import { EmailThemeProvider } from "./theme";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The page shell is a template string rather than JSX, and deliberately so:
|
|
9
|
+
* React emits no doctype, React 19 hoists and reorders head tags, and it would
|
|
10
|
+
* escape the `>` in `details>summary` inside a style block. Only the rows
|
|
11
|
+
* inside the 680 column go through React.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** jq's `esc`, in the same order, for the strings that land in the shell. */
|
|
15
|
+
function esc(value: string): string {
|
|
16
|
+
return value
|
|
17
|
+
.replace(/&/g, "&")
|
|
18
|
+
.replace(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, """)
|
|
21
|
+
.replace(/'/g, "'");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const WRAPPER_OPEN = "<table><tbody>";
|
|
25
|
+
const WRAPPER_CLOSE = "</tbody></table>";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rows render inside a throwaway table so React's nesting validation sees a
|
|
29
|
+
* `<tr>` where one belongs; the wrapper is sliced back off.
|
|
30
|
+
*/
|
|
31
|
+
function renderRows(children: ReactNode, palette: Palette, fonts: Fonts): string {
|
|
32
|
+
const markup = renderToStaticMarkup(
|
|
33
|
+
<EmailThemeProvider palette={palette} fonts={fonts}>
|
|
34
|
+
<table>
|
|
35
|
+
<tbody>{children}</tbody>
|
|
36
|
+
</table>
|
|
37
|
+
</EmailThemeProvider>,
|
|
38
|
+
);
|
|
39
|
+
if (markup.startsWith(WRAPPER_OPEN) && markup.endsWith(WRAPPER_CLOSE)) {
|
|
40
|
+
return markup.slice(WRAPPER_OPEN.length, markup.length - WRAPPER_CLOSE.length);
|
|
41
|
+
}
|
|
42
|
+
return markup;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RenderEmailOptions = {
|
|
46
|
+
title: string;
|
|
47
|
+
/** The inbox preview line, hidden in the body. */
|
|
48
|
+
preheader?: string;
|
|
49
|
+
/** The rows inside the 680 column: a Masthead, cards, a Footer. */
|
|
50
|
+
children?: ReactNode;
|
|
51
|
+
palette?: Palette;
|
|
52
|
+
fonts?: Fonts;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The whole document: preheader for the inbox preview, the 680 column, the rows. */
|
|
56
|
+
export function renderEmail({
|
|
57
|
+
title,
|
|
58
|
+
preheader,
|
|
59
|
+
children,
|
|
60
|
+
palette = atelicPalette,
|
|
61
|
+
fonts = atelicFonts,
|
|
62
|
+
}: RenderEmailOptions): string {
|
|
63
|
+
const ground = palette.ground;
|
|
64
|
+
const rows = renderRows(children, palette, fonts);
|
|
65
|
+
return (
|
|
66
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
|
|
67
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1">` +
|
|
68
|
+
`<meta name="color-scheme" content="light"><title>${esc(title)}</title>` +
|
|
69
|
+
`<style>body{margin:0;padding:0;background:${ground}}a{color:${palette.ink}}` +
|
|
70
|
+
`details>summary{list-style:none}details>summary::-webkit-details-marker{display:none}` +
|
|
71
|
+
`@media (max-width:700px){.wrap{width:100%!important}}</style></head>` +
|
|
72
|
+
`<body style="margin:0;padding:0;background:${ground};-webkit-text-size-adjust:100%">` +
|
|
73
|
+
(preheader
|
|
74
|
+
? `<span style="display:none;font-size:1px;color:${ground};max-height:0;overflow:hidden">${esc(preheader)}</span>`
|
|
75
|
+
: "") +
|
|
76
|
+
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${ground}">` +
|
|
77
|
+
`<tr><td align="center" style="padding:28px 12px 48px">` +
|
|
78
|
+
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="680" class="wrap" style="width:680px;max-width:680px">` +
|
|
79
|
+
rows +
|
|
80
|
+
`</table></td></tr></table></body></html>`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type RenderFailureEmailOptions = {
|
|
85
|
+
runnerTitle: string;
|
|
86
|
+
eyebrowText: string;
|
|
87
|
+
reason: string;
|
|
88
|
+
logTail: string;
|
|
89
|
+
palette?: Palette;
|
|
90
|
+
fonts?: Fonts;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** The failure page: the reason on top and the log's tail beneath. */
|
|
94
|
+
export function renderFailureEmail({
|
|
95
|
+
runnerTitle,
|
|
96
|
+
eyebrowText,
|
|
97
|
+
reason,
|
|
98
|
+
logTail,
|
|
99
|
+
palette,
|
|
100
|
+
fonts,
|
|
101
|
+
}: RenderFailureEmailOptions): string {
|
|
102
|
+
return renderEmail({
|
|
103
|
+
title: `${runnerTitle}: did not run`,
|
|
104
|
+
preheader: reason,
|
|
105
|
+
palette,
|
|
106
|
+
fonts,
|
|
107
|
+
children: (
|
|
108
|
+
<FailurePage
|
|
109
|
+
runnerTitle={runnerTitle}
|
|
110
|
+
eyebrowText={eyebrowText}
|
|
111
|
+
reason={reason}
|
|
112
|
+
logTail={logTail}
|
|
113
|
+
/>
|
|
114
|
+
),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
2
|
+
import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The scoreboard pieces, ported one to one from the scoreboard section of
|
|
6
|
+
* homebase `runners/lib/email.jq`. The accent marks a shortfall and nothing
|
|
7
|
+
* else a row says.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type BarProps = { logged: number; target: number };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A segmented bar, one segment per unit of the target, filled up to what was
|
|
14
|
+
* logged. Wider segments for small targets so three reads as three.
|
|
15
|
+
*/
|
|
16
|
+
export function Bar({ logged, target }: BarProps) {
|
|
17
|
+
const { palette } = useEmailTheme();
|
|
18
|
+
const w = target <= 3 ? "18" : "10";
|
|
19
|
+
const filled = Math.min(logged, target);
|
|
20
|
+
return (
|
|
21
|
+
<table {...tableReset}>
|
|
22
|
+
<tbody>
|
|
23
|
+
<tr>
|
|
24
|
+
{Array.from({ length: target }, (_, i) => (
|
|
25
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a segment's position is its identity
|
|
26
|
+
<Fragment key={i}>
|
|
27
|
+
{i > 0 ? (
|
|
28
|
+
<td width="3" style={{ fontSize: "0" }}>
|
|
29
|
+
{"\u00a0"}
|
|
30
|
+
</td>
|
|
31
|
+
) : null}
|
|
32
|
+
<td
|
|
33
|
+
width={w}
|
|
34
|
+
height="6"
|
|
35
|
+
style={{
|
|
36
|
+
background: i < filled ? palette.ink : palette.line,
|
|
37
|
+
fontSize: "0",
|
|
38
|
+
lineHeight: "0",
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
{"\u00a0"}
|
|
42
|
+
</td>
|
|
43
|
+
</Fragment>
|
|
44
|
+
))}
|
|
45
|
+
</tr>
|
|
46
|
+
</tbody>
|
|
47
|
+
</table>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type GroupRowProps = { text: string; first: boolean };
|
|
52
|
+
|
|
53
|
+
/** A small eyebrow row spanning the scoreboard: a group's name. */
|
|
54
|
+
export function GroupRow({ text, first }: GroupRowProps) {
|
|
55
|
+
const { palette, fonts } = useEmailTheme();
|
|
56
|
+
return (
|
|
57
|
+
<tr>
|
|
58
|
+
<td
|
|
59
|
+
colSpan={4}
|
|
60
|
+
style={{
|
|
61
|
+
padding: `${first ? "0" : "18px"} 0 6px`,
|
|
62
|
+
...eyebrowStyle(fonts),
|
|
63
|
+
color: palette.accent,
|
|
64
|
+
borderBottom: `1px solid ${palette.line}`,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{text}
|
|
68
|
+
</td>
|
|
69
|
+
</tr>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type TargetRowProps = {
|
|
74
|
+
text: string;
|
|
75
|
+
logged: number;
|
|
76
|
+
/** Null is a count with nothing to measure it against. */
|
|
77
|
+
target: number | null;
|
|
78
|
+
note: string;
|
|
79
|
+
last: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** One target: label, logged over target (accent when short), the bar, a note. */
|
|
83
|
+
export function TargetRow({ text, logged, target, note, last }: TargetRowProps) {
|
|
84
|
+
const { palette, fonts } = useEmailTheme();
|
|
85
|
+
const rule = last ? {} : { borderBottom: `1px solid ${palette.hair}` };
|
|
86
|
+
const short = target !== null && logged < target;
|
|
87
|
+
return (
|
|
88
|
+
<tr>
|
|
89
|
+
<td style={{ padding: "10px 0", ...rule, fontWeight: "500" }}>{text}</td>
|
|
90
|
+
<td
|
|
91
|
+
width="52"
|
|
92
|
+
align="right"
|
|
93
|
+
style={{
|
|
94
|
+
padding: "10px 0",
|
|
95
|
+
...rule,
|
|
96
|
+
fontFamily: fonts.mono,
|
|
97
|
+
fontSize: "13px",
|
|
98
|
+
color: short ? palette.accent : palette.ink,
|
|
99
|
+
whiteSpace: "nowrap",
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{String(logged)}
|
|
103
|
+
{target === null ? null : ` / ${target}`}
|
|
104
|
+
</td>
|
|
105
|
+
<td width="84" style={{ padding: "10px 0 10px 16px", ...rule }}>
|
|
106
|
+
{target === null || target === 0 ? null : <Bar logged={logged} target={target} />}
|
|
107
|
+
</td>
|
|
108
|
+
<td
|
|
109
|
+
align="right"
|
|
110
|
+
style={{ padding: "10px 0", ...rule, fontSize: "13px", color: palette.faint }}
|
|
111
|
+
>
|
|
112
|
+
{note}
|
|
113
|
+
</td>
|
|
114
|
+
</tr>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type ScoreboardProps = { children?: ReactNode };
|
|
119
|
+
|
|
120
|
+
/** The card row the GroupRow and TargetRow rows sit in. */
|
|
121
|
+
export function Scoreboard({ children }: ScoreboardProps) {
|
|
122
|
+
const { palette, fonts } = useEmailTheme();
|
|
123
|
+
return (
|
|
124
|
+
<tr>
|
|
125
|
+
<td style={{ padding: "22px 26px 0" }}>
|
|
126
|
+
<table
|
|
127
|
+
{...tableReset}
|
|
128
|
+
width="100%"
|
|
129
|
+
style={{ fontFamily: fonts.sans, fontSize: "14px", color: palette.ink }}
|
|
130
|
+
>
|
|
131
|
+
<tbody>{children}</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
</td>
|
|
134
|
+
</tr>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type WhatMovedItem = { subject: string; event: string };
|
|
139
|
+
export type WhatMovedProps = { items: WhatMovedItem[]; note?: string };
|
|
140
|
+
|
|
141
|
+
/** The note bar on the cream ground under the scoreboard. */
|
|
142
|
+
export function WhatMoved({ items, note }: WhatMovedProps) {
|
|
143
|
+
const { palette, fonts } = useEmailTheme();
|
|
144
|
+
return (
|
|
145
|
+
<tr>
|
|
146
|
+
<td style={{ padding: "22px 26px 26px" }}>
|
|
147
|
+
<table
|
|
148
|
+
{...tableReset}
|
|
149
|
+
width="100%"
|
|
150
|
+
style={{ background: palette.ground, borderRadius: "10px" }}
|
|
151
|
+
>
|
|
152
|
+
<tbody>
|
|
153
|
+
<tr>
|
|
154
|
+
<td
|
|
155
|
+
style={{
|
|
156
|
+
padding: "16px 18px",
|
|
157
|
+
fontFamily: fonts.sans,
|
|
158
|
+
fontSize: "14px",
|
|
159
|
+
lineHeight: "1.6",
|
|
160
|
+
color: palette.ink,
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
<span style={{ ...eyebrowStyle(fonts), color: palette.faint }}>What Moved</span>
|
|
164
|
+
<br />
|
|
165
|
+
{items.map((item, i) => (
|
|
166
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: an item's position is its identity
|
|
167
|
+
<Fragment key={i}>
|
|
168
|
+
<b style={{ fontWeight: "600" }}>{item.subject}</b> {item.event}
|
|
169
|
+
<br />
|
|
170
|
+
</Fragment>
|
|
171
|
+
))}
|
|
172
|
+
{note ? <span style={{ color: palette.dim }}>{note}</span> : null}
|
|
173
|
+
</td>
|
|
174
|
+
</tr>
|
|
175
|
+
</tbody>
|
|
176
|
+
</table>
|
|
177
|
+
</td>
|
|
178
|
+
</tr>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type ReadBlockProps = { text: string; divider: boolean };
|
|
183
|
+
|
|
184
|
+
/** A model written read at the top of a section card. */
|
|
185
|
+
export function ReadBlock({ text, divider }: ReadBlockProps) {
|
|
186
|
+
const { palette, fonts } = useEmailTheme();
|
|
187
|
+
return (
|
|
188
|
+
<tr>
|
|
189
|
+
<td
|
|
190
|
+
style={{
|
|
191
|
+
padding: "22px 24px 20px",
|
|
192
|
+
fontFamily: fonts.sans,
|
|
193
|
+
...(divider ? { borderBottom: `1px solid ${palette.line}` } : {}),
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
<span style={{ ...eyebrowStyle(fonts), color: palette.accent }}>The Read</span>
|
|
197
|
+
<div
|
|
198
|
+
style={{
|
|
199
|
+
fontSize: "15px",
|
|
200
|
+
lineHeight: "1.6",
|
|
201
|
+
color: palette.ink,
|
|
202
|
+
marginTop: "6px",
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
{text}
|
|
206
|
+
</div>
|
|
207
|
+
</td>
|
|
208
|
+
</tr>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type SubEyebrowProps = { text: string };
|
|
213
|
+
|
|
214
|
+
/** A faint eyebrow inside a card, over a block. */
|
|
215
|
+
export function SubEyebrow({ text }: SubEyebrowProps) {
|
|
216
|
+
const { palette, fonts } = useEmailTheme();
|
|
217
|
+
return (
|
|
218
|
+
<tr>
|
|
219
|
+
<td style={{ padding: "18px 24px 6px", ...eyebrowStyle(fonts), color: palette.faint }}>
|
|
220
|
+
{text}
|
|
221
|
+
</td>
|
|
222
|
+
</tr>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type BadgeProps = { letter: string };
|
|
227
|
+
|
|
228
|
+
/** A single letter in a hairline box after the word it tags (S for social). */
|
|
229
|
+
export function Badge({ letter }: BadgeProps) {
|
|
230
|
+
const { palette, fonts } = useEmailTheme();
|
|
231
|
+
return (
|
|
232
|
+
<span
|
|
233
|
+
style={{
|
|
234
|
+
display: "inline-block",
|
|
235
|
+
fontFamily: fonts.mono,
|
|
236
|
+
fontSize: "9px",
|
|
237
|
+
lineHeight: "12px",
|
|
238
|
+
width: "12px",
|
|
239
|
+
textAlign: "center",
|
|
240
|
+
border: `1px solid ${palette.accent}`,
|
|
241
|
+
borderRadius: "3px",
|
|
242
|
+
color: palette.accent,
|
|
243
|
+
verticalAlign: "1px",
|
|
244
|
+
marginLeft: "3px",
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
{letter}
|
|
248
|
+
</span>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type DayEntry = { name: string; strong?: boolean; badge?: string };
|
|
253
|
+
export type Day = { label: string; entries: DayEntry[] };
|
|
254
|
+
export type DayStripProps = { days: Day[]; last: boolean };
|
|
255
|
+
|
|
256
|
+
/** Monday to Sunday, entries top aligned. */
|
|
257
|
+
export function DayStrip({ days, last }: DayStripProps) {
|
|
258
|
+
const { palette, fonts } = useEmailTheme();
|
|
259
|
+
return (
|
|
260
|
+
<tr>
|
|
261
|
+
<td
|
|
262
|
+
style={{
|
|
263
|
+
padding: "0 24px 18px",
|
|
264
|
+
...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
<table
|
|
268
|
+
{...tableReset}
|
|
269
|
+
width="100%"
|
|
270
|
+
style={{
|
|
271
|
+
fontFamily: fonts.sans,
|
|
272
|
+
fontSize: "12px",
|
|
273
|
+
lineHeight: "1.5",
|
|
274
|
+
color: palette.dim,
|
|
275
|
+
textAlign: "center",
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
<tbody>
|
|
279
|
+
<tr style={{ fontFamily: fonts.mono, fontSize: "11px", color: palette.faint }}>
|
|
280
|
+
{days.map((day, i) => (
|
|
281
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a day's position is its identity
|
|
282
|
+
<td key={i} style={{ padding: "6px 2px" }}>
|
|
283
|
+
{day.label}
|
|
284
|
+
</td>
|
|
285
|
+
))}
|
|
286
|
+
</tr>
|
|
287
|
+
<tr>
|
|
288
|
+
{days.map((day, i) => (
|
|
289
|
+
<td
|
|
290
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a day's position is its identity
|
|
291
|
+
key={i}
|
|
292
|
+
width="14%"
|
|
293
|
+
style={{
|
|
294
|
+
padding: "8px 2px",
|
|
295
|
+
borderTop: `2px solid ${palette.ink}`,
|
|
296
|
+
verticalAlign: "top",
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
{day.entries.map((entry, e) => (
|
|
300
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: an entry's position is its identity
|
|
301
|
+
<Fragment key={e}>
|
|
302
|
+
{e > 0 ? <br /> : null}
|
|
303
|
+
{entry.strong ? (
|
|
304
|
+
<b style={{ color: palette.ink, fontWeight: "600" }}>
|
|
305
|
+
{entry.name}
|
|
306
|
+
{entry.badge ? <Badge letter={entry.badge} /> : null}
|
|
307
|
+
</b>
|
|
308
|
+
) : (
|
|
309
|
+
<>
|
|
310
|
+
{entry.name}
|
|
311
|
+
{entry.badge ? <Badge letter={entry.badge} /> : null}
|
|
312
|
+
</>
|
|
313
|
+
)}
|
|
314
|
+
</Fragment>
|
|
315
|
+
))}
|
|
316
|
+
</td>
|
|
317
|
+
))}
|
|
318
|
+
</tr>
|
|
319
|
+
</tbody>
|
|
320
|
+
</table>
|
|
321
|
+
</td>
|
|
322
|
+
</tr>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export type StatStripEntry = { n: string | number; label: string };
|
|
327
|
+
export type StatStripProps = { stats: StatStripEntry[] };
|
|
328
|
+
|
|
329
|
+
/** A row of big numbers over eyebrow labels. */
|
|
330
|
+
export function StatStrip({ stats }: StatStripProps) {
|
|
331
|
+
const { palette, fonts } = useEmailTheme();
|
|
332
|
+
const width = `${Math.floor(100 / stats.length)}%`;
|
|
333
|
+
return (
|
|
334
|
+
<table {...tableReset} width="100%">
|
|
335
|
+
<tbody>
|
|
336
|
+
<tr>
|
|
337
|
+
{stats.map((entry, i) => (
|
|
338
|
+
<td
|
|
339
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a stat's position is its identity
|
|
340
|
+
key={i}
|
|
341
|
+
width={width}
|
|
342
|
+
style={{
|
|
343
|
+
padding: "12px 6px 10px",
|
|
344
|
+
borderTop: `2px solid ${palette.ink}`,
|
|
345
|
+
textAlign: "center",
|
|
346
|
+
verticalAlign: "top",
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
<span
|
|
350
|
+
style={{
|
|
351
|
+
fontFamily: fonts.sans,
|
|
352
|
+
fontSize: "22px",
|
|
353
|
+
fontWeight: "600",
|
|
354
|
+
letterSpacing: "-0.02em",
|
|
355
|
+
color: palette.ink,
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
{String(entry.n)}
|
|
359
|
+
</span>
|
|
360
|
+
<br />
|
|
361
|
+
<span style={{ ...eyebrowStyle(fonts), color: palette.faint }}>{entry.label}</span>
|
|
362
|
+
</td>
|
|
363
|
+
))}
|
|
364
|
+
</tr>
|
|
365
|
+
</tbody>
|
|
366
|
+
</table>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export type RecordsColumn = {
|
|
371
|
+
label: string;
|
|
372
|
+
/** Aligns the column right and keeps its cells on one line. */
|
|
373
|
+
right?: boolean;
|
|
374
|
+
/** Pixels. A width on any column fixes the table's layout so sibling tables share a grid. */
|
|
375
|
+
width?: number;
|
|
376
|
+
/** Keeps the column even when every row leaves it empty. */
|
|
377
|
+
keep?: boolean;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
export type RecordsCell = {
|
|
381
|
+
value?: string;
|
|
382
|
+
mono?: boolean;
|
|
383
|
+
muted?: boolean;
|
|
384
|
+
hot?: boolean;
|
|
385
|
+
/** Rendered in place of the value; the one way a cell carries markup. */
|
|
386
|
+
html?: ReactNode;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const hasMarkup = (html: ReactNode): boolean => html !== undefined && html !== null && html !== "";
|
|
390
|
+
|
|
391
|
+
export type RecordsProps = { columns: RecordsColumn[]; rows: RecordsCell[][] };
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A table with a header row. A column every row leaves empty is dropped unless
|
|
395
|
+
* it says keep, so a table never shows a column of nothing.
|
|
396
|
+
*/
|
|
397
|
+
export function Records({ columns, rows }: RecordsProps) {
|
|
398
|
+
const { palette, fonts } = useEmailTheme();
|
|
399
|
+
const keep = columns
|
|
400
|
+
.map((_, i) => i)
|
|
401
|
+
.filter(
|
|
402
|
+
(i) =>
|
|
403
|
+
columns[i].keep ||
|
|
404
|
+
rows.some((row) => (row[i]?.value ?? "") !== "" || hasMarkup(row[i]?.html)),
|
|
405
|
+
);
|
|
406
|
+
const fixed = columns.some((c) => c.width !== undefined);
|
|
407
|
+
return (
|
|
408
|
+
<table
|
|
409
|
+
{...tableReset}
|
|
410
|
+
width="100%"
|
|
411
|
+
style={{
|
|
412
|
+
...(fixed ? { tableLayout: "fixed" } : {}),
|
|
413
|
+
fontFamily: fonts.sans,
|
|
414
|
+
fontSize: "13px",
|
|
415
|
+
lineHeight: "1.4",
|
|
416
|
+
color: palette.ink,
|
|
417
|
+
}}
|
|
418
|
+
>
|
|
419
|
+
<tbody>
|
|
420
|
+
<tr>
|
|
421
|
+
{keep.map((i) => {
|
|
422
|
+
const c = columns[i];
|
|
423
|
+
return (
|
|
424
|
+
<td
|
|
425
|
+
key={i}
|
|
426
|
+
align={c.right ? "right" : "left"}
|
|
427
|
+
{...(c.width !== undefined ? { width: String(c.width) } : {})}
|
|
428
|
+
style={{
|
|
429
|
+
...(c.width !== undefined ? { width: `${c.width}px` } : {}),
|
|
430
|
+
padding: "0 8px 6px 0",
|
|
431
|
+
fontFamily: fonts.mono,
|
|
432
|
+
fontSize: "10px",
|
|
433
|
+
letterSpacing: "0.1em",
|
|
434
|
+
textTransform: "uppercase",
|
|
435
|
+
color: palette.faint,
|
|
436
|
+
borderBottom: `1px solid ${palette.line}`,
|
|
437
|
+
whiteSpace: "nowrap",
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
{c.label}
|
|
441
|
+
</td>
|
|
442
|
+
);
|
|
443
|
+
})}
|
|
444
|
+
</tr>
|
|
445
|
+
{rows.map((cells, r) => (
|
|
446
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a row's position is its identity
|
|
447
|
+
<tr key={r}>
|
|
448
|
+
{keep.map((i) => {
|
|
449
|
+
const c = columns[i];
|
|
450
|
+
const cell = cells[i] ?? {};
|
|
451
|
+
const hasHTML = hasMarkup(cell.html);
|
|
452
|
+
return (
|
|
453
|
+
<td
|
|
454
|
+
key={i}
|
|
455
|
+
align={c.right ? "right" : "left"}
|
|
456
|
+
style={{
|
|
457
|
+
padding: "8px 8px 8px 0",
|
|
458
|
+
borderBottom: `1px solid ${r === rows.length - 1 ? palette.line : palette.hair}`,
|
|
459
|
+
...(cell.mono ? { fontFamily: fonts.mono, fontSize: "12px" } : {}),
|
|
460
|
+
color: cell.hot
|
|
461
|
+
? palette.accent
|
|
462
|
+
: cell.muted
|
|
463
|
+
? palette.faint
|
|
464
|
+
: cell.mono
|
|
465
|
+
? palette.dim
|
|
466
|
+
: palette.ink,
|
|
467
|
+
...(c.right ? { whiteSpace: "nowrap" } : {}),
|
|
468
|
+
}}
|
|
469
|
+
>
|
|
470
|
+
{hasHTML ? cell.html : (cell.value ?? "")}
|
|
471
|
+
</td>
|
|
472
|
+
);
|
|
473
|
+
})}
|
|
474
|
+
</tr>
|
|
475
|
+
))}
|
|
476
|
+
</tbody>
|
|
477
|
+
</table>
|
|
478
|
+
);
|
|
479
|
+
}
|