@atelic-action/ui 0.3.0 → 0.4.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
@@ -145,6 +145,31 @@ 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`), 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).
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.
155
+
156
+ ```tsx
157
+ const records = [
158
+ { title: "Pinewood Cabinetry", url: "https://example.test", meta: ["Lead", "Longmont", "fit 14"], note: "Answered the audit inside a day." },
159
+ ];
160
+
161
+ <Card>
162
+ <Row last={false}>
163
+ <StatStrip stats={[{ n: 6, label: "Lead", delta: "+3 · +12%" }, { n: 2, label: "MQL" }]} />
164
+ </Row>
165
+ <Row last>
166
+ <RecordStack records={records} />
167
+ </Row>
168
+ </Card>;
169
+
170
+ const text = recordStackText(records);
171
+ ```
172
+
148
173
  `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
174
 
150
175
  ### The Mapping
@@ -178,7 +203,8 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
178
203
  | `sub_eyebrow` | `SubEyebrow` | `text` |
179
204
  | `badge` | `Badge` | `letter` |
180
205
  | `day_strip` | `DayStrip` | `days`, `last` |
181
- | `stat_strip` | `StatStrip` | `stats` |
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) |
182
208
  | `records` | `Records` | `columns`, `rows`; a cell's `html` is a `ReactNode` |
183
209
  | `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`) |
184
210
  | `title_card` | `TitleCard` | `eyebrowText`, `headlineLines`, `lede`, `stats` (the rows under the lede, in place of the jq's `$stats_html`) |
@@ -186,7 +212,7 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
186
212
  | `page` | `renderEmail` | `title`, `preheader`, `children`, `palette`, `fonts` |
187
213
  | `failure_page` | `renderFailureEmail`, or `FailurePage` as body rows | `runnerTitle`, `eyebrowText`, `reason`, `logTail` |
188
214
 
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.
215
+ 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
216
 
191
217
  `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
218
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -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 {
@@ -1,4 +1,5 @@
1
1
  import { Fragment, type ReactNode } from "react";
2
+ import type { RecordStackItem } from "./text";
2
3
  import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
3
4
 
4
5
  /*
@@ -323,50 +324,159 @@ export function DayStrip({ days, last }: DayStripProps) {
323
324
  );
324
325
  }
325
326
 
326
- export type StatStripEntry = { n: string | number; label: string };
327
+ export type StatStripEntry = {
328
+ n: string | number;
329
+ label: string;
330
+ /** A change beside the number, small and muted under the label: "+3 · +12%". */
331
+ delta?: string;
332
+ };
327
333
  export type StatStripProps = { stats: StatStripEntry[] };
328
334
 
329
- /** A row of big numbers over eyebrow labels. */
335
+ /**
336
+ * A strip of big numbers over eyebrow labels. Each stat is an inline block
337
+ * cell with a floor on its width inside one centered cell, so six or seven
338
+ * stats flow onto a second row on a phone instead of shrinking to nothing.
339
+ */
330
340
  export function StatStrip({ stats }: StatStripProps) {
331
341
  const { palette, fonts } = useEmailTheme();
332
- const width = `${Math.floor(100 / stats.length)}%`;
342
+ const share = `${Math.floor(100 / Math.max(stats.length, 1))}%`;
333
343
  return (
334
344
  <table {...tableReset} width="100%">
335
345
  <tbody>
336
346
  <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
347
+ <td align="center" style={{ textAlign: "center" }}>
348
+ {stats.map((entry, i) => (
349
+ <div
350
+ // biome-ignore lint/suspicious/noArrayIndexKey: a stat's position is its identity
351
+ key={i}
350
352
  style={{
351
- fontFamily: fonts.sans,
352
- fontSize: "22px",
353
- fontWeight: "600",
354
- letterSpacing: "-0.02em",
355
- color: palette.ink,
353
+ display: "inline-block",
354
+ width: share,
355
+ minWidth: "88px",
356
+ boxSizing: "border-box",
357
+ verticalAlign: "top",
358
+ padding: "12px 6px 10px",
359
+ borderTop: `2px solid ${palette.ink}`,
360
+ textAlign: "center",
356
361
  }}
357
362
  >
358
- {String(entry.n)}
359
- </span>
360
- <br />
361
- <span style={{ ...eyebrowStyle(fonts), color: palette.faint }}>{entry.label}</span>
362
- </td>
363
- ))}
363
+ <span
364
+ style={{
365
+ fontFamily: fonts.sans,
366
+ fontSize: "22px",
367
+ fontWeight: "600",
368
+ letterSpacing: "-0.02em",
369
+ color: palette.ink,
370
+ }}
371
+ >
372
+ {String(entry.n)}
373
+ </span>
374
+ <br />
375
+ <span style={{ ...eyebrowStyle(fonts), color: palette.faint }}>{entry.label}</span>
376
+ {entry.delta ? (
377
+ <>
378
+ <br />
379
+ <span
380
+ style={{ fontFamily: fonts.mono, fontSize: "11px", color: palette.faint }}
381
+ >
382
+ {entry.delta}
383
+ </span>
384
+ </>
385
+ ) : null}
386
+ </div>
387
+ ))}
388
+ </td>
364
389
  </tr>
365
390
  </tbody>
366
391
  </table>
367
392
  );
368
393
  }
369
394
 
395
+ export type { RecordStackItem };
396
+ export type RecordStackProps = { records: RecordStackItem[] };
397
+
398
+ /**
399
+ * One record per row, stacked: the title on its own line, a mono meta line
400
+ * under it, an optional note under that. No column cells and no fixed widths,
401
+ * so a name of any length wraps instead of squashing on a phone. A fixed
402
+ * column table is for numbers alone; names go here.
403
+ */
404
+ export function RecordStack({ records }: RecordStackProps) {
405
+ const { palette, fonts } = useEmailTheme();
406
+ return (
407
+ <table {...tableReset} width="100%" style={{ fontFamily: fonts.sans, color: palette.ink }}>
408
+ <tbody>
409
+ {records.map((record, i) => {
410
+ const last = i === records.length - 1;
411
+ const meta = record.meta.filter((m) => m !== "");
412
+ return (
413
+ // biome-ignore lint/suspicious/noArrayIndexKey: a record's position is its identity
414
+ <tr key={i}>
415
+ <td
416
+ style={{
417
+ padding: `${i === 0 ? "0" : "12px"} 0 ${last ? "0" : "12px"}`,
418
+ verticalAlign: "top",
419
+ ...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
420
+ }}
421
+ >
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>
446
+ {meta.length > 0 ? (
447
+ <div
448
+ style={{
449
+ fontFamily: fonts.mono,
450
+ fontSize: "12px",
451
+ lineHeight: "1.5",
452
+ color: palette.faint,
453
+ marginTop: "4px",
454
+ }}
455
+ >
456
+ {meta.join(" · ")}
457
+ </div>
458
+ ) : null}
459
+ {record.note ? (
460
+ <div
461
+ style={{
462
+ fontSize: "13px",
463
+ lineHeight: "1.5",
464
+ color: palette.dim,
465
+ marginTop: "6px",
466
+ }}
467
+ >
468
+ {record.note}
469
+ </div>
470
+ ) : null}
471
+ </td>
472
+ </tr>
473
+ );
474
+ })}
475
+ </tbody>
476
+ </table>
477
+ );
478
+ }
479
+
370
480
  export type RecordsColumn = {
371
481
  label: string;
372
482
  /** Aligns the column right and keeps its cells on one line. */
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
- /** Prose wrapped at 66 columns under a two space indent. */
41
- export function wrap(text: string): string {
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) > 66) lines.push(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) => ` ${line}`).join("\n");
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,34 @@ 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
+ };
139
+
140
+ /**
141
+ * 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`.
145
+ */
146
+ export function recordStackText(records: RecordStackItem[]): string {
147
+ return records
148
+ .map((record) => {
149
+ const lines = [wrapIndented(record.title, 2)];
150
+ const meta = record.meta.filter((m) => m !== "");
151
+ if (meta.length > 0) lines.push(wrapIndented(meta.join(" · "), 4));
152
+ if (record.note) lines.push(wrapIndented(record.note, 4));
153
+ return lines.join("\n");
154
+ })
155
+ .join("\n\n");
156
+ }