@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.
package/README.md CHANGED
@@ -41,7 +41,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
41
41
  | Import | Exports |
42
42
  |---|---|
43
43
  | `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
44
- | `@atelic-action/ui/components` | `NotFound` and its prop types (see [The Not Found Page](#the-not-found-page)) |
44
+ | `@atelic-action/ui/components` | `NotFound` and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
45
45
  | `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
46
46
  | `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
47
47
  | `@atelic-action/ui/tokens` | `Palette`, `atelicPalette`, `Fonts`, `atelicFonts`, `toThemeCSS`, `themeTokenMap` |
@@ -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 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; 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
 
@@ -247,6 +247,23 @@ The plain text alternative part ports as plain functions with no React anywhere
247
247
 
248
248
  The palette carries three 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, and `warm` (`#B7791F`, an amber between `up` and the accent) for a record's aside that is cooling but not cold. All three 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. None maps to a site token.
249
249
 
250
+ ## Page Components
251
+
252
+ The blocks a page composes, each styled in `styles/components.css` inside the `atelic-ui` layer, so a site's own unlayered rule resizes or recolors any of them. They were cut from the SkySpec baseline writeup on 2026-10-02 (ATE-600), which is the worked example of all eight on one page.
253
+
254
+ | Component | What it is | Notes |
255
+ |---|---|---|
256
+ | `Checklist` | A short list of checks, each passed or failed | Two columns when there is room, one on a phone. `passLabel` and `failLabel` name the icons for a screen reader |
257
+ | `DataTable` | A table whose first column names each row, sortable by any column marked `sortable` | A click on a heading sorts on a wide screen. On a phone each row stacks, every fact wearing its column's label, and a dropdown sorts. A cell is a node, or `{ display, sort }` when what it shows is not what it sorts by; a formatted number in a string (`"$12,047"`, `"0.24%"`) sorts as a number. It renders in `defaultSort` on the server |
258
+ | `StatRow` | A row of large figures, each over what it counts and its `period` | `trend` draws an arrow before a figure that is itself a change. `deltas` list comparisons beneath: an arrow, how far, against what (`basis`), and the earlier figure (`was`), each in its own tone, since a spend falling and a cost falling read differently |
259
+ | `Callout` | One finding: an icon in its tone beside the title, the explainer under it | `variant="box"` sets it apart on a tinted ground, for something missing or owed |
260
+ | `Collapsible` | A labeled line the reader opens for the detail behind it | A native `details`, so it works before any script loads and find in page opens it. `summary` carries the point, so the detail needs no explaining once open. It prints open |
261
+ | `StackedBar` | One bar split into the parts of a whole, with a key | A part of zero is keyed and not drawn |
262
+ | `ColumnChart` | A bar a period, the periods in groups that carry a label and a total | Months in quarters is the case it was cut for: a quarter reads against the one before it and the same one a year earlier |
263
+ | `ThresholdScale` | Zones on a scale and a pin for each reading | For a number judged against lines, like a load time against good and poor |
264
+
265
+ **Tones.** `good`, `warn`, `bad`, `info`, and `muted` are the one palette the package carries, because good, a warning, and broken read green, amber, and red whatever the brand is. A component sets `data-tone`, and `--tone`, `--tone-fill`, and `--tone-bg` follow for whatever sits inside. A site's theme may restate `--tone-good` and the rest on `.mkt`; `info` and `muted` come from the theme's own primary and neutral tokens.
266
+
250
267
  ## Releasing
251
268
 
252
269
  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.7.1",
3
+ "version": "0.8.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",
@@ -0,0 +1,60 @@
1
+ import { CircleAlert, CircleCheck, CircleX, Info } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import type { Tone } from "./tone";
4
+
5
+ export interface CalloutProps {
6
+ /** How it reads; picks the icon and the color. Defaults to a warning. */
7
+ tone?: Tone;
8
+ /** The point, in a sentence. The icon sits beside it. */
9
+ title: ReactNode;
10
+ /** What the icon means, for a screen reader, e.g. "Needs work". */
11
+ toneLabel?: string;
12
+ /** `box` sets it apart on a tinted ground, for something missing or owed. */
13
+ variant?: "plain" | "box";
14
+ className?: string;
15
+ /** The one explainer under the title. */
16
+ children?: ReactNode;
17
+ }
18
+
19
+ const icons = {
20
+ good: CircleCheck,
21
+ warn: CircleAlert,
22
+ bad: CircleX,
23
+ info: Info,
24
+ muted: Info,
25
+ } as const;
26
+ const labels = {
27
+ good: "Working",
28
+ warn: "Needs work",
29
+ bad: "Broken",
30
+ info: "Note",
31
+ muted: "Note",
32
+ } as const;
33
+
34
+ /**
35
+ * One finding: an icon in its tone beside the title, and the explainer
36
+ * under it. Plain, it sits in a list of findings; boxed, it is a callout
37
+ * for something a reader must not miss.
38
+ */
39
+ export function Callout({
40
+ tone = "warn",
41
+ title,
42
+ toneLabel,
43
+ variant = "plain",
44
+ className,
45
+ children,
46
+ }: CalloutProps) {
47
+ const Icon = icons[tone];
48
+ return (
49
+ <div
50
+ className={["callout", variant === "box" && "is-box", className].filter(Boolean).join(" ")}
51
+ data-tone={tone}
52
+ >
53
+ <Icon className="callout-icon" size={18} role="img" aria-label={toneLabel ?? labels[tone]} />
54
+ <div className="callout-text">
55
+ <div className="callout-title">{title}</div>
56
+ {children && <div className="callout-body">{children}</div>}
57
+ </div>
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,43 @@
1
+ import { CircleCheck, CircleX } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+
4
+ export interface ChecklistItem {
5
+ label: ReactNode;
6
+ /** One line on what was checked or what failed. */
7
+ detail?: ReactNode;
8
+ pass: boolean;
9
+ }
10
+
11
+ export interface ChecklistProps {
12
+ items: ChecklistItem[];
13
+ /** What a screen reader calls a pass and a fail. */
14
+ passLabel?: string;
15
+ failLabel?: string;
16
+ className?: string;
17
+ }
18
+
19
+ /** A short list of checks, each passed or failed, in two columns when there is room. */
20
+ export function Checklist({
21
+ items,
22
+ passLabel = "Passes",
23
+ failLabel = "Fails",
24
+ className,
25
+ }: ChecklistProps) {
26
+ return (
27
+ <ul className={["checklist", className].filter(Boolean).join(" ")}>
28
+ {items.map((item, i) => {
29
+ const Icon = item.pass ? CircleCheck : CircleX;
30
+ return (
31
+ // biome-ignore lint/suspicious/noArrayIndexKey: checks are static content in a fixed order
32
+ <li key={i} data-tone={item.pass ? "good" : "bad"}>
33
+ <Icon size={18} role="img" aria-label={item.pass ? passLabel : failLabel} />
34
+ <span>
35
+ <span className="checklist-label">{item.label}</span>
36
+ {item.detail && <span className="checklist-detail">{item.detail}</span>}
37
+ </span>
38
+ </li>
39
+ );
40
+ })}
41
+ </ul>
42
+ );
43
+ }
@@ -0,0 +1,43 @@
1
+ import { ChevronDown } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+
4
+ export interface CollapsibleProps {
5
+ /** What is inside, e.g. "Your Main Pages". Always visible. */
6
+ label: ReactNode;
7
+ /**
8
+ * What the detail shows, in a sentence, so a reader who never opens it
9
+ * still has the point and the detail needs no explaining once open.
10
+ */
11
+ summary?: ReactNode;
12
+ /** Open on first render; closed by default. */
13
+ defaultOpen?: boolean;
14
+ className?: string;
15
+ children: ReactNode;
16
+ }
17
+
18
+ /**
19
+ * A labeled line the reader opens to see the detail behind it. It is a
20
+ * native details element, so it opens with a click, a tap, Enter, or Space,
21
+ * works before any script loads, and a browser's find in page opens it. It
22
+ * prints open.
23
+ */
24
+ export function Collapsible({
25
+ label,
26
+ summary,
27
+ defaultOpen,
28
+ className,
29
+ children,
30
+ }: CollapsibleProps) {
31
+ return (
32
+ <details className={["collapsible", className].filter(Boolean).join(" ")} open={defaultOpen}>
33
+ <summary>
34
+ <span className="collapsible-text">
35
+ <span className="collapsible-label">{label}</span>
36
+ {summary && <span className="collapsible-summary">{summary}</span>}
37
+ </span>
38
+ <ChevronDown className="collapsible-chevron" size={18} aria-hidden="true" />
39
+ </summary>
40
+ <div className="collapsible-body">{children}</div>
41
+ </details>
42
+ );
43
+ }
@@ -0,0 +1,97 @@
1
+ import type { Tone } from "./tone";
2
+
3
+ export interface ColumnChartBar {
4
+ /** Under the bar, e.g. "Jul". */
5
+ label: string;
6
+ value: number;
7
+ /** Over the bar, e.g. "1.7k"; defaults to the value with separators. */
8
+ display?: string;
9
+ tone?: Tone;
10
+ }
11
+
12
+ export interface ColumnChartGroup {
13
+ /** Under the group, e.g. "2026 Q3". */
14
+ label?: string;
15
+ /** The group's own figure beside its label, e.g. "3,948". */
16
+ total?: string;
17
+ /** Colors every bar in the group that sets no tone of its own. */
18
+ tone?: Tone;
19
+ bars: ColumnChartBar[];
20
+ }
21
+
22
+ export interface ColumnChartProps {
23
+ /** Bars in groups, e.g. months in quarters. One group draws a plain chart. */
24
+ groups: ColumnChartGroup[];
25
+ /** What each tone means, e.g. "The same quarter, a year apart". */
26
+ legend?: Array<{ label: string; tone: Tone }>;
27
+ /** The tallest a bar draws, in pixels. */
28
+ height?: number;
29
+ className?: string;
30
+ }
31
+
32
+ /**
33
+ * A column chart: one bar a period, the periods in groups that each carry
34
+ * their own label and total, so a reader compares a quarter with the one
35
+ * before it and with the same quarter a year earlier in one picture.
36
+ */
37
+ export function ColumnChart({ groups, legend, height = 120, className }: ColumnChartProps) {
38
+ const all = groups.flatMap((group) => group.bars);
39
+ const max = Math.max(...all.map((bar) => bar.value), 1);
40
+ const figure = (bar: ColumnChartBar) => bar.display ?? bar.value.toLocaleString("en-US");
41
+ const summary = groups
42
+ .map(
43
+ (group) =>
44
+ `${group.label ? `${group.label}${group.total ? `, ${group.total}` : ""}: ` : ""}${group.bars
45
+ .map((bar) => `${bar.label} ${bar.value.toLocaleString("en-US")}`)
46
+ .join(", ")}`,
47
+ )
48
+ .join("; ");
49
+ return (
50
+ <div className={["column-chart", className].filter(Boolean).join(" ")}>
51
+ <div className="column-chart-plot" role="img" aria-label={summary}>
52
+ {groups.map((group, g) => (
53
+ <div
54
+ className="column-chart-group"
55
+ // biome-ignore lint/suspicious/noArrayIndexKey: groups are static content in time order
56
+ key={g}
57
+ style={{ flexGrow: group.bars.length }}
58
+ >
59
+ <div className="column-chart-bars">
60
+ {group.bars.map((bar, b) => (
61
+ <div
62
+ className="column-chart-col"
63
+ // biome-ignore lint/suspicious/noArrayIndexKey: bars are static content in time order
64
+ key={b}
65
+ data-tone={bar.tone ?? group.tone ?? "muted"}
66
+ >
67
+ <span className="column-chart-value">{figure(bar)}</span>
68
+ <span
69
+ className="column-chart-bar"
70
+ style={{ height: `${Math.round((bar.value / max) * height)}px` }}
71
+ />
72
+ <span className="column-chart-label">{bar.label}</span>
73
+ </div>
74
+ ))}
75
+ </div>
76
+ {(group.label || group.total) && (
77
+ <div className="column-chart-group-label">
78
+ {group.label && <span>{group.label}</span>}
79
+ {group.total && <b>{group.total}</b>}
80
+ </div>
81
+ )}
82
+ </div>
83
+ ))}
84
+ </div>
85
+ {legend && (
86
+ <ul className="chart-key">
87
+ {legend.map((entry) => (
88
+ <li key={entry.label} data-tone={entry.tone}>
89
+ <span className="chart-swatch" aria-hidden="true" />
90
+ <span>{entry.label}</span>
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,194 @@
1
+ import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
2
+ import { type ReactNode, useId, useMemo, useState } from "react";
3
+
4
+ export type SortDirection = "asc" | "desc";
5
+
6
+ export interface DataTableColumn {
7
+ label: string;
8
+ align?: "left" | "center" | "right";
9
+ /**
10
+ * Lets a reader sort by this column. Needs every cell in it to carry a
11
+ * sort value, or to be a plain string or number.
12
+ */
13
+ sortable?: boolean;
14
+ /** The direction a first click sorts in; numbers usually read best largest first. */
15
+ firstSort?: SortDirection;
16
+ }
17
+
18
+ /** A cell whose display is not what it sorts by, e.g. an icon over a number. */
19
+ export interface DataTableValueCell {
20
+ display: ReactNode;
21
+ sort: number | string;
22
+ }
23
+
24
+ export type DataTableCell = ReactNode | DataTableValueCell;
25
+
26
+ export interface DataTableRow {
27
+ /** One cell per column; the first names the row. */
28
+ cells: DataTableCell[];
29
+ /** How the row reads, e.g. "bad"; set as data-tone for the site to color. */
30
+ tone?: string;
31
+ }
32
+
33
+ export interface DataTableProps {
34
+ columns: DataTableColumn[];
35
+ rows: DataTableRow[];
36
+ /** The sort the table opens in. */
37
+ defaultSort?: { column: number; direction: SortDirection };
38
+ /** The label on the phone's sort control. */
39
+ sortLabel?: string;
40
+ className?: string;
41
+ }
42
+
43
+ function isValueCell(cell: DataTableCell): cell is DataTableValueCell {
44
+ return typeof cell === "object" && cell !== null && "sort" in cell && "display" in cell;
45
+ }
46
+
47
+ function sortValue(cell: DataTableCell): number | string {
48
+ if (isValueCell(cell)) return cell.sort;
49
+ if (typeof cell === "number") return cell;
50
+ if (typeof cell === "string") {
51
+ const number = Number(cell.replace(/[$,%\s]/g, ""));
52
+ return cell.trim() !== "" && Number.isFinite(number) ? number : cell.toLowerCase();
53
+ }
54
+ return "";
55
+ }
56
+
57
+ function compare(a: number | string, b: number | string): number {
58
+ if (typeof a === "number" && typeof b === "number") return a - b;
59
+ return String(a).localeCompare(String(b), "en", { numeric: true });
60
+ }
61
+
62
+ /**
63
+ * A table whose first column names each row. A reader sorts it by any
64
+ * sortable column: a click on the heading on a wide screen, a dropdown on a
65
+ * phone, where each row stacks and every fact wears its column's label so
66
+ * nothing scrolls sideways. It renders sorted by `defaultSort` on the server
67
+ * and sorts in place once the page is live.
68
+ */
69
+ export function DataTable({
70
+ columns,
71
+ rows,
72
+ defaultSort,
73
+ sortLabel = "Sort by",
74
+ className,
75
+ }: DataTableProps) {
76
+ const [sort, setSort] = useState(defaultSort ?? null);
77
+ const selectId = useId();
78
+ const anySortable = columns.some((column) => column.sortable);
79
+
80
+ const sorted = useMemo(() => {
81
+ // Each row keeps its place in the source, which is its key: a sort moves
82
+ // a row's elements rather than rewriting every row in place.
83
+ const indexed = rows.map((row, index) => ({ row, index }));
84
+ if (!sort) return indexed;
85
+ const direction = sort.direction === "asc" ? 1 : -1;
86
+ return indexed.sort((a, b) => {
87
+ const order = compare(
88
+ sortValue(a.row.cells[sort.column]),
89
+ sortValue(b.row.cells[sort.column]),
90
+ );
91
+ return order === 0 ? a.index - b.index : order * direction;
92
+ });
93
+ }, [rows, sort]);
94
+
95
+ const sortBy = (column: number) => {
96
+ setSort((current) => {
97
+ if (current && current.column === column) {
98
+ return { column, direction: current.direction === "asc" ? "desc" : "asc" };
99
+ }
100
+ return { column, direction: columns[column]?.firstSort ?? "asc" };
101
+ });
102
+ };
103
+
104
+ return (
105
+ <div className={["data-table", className].filter(Boolean).join(" ")}>
106
+ {anySortable && (
107
+ <div className="data-table-sort">
108
+ <label htmlFor={selectId}>{sortLabel}</label>
109
+ <select
110
+ id={selectId}
111
+ value={sort ? `${sort.column}:${sort.direction}` : ""}
112
+ onChange={(event) => {
113
+ const [column, direction] = event.target.value.split(":");
114
+ if (column === undefined || column === "") return;
115
+ setSort({ column: Number(column), direction: direction as SortDirection });
116
+ }}
117
+ >
118
+ {!sort && <option value="">As listed</option>}
119
+ {columns.flatMap((column, index) =>
120
+ column.sortable
121
+ ? (["desc", "asc"] as const).map((direction) => (
122
+ // biome-ignore lint/suspicious/noArrayIndexKey: columns are fixed, and the index is the option's value
123
+ <option key={`${index}:${direction}`} value={`${index}:${direction}`}>
124
+ {`${column.label}, ${direction === "desc" ? "high to low" : "low to high"}`}
125
+ </option>
126
+ ))
127
+ : [],
128
+ )}
129
+ </select>
130
+ </div>
131
+ )}
132
+ <table>
133
+ <thead>
134
+ <tr>
135
+ {columns.map((column, index) => {
136
+ const active = sort?.column === index;
137
+ const Icon = !active ? ArrowUpDown : sort?.direction === "asc" ? ArrowUp : ArrowDown;
138
+ return (
139
+ <th
140
+ scope="col"
141
+ key={column.label}
142
+ data-align={column.align ?? "left"}
143
+ aria-sort={
144
+ column.sortable
145
+ ? active
146
+ ? sort?.direction === "asc"
147
+ ? "ascending"
148
+ : "descending"
149
+ : "none"
150
+ : undefined
151
+ }
152
+ >
153
+ {column.sortable ? (
154
+ <button
155
+ type="button"
156
+ onClick={() => sortBy(index)}
157
+ data-active={active || undefined}
158
+ >
159
+ {column.label}
160
+ <Icon size={12} aria-hidden="true" />
161
+ </button>
162
+ ) : (
163
+ column.label
164
+ )}
165
+ </th>
166
+ );
167
+ })}
168
+ </tr>
169
+ </thead>
170
+ <tbody>
171
+ {sorted.map(({ row, index }) => (
172
+ <tr key={index} data-tone={row.tone}>
173
+ {row.cells.map((cell, c) => {
174
+ const align = columns[c]?.align ?? "left";
175
+ const body = isValueCell(cell) ? cell.display : cell;
176
+ return c === 0 ? (
177
+ // biome-ignore lint/suspicious/noArrayIndexKey: cells sit in column order
178
+ <th scope="row" key={c} data-align={align}>
179
+ {body}
180
+ </th>
181
+ ) : (
182
+ // biome-ignore lint/suspicious/noArrayIndexKey: cells sit in column order
183
+ <td key={c} data-align={align} data-label={columns[c]?.label}>
184
+ {body}
185
+ </td>
186
+ );
187
+ })}
188
+ </tr>
189
+ ))}
190
+ </tbody>
191
+ </table>
192
+ </div>
193
+ );
194
+ }
@@ -0,0 +1,46 @@
1
+ import type { Tone } from "./tone";
2
+
3
+ export interface StackedBarSegment {
4
+ label: string;
5
+ value: number;
6
+ /** The figure as printed, e.g. "1,158"; defaults to the value with separators. */
7
+ display?: string;
8
+ tone: Tone;
9
+ }
10
+
11
+ export interface StackedBarProps {
12
+ segments: StackedBarSegment[];
13
+ className?: string;
14
+ }
15
+
16
+ /** One bar split into the parts of a whole, with a key naming each part and its figure. */
17
+ export function StackedBar({ segments, className }: StackedBarProps) {
18
+ const total = segments.reduce((sum, segment) => sum + segment.value, 0) || 1;
19
+ const figure = (segment: StackedBarSegment) =>
20
+ segment.display ?? segment.value.toLocaleString("en-US");
21
+ const summary = segments.map((segment) => `${segment.label}: ${figure(segment)}`).join(", ");
22
+ return (
23
+ <div className={["stacked-bar", className].filter(Boolean).join(" ")}>
24
+ <div className="stacked-bar-track" role="img" aria-label={summary}>
25
+ {segments
26
+ .filter((segment) => segment.value > 0)
27
+ .map((segment) => (
28
+ <span
29
+ key={segment.label}
30
+ data-tone={segment.tone}
31
+ style={{ flexGrow: segment.value / total }}
32
+ />
33
+ ))}
34
+ </div>
35
+ <ul className="chart-key">
36
+ {segments.map((segment) => (
37
+ <li key={segment.label} data-tone={segment.tone}>
38
+ <span className="chart-swatch" aria-hidden="true" />
39
+ <b>{figure(segment)}</b>
40
+ <span>{segment.label}</span>
41
+ </li>
42
+ ))}
43
+ </ul>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,96 @@
1
+ import { ArrowDown, ArrowRight, ArrowUp } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import type { Tone } from "./tone";
4
+
5
+ export type StatDirection = "up" | "down" | "flat";
6
+
7
+ /** One comparison under a figure: which way it moved, by how much, against what. */
8
+ export interface StatDelta {
9
+ direction: StatDirection;
10
+ /** How far it moved, e.g. "23%". */
11
+ change: string;
12
+ /** What it is measured against, short, e.g. "YoY". */
13
+ basis: string;
14
+ /** The basis spelled out, e.g. "year over year"; read aloud and shown on hover. */
15
+ basisTitle?: string;
16
+ /** The earlier figure, e.g. "5,157". It leads the line, small, under the current one. */
17
+ was?: string;
18
+ /** Whether the move is good or bad news; a spend falling and a cost falling read differently. */
19
+ tone?: Tone;
20
+ }
21
+
22
+ export interface StatItem {
23
+ /** The big figure, e.g. "$199", "3,948", "23%"; may embed an inline glyph. */
24
+ value: ReactNode;
25
+ /** An arrow before the figure, when the figure is itself a change. */
26
+ trend?: StatDirection;
27
+ /** What the figure counts. */
28
+ label: ReactNode;
29
+ /** The period the figure covers, e.g. "2026 Q3". */
30
+ period?: ReactNode;
31
+ /** Comparisons under the figure, each an arrow, a change, and what it is against. */
32
+ deltas?: StatDelta[];
33
+ /** A small dimmed qualifier at the foot (the source, a caveat). */
34
+ note?: ReactNode;
35
+ /** How the figure itself reads; set as data-tone. */
36
+ tone?: Tone;
37
+ }
38
+
39
+ export interface StatRowProps {
40
+ stats: StatItem[];
41
+ className?: string;
42
+ }
43
+
44
+ const arrow = { up: ArrowUp, down: ArrowDown, flat: ArrowRight } as const;
45
+ const spoken = { up: "Up", down: "Down", flat: "Unchanged" } as const;
46
+
47
+ /**
48
+ * A row of large figures. Under each sit its comparisons, one a line: what
49
+ * the figure was, an arrow, how far it moved, and against what. The label
50
+ * closes the stat, with the period it covers. The row wraps to as many
51
+ * columns as fit, and arrows are read aloud as up or down.
52
+ */
53
+ export function StatRow({ stats, className }: StatRowProps) {
54
+ return (
55
+ <div className={["stat-row", className].filter(Boolean).join(" ")}>
56
+ {stats.map((stat, i) => {
57
+ const Trend = stat.trend ? arrow[stat.trend] : null;
58
+ return (
59
+ // biome-ignore lint/suspicious/noArrayIndexKey: stats are static content in a fixed order
60
+ <div className="stat" key={i} data-tone={stat.tone}>
61
+ <div className="num">
62
+ {Trend && stat.trend && (
63
+ <Trend className="stat-trend" role="img" aria-label={spoken[stat.trend]} />
64
+ )}
65
+ {stat.value}
66
+ </div>
67
+ {stat.deltas?.map((delta) => {
68
+ const Icon = arrow[delta.direction];
69
+ return (
70
+ <div className="stat-delta" key={delta.basis}>
71
+ {delta.was && <span className="stat-delta-was">{delta.was}</span>}
72
+ <span className="stat-delta-change" data-tone={delta.tone ?? "muted"}>
73
+ <Icon size={14} role="img" aria-label={spoken[delta.direction]} />
74
+ {delta.change}
75
+ </span>
76
+ {delta.basisTitle ? (
77
+ <abbr className="stat-delta-basis" title={delta.basisTitle}>
78
+ {delta.basis}
79
+ </abbr>
80
+ ) : (
81
+ <span className="stat-delta-basis">{delta.basis}</span>
82
+ )}
83
+ </div>
84
+ );
85
+ })}
86
+ <div className="lbl">
87
+ {stat.label}
88
+ {stat.period && <span className="stat-period">{stat.period}</span>}
89
+ </div>
90
+ {stat.note && <div className="note">{stat.note}</div>}
91
+ </div>
92
+ );
93
+ })}
94
+ </div>
95
+ );
96
+ }