@atelic-action/ui 0.7.1 → 0.8.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.
@@ -0,0 +1,89 @@
1
+ import type { Tone } from "./tone";
2
+
3
+ export interface ThresholdZone {
4
+ label: string;
5
+ /** Where the zone ends, in the reading's unit; the last zone runs to `max`. */
6
+ upTo?: number;
7
+ tone: Tone;
8
+ }
9
+
10
+ export interface ThresholdReading {
11
+ label: string;
12
+ value: number;
13
+ /** The figure as printed, e.g. "5.9 s". */
14
+ display: string;
15
+ tone: Tone;
16
+ }
17
+
18
+ export interface ThresholdScaleProps {
19
+ /** The far end of the scale, in the reading's unit. */
20
+ max: number;
21
+ /** Zones in order, e.g. good, slow, poor. */
22
+ zones: ThresholdZone[];
23
+ /** The lines between zones, labeled, e.g. "2.5 s" at 2.5. */
24
+ ticks?: Array<{ at: number; label: string }>;
25
+ /** One line a reading, each a pin on the scale. */
26
+ readings: ThresholdReading[];
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * A scale with zones and one line a reading: where a number sits against
32
+ * the lines that decide good, slow, and poor.
33
+ */
34
+ export function ThresholdScale({ max, zones, ticks, readings, className }: ThresholdScaleProps) {
35
+ let from = 0;
36
+ const sized = zones.map((zone) => {
37
+ const to = zone.upTo ?? max;
38
+ const width = ((to - from) / max) * 100;
39
+ from = to;
40
+ return { ...zone, width };
41
+ });
42
+ return (
43
+ <div className={["threshold-scale", className].filter(Boolean).join(" ")}>
44
+ {readings.map((reading) => (
45
+ <div className="threshold-scale-row" key={reading.label}>
46
+ <span className="threshold-scale-label">{reading.label}</span>
47
+ <span
48
+ className="threshold-scale-track"
49
+ role="img"
50
+ aria-label={`${reading.label}: ${reading.display}`}
51
+ >
52
+ {sized.map((zone) => (
53
+ <span
54
+ className="threshold-scale-zone"
55
+ key={zone.label}
56
+ data-tone={zone.tone}
57
+ style={{ width: `${zone.width}%` }}
58
+ />
59
+ ))}
60
+ <span
61
+ className="threshold-scale-pin"
62
+ data-tone={reading.tone}
63
+ style={{ left: `${Math.min(reading.value / max, 1) * 100}%` }}
64
+ />
65
+ </span>
66
+ <b className="threshold-scale-value" data-tone={reading.tone}>
67
+ {reading.display}
68
+ </b>
69
+ </div>
70
+ ))}
71
+ <div className="threshold-scale-row is-axis" aria-hidden="true">
72
+ <span className="threshold-scale-label" />
73
+ <span className="threshold-scale-axis">
74
+ {sized.map((zone) => (
75
+ <span key={zone.label} data-tone={zone.tone} style={{ width: `${zone.width}%` }}>
76
+ {zone.label}
77
+ </span>
78
+ ))}
79
+ {ticks?.map((tick) => (
80
+ <i key={tick.label} style={{ left: `${(tick.at / max) * 100}%` }}>
81
+ {tick.label}
82
+ </i>
83
+ ))}
84
+ </span>
85
+ <span className="threshold-scale-value" />
86
+ </div>
87
+ </div>
88
+ );
89
+ }
@@ -1 +1,34 @@
1
+ export { Callout, type CalloutProps } from "./Callout";
2
+ export { Checklist, type ChecklistItem, type ChecklistProps } from "./Checklist";
3
+ export { Collapsible, type CollapsibleProps } from "./Collapsible";
4
+ export {
5
+ ColumnChart,
6
+ type ColumnChartBar,
7
+ type ColumnChartGroup,
8
+ type ColumnChartProps,
9
+ } from "./ColumnChart";
10
+ export {
11
+ DataTable,
12
+ type DataTableCell,
13
+ type DataTableColumn,
14
+ type DataTableProps,
15
+ type DataTableRow,
16
+ type DataTableValueCell,
17
+ type SortDirection,
18
+ } from "./DataTable";
1
19
  export { NotFound, type NotFoundProps } from "./NotFound";
20
+ export { StackedBar, type StackedBarProps, type StackedBarSegment } from "./StackedBar";
21
+ export {
22
+ type StatDelta,
23
+ type StatDirection,
24
+ type StatItem,
25
+ StatRow,
26
+ type StatRowProps,
27
+ } from "./StatRow";
28
+ export {
29
+ type ThresholdReading,
30
+ ThresholdScale,
31
+ type ThresholdScaleProps,
32
+ type ThresholdZone,
33
+ } from "./ThresholdScale";
34
+ export type { Tone } from "./tone";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * How a figure, a mark, or a bar reads: good, a warning, broken, plain
3
+ * information, or quiet. A component sets it as `data-tone`, and
4
+ * `styles/components.css` turns it into `--tone`, `--tone-fill`, and
5
+ * `--tone-bg` for whatever sits inside.
6
+ */
7
+ export type Tone = "good" | "warn" | "bad" | "info" | "muted";
@@ -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
  }
@@ -473,7 +473,7 @@ export type RecordTimelineStripProps = { timeline: RecordTimeline };
473
473
  * (Company Cards IA, 2026-09-29).
474
474
  */
475
475
  export function RecordTimelineStrip({ timeline }: RecordTimelineStripProps) {
476
- const { palette } = useEmailTheme();
476
+ const { palette, fonts } = useEmailTheme();
477
477
  const days = Math.max(1, Math.round(timeline.days));
478
478
  const touch = new Set(timeline.touches.map((d) => Math.round(d)));
479
479
  const open = new Set(timeline.opens.map((d) => Math.round(d)));
@@ -486,26 +486,37 @@ export function RecordTimelineStrip({ timeline }: RecordTimelineStripProps) {
486
486
  else cells.push({ ago, span: 1, sent, opened });
487
487
  }
488
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
+ };
489
498
  return (
490
499
  <table
491
500
  {...tableReset}
492
501
  width="100%"
493
502
  style={{
494
503
  marginTop: "8px",
495
- height: "14px",
496
504
  textAlign: "center",
497
- borderBottom: `1px solid ${palette.line}`,
498
- borderRight: `1.5px solid ${palette.ink}`,
499
505
  }}
500
506
  >
501
507
  <tbody>
502
508
  <tr>
503
- {cells.map((cell) => (
509
+ {cells.map((cell, i) => (
504
510
  <td
505
511
  key={cell.ago}
506
512
  valign="bottom"
507
513
  colSpan={cell.span}
508
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
+ }}
509
520
  >
510
521
  {cell.sent ? (
511
522
  <div
@@ -526,6 +537,18 @@ export function RecordTimelineStrip({ timeline }: RecordTimelineStripProps) {
526
537
  </td>
527
538
  ))}
528
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>
551
+ </tr>
529
552
  </tbody>
530
553
  </table>
531
554
  );