@tangle-network/ui 11.2.3 → 11.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.2.3",
3
+ "version": "11.3.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -141,7 +141,7 @@
141
141
  "react-dom": "^18 || ^19",
142
142
  "react-router": "^7 || ^8",
143
143
  "yjs": "^13.6.0",
144
- "@tangle-network/brand": "^1.1.0"
144
+ "@tangle-network/brand": "^1.4.0"
145
145
  },
146
146
  "peerDependenciesMeta": {
147
147
  "@nanostores/react": {
@@ -0,0 +1,127 @@
1
+ import { render, screen } from "@testing-library/react"
2
+ import { describe, expect, it } from "vitest"
3
+ import { Input, Textarea } from "./input"
4
+ import { StatCard } from "./stat-card"
5
+ import { TerminalLine } from "./terminal-display"
6
+
7
+ /**
8
+ * The quietest line in each primitive — a field hint, a stat subtitle, a log
9
+ * timestamp — must take its colour from a TOKEN, never from a faded stronger
10
+ * one.
11
+ *
12
+ * A translucent foreground does not render the colour it names: it renders that
13
+ * colour composited over whatever plane is behind it. So `text-muted-foreground/70`
14
+ * measures one ratio on a card and a different one on the canvas, and the same
15
+ * markup is legible in one place and not in another. That is how these three
16
+ * landed at 3.65:1, 2.91:1 and 3.4:1 while looking, in the source, like they had
17
+ * been styled deliberately.
18
+ *
19
+ * These assert the RULE rather than the current class string — the point is that
20
+ * faint text carries no alpha, not that it carries one specific token today.
21
+ */
22
+
23
+ /**
24
+ * A class with its variant chain removed: `dark:hover:text-white/50` → `text-white/50`.
25
+ *
26
+ * Split on the colons that separate variants, which are the ones at bracket
27
+ * depth zero — an arbitrary value carries its own (`text-[color:var(--x)]`,
28
+ * `supports-[display:grid]:`) and splitting on those would mangle the utility.
29
+ */
30
+ function bareUtility(cls: string): string {
31
+ let depth = 0
32
+ let start = 0
33
+ for (let i = 0; i < cls.length; i++) {
34
+ const c = cls[i]
35
+ if (c === "[") depth++
36
+ else if (c === "]") depth--
37
+ else if (c === ":" && depth === 0) start = i + 1
38
+ }
39
+ return cls.slice(start)
40
+ }
41
+
42
+ /** Colour utilities, where a trailing `/NN` is an alpha modifier rather than a fraction. */
43
+ const COLOUR = "text|bg|border|ring|outline|decoration|placeholder|caret|accent|fill|stroke|divide|from|via|to|shadow"
44
+ /** `text-white/70`, `bg-card/60`, `text-[var(--x)]/40`. */
45
+ const ALPHA_MODIFIER = new RegExp(`^(?:${COLOUR})-.+/\\d{1,3}$`)
46
+ /** `opacity-50` — the same fade by another route. */
47
+ const OPACITY_UTILITY = /^opacity-\d{1,3}$/
48
+
49
+ function expectSolidColour(el: HTMLElement | null, what: string) {
50
+ expect(el, `${what} should render`).not.toBeNull()
51
+ // Per class, not across the whole string: a variant prefix has no whitespace
52
+ // in it, so a single regex over the joined list never sees where one class
53
+ // ends — and `dark:text-white/50` slips past a pattern anchored on spaces.
54
+ const faded = (el?.className ?? "")
55
+ .split(/\s+/)
56
+ .filter(Boolean)
57
+ .filter((cls) => {
58
+ const bare = bareUtility(cls)
59
+ return ALPHA_MODIFIER.test(bare) || OPACITY_UTILITY.test(bare)
60
+ })
61
+ expect(faded, `${what} must take a token, not a faded colour`).toEqual([])
62
+ }
63
+
64
+ describe("the fade detector itself", () => {
65
+ // The guard below is only worth as much as this table. An earlier version
66
+ // anchored on whitespace and so never saw a variant prefix — `dark:text-white/50`
67
+ // read as clean, which is the exact mistake the guard exists to catch.
68
+ const FADED = [
69
+ "text-muted-foreground/70",
70
+ "text-[var(--text-dim)]/70",
71
+ "dark:text-white/50",
72
+ "hover:text-muted-foreground/70",
73
+ "group-hover:text-foreground/40",
74
+ "md:bg-card/60",
75
+ "supports-[display:grid]:text-white/50",
76
+ "opacity-50",
77
+ "hover:opacity-50",
78
+ ]
79
+ const SOLID = [
80
+ "text-[var(--text-dim)]",
81
+ "text-sm",
82
+ "text-muted-foreground",
83
+ "dark:text-white",
84
+ // A slash that is a FRACTION, not an alpha modifier — must not trip the guard.
85
+ "w-1/2",
86
+ "basis-1/3",
87
+ // A colon inside an arbitrary value, which must survive variant-stripping.
88
+ "text-[color:var(--text-dim)]",
89
+ ]
90
+ const isFaded = (cls: string) =>
91
+ ALPHA_MODIFIER.test(bareUtility(cls)) || OPACITY_UTILITY.test(bareUtility(cls))
92
+
93
+ for (const cls of FADED) {
94
+ it(`catches ${cls}`, () => expect(isFaded(cls)).toBe(true))
95
+ }
96
+ for (const cls of SOLID) {
97
+ it(`allows ${cls}`, () => expect(isFaded(cls)).toBe(false))
98
+ }
99
+ })
100
+
101
+ describe("faint text is a token, not a faded one", () => {
102
+ it("Input renders its hint at full strength", () => {
103
+ render(<Input label="Name" hint="Shown on your public profile" />)
104
+ expectSolidColour(
105
+ screen.getByText("Shown on your public profile"),
106
+ "the Input hint",
107
+ )
108
+ })
109
+
110
+ it("Textarea renders its hint at full strength", () => {
111
+ render(<Textarea label="Bio" hint="Markdown is supported" />)
112
+ expectSolidColour(
113
+ screen.getByText("Markdown is supported"),
114
+ "the Textarea hint",
115
+ )
116
+ })
117
+
118
+ it("StatCard renders its subtitle at full strength", () => {
119
+ render(<StatCard title="Sandboxes" value={12} subtitle="up from 9" />)
120
+ expectSolidColour(screen.getByText("up from 9"), "the StatCard subtitle")
121
+ })
122
+
123
+ it("TerminalLine renders its timestamp at full strength", () => {
124
+ render(<TerminalLine timestamp="12:04:11">compiling</TerminalLine>)
125
+ expectSolidColour(screen.getByText("[12:04:11]"), "the TerminalLine timestamp")
126
+ })
127
+ })
@@ -118,3 +118,15 @@ export { CodeBlock, CopyButton, InlineCode } from "./code-block";
118
118
  export type { CodeBlockProps, InlineCodeProps } from "./code-block";
119
119
 
120
120
  export { ThemeToggle, useTheme } from "./theme-toggle";
121
+
122
+ export { PageHeader } from "./page-header";
123
+ export type { PageHeaderProps } from "./page-header";
124
+
125
+ export { StatusPill } from "./status-pill";
126
+ export type { StatusPillProps, StatusTone } from "./status-pill";
127
+
128
+ export { MetricStrip, Metric } from "./metric-strip";
129
+ export type { MetricStripProps, MetricProps } from "./metric-strip";
130
+
131
+ export { Toolbar, FilterField } from "./toolbar";
132
+ export type { ToolbarProps, FilterFieldProps } from "./toolbar";
@@ -65,7 +65,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
65
65
  {input}
66
66
  {error && <p className="text-[var(--surface-danger-text)] text-sm font-medium">{error}</p>}
67
67
  {hint && !error && (
68
- <p className="text-muted-foreground/70 text-sm">{hint}</p>
68
+ <p className="text-[var(--text-dim)] text-sm">{hint}</p>
69
69
  )}
70
70
  </div>
71
71
  );
@@ -125,7 +125,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
125
125
  {textarea}
126
126
  {error && <p className="text-[var(--surface-danger-text)] text-sm">{error}</p>}
127
127
  {hint && !error && (
128
- <p className="text-muted-foreground/70 text-sm">{hint}</p>
128
+ <p className="text-[var(--text-dim)] text-sm">{hint}</p>
129
129
  )}
130
130
  </div>
131
131
  );
@@ -0,0 +1,181 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { Metric, MetricStrip } from "./metric-strip";
4
+
5
+ describe("MetricStrip", () => {
6
+ it("pairs each label with its value as a described term", () => {
7
+ render(
8
+ <MetricStrip>
9
+ <Metric label="Balance" value="$248.55" />
10
+ </MetricStrip>,
11
+ );
12
+ // The label is wrapped for truncation, so assert the term it belongs to.
13
+ expect(screen.getByText("Balance").closest("dt")).not.toBeNull();
14
+ expect(screen.getByText("$248.55").tagName).toBe("DD");
15
+ });
16
+
17
+ // axe's `definition-list` rule allows only dt, dd, script, template and div
18
+ // as direct children of a dl. The hint is a second dd for this reason: a <p>
19
+ // between the terms is a violation, and it was one.
20
+ it("puts only permitted elements directly inside the dl", () => {
21
+ const { container } = render(
22
+ <MetricStrip>
23
+ <Metric label="Spend" value="$12.00" hint="This month" />
24
+ <Metric label="Runs" value="41" />
25
+ </MetricStrip>,
26
+ );
27
+ const dl = container.querySelector("dl") as HTMLElement;
28
+ const permitted = new Set(["DT", "DD", "SCRIPT", "TEMPLATE", "DIV"]);
29
+ for (const child of Array.from(dl.children)) {
30
+ expect(permitted).toContain(child.tagName);
31
+ }
32
+ });
33
+
34
+ it("renders the hint as a dd rather than a paragraph", () => {
35
+ render(
36
+ <MetricStrip>
37
+ <Metric label="Spend" value="$12.00" hint="Personal wallet" />
38
+ </MetricStrip>,
39
+ );
40
+ const hint = screen.getByText("Personal wallet");
41
+ expect(hint.tagName).toBe("DD");
42
+ });
43
+
44
+ it("omits the hint entirely when not given", () => {
45
+ const { container } = render(
46
+ <MetricStrip>
47
+ <Metric label="Runs" value="41" />
48
+ </MetricStrip>,
49
+ );
50
+ expect(container.querySelectorAll("dd")).toHaveLength(1);
51
+ });
52
+
53
+ // `attention` means a person has to do something, so it shows a pill next to
54
+ // the label. A zero on its own is not attention.
55
+ it("shows a pill beside the label only when attention is set", () => {
56
+ const { rerender, container } = render(
57
+ <MetricStrip>
58
+ <Metric label="Balance" value="$0.00" />
59
+ </MetricStrip>,
60
+ );
61
+ expect(container.querySelector("svg")).toBeNull();
62
+
63
+ rerender(
64
+ <MetricStrip>
65
+ <Metric
66
+ attention={{ tone: "danger", label: "Empty" }}
67
+ label="Balance"
68
+ value="$0.00"
69
+ />
70
+ </MetricStrip>,
71
+ );
72
+ expect(screen.getByText("Empty")).toBeInTheDocument();
73
+ expect(container.querySelector("svg")).not.toBeNull();
74
+ });
75
+
76
+ it("raises the value's tone only for a danger attention", () => {
77
+ const { container } = render(
78
+ <MetricStrip>
79
+ <Metric
80
+ attention={{ tone: "danger", label: "Empty" }}
81
+ label="Balance"
82
+ value="$0.00"
83
+ />
84
+ </MetricStrip>,
85
+ );
86
+ const value = screen.getByText("$0.00");
87
+ expect(value.className).toContain("--surface-danger-text");
88
+ expect(container.querySelectorAll("dd")).toHaveLength(1);
89
+ });
90
+
91
+ // The divider is drawn only where it belongs, never drawn and then
92
+ // suppressed: `border-l` alongside `border-l-0` variants puts both in one
93
+ // conflict group whose winner is decided by emitted-rule order, and that
94
+ // resolved the wrong way at every breakpoint — every item kept a left border,
95
+ // including the first in each row.
96
+ it("draws the divider without a suppressing counterpart", () => {
97
+ const { container } = render(
98
+ <MetricStrip>
99
+ <Metric label="Balance" value="$0.00" />
100
+ </MetricStrip>,
101
+ );
102
+ const item = (container.querySelector("dl") as HTMLElement)
103
+ .children[0] as HTMLElement;
104
+ expect(item.className).not.toContain("border-l-0");
105
+ });
106
+
107
+ // The row-start test has to name the same column count the grid does, at each
108
+ // breakpoint. Nothing in the type system ties them together, so changing the
109
+ // grid to three columns without moving the divider puts a rule mid-row. Read
110
+ // both out of the rendered classes and require them to agree, so the coupling
111
+ // is enforced rather than only described.
112
+ it("keeps the divider's row-start test matching the grid's column count", () => {
113
+ const { container } = render(
114
+ <MetricStrip>
115
+ <Metric label="Balance" value="$0.00" />
116
+ </MetricStrip>,
117
+ );
118
+ const dl = container.querySelector("dl") as HTMLElement;
119
+ const item = dl.children[0] as HTMLElement;
120
+
121
+ // `grid-cols-N` unprefixed is the base breakpoint; `<bp>:grid-cols-N` is that
122
+ // breakpoint. The divider spells the same breakpoints as `max-<bp>:` / `<bp>:`.
123
+ const columnsAt = (prefix: string) => {
124
+ const re = prefix
125
+ ? new RegExp(`(?:^| )${prefix}:grid-cols-(\\d+)(?: |$)`)
126
+ : /(?:^| )grid-cols-(\d+)(?: |$)/;
127
+ const m = dl.className.match(re);
128
+ return m ? Number(m[1]) : null;
129
+ };
130
+ const modulusAt = (prefix: string) => {
131
+ const m = item.className.match(
132
+ new RegExp(`(?:^| )${prefix}:\\[&:not\\(:nth-child\\((\\d+)n\\+1\\)\\)\\]:border-l(?: |$)`),
133
+ );
134
+ return m ? Number(m[1]) : null;
135
+ };
136
+
137
+ // base grid (below sm) is guarded by the `max-sm:` divider rule
138
+ expect(modulusAt("max-sm")).toBe(columnsAt(""));
139
+ // the `sm:` grid is guarded by the `sm:` divider rule
140
+ expect(modulusAt("sm")).toBe(columnsAt("sm"));
141
+
142
+ // Every grid-cols breakpoint the strip declares must have a divider rule.
143
+ const declared = [...dl.className.matchAll(/(?:^| )(?:([a-z]+):)?grid-cols-\d+(?= |$)/g)]
144
+ .map((m) => m[1] ?? "");
145
+ for (const bp of declared) {
146
+ const guard = bp === "" ? "max-sm" : bp;
147
+ expect(
148
+ modulusAt(guard),
149
+ `grid-cols at "${bp || "base"}" has no matching ${guard}: divider rule`,
150
+ ).not.toBeNull();
151
+ }
152
+ });
153
+
154
+ it("titles the hint too, so a truncated qualifier stays recoverable", () => {
155
+ render(
156
+ <MetricStrip>
157
+ <Metric
158
+ hint="Personal wallet (0x1234abcd5678efgh)"
159
+ label="Balance"
160
+ value="$248.55"
161
+ />
162
+ </MetricStrip>,
163
+ );
164
+ expect(screen.getByText("Personal wallet (0x1234abcd5678efgh)")).toHaveAttribute(
165
+ "title",
166
+ "Personal wallet (0x1234abcd5678efgh)",
167
+ );
168
+ });
169
+
170
+ it("titles a string value so a truncated figure stays readable", () => {
171
+ render(
172
+ <MetricStrip>
173
+ <Metric label="Balance" value="$1,284,003.10" />
174
+ </MetricStrip>,
175
+ );
176
+ expect(screen.getByText("$1,284,003.10")).toHaveAttribute(
177
+ "title",
178
+ "$1,284,003.10",
179
+ );
180
+ });
181
+ });
@@ -0,0 +1,122 @@
1
+ import * as React from "react";
2
+ import { cn } from "../lib/utils";
3
+ import { StatusPill, type StatusTone } from "./status-pill";
4
+
5
+ /**
6
+ * The headline figures for a console page, as ONE statement rather than four.
7
+ *
8
+ * A row of separate stat cards is the default and it is usually wrong here.
9
+ * Each card is a box that must be tall enough for its longest member, so a row
10
+ * mixing "$248.55 / Personal wallet" against a bare "0" leaves the short ones
11
+ * mostly empty; and four bordered boxes read as four unrelated facts when they
12
+ * are four readings of one account. `MetricStrip` puts them on one plane
13
+ * divided by hairlines: same information, one object, no holes.
14
+ *
15
+ * The divider is a border on the item rather than a `divide-*` utility on the
16
+ * parent, because these wrap. `divide-x` draws from DOM order and leaves a
17
+ * stray rule at the start of every wrapped line; a per-item leading border
18
+ * suppressed at each row start does not.
19
+ */
20
+ export interface MetricStripProps
21
+ extends React.HTMLAttributes<HTMLDListElement> {
22
+ children: React.ReactNode;
23
+ }
24
+
25
+ const MetricStrip = React.forwardRef<HTMLDListElement, MetricStripProps>(
26
+ ({ className, children, ...props }, ref) => (
27
+ <dl
28
+ ref={ref}
29
+ className={cn(
30
+ "grid grid-cols-2 rounded-[var(--radius-lg)] border border-border bg-card shadow-[var(--shadow-card)] sm:grid-cols-4",
31
+ className,
32
+ )}
33
+ {...props}
34
+ >
35
+ {children}
36
+ </dl>
37
+ ),
38
+ );
39
+ MetricStrip.displayName = "MetricStrip";
40
+
41
+ export interface MetricProps extends React.HTMLAttributes<HTMLDivElement> {
42
+ label: React.ReactNode;
43
+ value: React.ReactNode;
44
+ /** The qualifying line: which wallet, which window, what the limit is. */
45
+ hint?: React.ReactNode;
46
+ /** Raises the value's tone and shows a pill beside the label. A metric is
47
+ * `attention` only when a person has to DO something — not merely when a
48
+ * number is zero. */
49
+ attention?: { tone: StatusTone; label: string };
50
+ }
51
+
52
+ /**
53
+ * One reading inside a `MetricStrip`.
54
+ *
55
+ * `<dt>` carries the label and `<dd>` the value, so the pair is announced as a
56
+ * described term rather than as two loose strings — the semantic that makes a
57
+ * figure legible without the visual grouping.
58
+ *
59
+ * The value is `tabular-nums`: these sit in a row and change on a timer, and
60
+ * proportional digits make the column jitter every time a 1 becomes an 8.
61
+ */
62
+ const Metric = React.forwardRef<HTMLDivElement, MetricProps>(
63
+ ({ className, label, value, hint, attention, ...props }, ref) => (
64
+ <div
65
+ ref={ref}
66
+ className={cn(
67
+ // Leading hairline, DRAWN only where a divider belongs — never drawn
68
+ // and then suppressed. Stating it as `border-l` plus `border-l-0`
69
+ // variants puts both in one conflict group, where the winner is decided
70
+ // by emitted-rule order rather than by intent; that resolved the wrong
71
+ // way at every breakpoint and left a rule down the start of each row.
72
+ // `:not(:nth-child(Nn+1))` is the row-start test for the column count
73
+ // at that breakpoint, so no rule ever contradicts another.
74
+ //
75
+ // N is COUPLED to the grid template on `MetricStrip`: 2 columns below
76
+ // `sm`, 4 from `sm`. A new breakpoint there — `md:grid-cols-3`, say —
77
+ // needs its `md:[&:not(:nth-child(3n+1))]:border-l` here in the same
78
+ // change, or the dividers land mid-row at that width.
79
+ "border-border p-4 sm:p-5",
80
+ "max-sm:[&:not(:nth-child(2n+1))]:border-l",
81
+ "sm:[&:not(:nth-child(4n+1))]:border-l",
82
+ className,
83
+ )}
84
+ {...props}
85
+ >
86
+ <dt className="flex items-center gap-2 text-muted-foreground text-sm">
87
+ <span className="truncate">{label}</span>
88
+ {attention && (
89
+ <StatusPill tone={attention.tone}>{attention.label}</StatusPill>
90
+ )}
91
+ </dt>
92
+ {/* `truncate`, and a step down until the column is wide enough for the
93
+ longest figure this strip carries. A balance renders as "$248.55" on a
94
+ funded account and as "$1,284,003.10" on a busy one, and at four
95
+ columns the wide case has nowhere to go — it either overflows its cell
96
+ or pushes the whole strip past the page. */}
97
+ <dd
98
+ className={cn(
99
+ "mt-1 truncate font-semibold text-xl tabular-nums tracking-tight lg:text-2xl",
100
+ attention?.tone === "danger" && "text-[var(--surface-danger-text)]",
101
+ )}
102
+ title={typeof value === "string" ? value : undefined}
103
+ >
104
+ {value}
105
+ </dd>
106
+ {hint && (
107
+ // Titled for the same reason the value is: the line truncates, and a
108
+ // hint is where the qualifying detail lives — which wallet, which
109
+ // window — so a clipped one is the case most worth recovering.
110
+ <dd
111
+ className="mt-0.5 truncate text-[var(--text-dim)] text-xs"
112
+ title={typeof hint === "string" ? hint : undefined}
113
+ >
114
+ {hint}
115
+ </dd>
116
+ )}
117
+ </div>
118
+ ),
119
+ );
120
+ Metric.displayName = "Metric";
121
+
122
+ export { Metric, MetricStrip };
@@ -0,0 +1,57 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { PageHeader } from "./page-header";
4
+
5
+ describe("PageHeader", () => {
6
+ it("renders the title as the page's only h1", () => {
7
+ render(<PageHeader title="Workflows" />);
8
+ const headings = screen.getAllByRole("heading", { level: 1 });
9
+ expect(headings).toHaveLength(1);
10
+ expect(headings[0]).toHaveTextContent("Workflows");
11
+ });
12
+
13
+ it("steps the title down to h2 for a nested surface", () => {
14
+ render(<PageHeader level={2} title="Skills" />);
15
+ expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
16
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
17
+ "Skills",
18
+ );
19
+ });
20
+
21
+ it("puts the id on the heading so a region can point aria-labelledby at it", () => {
22
+ render(<PageHeader titleId="keys-title" title="API keys" />);
23
+ expect(screen.getByRole("heading", { level: 1 })).toHaveAttribute(
24
+ "id",
25
+ "keys-title",
26
+ );
27
+ });
28
+
29
+ // The description is prose ABOUT the page, so it must not land in the
30
+ // heading — a paragraph rendered where a heading goes reads as body copy to a
31
+ // sighted reader and as the page's accessible name to a screen reader.
32
+ it("keeps the description out of the heading", () => {
33
+ render(<PageHeader title="Billing" description="Spend and invoices." />);
34
+ const heading = screen.getByRole("heading", { level: 1 });
35
+ expect(heading).toHaveTextContent("Billing");
36
+ expect(heading).not.toHaveTextContent("Spend and invoices.");
37
+ expect(screen.getByText("Spend and invoices.")).toBeInTheDocument();
38
+ });
39
+
40
+ it("renders the actions and meta slots when given", () => {
41
+ render(
42
+ <PageHeader
43
+ title="Teams"
44
+ actions={<button type="button">Invite</button>}
45
+ meta={<span>4 members</span>}
46
+ />,
47
+ );
48
+ expect(screen.getByRole("button", { name: "Invite" })).toBeInTheDocument();
49
+ expect(screen.getByText("4 members")).toBeInTheDocument();
50
+ });
51
+
52
+ it("omits the optional slots entirely when not given", () => {
53
+ const { container } = render(<PageHeader title="Alerts" />);
54
+ expect(container.querySelector("p")).toBeNull();
55
+ expect(screen.queryByRole("button")).toBeNull();
56
+ });
57
+ });
@@ -0,0 +1,86 @@
1
+ import * as React from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * The one masthead every console page opens with.
6
+ *
7
+ * A page identifies itself in three separate registers, and each gets its own
8
+ * slot here so they cannot compete for the same one:
9
+ *
10
+ * - `title` — what the page IS. The `<h1>`, and the only `<h1>`.
11
+ * - `description` — one line on what it is for. Prose belongs here, never in
12
+ * the title slot: a paragraph rendered where a heading goes reads as body
13
+ * copy to a sighted reader and as nothing at all to a screen reader, which
14
+ * is how a page ends up with no accessible name.
15
+ * - `actions` — what the reader can DO from here, right-aligned and wrapping
16
+ * under the title on a narrow viewport rather than squeezing it.
17
+ *
18
+ * `meta` is the fourth register and the one most often missing: the small facts
19
+ * that qualify everything below (a count, a scope, a window). They sit on their
20
+ * own line under the title so a reader can tell the difference between "this
21
+ * page has nothing in it" and "this filter matches nothing".
22
+ *
23
+ * `titleId` is exposed so a page can point `aria-labelledby` at the heading
24
+ * from a region further down without minting a second copy of the string.
25
+ */
26
+ export interface PageHeaderProps
27
+ extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
28
+ title: React.ReactNode;
29
+ description?: React.ReactNode;
30
+ actions?: React.ReactNode;
31
+ meta?: React.ReactNode;
32
+ titleId?: string;
33
+ /** Renders the title one step down for a nested/tab surface. */
34
+ level?: 1 | 2;
35
+ }
36
+
37
+ const PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(
38
+ (
39
+ {
40
+ className,
41
+ title,
42
+ description,
43
+ actions,
44
+ meta,
45
+ titleId,
46
+ level = 1,
47
+ ...props
48
+ },
49
+ ref,
50
+ ) => {
51
+ const Heading = level === 1 ? "h1" : "h2";
52
+ return (
53
+ <header ref={ref} className={cn("mb-6", className)} {...props}>
54
+ <div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
55
+ <div className="min-w-0">
56
+ <Heading
57
+ id={titleId}
58
+ className={cn(
59
+ "text-balance font-semibold tracking-tight",
60
+ level === 1 ? "text-2xl" : "text-lg",
61
+ )}
62
+ >
63
+ {title}
64
+ </Heading>
65
+ {description && (
66
+ <p className="mt-1 max-w-prose text-muted-foreground text-sm">
67
+ {description}
68
+ </p>
69
+ )}
70
+ </div>
71
+ {actions && (
72
+ <div className="flex flex-wrap items-center gap-2">{actions}</div>
73
+ )}
74
+ </div>
75
+ {meta && (
76
+ <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-[var(--text-dim)] text-xs">
77
+ {meta}
78
+ </div>
79
+ )}
80
+ </header>
81
+ );
82
+ },
83
+ );
84
+ PageHeader.displayName = "PageHeader";
85
+
86
+ export { PageHeader };
@@ -58,8 +58,12 @@ const StatCard = React.forwardRef<HTMLDivElement, StatCardProps>(
58
58
  <div className="space-y-1">
59
59
  <p className="text-muted-foreground text-sm">{title}</p>
60
60
  <p className="font-bold text-3xl tracking-tight">{value}</p>
61
+ {/* A token, never `opacity-70` over the muted tone: a translucent
62
+ foreground renders as the token COMPOSITED over whatever plane sits
63
+ behind it, so the same class lands a different colour — and a
64
+ different contrast ratio — on a card than on the canvas. */}
61
65
  {subtitle && (
62
- <p className="text-muted-foreground opacity-70 text-xs">{subtitle}</p>
66
+ <p className="text-[var(--text-dim)] text-xs">{subtitle}</p>
63
67
  )}
64
68
  {trend && trendStatus && (
65
69
  <div