@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.
- package/README.md +94 -1
- package/package.json +4 -1
- 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/tokens/css.ts +27 -0
- package/src/tokens/index.ts +2 -0
- package/src/tokens/palettes.ts +54 -0
package/README.md
CHANGED
|
@@ -40,6 +40,9 @@ Both package sheets sit inside `@layer atelic-ui`, so any rule a site writes out
|
|
|
40
40
|
| Import | Exports |
|
|
41
41
|
|---|---|
|
|
42
42
|
| `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
|
|
43
|
+
| `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
|
|
44
|
+
| `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
|
|
45
|
+
| `@atelic-action/ui/tokens` | `Palette`, `atelicPalette`, `Fonts`, `atelicFonts`, `toThemeCSS`, `themeTokenMap` |
|
|
43
46
|
| `@atelic-action/ui/hooks` | `useScrollSpy` and its `PageStop` type |
|
|
44
47
|
| `@atelic-action/ui/styles/base.css` | Resets, the `.mkt` canvas, typography, and layout helpers |
|
|
45
48
|
| `@atelic-action/ui/styles/chrome.css` | Styles for everything under `chrome` |
|
|
@@ -66,6 +69,96 @@ import { Footer, SiteHeader, SkipLink, StickyCTABar } from "@atelic-action/ui/ch
|
|
|
66
69
|
|
|
67
70
|
The menu is a native `<dialog>` opened with `showModal()`, so Escape, focus containment, and focus return come from the browser.
|
|
68
71
|
|
|
72
|
+
## Email
|
|
73
|
+
|
|
74
|
+
`@atelic-action/ui/email` is the runner email design as React components, ported one to one from the jq library the runners compose their mail from (homebase `runners/lib/email.jq`). The components are born inline: every layout is a table, every style is an inline style object, and there is no CSS file and no `className`, because Gmail strips a style block and ignores media queries on some accounts. The same components therefore mount on a web page as happily as they render into a mail client.
|
|
75
|
+
|
|
76
|
+
Colors and fonts come from context, so a client branded email passes its own palette:
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
import { Card, Eyebrow, Footer, Item, Masthead, TitleCard } from "@atelic-action/ui/email";
|
|
80
|
+
import { renderEmail } from "@atelic-action/ui/email/render";
|
|
81
|
+
import { atelicPalette } from "@atelic-action/ui/tokens";
|
|
82
|
+
|
|
83
|
+
const html = renderEmail({
|
|
84
|
+
title: "Week 40 Recruiter",
|
|
85
|
+
preheader: "Three roles cleared every filter.",
|
|
86
|
+
palette: { ...atelicPalette, accent: "#0B6E4F" },
|
|
87
|
+
children: (
|
|
88
|
+
<>
|
|
89
|
+
<Masthead title="Recruiter" />
|
|
90
|
+
<TitleCard
|
|
91
|
+
eyebrowText="Week 40"
|
|
92
|
+
headlineLines={["Three cleared", "every filter."]}
|
|
93
|
+
lede="Two of them are remote."
|
|
94
|
+
/>
|
|
95
|
+
<Eyebrow text="Shortlist" />
|
|
96
|
+
<Card>
|
|
97
|
+
<Item
|
|
98
|
+
name="Pinewood Cabinetry"
|
|
99
|
+
right="4.5"
|
|
100
|
+
subparts={["Staff engineer", "Remote"]}
|
|
101
|
+
body="They answered inside a day."
|
|
102
|
+
linkText="Posting"
|
|
103
|
+
url="https://example.test/posting"
|
|
104
|
+
last
|
|
105
|
+
/>
|
|
106
|
+
</Card>
|
|
107
|
+
<Footer meta="Run 2026-10-05" />
|
|
108
|
+
</>
|
|
109
|
+
),
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`renderEmail` builds the document shell itself and puts only the rows through React, because React emits no doctype, React 19 hoists and reorders head tags, and it would escape the `>` in `details>summary`. `renderFailureEmail` is the same shell around `FailurePage`. Both live at `@atelic-action/ui/email/render`, apart from the components, so a site that mounts a component on a page never pulls React's server renderer into its browser bundle.
|
|
114
|
+
|
|
115
|
+
### The Mapping
|
|
116
|
+
|
|
117
|
+
Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_html`, a records cell's `html`, a stat's caption), the React prop is `children` or a `ReactNode`. Where the jq escapes a string argument, the prop is a plain string and React does the escaping.
|
|
118
|
+
|
|
119
|
+
| jq Function | Component | Props |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `eyebrow` | `Eyebrow` | `text` |
|
|
122
|
+
| `card` | `Card` | `children` |
|
|
123
|
+
| `fold` | `Fold` | `summary`, `children` |
|
|
124
|
+
| `big_fold` | `BigFold` | `summary`, `count` (a `ReactNode`, absent for the jq's `""`), `children` |
|
|
125
|
+
| `title_line` | `TitleLine` | `name`, `right` |
|
|
126
|
+
| `item` | `Item` | `name`, `right`, `subparts`, `body`, `foldLabel`, `foldBody`, `linkText`, `url`, `last` |
|
|
127
|
+
| `note` | `Note` | `eyebrowText`, `text`, `accented`, `last` |
|
|
128
|
+
| `empty_row` | `EmptyRow` | `text` |
|
|
129
|
+
| `stat` | `Stat` | `n`, `caption` (a `ReactNode`: the jq takes raw html here) |
|
|
130
|
+
| `stats_row` | `StatsRow` | `children`, the `Stat` cells |
|
|
131
|
+
| `list` | `List` | `fontSize`, `children` |
|
|
132
|
+
| `list_row` | `ListRow` | `children` |
|
|
133
|
+
| `lead_row` | `LeadRow` | `lead`, `rest` |
|
|
134
|
+
| `row` | `Row` | `last`, `children` |
|
|
135
|
+
| `fold_row` | `FoldRow` | `last`, `children` |
|
|
136
|
+
| `mono_table` | `MonoTable` | `headers`, `rows` |
|
|
137
|
+
| `bar` | `Bar` | `logged`, `target` |
|
|
138
|
+
| `group_row` | `GroupRow` | `text`, `first` |
|
|
139
|
+
| `target_row` | `TargetRow` | `text`, `logged`, `target` (null is a count with nothing to measure it against), `note`, `last` |
|
|
140
|
+
| `scoreboard` | `Scoreboard` | `children`, the `GroupRow` and `TargetRow` rows |
|
|
141
|
+
| `what_moved` | `WhatMoved` | `items`, `note` |
|
|
142
|
+
| `read_block` | `ReadBlock` | `text`, `divider` |
|
|
143
|
+
| `sub_eyebrow` | `SubEyebrow` | `text` |
|
|
144
|
+
| `badge` | `Badge` | `letter` |
|
|
145
|
+
| `day_strip` | `DayStrip` | `days`, `last` |
|
|
146
|
+
| `stat_strip` | `StatStrip` | `stats` |
|
|
147
|
+
| `records` | `Records` | `columns`, `rows`; a cell's `html` is a `ReactNode` |
|
|
148
|
+
| `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`) |
|
|
149
|
+
| `title_card` | `TitleCard` | `eyebrowText`, `headlineLines`, `lede`, `stats` (the rows under the lede, in place of the jq's `$stats_html`) |
|
|
150
|
+
| `footer` | `Footer` | `meta` |
|
|
151
|
+
| `page` | `renderEmail` | `title`, `preheader`, `children`, `palette`, `fonts` |
|
|
152
|
+
| `failure_page` | `renderFailureEmail`, or `FailurePage` as body rows | `runnerTitle`, `eyebrowText`, `reason`, `logTail` |
|
|
153
|
+
|
|
154
|
+
The plain text alternative part ports as plain functions with no React anywhere in them: `spaces`, `rpad`, `lpad`, `wrap`, `textRule`, `textSection`, `textRead`, `textBar`, `textTarget`, `textTableGrid`, `textTable`, plus `asciiUpcase` and `asciiDowncase`. Every width counts Unicode codepoints, the way jq's `length` does.
|
|
155
|
+
|
|
156
|
+
`tst/email/expected/` holds frozen goldens generated from the jq library, and the component tests compare the rendered DOM against them. See that folder's README before touching one.
|
|
157
|
+
|
|
158
|
+
### Tokens
|
|
159
|
+
|
|
160
|
+
`@atelic-action/ui/tokens` carries the palette and the font stacks with no React import, so a build script or a plain text renderer can read them. `toThemeCSS(palette)` writes the palette out as the site token declarations (`--surface`, `--surface-dark`, `--card`, `--ink`, `--primary`), and `themeTokenMap` exposes which palette key each token takes.
|
|
161
|
+
|
|
69
162
|
## Releasing
|
|
70
163
|
|
|
71
164
|
1. Bump `version` in `package.json` and merge it to `main`: patch for a fix, minor for a new component or prop, major for a breaking prop or class rename.
|
|
@@ -76,4 +169,4 @@ The menu is a native `<dialog>` opened with `showModal()`, so Escape, focus cont
|
|
|
76
169
|
git push origin v0.1.1
|
|
77
170
|
```
|
|
78
171
|
|
|
79
|
-
3. The Publish workflow checks that the tag matches the version, runs the gates, and publishes to npm with provenance. It
|
|
172
|
+
3. The Publish workflow checks that the tag matches the version, runs the gates, and publishes to npm with provenance. It authenticates through npm trusted publishing (the package's Trusted Publisher names this repo and `publish.yml`, and its allowed actions must permit direct `npm publish`, since a configuration created after 2026-09-03 allows only `npm stage publish` by default), so no token is stored, and it needs npm 11.5.1 or later, which Node 24 ships.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atelic-action/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared UI for the Atelic templates: site chrome, base styles, and the component library they install",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
],
|
|
20
20
|
"exports": {
|
|
21
21
|
"./chrome": "./src/chrome/index.ts",
|
|
22
|
+
"./email": "./src/email/index.ts",
|
|
23
|
+
"./email/render": "./src/email/render.tsx",
|
|
22
24
|
"./hooks": "./src/hooks/index.ts",
|
|
25
|
+
"./tokens": "./src/tokens/index.ts",
|
|
23
26
|
"./styles/base.css": "./src/styles/base.css",
|
|
24
27
|
"./styles/chrome.css": "./src/styles/chrome.css",
|
|
25
28
|
"./package.json": "./package.json"
|
|
@@ -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
|
+
}
|