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