@atelic-action/ui 0.4.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 CHANGED
@@ -149,18 +149,30 @@ const html = renderEmail({
149
149
 
150
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
151
 
152
- `RecordStack` takes `records`, each a `RecordStackItem` (`title`, `url`, `meta`, `note`), 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. `recordStackText(records)` is its plain text twin: each title on its own line under a two space indent, the meta and the note wrapped under a four space indent, a blank line between records, and no line past `textWidth` (68 columns, indent included).
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
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 and muted under its label.
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.
155
157
 
156
158
  ```tsx
157
159
  const records = [
158
- { title: "Pinewood Cabinetry", url: "https://example.test", meta: ["Lead", "Longmont", "fit 14"], note: "Answered the audit inside a day." },
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
+ },
159
168
  ];
160
169
 
170
+ <Masthead title="Pipeline" meta="Week 39 · 09/21 to 09/27" />;
171
+ <Eyebrow text="MQL · 15" strong />;
172
+
161
173
  <Card>
162
174
  <Row last={false}>
163
- <StatStrip stats={[{ n: 6, label: "Lead", delta: "+3 · +12%" }, { n: 2, label: "MQL" }]} />
175
+ <StatStrip stats={[{ n: 6, label: "Lead", delta: "+3 (12%)" }, { n: 2, label: "MQL", delta: "-1 (4%)" }]} />
164
176
  </Row>
165
177
  <Row last>
166
178
  <RecordStack records={records} />
@@ -178,7 +190,7 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
178
190
 
179
191
  | jq Function | Component | Props |
180
192
  |---|---|---|
181
- | `eyebrow` | `Eyebrow` | `text` |
193
+ | `eyebrow` | `Eyebrow` | `text`, `strong` (added 0.5.0, no jq counterpart) |
182
194
  | `card` | `Card` | `children` |
183
195
  | `fold` | `Fold` | `summary`, `children` |
184
196
  | `big_fold` | `BigFold` | `summary`, `count` (a `ReactNode`, absent for the jq's `""`), `children` |
@@ -203,10 +215,10 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
203
215
  | `sub_eyebrow` | `SubEyebrow` | `text` |
204
216
  | `badge` | `Badge` | `letter` |
205
217
  | `day_strip` | `DayStrip` | `days`, `last` |
206
- | `stat_strip` | `StatStrip` | `stats`, each with an optional `delta` (the component wraps where the jq does not) |
207
- | none | `RecordStack` | `records` (born here on 2026-09-24, with no jq counterpart) |
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) |
208
220
  | `records` | `Records` | `columns`, `rows`; a cell's `html` is a `ReactNode` |
209
- | `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`) |
221
+ | `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`), `meta` (added 0.5.0, no jq counterpart) |
210
222
  | `title_card` | `TitleCard` | `eyebrowText`, `headlineLines`, `lede`, `stats` (the rows under the lede, in place of the jq's `$stats_html`) |
211
223
  | `footer` | `Footer` | `meta` |
212
224
  | `page` | `renderEmail` | `title`, `preheader`, `children`, `palette`, `fonts` |
@@ -220,6 +232,8 @@ The plain text alternative part ports as plain functions with no React anywhere
220
232
 
221
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.
222
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
+
223
237
  ## Releasing
224
238
 
225
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -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 = { text: string };
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", ...eyebrowStyle(fonts), color: palette.faint }}>
21
- {text}
22
- </td>
33
+ <td style={{ padding: "36px 8px 12px", ...look }}>{text}</td>
23
34
  </tr>
24
35
  );
25
36
  }
@@ -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
- <table {...tableReset}>
29
- <tbody>
30
- <tr>
31
- <td
32
- style={{
33
- fontSize: "20px",
34
- fontWeight: "600",
35
- letterSpacing: "-0.045em",
36
- color: palette.ink,
37
- paddingRight: "8px",
38
- lineHeight: "1",
39
- }}
40
- >
41
- {wordmark}
42
- </td>
43
- <td
44
- width="34"
45
- style={{ width: "34px", verticalAlign: "middle", paddingRight: "12px" }}
46
- >
47
- <div
48
- style={{
49
- width: "34px",
50
- height: "2px",
51
- fontSize: "0",
52
- lineHeight: "0",
53
- backgroundColor: palette.accent,
54
- background: `linear-gradient(90deg,${palette.accent},${fadeStop(palette.accent)})`,
55
- }}
56
- >
57
- {"\u00a0"}
58
- </div>
59
- </td>
60
- <td
61
- style={{
62
- ...eyebrowStyle(fonts),
63
- color: palette.faint,
64
- verticalAlign: "middle",
65
- lineHeight: "1",
66
- }}
67
- >
68
- {title}
69
- </td>
70
- </tr>
71
- </tbody>
72
- </table>
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
  );
@@ -1,4 +1,5 @@
1
- import { Fragment, type ReactNode } from "react";
1
+ import { type CSSProperties, Fragment, type ReactNode } from "react";
2
+ import { atelicPalette, type Palette } from "../tokens";
2
3
  import type { RecordStackItem } from "./text";
3
4
  import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
4
5
 
@@ -327,9 +328,22 @@ export function DayStrip({ days, last }: DayStripProps) {
327
328
  export type StatStripEntry = {
328
329
  n: string | number;
329
330
  label: string;
330
- /** A change beside the number, small and muted under the label: "+3 · +12%". */
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
+ */
331
336
  delta?: string;
332
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
+ }
333
347
  export type StatStripProps = { stats: StatStripEntry[] };
334
348
 
335
349
  /**
@@ -377,7 +391,11 @@ export function StatStrip({ stats }: StatStripProps) {
377
391
  <>
378
392
  <br />
379
393
  <span
380
- style={{ fontFamily: fonts.mono, fontSize: "11px", color: palette.faint }}
394
+ style={{
395
+ fontFamily: fonts.mono,
396
+ fontSize: "11px",
397
+ color: deltaColor(entry.delta, palette),
398
+ }}
381
399
  >
382
400
  {entry.delta}
383
401
  </span>
@@ -392,6 +410,35 @@ export function StatStrip({ stats }: StatStripProps) {
392
410
  );
393
411
  }
394
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
+
395
442
  export type { RecordStackItem };
396
443
  export type RecordStackProps = { records: RecordStackItem[] };
397
444
 
@@ -403,12 +450,33 @@ export type RecordStackProps = { records: RecordStackItem[] };
403
450
  */
404
451
  export function RecordStack({ records }: RecordStackProps) {
405
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
+ };
406
460
  return (
407
461
  <table {...tableReset} width="100%" style={{ fontFamily: fonts.sans, color: palette.ink }}>
408
462
  <tbody>
409
463
  {records.map((record, i) => {
410
464
  const last = i === records.length - 1;
411
465
  const meta = record.meta.filter((m) => m !== "");
466
+ const title = record.url ? (
467
+ <a
468
+ href={record.url}
469
+ style={{
470
+ color: palette.ink,
471
+ textDecoration: "none",
472
+ borderBottom: `1px solid ${palette.accent}`,
473
+ }}
474
+ >
475
+ {record.title}
476
+ </a>
477
+ ) : (
478
+ record.title
479
+ );
412
480
  return (
413
481
  // biome-ignore lint/suspicious/noArrayIndexKey: a record's position is its identity
414
482
  <tr key={i}>
@@ -419,30 +487,29 @@ export function RecordStack({ records }: RecordStackProps) {
419
487
  ...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
420
488
  }}
421
489
  >
422
- <div
423
- style={{
424
- fontSize: "15px",
425
- fontWeight: "600",
426
- lineHeight: "1.35",
427
- color: palette.ink,
428
- wordBreak: "break-word",
429
- }}
430
- >
431
- {record.url ? (
432
- <a
433
- href={record.url}
434
- style={{
435
- color: palette.ink,
436
- textDecoration: "none",
437
- borderBottom: `1px solid ${palette.accent}`,
438
- }}
439
- >
440
- {record.title}
441
- </a>
442
- ) : (
443
- record.title
444
- )}
445
- </div>
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
+ )}
446
513
  {meta.length > 0 ? (
447
514
  <div
448
515
  style={{
@@ -468,6 +535,25 @@ export function RecordStack({ records }: RecordStackProps) {
468
535
  {record.note}
469
536
  </div>
470
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}
471
557
  </td>
472
558
  </tr>
473
559
  );
package/src/email/text.ts CHANGED
@@ -135,21 +135,32 @@ export type RecordStackItem = {
135
135
  /** Facts joined by a middle dot on the line under the title; empty items drop. */
136
136
  meta: string[];
137
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 };
138
142
  };
139
143
 
140
144
  /**
141
145
  * The plain text twin of `RecordStack`: each title on its own line under a
142
- * two space indent, the meta joined by middle dots and the note beneath it,
143
- * both wrapped under a four space indent, a blank line between records. No
144
- * line runs past `textWidth`.
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`.
145
151
  */
146
152
  export function recordStackText(records: RecordStackItem[]): string {
147
153
  return records
148
154
  .map((record) => {
149
- const lines = [wrapIndented(record.title, 2)];
155
+ const title = record.badge ? `${record.title} [${record.badge}]` : record.title;
156
+ const lines = [wrapIndented(title, 2)];
150
157
  const meta = record.meta.filter((m) => m !== "");
151
158
  if (meta.length > 0) lines.push(wrapIndented(meta.join(" · "), 4));
152
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
+ }
153
164
  return lines.join("\n");
154
165
  })
155
166
  .join("\n\n");
@@ -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
  /**