@atelic-action/ui 0.7.2 → 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 +18 -1
- package/package.json +1 -1
- package/src/components/Callout.tsx +60 -0
- package/src/components/Checklist.tsx +43 -0
- package/src/components/Collapsible.tsx +43 -0
- package/src/components/ColumnChart.tsx +97 -0
- package/src/components/DataTable.tsx +194 -0
- package/src/components/StackedBar.tsx +46 -0
- package/src/components/StatRow.tsx +96 -0
- package/src/components/ThresholdScale.tsx +89 -0
- package/src/components/index.ts +33 -0
- package/src/components/tone.ts +7 -0
- package/src/styles/components.css +585 -0
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
|
|
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` |
|
|
@@ -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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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";
|
|
@@ -17,4 +17,589 @@
|
|
|
17
17
|
.mkt .not-found-closing .cta-row {
|
|
18
18
|
margin-top: 22px;
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/* ---- Tones: the one palette this package carries, because good, a
|
|
22
|
+
warning, and broken read green, amber, and red whatever the brand is.
|
|
23
|
+
A site's theme may restate any of these on .mkt. A component sets
|
|
24
|
+
data-tone, and --tone, --tone-fill, and --tone-bg follow. ---- */
|
|
25
|
+
.mkt {
|
|
26
|
+
--tone-good: #1b7f3b;
|
|
27
|
+
--tone-good-fill: #37a35c;
|
|
28
|
+
--tone-good-bg: #eaf6ee;
|
|
29
|
+
--tone-warn: #a84500;
|
|
30
|
+
--tone-warn-fill: #e8a317;
|
|
31
|
+
--tone-warn-bg: #fff3df;
|
|
32
|
+
--tone-bad: #b3261e;
|
|
33
|
+
--tone-bad-fill: #d9483f;
|
|
34
|
+
--tone-bad-bg: #fdecea;
|
|
35
|
+
}
|
|
36
|
+
.mkt [data-tone="good"] {
|
|
37
|
+
--tone: var(--tone-good);
|
|
38
|
+
--tone-fill: var(--tone-good-fill);
|
|
39
|
+
--tone-bg: var(--tone-good-bg);
|
|
40
|
+
}
|
|
41
|
+
.mkt [data-tone="warn"] {
|
|
42
|
+
--tone: var(--tone-warn);
|
|
43
|
+
--tone-fill: var(--tone-warn-fill);
|
|
44
|
+
--tone-bg: var(--tone-warn-bg);
|
|
45
|
+
}
|
|
46
|
+
.mkt [data-tone="bad"] {
|
|
47
|
+
--tone: var(--tone-bad);
|
|
48
|
+
--tone-fill: var(--tone-bad-fill);
|
|
49
|
+
--tone-bg: var(--tone-bad-bg);
|
|
50
|
+
}
|
|
51
|
+
.mkt [data-tone="info"] {
|
|
52
|
+
--tone: var(--primary);
|
|
53
|
+
--tone-fill: var(--primary-bright);
|
|
54
|
+
--tone-bg: var(--primary-tint);
|
|
55
|
+
}
|
|
56
|
+
.mkt [data-tone="muted"] {
|
|
57
|
+
--tone: var(--neutral-500);
|
|
58
|
+
--tone-fill: var(--neutral-200);
|
|
59
|
+
--tone-bg: var(--surface-alt);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ---- StatRow: large figures, each over what it counts, its period,
|
|
63
|
+
and its comparisons ---- */
|
|
64
|
+
.mkt .stat-row {
|
|
65
|
+
display: grid;
|
|
66
|
+
gap: 24px;
|
|
67
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
68
|
+
}
|
|
69
|
+
@media (min-width: 760px) {
|
|
70
|
+
.mkt .stat-row {
|
|
71
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
.mkt .stat-row.is-center .stat {
|
|
75
|
+
text-align: center;
|
|
76
|
+
}
|
|
77
|
+
.mkt .stat .num {
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: 0.12em;
|
|
81
|
+
font-size: clamp(2.2rem, 5vw, 3rem);
|
|
82
|
+
font-weight: 800;
|
|
83
|
+
letter-spacing: -0.03em;
|
|
84
|
+
color: var(--tone, var(--primary));
|
|
85
|
+
line-height: 1;
|
|
86
|
+
font-variant-numeric: tabular-nums;
|
|
87
|
+
}
|
|
88
|
+
.mkt .stat-row.is-center .stat .num {
|
|
89
|
+
justify-content: center;
|
|
90
|
+
}
|
|
91
|
+
.mkt .stat-trend {
|
|
92
|
+
width: 0.8em;
|
|
93
|
+
height: 0.8em;
|
|
94
|
+
flex: none;
|
|
95
|
+
}
|
|
96
|
+
.mkt .stat .lbl {
|
|
97
|
+
font-size: 0.95rem;
|
|
98
|
+
font-weight: 650;
|
|
99
|
+
color: var(--ink);
|
|
100
|
+
margin-top: 8px;
|
|
101
|
+
}
|
|
102
|
+
.mkt .stat-period {
|
|
103
|
+
margin-left: 0.5em;
|
|
104
|
+
font-weight: 400;
|
|
105
|
+
color: var(--neutral-500);
|
|
106
|
+
}
|
|
107
|
+
.mkt .stat-delta {
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-wrap: wrap;
|
|
110
|
+
align-items: baseline;
|
|
111
|
+
gap: 2px 6px;
|
|
112
|
+
margin-top: 4px;
|
|
113
|
+
font-size: 0.82rem;
|
|
114
|
+
line-height: 1.35;
|
|
115
|
+
color: var(--neutral-500);
|
|
116
|
+
font-variant-numeric: tabular-nums;
|
|
117
|
+
}
|
|
118
|
+
.mkt .stat-delta-change {
|
|
119
|
+
display: inline-flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
gap: 2px;
|
|
122
|
+
font-weight: 700;
|
|
123
|
+
color: var(--tone);
|
|
124
|
+
}
|
|
125
|
+
.mkt .stat-delta-change svg {
|
|
126
|
+
align-self: center;
|
|
127
|
+
}
|
|
128
|
+
.mkt .stat-delta-basis {
|
|
129
|
+
text-decoration: none;
|
|
130
|
+
}
|
|
131
|
+
.mkt .stat .note {
|
|
132
|
+
font-size: 0.8rem;
|
|
133
|
+
color: var(--neutral-500);
|
|
134
|
+
margin-top: 4px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ---- Collapsible: a labeled line that opens to the detail behind it ---- */
|
|
138
|
+
.mkt .collapsible > summary {
|
|
139
|
+
display: flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
justify-content: space-between;
|
|
142
|
+
gap: 14px;
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
list-style: none;
|
|
145
|
+
}
|
|
146
|
+
.mkt .collapsible > summary::-webkit-details-marker {
|
|
147
|
+
display: none;
|
|
148
|
+
}
|
|
149
|
+
.mkt .collapsible-text {
|
|
150
|
+
display: flex;
|
|
151
|
+
flex-direction: column;
|
|
152
|
+
gap: 2px;
|
|
153
|
+
min-width: 0;
|
|
154
|
+
}
|
|
155
|
+
.mkt .collapsible-label {
|
|
156
|
+
font-weight: 650;
|
|
157
|
+
color: var(--ink);
|
|
158
|
+
}
|
|
159
|
+
.mkt .collapsible-summary {
|
|
160
|
+
color: var(--ink-soft);
|
|
161
|
+
text-wrap: pretty;
|
|
162
|
+
}
|
|
163
|
+
.mkt .collapsible > summary:hover .collapsible-label {
|
|
164
|
+
color: var(--primary);
|
|
165
|
+
}
|
|
166
|
+
.mkt .collapsible-chevron {
|
|
167
|
+
flex: none;
|
|
168
|
+
color: var(--neutral-500);
|
|
169
|
+
transition: transform 0.15s;
|
|
170
|
+
}
|
|
171
|
+
.mkt .collapsible[open] > summary .collapsible-chevron {
|
|
172
|
+
transform: rotate(180deg);
|
|
173
|
+
}
|
|
174
|
+
.mkt .collapsible-body {
|
|
175
|
+
margin-top: 12px;
|
|
176
|
+
}
|
|
177
|
+
@media (prefers-reduced-motion: reduce) {
|
|
178
|
+
.mkt .collapsible-chevron {
|
|
179
|
+
transition: none;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/* Paper cannot be clicked, so a printed collapsible is open. */
|
|
183
|
+
@media print {
|
|
184
|
+
.mkt .collapsible::details-content {
|
|
185
|
+
content-visibility: visible;
|
|
186
|
+
display: block;
|
|
187
|
+
}
|
|
188
|
+
.mkt .collapsible-chevron {
|
|
189
|
+
display: none;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ---- Callout: an icon in its tone beside the title, the explainer
|
|
194
|
+
under it; boxed, it sits apart on a tinted ground ---- */
|
|
195
|
+
.mkt .callout {
|
|
196
|
+
display: grid;
|
|
197
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
198
|
+
gap: 10px;
|
|
199
|
+
align-items: start;
|
|
200
|
+
}
|
|
201
|
+
.mkt .callout-icon {
|
|
202
|
+
margin-top: 0.15em;
|
|
203
|
+
color: var(--tone);
|
|
204
|
+
flex: none;
|
|
205
|
+
}
|
|
206
|
+
.mkt .callout-title {
|
|
207
|
+
font-weight: 650;
|
|
208
|
+
color: var(--ink);
|
|
209
|
+
text-wrap: pretty;
|
|
210
|
+
}
|
|
211
|
+
.mkt .callout-body {
|
|
212
|
+
margin-top: 2px;
|
|
213
|
+
color: var(--ink-soft);
|
|
214
|
+
text-wrap: pretty;
|
|
215
|
+
}
|
|
216
|
+
.mkt .callout.is-box {
|
|
217
|
+
padding: 14px 16px;
|
|
218
|
+
border-radius: var(--radius-sm);
|
|
219
|
+
background: var(--tone-bg);
|
|
220
|
+
border: 1px solid color-mix(in srgb, var(--tone-fill) 45%, transparent);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* ---- Checklist: checks passed or failed, two columns when there is room ---- */
|
|
224
|
+
.mkt .checklist {
|
|
225
|
+
list-style: none;
|
|
226
|
+
margin: 0;
|
|
227
|
+
padding: 0;
|
|
228
|
+
display: grid;
|
|
229
|
+
grid-template-columns: minmax(0, 1fr);
|
|
230
|
+
gap: 12px 28px;
|
|
231
|
+
}
|
|
232
|
+
@media print, (min-width: 721px) {
|
|
233
|
+
.mkt .checklist {
|
|
234
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
.mkt .checklist li {
|
|
238
|
+
display: grid;
|
|
239
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
240
|
+
gap: 10px;
|
|
241
|
+
align-items: start;
|
|
242
|
+
line-height: 1.4;
|
|
243
|
+
}
|
|
244
|
+
.mkt .checklist li > svg {
|
|
245
|
+
margin-top: 0.1em;
|
|
246
|
+
color: var(--tone);
|
|
247
|
+
}
|
|
248
|
+
.mkt .checklist-label {
|
|
249
|
+
display: block;
|
|
250
|
+
font-weight: 600;
|
|
251
|
+
color: var(--ink);
|
|
252
|
+
}
|
|
253
|
+
.mkt .checklist li[data-tone="bad"] .checklist-label {
|
|
254
|
+
color: var(--tone);
|
|
255
|
+
}
|
|
256
|
+
.mkt .checklist-detail {
|
|
257
|
+
display: block;
|
|
258
|
+
color: var(--ink-soft);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ---- The charts' shared key: a swatch, a figure, a label ---- */
|
|
262
|
+
.mkt .chart-key {
|
|
263
|
+
list-style: none;
|
|
264
|
+
margin: 10px 0 0;
|
|
265
|
+
padding: 0;
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-wrap: wrap;
|
|
268
|
+
gap: 5px 20px;
|
|
269
|
+
font-size: 0.82rem;
|
|
270
|
+
color: var(--ink-soft);
|
|
271
|
+
}
|
|
272
|
+
.mkt .chart-key li {
|
|
273
|
+
display: inline-flex;
|
|
274
|
+
align-items: baseline;
|
|
275
|
+
gap: 7px;
|
|
276
|
+
}
|
|
277
|
+
.mkt .chart-key b {
|
|
278
|
+
font-weight: 650;
|
|
279
|
+
color: var(--ink);
|
|
280
|
+
font-variant-numeric: tabular-nums;
|
|
281
|
+
}
|
|
282
|
+
.mkt .chart-swatch {
|
|
283
|
+
width: 10px;
|
|
284
|
+
height: 10px;
|
|
285
|
+
border-radius: 3px;
|
|
286
|
+
background: var(--tone-fill);
|
|
287
|
+
align-self: center;
|
|
288
|
+
flex: none;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ---- StackedBar: one bar, the parts of a whole ---- */
|
|
292
|
+
.mkt .stacked-bar-track {
|
|
293
|
+
display: flex;
|
|
294
|
+
gap: 2px;
|
|
295
|
+
height: 16px;
|
|
296
|
+
border-radius: 999px;
|
|
297
|
+
overflow: hidden;
|
|
298
|
+
}
|
|
299
|
+
.mkt .stacked-bar-track span {
|
|
300
|
+
background: var(--tone-fill);
|
|
301
|
+
min-width: 4px;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* ---- ColumnChart: a bar a period, the periods in labeled groups ---- */
|
|
305
|
+
.mkt .column-chart-plot {
|
|
306
|
+
display: flex;
|
|
307
|
+
align-items: flex-end;
|
|
308
|
+
gap: 12px;
|
|
309
|
+
min-width: 0;
|
|
310
|
+
}
|
|
311
|
+
.mkt .column-chart-group {
|
|
312
|
+
flex: 1 1 0;
|
|
313
|
+
min-width: 0;
|
|
314
|
+
}
|
|
315
|
+
.mkt .column-chart-bars {
|
|
316
|
+
display: flex;
|
|
317
|
+
align-items: flex-end;
|
|
318
|
+
gap: 3px;
|
|
319
|
+
}
|
|
320
|
+
.mkt .column-chart-col {
|
|
321
|
+
flex: 1 1 0;
|
|
322
|
+
min-width: 0;
|
|
323
|
+
display: flex;
|
|
324
|
+
flex-direction: column;
|
|
325
|
+
align-items: center;
|
|
326
|
+
gap: 3px;
|
|
327
|
+
}
|
|
328
|
+
.mkt .column-chart-value {
|
|
329
|
+
font-size: 0.64rem;
|
|
330
|
+
font-variant-numeric: tabular-nums;
|
|
331
|
+
color: var(--ink-soft);
|
|
332
|
+
white-space: nowrap;
|
|
333
|
+
}
|
|
334
|
+
.mkt .column-chart-bar {
|
|
335
|
+
width: 100%;
|
|
336
|
+
max-width: 48px;
|
|
337
|
+
border-radius: 4px 4px 0 0;
|
|
338
|
+
background: var(--tone-fill);
|
|
339
|
+
}
|
|
340
|
+
.mkt .column-chart-label {
|
|
341
|
+
font-size: 0.66rem;
|
|
342
|
+
color: var(--ink-soft);
|
|
343
|
+
white-space: nowrap;
|
|
344
|
+
}
|
|
345
|
+
.mkt .column-chart-group-label {
|
|
346
|
+
display: flex;
|
|
347
|
+
flex-wrap: wrap;
|
|
348
|
+
justify-content: center;
|
|
349
|
+
gap: 0 6px;
|
|
350
|
+
margin-top: 6px;
|
|
351
|
+
padding-top: 5px;
|
|
352
|
+
border-top: 1px solid var(--neutral-200);
|
|
353
|
+
font-family: var(--font-mono);
|
|
354
|
+
font-size: 0.66rem;
|
|
355
|
+
letter-spacing: 0.04em;
|
|
356
|
+
color: var(--neutral-500);
|
|
357
|
+
white-space: nowrap;
|
|
358
|
+
}
|
|
359
|
+
.mkt .column-chart-group-label b {
|
|
360
|
+
font-family: var(--font-sans);
|
|
361
|
+
font-size: 0.8rem;
|
|
362
|
+
letter-spacing: 0;
|
|
363
|
+
font-weight: 650;
|
|
364
|
+
color: var(--ink);
|
|
365
|
+
font-variant-numeric: tabular-nums;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ---- ThresholdScale: zones, and a pin a reading ---- */
|
|
369
|
+
.mkt .threshold-scale {
|
|
370
|
+
display: flex;
|
|
371
|
+
flex-direction: column;
|
|
372
|
+
gap: 10px;
|
|
373
|
+
}
|
|
374
|
+
.mkt .threshold-scale-row {
|
|
375
|
+
display: grid;
|
|
376
|
+
grid-template-columns: 4.4em minmax(0, 1fr) 3.4em;
|
|
377
|
+
gap: 10px;
|
|
378
|
+
align-items: center;
|
|
379
|
+
}
|
|
380
|
+
.mkt .threshold-scale-label {
|
|
381
|
+
color: var(--ink-soft);
|
|
382
|
+
}
|
|
383
|
+
.mkt .threshold-scale-track {
|
|
384
|
+
position: relative;
|
|
385
|
+
display: flex;
|
|
386
|
+
gap: 2px;
|
|
387
|
+
height: 12px;
|
|
388
|
+
}
|
|
389
|
+
.mkt .threshold-scale-zone {
|
|
390
|
+
background: var(--tone-fill);
|
|
391
|
+
opacity: 0.32;
|
|
392
|
+
}
|
|
393
|
+
.mkt .threshold-scale-zone:first-child {
|
|
394
|
+
border-radius: 999px 0 0 999px;
|
|
395
|
+
}
|
|
396
|
+
.mkt .threshold-scale-zone:nth-last-child(2) {
|
|
397
|
+
border-radius: 0 999px 999px 0;
|
|
398
|
+
}
|
|
399
|
+
.mkt .threshold-scale-pin {
|
|
400
|
+
position: absolute;
|
|
401
|
+
top: 50%;
|
|
402
|
+
width: 18px;
|
|
403
|
+
height: 18px;
|
|
404
|
+
margin: -9px 0 0 -9px;
|
|
405
|
+
border-radius: 50%;
|
|
406
|
+
background: var(--tone-fill);
|
|
407
|
+
border: 3px solid var(--card);
|
|
408
|
+
box-shadow: 0 0 0 1.5px var(--tone-fill);
|
|
409
|
+
}
|
|
410
|
+
.mkt .threshold-scale-value {
|
|
411
|
+
font-weight: 700;
|
|
412
|
+
font-variant-numeric: tabular-nums;
|
|
413
|
+
color: var(--tone);
|
|
414
|
+
text-align: right;
|
|
415
|
+
}
|
|
416
|
+
.mkt .threshold-scale-axis {
|
|
417
|
+
position: relative;
|
|
418
|
+
display: flex;
|
|
419
|
+
gap: 2px;
|
|
420
|
+
height: 2.3em;
|
|
421
|
+
font-size: 0.8rem;
|
|
422
|
+
}
|
|
423
|
+
.mkt .threshold-scale-axis > span {
|
|
424
|
+
color: var(--tone);
|
|
425
|
+
font-weight: 600;
|
|
426
|
+
white-space: nowrap;
|
|
427
|
+
overflow: hidden;
|
|
428
|
+
text-overflow: ellipsis;
|
|
429
|
+
}
|
|
430
|
+
.mkt .threshold-scale-axis > i {
|
|
431
|
+
position: absolute;
|
|
432
|
+
top: 1.25em;
|
|
433
|
+
transform: translateX(-50%);
|
|
434
|
+
font-style: normal;
|
|
435
|
+
font-family: var(--font-mono);
|
|
436
|
+
font-size: 0.72rem;
|
|
437
|
+
color: var(--neutral-500);
|
|
438
|
+
white-space: nowrap;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/* ---- DataTable: on a phone each row stacks, its facts wearing their
|
|
442
|
+
column's label, and a dropdown sorts; wide, it is a table whose
|
|
443
|
+
headings sort ---- */
|
|
444
|
+
.mkt .data-table table,
|
|
445
|
+
.mkt .data-table tbody {
|
|
446
|
+
display: block;
|
|
447
|
+
width: 100%;
|
|
448
|
+
}
|
|
449
|
+
.mkt .data-table thead {
|
|
450
|
+
position: absolute;
|
|
451
|
+
width: 1px;
|
|
452
|
+
height: 1px;
|
|
453
|
+
overflow: hidden;
|
|
454
|
+
clip: rect(0, 0, 0, 0);
|
|
455
|
+
white-space: nowrap;
|
|
456
|
+
}
|
|
457
|
+
.mkt .data-table-sort {
|
|
458
|
+
display: flex;
|
|
459
|
+
align-items: center;
|
|
460
|
+
gap: 8px;
|
|
461
|
+
margin-bottom: 8px;
|
|
462
|
+
font-size: 0.85rem;
|
|
463
|
+
color: var(--ink-soft);
|
|
464
|
+
}
|
|
465
|
+
.mkt .data-table-sort select {
|
|
466
|
+
font: inherit;
|
|
467
|
+
color: var(--ink);
|
|
468
|
+
background: var(--card);
|
|
469
|
+
border: 1px solid var(--neutral-200);
|
|
470
|
+
border-radius: var(--radius-sm);
|
|
471
|
+
padding: 5px 8px;
|
|
472
|
+
max-width: 100%;
|
|
473
|
+
}
|
|
474
|
+
.mkt .data-table tbody tr {
|
|
475
|
+
display: flex;
|
|
476
|
+
flex-wrap: wrap;
|
|
477
|
+
align-items: center;
|
|
478
|
+
gap: 4px 18px;
|
|
479
|
+
padding: 9px 0;
|
|
480
|
+
border-bottom: 1px solid var(--neutral-100);
|
|
481
|
+
line-height: 1.35;
|
|
482
|
+
}
|
|
483
|
+
.mkt .data-table tbody tr:first-child {
|
|
484
|
+
border-top: 1.5px solid var(--ink);
|
|
485
|
+
}
|
|
486
|
+
.mkt .data-table tbody tr:last-child {
|
|
487
|
+
border-bottom: 0;
|
|
488
|
+
padding-bottom: 0;
|
|
489
|
+
}
|
|
490
|
+
.mkt .data-table tbody th {
|
|
491
|
+
width: 100%;
|
|
492
|
+
text-align: left;
|
|
493
|
+
font-weight: 400;
|
|
494
|
+
}
|
|
495
|
+
.mkt .data-table td {
|
|
496
|
+
display: inline-flex;
|
|
497
|
+
align-items: center;
|
|
498
|
+
gap: 6px;
|
|
499
|
+
font-variant-numeric: tabular-nums;
|
|
500
|
+
}
|
|
501
|
+
.mkt .data-table td::before {
|
|
502
|
+
content: attr(data-label);
|
|
503
|
+
font-family: var(--font-mono);
|
|
504
|
+
font-size: 0.7rem;
|
|
505
|
+
letter-spacing: 0.08em;
|
|
506
|
+
text-transform: uppercase;
|
|
507
|
+
color: var(--neutral-500);
|
|
508
|
+
}
|
|
509
|
+
@media print, (min-width: 721px) {
|
|
510
|
+
.mkt .data-table-sort {
|
|
511
|
+
display: none;
|
|
512
|
+
}
|
|
513
|
+
.mkt .data-table table {
|
|
514
|
+
display: table;
|
|
515
|
+
border-collapse: collapse;
|
|
516
|
+
}
|
|
517
|
+
.mkt .data-table tbody {
|
|
518
|
+
display: table-row-group;
|
|
519
|
+
}
|
|
520
|
+
.mkt .data-table thead {
|
|
521
|
+
position: static;
|
|
522
|
+
width: auto;
|
|
523
|
+
height: auto;
|
|
524
|
+
overflow: visible;
|
|
525
|
+
clip: auto;
|
|
526
|
+
}
|
|
527
|
+
.mkt .data-table tbody tr {
|
|
528
|
+
display: table-row;
|
|
529
|
+
padding: 0;
|
|
530
|
+
border: 0;
|
|
531
|
+
}
|
|
532
|
+
.mkt .data-table tbody tr:first-child {
|
|
533
|
+
border-top: 0;
|
|
534
|
+
}
|
|
535
|
+
.mkt .data-table th,
|
|
536
|
+
.mkt .data-table td {
|
|
537
|
+
display: table-cell;
|
|
538
|
+
padding: 7px 14px 7px 0;
|
|
539
|
+
border-bottom: 1px solid var(--neutral-100);
|
|
540
|
+
text-align: left;
|
|
541
|
+
vertical-align: middle;
|
|
542
|
+
font-weight: 400;
|
|
543
|
+
}
|
|
544
|
+
.mkt .data-table tbody th {
|
|
545
|
+
width: auto;
|
|
546
|
+
}
|
|
547
|
+
.mkt .data-table td::before {
|
|
548
|
+
content: none;
|
|
549
|
+
}
|
|
550
|
+
.mkt .data-table th:last-child,
|
|
551
|
+
.mkt .data-table td:last-child {
|
|
552
|
+
padding-right: 0;
|
|
553
|
+
}
|
|
554
|
+
.mkt .data-table [data-align="center"] {
|
|
555
|
+
text-align: center;
|
|
556
|
+
}
|
|
557
|
+
.mkt .data-table [data-align="right"] {
|
|
558
|
+
text-align: right;
|
|
559
|
+
}
|
|
560
|
+
.mkt .data-table thead th {
|
|
561
|
+
padding-top: 0;
|
|
562
|
+
padding-bottom: 8px;
|
|
563
|
+
border-bottom: 1.5px solid var(--ink);
|
|
564
|
+
font-family: var(--font-mono);
|
|
565
|
+
font-size: 0.72rem;
|
|
566
|
+
letter-spacing: 0.08em;
|
|
567
|
+
text-transform: uppercase;
|
|
568
|
+
color: var(--ink-soft);
|
|
569
|
+
white-space: nowrap;
|
|
570
|
+
vertical-align: bottom;
|
|
571
|
+
}
|
|
572
|
+
.mkt .data-table thead th button {
|
|
573
|
+
display: inline-flex;
|
|
574
|
+
align-items: center;
|
|
575
|
+
gap: 4px;
|
|
576
|
+
padding: 0;
|
|
577
|
+
border: 0;
|
|
578
|
+
background: none;
|
|
579
|
+
font: inherit;
|
|
580
|
+
letter-spacing: inherit;
|
|
581
|
+
text-transform: inherit;
|
|
582
|
+
color: inherit;
|
|
583
|
+
cursor: pointer;
|
|
584
|
+
}
|
|
585
|
+
.mkt .data-table thead th button:hover,
|
|
586
|
+
.mkt .data-table thead th button[data-active] {
|
|
587
|
+
color: var(--primary);
|
|
588
|
+
}
|
|
589
|
+
.mkt .data-table thead th button svg {
|
|
590
|
+
opacity: 0.55;
|
|
591
|
+
}
|
|
592
|
+
.mkt .data-table thead th button[data-active] svg {
|
|
593
|
+
opacity: 1;
|
|
594
|
+
}
|
|
595
|
+
.mkt .data-table tbody tr:last-child th,
|
|
596
|
+
.mkt .data-table tbody tr:last-child td {
|
|
597
|
+
border-bottom: 0;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
@media print {
|
|
601
|
+
.mkt .data-table thead th button svg {
|
|
602
|
+
display: none;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
20
605
|
}
|