@atelic-action/ui 0.7.0 → 0.7.2

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
@@ -162,7 +162,7 @@ Runner emails are mobile first. A layout that squashes on a phone is fixed here,
162
162
 
163
163
  `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).
164
164
 
165
- The Company Cards additions (0.7.0, from the `Company Cards IA` design, 2026-09-29) ride on the same item and change nothing for a record without them. `badges` is a list of further pills after `badge`, each with a `tone`: `faint` is the quiet stage word, `ink` a bold pill in ink on a 1.5px border (the next touch), `accent` the same in the accent (New). `timeline` (`{ days, touches, opens }`, each day count back from today) draws the record's recent days between the title and the meta line as one table cell per day, an 8px ink square on each send day and a 5px accent dot on each known open day, the two stacked when they share a day, all standing on a `line` hairline with a 1.5px ink tick at today's edge; nothing is positioned, so it survives Gmail. `RecordTimelineStrip` renders one on its own and `RecordTimelineLegend` names the marks once above a list. `aside` (`{ text, strong, tone }`) sets a fact right on the meta line with its number in bold and colored by tone: `up` fresh, `warm` cooling, `accent` cold. In the text twin every pill follows the title in brackets, the timeline is one character per day (`10d [#······@·o|]`: `#` a send, `o` an open, `@` both, `·` nothing, `|` today), and the aside ends the meta line; `recordTimelineText` and `recordBadges` are the helpers it uses.
165
+ The Company Cards additions (0.7.0, from the `Company Cards IA` design, 2026-09-29) ride on the same item and change nothing for a record without them. `badges` is a list of further pills after `badge`, each with a `tone`: `faint` is the quiet stage word, `ink` a bold pill in ink on a 1.5px border (the next touch), `accent` the same in the accent (New). `timeline` (`{ days, touches, opens }`, each day count back from today) draws the record's recent days between the title and the meta line as one table cell per marked day and one per run of empty days between them (seven cells for three marks, never thirty, since Gmail clips a message past about 100 KB), an 8px ink square on each send day and a 5px accent dot on each known open day, the two stacked when they share a day, all standing on a `line` hairline with a 1.5px ink tick at today's edge and an axis row beneath (`30d` on the left, `today` on the right); nothing is positioned, so it survives Gmail. `RecordTimelineStrip` renders one on its own and `RecordTimelineLegend` names the marks once above a list. `aside` (`{ text, strong, tone }`) sets a fact right on the meta line with its number in bold and colored by tone: `up` fresh, `warm` cooling, `accent` cold. In the text twin every pill follows the title in brackets, the timeline is one character per day (`10d [#······@·o|]`: `#` a send, `o` an open, `@` both, `·` nothing, `|` today), and the aside ends the meta line; `recordTimelineText` and `recordBadges` are the helpers it uses.
166
166
 
167
167
  `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.
168
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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",
@@ -14,10 +14,12 @@ export type EyebrowProps = {
14
14
  text: string;
15
15
  /** Reads as a heading on a phone: larger, bold, in ink rather than faint. */
16
16
  strong?: boolean;
17
+ /** Something small set right on the same line: a legend, a count. */
18
+ aside?: ReactNode;
17
19
  };
18
20
 
19
21
  /** A section label between cards. */
20
- export function Eyebrow({ text, strong = false }: EyebrowProps) {
22
+ export function Eyebrow({ text, strong = false, aside }: EyebrowProps) {
21
23
  const { palette, fonts } = useEmailTheme();
22
24
  const look = strong
23
25
  ? {
@@ -28,9 +30,31 @@ export function Eyebrow({ text, strong = false }: EyebrowProps) {
28
30
  color: palette.ink,
29
31
  }
30
32
  : { ...eyebrowStyle(fonts), color: palette.faint };
33
+ if (aside === undefined || aside === null) {
34
+ return (
35
+ <tr>
36
+ <td style={{ padding: "36px 8px 12px", ...look }}>{text}</td>
37
+ </tr>
38
+ );
39
+ }
40
+ // With an aside (a legend, a count) the eyebrow keeps its cell and the
41
+ // aside sits right on the same line, bottom aligned to the text.
31
42
  return (
32
43
  <tr>
33
- <td style={{ padding: "36px 8px 12px", ...look }}>{text}</td>
44
+ <td style={{ padding: "36px 8px 12px" }}>
45
+ <table {...tableReset} width="100%">
46
+ <tbody>
47
+ <tr>
48
+ <td valign="bottom" style={look}>
49
+ {text}
50
+ </td>
51
+ <td valign="bottom" align="right" style={{ textAlign: "right" }}>
52
+ {aside}
53
+ </td>
54
+ </tr>
55
+ </tbody>
56
+ </table>
57
+ </td>
34
58
  </tr>
35
59
  );
36
60
  }
@@ -441,7 +441,6 @@ function RecordBadge({ text, tone = "faint" }: RecordBadgeProps) {
441
441
  border: tone === "faint" ? `1px solid ${palette.line}` : `1.5px solid ${color}`,
442
442
  borderRadius: "3px",
443
443
  color,
444
- whiteSpace: "nowrap",
445
444
  }}
446
445
  >
447
446
  {text}
@@ -467,61 +466,88 @@ export type RecordTimelineStripProps = { timeline: RecordTimeline };
467
466
  * A record's recent days on one line, today at the right edge: an ink square
468
467
  * on each day a send went out, an accent dot on each day an open is known,
469
468
  * the two stacked when they share a day, all standing on a hairline with a
470
- * tick for today. One table cell per day and nothing positioned, so it
471
- * survives every mail client (Company Cards IA, 2026-09-29).
469
+ * tick for today. One cell per marked day and one per run of empty days
470
+ * between them, so a record with three marks is seven cells rather than
471
+ * thirty: Gmail clips a message past about 100 KB and a stage list carries
472
+ * dozens of records. Nothing is positioned, so it survives every mail client
473
+ * (Company Cards IA, 2026-09-29).
472
474
  */
473
475
  export function RecordTimelineStrip({ timeline }: RecordTimelineStripProps) {
474
- const { palette } = useEmailTheme();
476
+ const { palette, fonts } = useEmailTheme();
475
477
  const days = Math.max(1, Math.round(timeline.days));
476
478
  const touch = new Set(timeline.touches.map((d) => Math.round(d)));
477
479
  const open = new Set(timeline.opens.map((d) => Math.round(d)));
478
- const cells = Array.from({ length: days }, (_, i) => days - 1 - i);
480
+ const cells: { ago: number; span: number; sent: boolean; opened: boolean }[] = [];
481
+ for (let ago = days - 1; ago >= 0; ago -= 1) {
482
+ const sent = touch.has(ago);
483
+ const opened = open.has(ago);
484
+ const last = cells[cells.length - 1];
485
+ if (!sent && !opened && last && !last.sent && !last.opened) last.span += 1;
486
+ else cells.push({ ago, span: 1, sent, opened });
487
+ }
488
+ const unit = 100 / days;
489
+ // The axis under the strip: how far back it reaches on the left, today on
490
+ // the right (Forni, 2026-09-29, asking whether it was a thirty day strip).
491
+ const axisStyle: CSSProperties = {
492
+ fontFamily: fonts.mono,
493
+ fontSize: "9px",
494
+ lineHeight: "12px",
495
+ color: palette.faint,
496
+ paddingTop: "2px",
497
+ };
479
498
  return (
480
- <table {...tableReset} width="100%" style={{ marginTop: "8px" }}>
499
+ <table
500
+ {...tableReset}
501
+ width="100%"
502
+ style={{
503
+ marginTop: "8px",
504
+ textAlign: "center",
505
+ }}
506
+ >
481
507
  <tbody>
482
508
  <tr>
483
- {cells.map((ago) => {
484
- const sent = touch.has(ago);
485
- const opened = open.has(ago);
486
- return (
487
- <td
488
- key={ago}
489
- width={`${100 / days}%`}
490
- style={{
491
- height: "16px",
492
- verticalAlign: "bottom",
493
- textAlign: "center",
494
- borderBottom: `1px solid ${palette.line}`,
495
- ...(ago === 0 ? { borderRight: `1.5px solid ${palette.ink}` } : {}),
496
- }}
497
- >
498
- {sent ? (
499
- <div
500
- style={{
501
- width: "8px",
502
- height: "8px",
503
- margin: "0 auto",
504
- background: palette.ink,
505
- }}
506
- />
507
- ) : null}
508
- {opened ? (
509
- <div
510
- style={{
511
- width: "5px",
512
- height: "5px",
513
- margin: `${sent ? "2px" : "0"} auto 0`,
514
- borderRadius: "50%",
515
- background: palette.accent,
516
- }}
517
- />
518
- ) : null}
519
- {sent || opened ? null : (
520
- <div style={{ height: "1px", fontSize: "1px", lineHeight: "1px" }}>&nbsp;</div>
521
- )}
522
- </td>
523
- );
524
- })}
509
+ {cells.map((cell, i) => (
510
+ <td
511
+ key={cell.ago}
512
+ valign="bottom"
513
+ colSpan={cell.span}
514
+ width={`${Math.round(unit * cell.span * 100) / 100}%`}
515
+ height="14"
516
+ style={{
517
+ borderBottom: `1px solid ${palette.line}`,
518
+ ...(i === cells.length - 1 ? { borderRight: `1.5px solid ${palette.ink}` } : {}),
519
+ }}
520
+ >
521
+ {cell.sent ? (
522
+ <div
523
+ style={{ width: "8px", height: "8px", margin: "0 auto", background: palette.ink }}
524
+ />
525
+ ) : null}
526
+ {cell.opened ? (
527
+ <div
528
+ style={{
529
+ width: "5px",
530
+ height: "5px",
531
+ margin: `${cell.sent ? "2px" : "0"} auto 0`,
532
+ borderRadius: "50%",
533
+ background: palette.accent,
534
+ }}
535
+ />
536
+ ) : null}
537
+ </td>
538
+ ))}
539
+ </tr>
540
+ <tr>
541
+ <td colSpan={Math.max(1, Math.ceil(days / 2))} align="left" style={axisStyle}>
542
+ {`${days}d`}
543
+ </td>
544
+ <td
545
+ colSpan={Math.max(1, Math.floor(days / 2))}
546
+ align="right"
547
+ style={{ ...axisStyle, textAlign: "right" }}
548
+ >
549
+ today
550
+ </td>
525
551
  </tr>
526
552
  </tbody>
527
553
  </table>
@@ -624,25 +650,26 @@ export function RecordStack({ records }: RecordStackProps) {
624
650
  }}
625
651
  >
626
652
  {badges.length > 0 ? (
627
- <table {...tableReset} width="100%">
653
+ <table {...tableReset} width="100%" style={titleStyle}>
628
654
  <tbody>
629
655
  <tr>
630
- <td style={{ ...titleStyle, verticalAlign: "top" }}>{title}</td>
656
+ <td valign="top">{title}</td>
631
657
  <td
632
658
  align="right"
659
+ valign="top"
633
660
  style={{
634
661
  textAlign: "right",
635
662
  whiteSpace: "nowrap",
636
- verticalAlign: "top",
637
663
  paddingLeft: "12px",
638
664
  paddingTop: "2px",
639
665
  }}
640
666
  >
641
667
  {badges.map((badge, j) => (
642
668
  // biome-ignore lint/suspicious/noArrayIndexKey: a pill's position is its identity
643
- <span key={j} style={{ marginLeft: j === 0 ? "0" : "4px" }}>
669
+ <Fragment key={j}>
670
+ {j === 0 ? null : " "}
644
671
  <RecordBadge text={badge.text} tone={badge.tone} />
645
- </span>
672
+ </Fragment>
646
673
  ))}
647
674
  </td>
648
675
  </tr>
@@ -654,19 +681,11 @@ export function RecordStack({ records }: RecordStackProps) {
654
681
  {record.timeline ? <RecordTimelineStrip timeline={record.timeline} /> : null}
655
682
  {meta.length > 0 || aside ? (
656
683
  aside ? (
657
- <table {...tableReset} width="100%" style={{ marginTop: "4px" }}>
684
+ <table {...tableReset} width="100%" style={{ ...metaStyle, marginTop: "4px" }}>
658
685
  <tbody>
659
686
  <tr>
660
- <td style={metaStyle}>{meta.join(" · ")}</td>
661
- <td
662
- align="right"
663
- style={{
664
- ...metaStyle,
665
- textAlign: "right",
666
- whiteSpace: "nowrap",
667
- paddingLeft: "12px",
668
- }}
669
- >
687
+ <td>{meta.join(" · ")}</td>
688
+ <td align="right" style={{ whiteSpace: "nowrap", paddingLeft: "12px" }}>
670
689
  {aside}
671
690
  </td>
672
691
  </tr>