@tangle-network/ui 11.2.4 → 11.4.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.
@@ -336,4 +336,174 @@ declare function useTheme(): {
336
336
  };
337
337
  declare function ThemeToggle(): react_jsx_runtime.JSX.Element;
338
338
 
339
- export { Avatar, AvatarFallback, AvatarImage, Badge, type BadgeProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DropZone, type DropZoneProps, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, type EmptyStateProps, InlineCode, type InlineCodeProps, Input, type InputProps, Label, Progress, SegmentedControl, type SegmentedControlOption, type SegmentedControlProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, SidebarDropZone, type SidebarDropZoneProps, Skeleton, SkeletonCard, SkeletonTable, StatCard, type StatCardProps, Switch, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, Textarea, type TextareaProps, ThemeToggle, type Toast, ToastContainer, ToastProvider, type UploadFile, UploadProgress, type UploadProgressProps, badgeVariants, useTheme, useToast };
339
+ /**
340
+ * The one masthead every console page opens with.
341
+ *
342
+ * A page identifies itself in three separate registers, and each gets its own
343
+ * slot here so they cannot compete for the same one:
344
+ *
345
+ * - `title` — what the page IS. The `<h1>`, and the only `<h1>`.
346
+ * - `description` — one line on what it is for. Prose belongs here, never in
347
+ * the title slot: a paragraph rendered where a heading goes reads as body
348
+ * copy to a sighted reader and as nothing at all to a screen reader, which
349
+ * is how a page ends up with no accessible name.
350
+ * - `actions` — what the reader can DO from here, right-aligned and wrapping
351
+ * under the title on a narrow viewport rather than squeezing it.
352
+ *
353
+ * `meta` is the fourth register and the one most often missing: the small facts
354
+ * that qualify everything below (a count, a scope, a window). They sit on their
355
+ * own line under the title so a reader can tell the difference between "this
356
+ * page has nothing in it" and "this filter matches nothing".
357
+ *
358
+ * `titleId` is exposed so a page can point `aria-labelledby` at the heading
359
+ * from a region further down without minting a second copy of the string.
360
+ */
361
+ interface PageHeaderProps extends Omit<React$1.HTMLAttributes<HTMLElement>, "title"> {
362
+ title: React$1.ReactNode;
363
+ description?: React$1.ReactNode;
364
+ actions?: React$1.ReactNode;
365
+ meta?: React$1.ReactNode;
366
+ titleId?: string;
367
+ /** Renders the title one step down for a nested/tab surface. */
368
+ level?: 1 | 2;
369
+ }
370
+ declare const PageHeader: React$1.ForwardRefExoticComponent<PageHeaderProps & React$1.RefAttributes<HTMLElement>>;
371
+
372
+ /**
373
+ * A run/resource state, told three ways at once.
374
+ *
375
+ * Status is the one place a console cannot afford to speak in colour alone: a
376
+ * red dot and a green dot are the same dot to roughly one man in twelve, and
377
+ * identical in a greyscale print or a screenshot pasted into a ticket. So every
378
+ * pill carries all three channels — a GLYPH whose silhouette differs per tone,
379
+ * the tone's COLOUR, and the state's own LABEL as text.
380
+ *
381
+ * The glyphs are chosen to survive at 8px and to differ in outline rather than
382
+ * in fill: a ring reads as "still open", a solid disc as "settled", a slashed
383
+ * disc as "stopped". Two states never share one silhouette.
384
+ *
385
+ * Each tone draws its fill, border and text from ONE matched token triple.
386
+ * That pairing is the whole point: a status colour is solved against its own
387
+ * background, and nothing guarantees it against an arbitrary one. A component
388
+ * that always brings its own background cannot be placed onto a plane that
389
+ * breaks it.
390
+ */
391
+ type StatusTone = "success" | "warning" | "danger" | "info" | "neutral" | "running";
392
+ interface StatusPillProps extends Omit<React$1.HTMLAttributes<HTMLSpanElement>, "children"> {
393
+ tone: StatusTone;
394
+ children: React$1.ReactNode;
395
+ /**
396
+ * Drops the fill and border, leaving a toned GLYPH beside a label in the
397
+ * inherited body colour.
398
+ *
399
+ * The label deliberately does not keep the tone. Every status text token
400
+ * clears the 4.5:1 body floor on the page canvas — a gate holds them there —
401
+ * but in light the margin is thin (4.51:1 for warning, 4.54:1 for success),
402
+ * and the canvas is only one of the planes a caller can put a pill on. The
403
+ * glyph keeps the tone instead because a glyph is non-text content against a
404
+ * 3:1 floor, so the tone still reads with room to spare on any plane.
405
+ *
406
+ * For a control that supplies its own surface (a chip, a selected row).
407
+ */
408
+ bare?: boolean;
409
+ size?: "sm" | "md";
410
+ }
411
+ declare const StatusPill: React$1.ForwardRefExoticComponent<StatusPillProps & React$1.RefAttributes<HTMLSpanElement>>;
412
+
413
+ /**
414
+ * The headline figures for a console page, as ONE statement rather than four.
415
+ *
416
+ * A row of separate stat cards is the default and it is usually wrong here.
417
+ * Each card is a box that must be tall enough for its longest member, so a row
418
+ * mixing "$248.55 / Personal wallet" against a bare "0" leaves the short ones
419
+ * mostly empty; and four bordered boxes read as four unrelated facts when they
420
+ * are four readings of one account. `MetricStrip` puts them on one plane
421
+ * divided by hairlines: same information, one object, no holes.
422
+ *
423
+ * The divider is a border on the item rather than a `divide-*` utility on the
424
+ * parent, because these wrap. `divide-x` draws from DOM order and leaves a
425
+ * stray rule at the start of every wrapped line; a per-item leading border
426
+ * suppressed at each row start does not.
427
+ */
428
+ interface MetricStripProps extends React$1.HTMLAttributes<HTMLDListElement> {
429
+ children: React$1.ReactNode;
430
+ }
431
+ declare const MetricStrip: React$1.ForwardRefExoticComponent<MetricStripProps & React$1.RefAttributes<HTMLDListElement>>;
432
+ interface MetricProps extends React$1.HTMLAttributes<HTMLDivElement> {
433
+ label: React$1.ReactNode;
434
+ value: React$1.ReactNode;
435
+ /** The qualifying line: which wallet, which window, what the limit is. */
436
+ hint?: React$1.ReactNode;
437
+ /** Raises the value's tone and shows a pill beside the label. A metric is
438
+ * `attention` only when a person has to DO something — not merely when a
439
+ * number is zero. */
440
+ attention?: {
441
+ tone: StatusTone;
442
+ label: string;
443
+ };
444
+ }
445
+ /**
446
+ * One reading inside a `MetricStrip`.
447
+ *
448
+ * `<dt>` carries the label and `<dd>` the value, so the pair is announced as a
449
+ * described term rather than as two loose strings — the semantic that makes a
450
+ * figure legible without the visual grouping.
451
+ *
452
+ * The value is `tabular-nums`: these sit in a row and change on a timer, and
453
+ * proportional digits make the column jitter every time a 1 becomes an 8.
454
+ */
455
+ declare const Metric: React$1.ForwardRefExoticComponent<MetricProps & React$1.RefAttributes<HTMLDivElement>>;
456
+
457
+ /**
458
+ * The control row above a table or list: search, filters, then the view switch.
459
+ *
460
+ * It exists because the alternative keeps producing the same two failures. A
461
+ * bare flex row gives every control its natural width, so a search field sits
462
+ * at 1120px on one line and three selects stack full-width beneath it — three
463
+ * enormous empty bars where a compact bar belongs. And each control brought its
464
+ * own visual language, so one row carried a native `<select>`, a custom input
465
+ * and a segmented control side by side.
466
+ *
467
+ * So search and filters SHARE the free space and `actions` stays at its content
468
+ * width, and the row is the one place those controls are composed, which is what
469
+ * keeps them looking like one set.
470
+ *
471
+ * Sharing is deliberate, and the alternative is worse. Giving the filter row its
472
+ * content width starves search: measured with a 537px filter row in an 832px
473
+ * toolbar, the search field collapses to 89px. Letting both flex bounds the
474
+ * filter row instead, so it scrolls within its half — 313px each in that same
475
+ * case — and search stays usable however many filters a page adds. `max-w-sm`
476
+ * then stops search claiming more than it can use when there are few filters.
477
+ *
478
+ * On a narrow viewport the row becomes a column and the filters scroll
479
+ * horizontally as a group rather than wrapping into a tall stack that pushes
480
+ * the table itself below the fold.
481
+ */
482
+ /**
483
+ * Slots only — `children` is omitted deliberately. A free child would render as
484
+ * a bare flex item with none of the `min-w-0` / `shrink-0` guards the named
485
+ * slots carry, so it would size itself off its content and push the row into
486
+ * the overflow this layout exists to prevent.
487
+ */
488
+ interface ToolbarProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>, "children"> {
489
+ /** The one control that expands to fill the row. */
490
+ search?: React$1.ReactNode;
491
+ /** Filters, in reading order. Kept on one scrollable line when space runs out. */
492
+ filters?: React$1.ReactNode;
493
+ /** View switches and exports, pinned to the end. */
494
+ actions?: React$1.ReactNode;
495
+ }
496
+ declare const Toolbar: React$1.ForwardRefExoticComponent<ToolbarProps & React$1.RefAttributes<HTMLDivElement>>;
497
+ /**
498
+ * A labelled filter control. The label is visible, not a placeholder: a select
499
+ * whose current value IS its label ("All products") tells the reader what is
500
+ * selected but never what the control governs, so a row of three reads as three
501
+ * unrelated words.
502
+ */
503
+ interface FilterFieldProps extends React$1.HTMLAttributes<HTMLDivElement> {
504
+ label: string;
505
+ htmlFor?: string;
506
+ }
507
+ declare const FilterField: React$1.ForwardRefExoticComponent<FilterFieldProps & React$1.RefAttributes<HTMLDivElement>>;
508
+
509
+ export { Avatar, AvatarFallback, AvatarImage, Badge, type BadgeProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DropZone, type DropZoneProps, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, type EmptyStateProps, FilterField, type FilterFieldProps, InlineCode, type InlineCodeProps, Input, type InputProps, Label, Metric, type MetricProps, MetricStrip, type MetricStripProps, PageHeader, type PageHeaderProps, Progress, SegmentedControl, type SegmentedControlOption, type SegmentedControlProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, SidebarDropZone, type SidebarDropZoneProps, Skeleton, SkeletonCard, SkeletonTable, StatCard, type StatCardProps, StatusPill, type StatusPillProps, type StatusTone, Switch, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, Textarea, type TextareaProps, ThemeToggle, type Toast, ToastContainer, ToastProvider, Toolbar, type ToolbarProps, type UploadFile, UploadProgress, type UploadProgressProps, badgeVariants, useTheme, useToast };
@@ -11,9 +11,13 @@ import {
11
11
  DialogTrigger,
12
12
  DropZone,
13
13
  EmptyState,
14
+ FilterField,
14
15
  Input,
15
16
  Label,
16
17
  Logo,
18
+ Metric,
19
+ MetricStrip,
20
+ PageHeader,
17
21
  Progress,
18
22
  SegmentedControl,
19
23
  Select,
@@ -31,6 +35,7 @@ import {
31
35
  SkeletonCard,
32
36
  SkeletonTable,
33
37
  StatCard,
38
+ StatusPill,
34
39
  Switch,
35
40
  TangleKnot,
36
41
  TerminalCursor,
@@ -41,10 +46,11 @@ import {
41
46
  ThemeToggle,
42
47
  ToastContainer,
43
48
  ToastProvider,
49
+ Toolbar,
44
50
  UploadProgress,
45
51
  useTheme,
46
52
  useToast
47
- } from "./chunk-HSMVDKL3.js";
53
+ } from "./chunk-EJ5NBW7V.js";
48
54
  import {
49
55
  Avatar,
50
56
  AvatarFallback,
@@ -144,10 +150,14 @@ export {
144
150
  DropdownMenuSubTrigger,
145
151
  DropdownMenuTrigger,
146
152
  EmptyState,
153
+ FilterField,
147
154
  InlineCode,
148
155
  Input,
149
156
  Label,
150
157
  Logo,
158
+ Metric,
159
+ MetricStrip,
160
+ PageHeader,
151
161
  Progress,
152
162
  SegmentedControl,
153
163
  Select,
@@ -165,6 +175,7 @@ export {
165
175
  SkeletonCard,
166
176
  SkeletonTable,
167
177
  StatCard,
178
+ StatusPill,
168
179
  Switch,
169
180
  Table,
170
181
  TableBody,
@@ -187,6 +198,7 @@ export {
187
198
  ThemeToggle,
188
199
  ToastContainer,
189
200
  ToastProvider,
201
+ Toolbar,
190
202
  UploadProgress,
191
203
  badgeVariants,
192
204
  buttonVariants,
@@ -0,0 +1,42 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /**
4
+ * Viewer for a server-produced redacted document. Renders text inline and each
5
+ * redacted span as a masked chip; clicking a chip asks the server to reveal that
6
+ * one span. The original plaintext is NEVER in the document the client holds —
7
+ * the chip carries only an id + kind; `onReveal` round-trips to the server, where
8
+ * `@tangle-network/agent-app/redact`'s `revealSpan` runs the authorization check
9
+ * and writes the audit trail. So authz + audit are server-truth; this is display.
10
+ *
11
+ * Structural types (no `@tangle-network/agent-app` dependency) — the viewer needs
12
+ * only `{ id, kind }` per span; the cipher stays server-side.
13
+ */
14
+ type RedactedDocSegment = {
15
+ type: "text";
16
+ text: string;
17
+ } | {
18
+ type: "redacted";
19
+ id: string;
20
+ kind: string;
21
+ };
22
+ interface RedactedDocumentData {
23
+ segments: RedactedDocSegment[];
24
+ }
25
+ interface RevealResult {
26
+ ok: boolean;
27
+ value?: string;
28
+ /** e.g. `forbidden` | `not_found` when `ok` is false. */
29
+ reason?: string;
30
+ }
31
+ interface RedactedDocumentProps {
32
+ document: RedactedDocumentData;
33
+ /** Reveal one span by id. Wire to a server route that calls agent-app's
34
+ * `revealSpan` (authz + audit happen there). Resolves with the original. */
35
+ onReveal: (spanId: string) => Promise<RevealResult>;
36
+ /** Display label for a redaction kind (default: the kind, upper-cased). */
37
+ labelForKind?: (kind: string) => string;
38
+ className?: string;
39
+ }
40
+ declare function RedactedDocument({ document, onReveal, labelForKind, className, }: RedactedDocumentProps): ReactNode;
41
+
42
+ export { type RedactedDocSegment, RedactedDocument, type RedactedDocumentData, type RedactedDocumentProps, type RevealResult };
@@ -0,0 +1,7 @@
1
+ import {
2
+ RedactedDocument
3
+ } from "./chunk-HGML45QH.js";
4
+ import "./chunk-RQHJBTEU.js";
5
+ export {
6
+ RedactedDocument
7
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.2.4",
3
+ "version": "11.4.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",
@@ -101,6 +101,11 @@
101
101
  "types": "./dist/nav.d.ts",
102
102
  "import": "./dist/nav.js",
103
103
  "default": "./dist/nav.js"
104
+ },
105
+ "./redaction": {
106
+ "types": "./dist/redaction.d.ts",
107
+ "import": "./dist/redaction.js",
108
+ "default": "./dist/redaction.js"
104
109
  }
105
110
  },
106
111
  "dependencies": {
@@ -141,7 +146,7 @@
141
146
  "react-dom": "^18 || ^19",
142
147
  "react-router": "^7 || ^8",
143
148
  "yjs": "^13.6.0",
144
- "@tangle-network/brand": "^1.3.0"
149
+ "@tangle-network/brand": "^1.5.0"
145
150
  },
146
151
  "peerDependenciesMeta": {
147
152
  "@nanostores/react": {
@@ -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";
@@ -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 };