@atelic-action/ui 0.1.0 → 0.2.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,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
+ }
@@ -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, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;")
20
+ .replace(/"/g, "&quot;")
21
+ .replace(/'/g, "&#39;");
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
+ }