@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,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
+ }
@@ -0,0 +1,116 @@
1
+ /*
2
+ * The plain text alternative part, ported one to one from the plain text
3
+ * section of homebase `runners/lib/email.jq`. Every label and number sits in
4
+ * its own padded column or on its own line; nothing is ever concatenated,
5
+ * which is what a mail service's own html to text conversion does to a table.
6
+ *
7
+ * jq counts a string's length in Unicode codepoints, so every width here does
8
+ * too. `.length` on a JavaScript string counts UTF-16 units and would pad an
9
+ * astral character two columns short.
10
+ */
11
+
12
+ function codepoints(value: string): number {
13
+ return [...value].length;
14
+ }
15
+
16
+ /** jq's ascii_upcase: ASCII letters only, everything else untouched. */
17
+ export function asciiUpcase(value: string): string {
18
+ return value.replace(/[a-z]/g, (c) => c.toUpperCase());
19
+ }
20
+
21
+ /** jq's ascii_downcase: ASCII letters only, everything else untouched. */
22
+ export function asciiDowncase(value: string): string {
23
+ return value.replace(/[A-Z]/g, (c) => c.toLowerCase());
24
+ }
25
+
26
+ export function spaces(n: number): string {
27
+ return " ".repeat(Math.max(n, 0));
28
+ }
29
+
30
+ /** Pads on the right to a column count. */
31
+ export function rpad(value: string, width: number): string {
32
+ return value + spaces(width - codepoints(value));
33
+ }
34
+
35
+ /** Pads on the left to a column count. */
36
+ export function lpad(value: string, width: number): string {
37
+ return spaces(width - codepoints(value)) + value;
38
+ }
39
+
40
+ /** Prose wrapped at 66 columns under a two space indent. */
41
+ export function wrap(text: string): string {
42
+ const lines = [""];
43
+ for (const word of text.split(" ")) {
44
+ const last = lines[lines.length - 1];
45
+ if (codepoints(last) === 0) lines[lines.length - 1] = word;
46
+ else if (codepoints(last) + 1 + codepoints(word) > 66) lines.push(word);
47
+ else lines[lines.length - 1] = `${last} ${word}`;
48
+ }
49
+ return lines.map((line) => ` ${line}`).join("\n");
50
+ }
51
+
52
+ export const textRule = "=".repeat(64);
53
+
54
+ export function textSection(title: string): string {
55
+ return `\n\n${textRule}\n${asciiUpcase(title)}\n${textRule}\n\n`;
56
+ }
57
+
58
+ export function textRead(text: string): string {
59
+ return `The Read\n${wrap(text)}\n`;
60
+ }
61
+
62
+ export function textBar(logged: number, target: number | null): string {
63
+ if (target === null || target === 0) return "";
64
+ const filled = Math.min(logged, target);
65
+ return Array.from({ length: target }, (_, i) => (i < filled ? "#" : ".")).join("");
66
+ }
67
+
68
+ export type TextTarget = {
69
+ label: string;
70
+ logged: number;
71
+ target: number | null;
72
+ note: string;
73
+ };
74
+
75
+ /** One scoreboard line. */
76
+ export function textTarget({ label, logged, target, note }: TextTarget): string {
77
+ const count = `${logged}${target === null ? "" : ` / ${target}`}`;
78
+ return ` ${rpad(label, 15)}${rpad(count, 8)}${rpad(textBar(logged, target), 7)}${note}`;
79
+ }
80
+
81
+ /**
82
+ * A column table. `right` holds the column indexes that align right. `widths`,
83
+ * when given, fixes every column's width and keeps every column, so sibling
84
+ * tables line up; null sizes each column to its content and drops any column
85
+ * every row leaves empty.
86
+ */
87
+ export function textTableGrid(
88
+ cols: string[],
89
+ rows: string[][],
90
+ right: number[],
91
+ widths: number[] | null,
92
+ ): string {
93
+ const keep = cols
94
+ .map((_, i) => i)
95
+ .filter((i) => widths !== null || rows.some((row) => (row[i] ?? "") !== ""));
96
+ const widthOf = keep.map((i) =>
97
+ widths !== null
98
+ ? widths[i]
99
+ : Math.max(...[cols[i], ...rows.map((row) => row[i] ?? "")].map(codepoints)),
100
+ );
101
+ return [cols, ...rows]
102
+ .map(
103
+ (row) =>
104
+ ` ${keep
105
+ .map((i, k) =>
106
+ right.includes(i) ? lpad(row[i] ?? "", widthOf[k]) : rpad(row[i] ?? "", widthOf[k]),
107
+ )
108
+ .join(" ")
109
+ .replace(/ +$/, "")}`,
110
+ )
111
+ .join("\n");
112
+ }
113
+
114
+ export function textTable(cols: string[], rows: string[][], right: number[]): string {
115
+ return textTableGrid(cols, rows, right, null);
116
+ }
@@ -0,0 +1,77 @@
1
+ import { type CSSProperties, createContext, type ReactNode, useContext } from "react";
2
+ import { atelicFonts, atelicPalette, type Fonts, type Palette } from "../tokens";
3
+
4
+ export type EmailTheme = {
5
+ palette: Palette;
6
+ fonts: Fonts;
7
+ };
8
+
9
+ const EmailThemeContext = createContext<EmailTheme>({
10
+ palette: atelicPalette,
11
+ fonts: atelicFonts,
12
+ });
13
+
14
+ export type EmailThemeProviderProps = {
15
+ palette?: Palette;
16
+ fonts?: Fonts;
17
+ children?: ReactNode;
18
+ };
19
+
20
+ /**
21
+ * Wraps a tree of email rows in a palette and a font pair. A client branded
22
+ * email passes its own; everything defaults to Atelic.
23
+ */
24
+ export function EmailThemeProvider({
25
+ palette = atelicPalette,
26
+ fonts = atelicFonts,
27
+ children,
28
+ }: EmailThemeProviderProps) {
29
+ return (
30
+ <EmailThemeContext.Provider value={{ palette, fonts }}>{children}</EmailThemeContext.Provider>
31
+ );
32
+ }
33
+
34
+ export function useEmailTheme(): EmailTheme {
35
+ return useContext(EmailThemeContext);
36
+ }
37
+
38
+ /**
39
+ * The shared eyebrow: monospace, small, wide, uppercase. The caller adds the
40
+ * color, because which color an eyebrow wears is what it says.
41
+ */
42
+ export function eyebrowStyle(fonts: Fonts): CSSProperties {
43
+ return {
44
+ fontFamily: fonts.mono,
45
+ fontSize: "11px",
46
+ letterSpacing: "0.12em",
47
+ textTransform: "uppercase",
48
+ };
49
+ }
50
+
51
+ /** The props every layout table in an email carries. */
52
+ export const tableReset = {
53
+ role: "presentation" as const,
54
+ cellPadding: "0",
55
+ cellSpacing: "0",
56
+ border: 0,
57
+ };
58
+
59
+ /**
60
+ * `#FC4A1A` to `rgba(252,74,26,0)`, the masthead rule's fade out stop. An accent
61
+ * that is not a three or six digit hex fades to `transparent` rather than to NaN.
62
+ */
63
+ export function fadeStop(hex: string): string {
64
+ if (!/^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) return "transparent";
65
+ const digits = hex.replace("#", "");
66
+ const full =
67
+ digits.length === 3
68
+ ? digits
69
+ .split("")
70
+ .map((c) => c + c)
71
+ .join("")
72
+ : digits;
73
+ const r = Number.parseInt(full.slice(0, 2), 16);
74
+ const g = Number.parseInt(full.slice(2, 4), 16);
75
+ const b = Number.parseInt(full.slice(4, 6), 16);
76
+ return `rgba(${r},${g},${b},0)`;
77
+ }
@@ -0,0 +1,27 @@
1
+ import type { Palette } from "./palettes";
2
+
3
+ /**
4
+ * Which site token each email palette key answers to, so an email palette can
5
+ * dress a web page and a page's theme can dress an email. The names are the
6
+ * ones in a site's `theme.css`; only the keys with a clear counterpart are
7
+ * mapped, which is why the neutral ramp, the radii, and the shadows are
8
+ * absent. `--ink` and `--surface-dark` both take the ink, exactly as the site
9
+ * defines them.
10
+ */
11
+ export const themeTokenMap: Record<string, keyof Palette> = {
12
+ "--surface": "ground",
13
+ "--surface-dark": "ink",
14
+ "--card": "paper",
15
+ "--ink": "ink",
16
+ "--primary": "accent",
17
+ };
18
+
19
+ /**
20
+ * The palette as CSS custom property declarations, one per line, ready to drop
21
+ * inside a selector block.
22
+ */
23
+ export function toThemeCSS(palette: Palette): string {
24
+ return Object.entries(themeTokenMap)
25
+ .map(([token, key]) => `${token}: ${palette[key]};`)
26
+ .join("\n");
27
+ }
@@ -0,0 +1,2 @@
1
+ export { themeTokenMap, toThemeCSS } from "./css";
2
+ export { atelicFonts, atelicPalette, type Fonts, type Palette } from "./palettes";