@atelic-action/ui 0.3.0 → 0.5.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 +44 -4
- package/package.json +1 -1
- package/src/email/atoms.tsx +16 -5
- package/src/email/frame.tsx +73 -47
- package/src/email/index.ts +5 -0
- package/src/email/scoreboard.tsx +222 -26
- package/src/email/text.ts +55 -4
- package/src/tokens/palettes.ts +10 -0
package/README.md
CHANGED
|
@@ -145,6 +145,43 @@ const html = renderEmail({
|
|
|
145
145
|
});
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
### Mobile First
|
|
149
|
+
|
|
150
|
+
Runner emails are mobile first. A layout that squashes on a phone is fixed here, in the components every runner shares, never in a report page. A fixed column table is for numbers alone and never carries a name; names go in a `RecordStack`.
|
|
151
|
+
|
|
152
|
+
`RecordStack` takes `records`, each a `RecordStackItem` (`title`, `url`, `meta`, `note`, `badge`, `callout`), and renders one table row per record: the title on its own line at 15px, bold and wrapping freely, linked when `url` is set; a mono meta line beneath it with the items joined by middle dots (empty items drop); an optional note beneath that; a hairline between records and none after the last. No cell carries a width, so a name of any length wraps at 320 pixels instead of squashing. An optional `badge` (a stage word such as `"Contacted"`) sits on the title line in a small mono uppercase pill on a `line` hairline, in its own right aligned unbroken cell, so the title keeps wrapping beside it and the badge stays on the title's first line. An optional `callout` (`{ eyebrow, text }`) sets a short task under the meta and the note in the accented `Note` idiom: the eyebrow in the accent, the text at 13px, and a 2px accent rule down its left edge. A record with neither renders exactly as it did before. `recordStackText(records)` is its plain text twin: each title on its own line under a two space indent with any badge after it in square brackets (`Blue Heron Plumbing [Contacted]`), the meta and the note wrapped under a four space indent, any callout as its eyebrow in upper case over its text under that same indent, a blank line between records, and no line past `textWidth` (68 columns, indent included).
|
|
153
|
+
|
|
154
|
+
`StatStrip` lays its stats out as inline block cells with an 88 pixel floor inside one centered cell, so six or seven stats flow onto a second row on a phone rather than shrinking. Each stat takes an optional `delta` (`"+3 (12%)"`), set small under its label and colored by its sign: one that opens on `+` wears the palette's `up`, one that opens on `-` its `down`, and anything else stays muted.
|
|
155
|
+
|
|
156
|
+
`Masthead` takes an optional `meta` (`"Week 39 · 09/21 to 09/27"`) set in the eyebrow style at the right end of the row, so the title card no longer carries the week. It is a right aligned, unbroken block floated after the wordmark and title: on the same line while the row has room, and dropped under the title on its own line, still right aligned, when it does not (checked at 320 pixels). `Eyebrow` takes `strong`, which sets the label at 15px and weight 700 in ink rather than faint, with a slightly tighter tracking, so a section such as `MQL · 15` reads as a heading on a phone.
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
const records = [
|
|
160
|
+
{
|
|
161
|
+
title: "Pinewood Cabinetry",
|
|
162
|
+
url: "https://example.test",
|
|
163
|
+
meta: ["Lead", "Longmont", "fit 14"],
|
|
164
|
+
note: "Answered the audit inside a day.",
|
|
165
|
+
badge: "Contacted",
|
|
166
|
+
callout: { eyebrow: "Next", text: "Walk the Business Profile findings Thursday." },
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
<Masthead title="Pipeline" meta="Week 39 · 09/21 to 09/27" />;
|
|
171
|
+
<Eyebrow text="MQL · 15" strong />;
|
|
172
|
+
|
|
173
|
+
<Card>
|
|
174
|
+
<Row last={false}>
|
|
175
|
+
<StatStrip stats={[{ n: 6, label: "Lead", delta: "+3 (12%)" }, { n: 2, label: "MQL", delta: "-1 (4%)" }]} />
|
|
176
|
+
</Row>
|
|
177
|
+
<Row last>
|
|
178
|
+
<RecordStack records={records} />
|
|
179
|
+
</Row>
|
|
180
|
+
</Card>;
|
|
181
|
+
|
|
182
|
+
const text = recordStackText(records);
|
|
183
|
+
```
|
|
184
|
+
|
|
148
185
|
`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.
|
|
149
186
|
|
|
150
187
|
### The Mapping
|
|
@@ -153,7 +190,7 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
|
|
|
153
190
|
|
|
154
191
|
| jq Function | Component | Props |
|
|
155
192
|
|---|---|---|
|
|
156
|
-
| `eyebrow` | `Eyebrow` | `text` |
|
|
193
|
+
| `eyebrow` | `Eyebrow` | `text`, `strong` (added 0.5.0, no jq counterpart) |
|
|
157
194
|
| `card` | `Card` | `children` |
|
|
158
195
|
| `fold` | `Fold` | `summary`, `children` |
|
|
159
196
|
| `big_fold` | `BigFold` | `summary`, `count` (a `ReactNode`, absent for the jq's `""`), `children` |
|
|
@@ -178,15 +215,16 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
|
|
|
178
215
|
| `sub_eyebrow` | `SubEyebrow` | `text` |
|
|
179
216
|
| `badge` | `Badge` | `letter` |
|
|
180
217
|
| `day_strip` | `DayStrip` | `days`, `last` |
|
|
181
|
-
| `stat_strip` | `StatStrip` | `stats` |
|
|
218
|
+
| `stat_strip` | `StatStrip` | `stats`, each with an optional `delta` colored by its sign (the component wraps where the jq does not) |
|
|
219
|
+
| none | `RecordStack` | `records`, each with an optional `badge` and `callout` (born here on 2026-09-24, with no jq counterpart) |
|
|
182
220
|
| `records` | `Records` | `columns`, `rows`; a cell's `html` is a `ReactNode` |
|
|
183
|
-
| `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`) |
|
|
221
|
+
| `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`), `meta` (added 0.5.0, no jq counterpart) |
|
|
184
222
|
| `title_card` | `TitleCard` | `eyebrowText`, `headlineLines`, `lede`, `stats` (the rows under the lede, in place of the jq's `$stats_html`) |
|
|
185
223
|
| `footer` | `Footer` | `meta` |
|
|
186
224
|
| `page` | `renderEmail` | `title`, `preheader`, `children`, `palette`, `fonts` |
|
|
187
225
|
| `failure_page` | `renderFailureEmail`, or `FailurePage` as body rows | `runnerTitle`, `eyebrowText`, `reason`, `logTail` |
|
|
188
226
|
|
|
189
|
-
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.
|
|
227
|
+
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`, `recordStackText`, `textWidth`, plus `asciiUpcase` and `asciiDowncase`. Every width counts Unicode codepoints, the way jq's `length` does.
|
|
190
228
|
|
|
191
229
|
`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.
|
|
192
230
|
|
|
@@ -194,6 +232,8 @@ The plain text alternative part ports as plain functions with no React anywhere
|
|
|
194
232
|
|
|
195
233
|
`@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.
|
|
196
234
|
|
|
235
|
+
The palette carries two optional keys beyond the jq's eight: `up` (`#2F7A4B`, a calm green) and `down` (`#B23A2E`, a calm red, kept well apart from the orange accent), the colors a `StatStrip` delta wears by its sign. Both read on the cream card. They are optional so a client palette written before 0.5.0 still compiles, and a palette without them borrows the Atelic values. Neither maps to a site token.
|
|
236
|
+
|
|
197
237
|
## Releasing
|
|
198
238
|
|
|
199
239
|
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.
|
package/package.json
CHANGED
package/src/email/atoms.tsx
CHANGED
|
@@ -10,16 +10,27 @@ import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
|
|
|
10
10
|
* is a plain string and React does the escaping.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export type EyebrowProps = {
|
|
13
|
+
export type EyebrowProps = {
|
|
14
|
+
text: string;
|
|
15
|
+
/** Reads as a heading on a phone: larger, bold, in ink rather than faint. */
|
|
16
|
+
strong?: boolean;
|
|
17
|
+
};
|
|
14
18
|
|
|
15
19
|
/** A section label between cards. */
|
|
16
|
-
export function Eyebrow({ text }: EyebrowProps) {
|
|
20
|
+
export function Eyebrow({ text, strong = false }: EyebrowProps) {
|
|
17
21
|
const { palette, fonts } = useEmailTheme();
|
|
22
|
+
const look = strong
|
|
23
|
+
? {
|
|
24
|
+
...eyebrowStyle(fonts),
|
|
25
|
+
fontSize: "15px",
|
|
26
|
+
fontWeight: "700",
|
|
27
|
+
letterSpacing: "0.06em",
|
|
28
|
+
color: palette.ink,
|
|
29
|
+
}
|
|
30
|
+
: { ...eyebrowStyle(fonts), color: palette.faint };
|
|
18
31
|
return (
|
|
19
32
|
<tr>
|
|
20
|
-
<td style={{ padding: "36px 8px 12px", ...
|
|
21
|
-
{text}
|
|
22
|
-
</td>
|
|
33
|
+
<td style={{ padding: "36px 8px 12px", ...look }}>{text}</td>
|
|
23
34
|
</tr>
|
|
24
35
|
);
|
|
25
36
|
}
|
package/src/email/frame.tsx
CHANGED
|
@@ -14,62 +14,88 @@ export type MastheadProps = {
|
|
|
14
14
|
title: string;
|
|
15
15
|
/** The wordmark at the top left. */
|
|
16
16
|
wordmark?: string;
|
|
17
|
+
/**
|
|
18
|
+
* A quiet fact at the right end of the row, such as the week. It sits on
|
|
19
|
+
* the title's line while it fits and drops under it, still right aligned,
|
|
20
|
+
* when the row runs out of room.
|
|
21
|
+
*/
|
|
22
|
+
meta?: string;
|
|
17
23
|
};
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* The wordmark and one orange rule at the top left, the email's title beside
|
|
21
|
-
* them: the runner's name, not the week.
|
|
27
|
+
* them: the runner's name, not the week. A `meta` rides at the right end of
|
|
28
|
+
* the row in its own right aligned, unbroken block, floated after the brand so
|
|
29
|
+
* that on a narrow screen it drops under the title instead of squashing it.
|
|
22
30
|
*/
|
|
23
|
-
export function Masthead({ title, wordmark = "atelic" }: MastheadProps) {
|
|
31
|
+
export function Masthead({ title, wordmark = "atelic", meta }: MastheadProps) {
|
|
24
32
|
const { palette, fonts } = useEmailTheme();
|
|
33
|
+
const brand = (
|
|
34
|
+
<table {...tableReset}>
|
|
35
|
+
<tbody>
|
|
36
|
+
<tr>
|
|
37
|
+
<td
|
|
38
|
+
style={{
|
|
39
|
+
fontSize: "20px",
|
|
40
|
+
fontWeight: "600",
|
|
41
|
+
letterSpacing: "-0.045em",
|
|
42
|
+
color: palette.ink,
|
|
43
|
+
paddingRight: "8px",
|
|
44
|
+
lineHeight: "1",
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{wordmark}
|
|
48
|
+
</td>
|
|
49
|
+
<td width="34" style={{ width: "34px", verticalAlign: "middle", paddingRight: "12px" }}>
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
width: "34px",
|
|
53
|
+
height: "2px",
|
|
54
|
+
fontSize: "0",
|
|
55
|
+
lineHeight: "0",
|
|
56
|
+
backgroundColor: palette.accent,
|
|
57
|
+
background: `linear-gradient(90deg,${palette.accent},${fadeStop(palette.accent)})`,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{"\u00a0"}
|
|
61
|
+
</div>
|
|
62
|
+
</td>
|
|
63
|
+
<td
|
|
64
|
+
style={{
|
|
65
|
+
...eyebrowStyle(fonts),
|
|
66
|
+
color: palette.faint,
|
|
67
|
+
verticalAlign: "middle",
|
|
68
|
+
lineHeight: "1",
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{title}
|
|
72
|
+
</td>
|
|
73
|
+
</tr>
|
|
74
|
+
</tbody>
|
|
75
|
+
</table>
|
|
76
|
+
);
|
|
25
77
|
return (
|
|
26
78
|
<tr>
|
|
27
79
|
<td style={{ padding: "8px 8px 22px", fontFamily: fonts.sans }}>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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>
|
|
80
|
+
{meta ? (
|
|
81
|
+
<>
|
|
82
|
+
<div style={{ display: "inline-block", verticalAlign: "middle" }}>{brand}</div>
|
|
83
|
+
<div
|
|
84
|
+
style={{
|
|
85
|
+
float: "right",
|
|
86
|
+
textAlign: "right",
|
|
87
|
+
whiteSpace: "nowrap",
|
|
88
|
+
...eyebrowStyle(fonts),
|
|
89
|
+
lineHeight: "20px",
|
|
90
|
+
color: palette.faint,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{meta}
|
|
94
|
+
</div>
|
|
95
|
+
</>
|
|
96
|
+
) : (
|
|
97
|
+
brand
|
|
98
|
+
)}
|
|
73
99
|
</td>
|
|
74
100
|
</tr>
|
|
75
101
|
);
|
package/src/email/index.ts
CHANGED
|
@@ -56,6 +56,8 @@ export {
|
|
|
56
56
|
type GroupRowProps,
|
|
57
57
|
ReadBlock,
|
|
58
58
|
type ReadBlockProps,
|
|
59
|
+
RecordStack,
|
|
60
|
+
type RecordStackProps,
|
|
59
61
|
Records,
|
|
60
62
|
type RecordsCell,
|
|
61
63
|
type RecordsColumn,
|
|
@@ -77,6 +79,8 @@ export {
|
|
|
77
79
|
asciiDowncase,
|
|
78
80
|
asciiUpcase,
|
|
79
81
|
lpad,
|
|
82
|
+
type RecordStackItem,
|
|
83
|
+
recordStackText,
|
|
80
84
|
rpad,
|
|
81
85
|
spaces,
|
|
82
86
|
type TextTarget,
|
|
@@ -87,6 +91,7 @@ export {
|
|
|
87
91
|
textTable,
|
|
88
92
|
textTableGrid,
|
|
89
93
|
textTarget,
|
|
94
|
+
textWidth,
|
|
90
95
|
wrap,
|
|
91
96
|
} from "./text";
|
|
92
97
|
export {
|
package/src/email/scoreboard.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { Fragment, type ReactNode } from "react";
|
|
1
|
+
import { type CSSProperties, Fragment, type ReactNode } from "react";
|
|
2
|
+
import { atelicPalette, type Palette } from "../tokens";
|
|
3
|
+
import type { RecordStackItem } from "./text";
|
|
2
4
|
import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
|
|
3
5
|
|
|
4
6
|
/*
|
|
@@ -323,45 +325,239 @@ export function DayStrip({ days, last }: DayStripProps) {
|
|
|
323
325
|
);
|
|
324
326
|
}
|
|
325
327
|
|
|
326
|
-
export type StatStripEntry = {
|
|
328
|
+
export type StatStripEntry = {
|
|
329
|
+
n: string | number;
|
|
330
|
+
label: string;
|
|
331
|
+
/**
|
|
332
|
+
* A change beside the number, small under the label: "+3 (12%)". One that
|
|
333
|
+
* opens on "+" wears the palette's `up`, one that opens on "-" its `down`,
|
|
334
|
+
* anything else stays muted.
|
|
335
|
+
*/
|
|
336
|
+
delta?: string;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** The color a delta wears, read off its sign. */
|
|
340
|
+
export function deltaColor(delta: string, palette: Palette): string {
|
|
341
|
+
if (delta.startsWith("+")) return palette.up ?? atelicPalette.up ?? palette.faint;
|
|
342
|
+
if (delta.startsWith("-") || delta.startsWith("\u2212")) {
|
|
343
|
+
return palette.down ?? atelicPalette.down ?? palette.faint;
|
|
344
|
+
}
|
|
345
|
+
return palette.faint;
|
|
346
|
+
}
|
|
327
347
|
export type StatStripProps = { stats: StatStripEntry[] };
|
|
328
348
|
|
|
329
|
-
/**
|
|
349
|
+
/**
|
|
350
|
+
* A strip of big numbers over eyebrow labels. Each stat is an inline block
|
|
351
|
+
* cell with a floor on its width inside one centered cell, so six or seven
|
|
352
|
+
* stats flow onto a second row on a phone instead of shrinking to nothing.
|
|
353
|
+
*/
|
|
330
354
|
export function StatStrip({ stats }: StatStripProps) {
|
|
331
355
|
const { palette, fonts } = useEmailTheme();
|
|
332
|
-
const
|
|
356
|
+
const share = `${Math.floor(100 / Math.max(stats.length, 1))}%`;
|
|
333
357
|
return (
|
|
334
358
|
<table {...tableReset} width="100%">
|
|
335
359
|
<tbody>
|
|
336
360
|
<tr>
|
|
337
|
-
{
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
361
|
+
<td align="center" style={{ textAlign: "center" }}>
|
|
362
|
+
{stats.map((entry, i) => (
|
|
363
|
+
<div
|
|
364
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a stat's position is its identity
|
|
365
|
+
key={i}
|
|
366
|
+
style={{
|
|
367
|
+
display: "inline-block",
|
|
368
|
+
width: share,
|
|
369
|
+
minWidth: "88px",
|
|
370
|
+
boxSizing: "border-box",
|
|
371
|
+
verticalAlign: "top",
|
|
372
|
+
padding: "12px 6px 10px",
|
|
373
|
+
borderTop: `2px solid ${palette.ink}`,
|
|
374
|
+
textAlign: "center",
|
|
375
|
+
}}
|
|
376
|
+
>
|
|
377
|
+
<span
|
|
378
|
+
style={{
|
|
379
|
+
fontFamily: fonts.sans,
|
|
380
|
+
fontSize: "22px",
|
|
381
|
+
fontWeight: "600",
|
|
382
|
+
letterSpacing: "-0.02em",
|
|
383
|
+
color: palette.ink,
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
{String(entry.n)}
|
|
387
|
+
</span>
|
|
388
|
+
<br />
|
|
389
|
+
<span style={{ ...eyebrowStyle(fonts), color: palette.faint }}>{entry.label}</span>
|
|
390
|
+
{entry.delta ? (
|
|
391
|
+
<>
|
|
392
|
+
<br />
|
|
393
|
+
<span
|
|
394
|
+
style={{
|
|
395
|
+
fontFamily: fonts.mono,
|
|
396
|
+
fontSize: "11px",
|
|
397
|
+
color: deltaColor(entry.delta, palette),
|
|
398
|
+
}}
|
|
399
|
+
>
|
|
400
|
+
{entry.delta}
|
|
401
|
+
</span>
|
|
402
|
+
</>
|
|
403
|
+
) : null}
|
|
404
|
+
</div>
|
|
405
|
+
))}
|
|
406
|
+
</td>
|
|
407
|
+
</tr>
|
|
408
|
+
</tbody>
|
|
409
|
+
</table>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
type RecordBadgeProps = { text: string };
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A word in a small mono pill on a hairline, the Badge idiom sized for a stage
|
|
417
|
+
* such as "Contacted". Only RecordStack wears it.
|
|
418
|
+
*/
|
|
419
|
+
function RecordBadge({ text }: RecordBadgeProps) {
|
|
420
|
+
const { palette, fonts } = useEmailTheme();
|
|
421
|
+
return (
|
|
422
|
+
<span
|
|
423
|
+
style={{
|
|
424
|
+
display: "inline-block",
|
|
425
|
+
fontFamily: fonts.mono,
|
|
426
|
+
fontSize: "10px",
|
|
427
|
+
lineHeight: "14px",
|
|
428
|
+
letterSpacing: "0.08em",
|
|
429
|
+
textTransform: "uppercase",
|
|
430
|
+
padding: "0 6px",
|
|
431
|
+
border: `1px solid ${palette.line}`,
|
|
432
|
+
borderRadius: "3px",
|
|
433
|
+
color: palette.dim,
|
|
434
|
+
whiteSpace: "nowrap",
|
|
435
|
+
}}
|
|
436
|
+
>
|
|
437
|
+
{text}
|
|
438
|
+
</span>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export type { RecordStackItem };
|
|
443
|
+
export type RecordStackProps = { records: RecordStackItem[] };
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* One record per row, stacked: the title on its own line, a mono meta line
|
|
447
|
+
* under it, an optional note under that. No column cells and no fixed widths,
|
|
448
|
+
* so a name of any length wraps instead of squashing on a phone. A fixed
|
|
449
|
+
* column table is for numbers alone; names go here.
|
|
450
|
+
*/
|
|
451
|
+
export function RecordStack({ records }: RecordStackProps) {
|
|
452
|
+
const { palette, fonts } = useEmailTheme();
|
|
453
|
+
const titleStyle: CSSProperties = {
|
|
454
|
+
fontSize: "15px",
|
|
455
|
+
fontWeight: "600",
|
|
456
|
+
lineHeight: "1.35",
|
|
457
|
+
color: palette.ink,
|
|
458
|
+
wordBreak: "break-word",
|
|
459
|
+
};
|
|
460
|
+
return (
|
|
461
|
+
<table {...tableReset} width="100%" style={{ fontFamily: fonts.sans, color: palette.ink }}>
|
|
462
|
+
<tbody>
|
|
463
|
+
{records.map((record, i) => {
|
|
464
|
+
const last = i === records.length - 1;
|
|
465
|
+
const meta = record.meta.filter((m) => m !== "");
|
|
466
|
+
const title = record.url ? (
|
|
467
|
+
<a
|
|
468
|
+
href={record.url}
|
|
342
469
|
style={{
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
verticalAlign: "top",
|
|
470
|
+
color: palette.ink,
|
|
471
|
+
textDecoration: "none",
|
|
472
|
+
borderBottom: `1px solid ${palette.accent}`,
|
|
347
473
|
}}
|
|
348
474
|
>
|
|
349
|
-
|
|
475
|
+
{record.title}
|
|
476
|
+
</a>
|
|
477
|
+
) : (
|
|
478
|
+
record.title
|
|
479
|
+
);
|
|
480
|
+
return (
|
|
481
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a record's position is its identity
|
|
482
|
+
<tr key={i}>
|
|
483
|
+
<td
|
|
350
484
|
style={{
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
letterSpacing: "-0.02em",
|
|
355
|
-
color: palette.ink,
|
|
485
|
+
padding: `${i === 0 ? "0" : "12px"} 0 ${last ? "0" : "12px"}`,
|
|
486
|
+
verticalAlign: "top",
|
|
487
|
+
...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
|
|
356
488
|
}}
|
|
357
489
|
>
|
|
358
|
-
{
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
490
|
+
{record.badge ? (
|
|
491
|
+
<table {...tableReset} width="100%">
|
|
492
|
+
<tbody>
|
|
493
|
+
<tr>
|
|
494
|
+
<td style={{ ...titleStyle, verticalAlign: "top" }}>{title}</td>
|
|
495
|
+
<td
|
|
496
|
+
align="right"
|
|
497
|
+
style={{
|
|
498
|
+
textAlign: "right",
|
|
499
|
+
whiteSpace: "nowrap",
|
|
500
|
+
verticalAlign: "top",
|
|
501
|
+
paddingLeft: "12px",
|
|
502
|
+
paddingTop: "2px",
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
505
|
+
<RecordBadge text={record.badge} />
|
|
506
|
+
</td>
|
|
507
|
+
</tr>
|
|
508
|
+
</tbody>
|
|
509
|
+
</table>
|
|
510
|
+
) : (
|
|
511
|
+
<div style={titleStyle}>{title}</div>
|
|
512
|
+
)}
|
|
513
|
+
{meta.length > 0 ? (
|
|
514
|
+
<div
|
|
515
|
+
style={{
|
|
516
|
+
fontFamily: fonts.mono,
|
|
517
|
+
fontSize: "12px",
|
|
518
|
+
lineHeight: "1.5",
|
|
519
|
+
color: palette.faint,
|
|
520
|
+
marginTop: "4px",
|
|
521
|
+
}}
|
|
522
|
+
>
|
|
523
|
+
{meta.join(" · ")}
|
|
524
|
+
</div>
|
|
525
|
+
) : null}
|
|
526
|
+
{record.note ? (
|
|
527
|
+
<div
|
|
528
|
+
style={{
|
|
529
|
+
fontSize: "13px",
|
|
530
|
+
lineHeight: "1.5",
|
|
531
|
+
color: palette.dim,
|
|
532
|
+
marginTop: "6px",
|
|
533
|
+
}}
|
|
534
|
+
>
|
|
535
|
+
{record.note}
|
|
536
|
+
</div>
|
|
537
|
+
) : null}
|
|
538
|
+
{record.callout ? (
|
|
539
|
+
<div
|
|
540
|
+
style={{
|
|
541
|
+
marginTop: "10px",
|
|
542
|
+
paddingLeft: "10px",
|
|
543
|
+
borderLeft: `2px solid ${palette.accent}`,
|
|
544
|
+
fontFamily: fonts.sans,
|
|
545
|
+
fontSize: "13px",
|
|
546
|
+
lineHeight: "1.5",
|
|
547
|
+
color: palette.ink,
|
|
548
|
+
}}
|
|
549
|
+
>
|
|
550
|
+
<span style={{ ...eyebrowStyle(fonts), color: palette.accent }}>
|
|
551
|
+
{record.callout.eyebrow}
|
|
552
|
+
</span>
|
|
553
|
+
<br />
|
|
554
|
+
{record.callout.text}
|
|
555
|
+
</div>
|
|
556
|
+
) : null}
|
|
557
|
+
</td>
|
|
558
|
+
</tr>
|
|
559
|
+
);
|
|
560
|
+
})}
|
|
365
561
|
</tbody>
|
|
366
562
|
</table>
|
|
367
563
|
);
|
package/src/email/text.ts
CHANGED
|
@@ -37,16 +37,25 @@ export function lpad(value: string, width: number): string {
|
|
|
37
37
|
return spaces(width - codepoints(value)) + value;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
export
|
|
40
|
+
/** The widest a plain text line runs, indent included: two spaces and 66 columns of prose. */
|
|
41
|
+
export const textWidth = 68;
|
|
42
|
+
|
|
43
|
+
/** Prose broken into lines of at most `columns` codepoints, each under `indent`. */
|
|
44
|
+
function wrapIndented(text: string, indent: number): string {
|
|
45
|
+
const columns = textWidth - indent;
|
|
42
46
|
const lines = [""];
|
|
43
47
|
for (const word of text.split(" ")) {
|
|
44
48
|
const last = lines[lines.length - 1];
|
|
45
49
|
if (codepoints(last) === 0) lines[lines.length - 1] = word;
|
|
46
|
-
else if (codepoints(last) + 1 + codepoints(word) >
|
|
50
|
+
else if (codepoints(last) + 1 + codepoints(word) > columns) lines.push(word);
|
|
47
51
|
else lines[lines.length - 1] = `${last} ${word}`;
|
|
48
52
|
}
|
|
49
|
-
return lines.map((line) =>
|
|
53
|
+
return lines.map((line) => `${spaces(indent)}${line}`).join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Prose wrapped at 66 columns under a two space indent. */
|
|
57
|
+
export function wrap(text: string): string {
|
|
58
|
+
return wrapIndented(text, 2);
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export const textRule = "=".repeat(64);
|
|
@@ -114,3 +123,45 @@ export function textTableGrid(
|
|
|
114
123
|
export function textTable(cols: string[], rows: string[][], right: number[]): string {
|
|
115
124
|
return textTableGrid(cols, rows, right, null);
|
|
116
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One record in a stack: what `RecordStack` renders and `recordStackText`
|
|
129
|
+
* writes. It lives here, with no React, so a plain text renderer can import it.
|
|
130
|
+
*/
|
|
131
|
+
export type RecordStackItem = {
|
|
132
|
+
title: string;
|
|
133
|
+
/** Links the title when set; the plain text twin leaves it out. */
|
|
134
|
+
url?: string | null;
|
|
135
|
+
/** Facts joined by a middle dot on the line under the title; empty items drop. */
|
|
136
|
+
meta: string[];
|
|
137
|
+
note?: string;
|
|
138
|
+
/** A stage word on the title line, right aligned in a pill; "[Contacted]" in the text twin. */
|
|
139
|
+
badge?: string;
|
|
140
|
+
/** A short task under the record, an orange eyebrow over a line of text. */
|
|
141
|
+
callout?: { eyebrow: string; text: string };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The plain text twin of `RecordStack`: each title on its own line under a
|
|
146
|
+
* two space indent with any badge after it in square brackets, the meta joined
|
|
147
|
+
* by middle dots and the note beneath it, both wrapped under a four space
|
|
148
|
+
* indent, then any callout as its eyebrow in upper case over its text under
|
|
149
|
+
* the same indent, a blank line between records. No line runs past
|
|
150
|
+
* `textWidth`.
|
|
151
|
+
*/
|
|
152
|
+
export function recordStackText(records: RecordStackItem[]): string {
|
|
153
|
+
return records
|
|
154
|
+
.map((record) => {
|
|
155
|
+
const title = record.badge ? `${record.title} [${record.badge}]` : record.title;
|
|
156
|
+
const lines = [wrapIndented(title, 2)];
|
|
157
|
+
const meta = record.meta.filter((m) => m !== "");
|
|
158
|
+
if (meta.length > 0) lines.push(wrapIndented(meta.join(" · "), 4));
|
|
159
|
+
if (record.note) lines.push(wrapIndented(record.note, 4));
|
|
160
|
+
if (record.callout) {
|
|
161
|
+
lines.push(wrapIndented(asciiUpcase(record.callout.eyebrow), 4));
|
|
162
|
+
lines.push(wrapIndented(record.callout.text, 4));
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
})
|
|
166
|
+
.join("\n\n");
|
|
167
|
+
}
|
package/src/tokens/palettes.ts
CHANGED
|
@@ -24,6 +24,14 @@ export type Palette = {
|
|
|
24
24
|
line: string;
|
|
25
25
|
/** The lighter rule inside a card. */
|
|
26
26
|
hair: string;
|
|
27
|
+
/**
|
|
28
|
+
* A change for the better, such as a positive delta under a stat. Optional
|
|
29
|
+
* so a palette written before 0.5.0 still compiles; a palette without it
|
|
30
|
+
* borrows the Atelic value.
|
|
31
|
+
*/
|
|
32
|
+
up?: string;
|
|
33
|
+
/** A change for the worse, the negative delta's color. Optional the same way. */
|
|
34
|
+
down?: string;
|
|
27
35
|
};
|
|
28
36
|
|
|
29
37
|
export const atelicPalette: Palette = {
|
|
@@ -35,6 +43,8 @@ export const atelicPalette: Palette = {
|
|
|
35
43
|
accent: "#FC4A1A",
|
|
36
44
|
line: "#E6DFD2",
|
|
37
45
|
hair: "#F0EAE0",
|
|
46
|
+
up: "#2F7A4B",
|
|
47
|
+
down: "#B23A2E",
|
|
38
48
|
};
|
|
39
49
|
|
|
40
50
|
/**
|