@tangle-network/ui 11.41.0 → 11.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { ComplianceBadge } from './compliance-badge'
3
+
4
+ const meta: Meta<typeof ComplianceBadge> = {
5
+ title: 'Primitives/ComplianceBadge',
6
+ component: ComplianceBadge,
7
+ parameters: { layout: 'centered' },
8
+ }
9
+
10
+ export default meta
11
+ type Story = StoryObj<typeof ComplianceBadge>
12
+
13
+ /**
14
+ * The default and the only HIPAA state surfaces may ship before the
15
+ * documented launch gate closes. Renders "HIPAA-Ready", outlined, on the
16
+ * info triple.
17
+ */
18
+ export const Ready: Story = { args: { status: 'ready' } }
19
+
20
+ /**
21
+ * Reserved for after HIPAA's launch gate closes (BAAs executed +
22
+ * infrastructure blockers resolved, per
23
+ * compliance/policies/hipaa-security-program.md). Renders "HIPAA Compliant",
24
+ * filled, on the success triple.
25
+ */
26
+ export const Compliant: Story = { args: { status: 'compliant' } }
27
+
28
+ export const ReadyMedium: Story = { args: { status: 'ready', size: 'md' } }
29
+ export const CompliantMedium: Story = { args: { status: 'compliant', size: 'md' } }
30
+
31
+ /**
32
+ * SOC 2 before an attestation: plain "SOC 2", outlined.
33
+ */
34
+ export const Soc2Ready: Story = { args: { product: 'soc2', status: 'ready' } }
35
+
36
+ /**
37
+ * SOC 2 Type II is an ATTAINED audit (final report on file at
38
+ * compliance/evidence/2026-09-01-final-audit), so unlike HIPAA's "compliant"
39
+ * this filled form is legitimate to ship today. Renders "SOC 2 Type II".
40
+ */
41
+ export const Soc2TypeII: Story = { args: { product: 'soc2', status: 'compliant' } }
42
+
43
+ export const Soc2TypeIIMedium: Story = {
44
+ args: { product: 'soc2', status: 'compliant', size: 'md' },
45
+ }
46
+
47
+ export const Linked: Story = {
48
+ args: { status: 'ready', href: '/trust' },
49
+ }
50
+
51
+ export const AllStates: Story = {
52
+ name: 'All States',
53
+ render: () => (
54
+ <div className="flex flex-col gap-4 p-6">
55
+ <div className="text-muted-foreground text-xs font-mono uppercase tracking-widest">HIPAA</div>
56
+ <div className="flex flex-wrap items-center gap-2">
57
+ <ComplianceBadge status="ready" />
58
+ <ComplianceBadge status="compliant" />
59
+ <ComplianceBadge status="ready" size="md" />
60
+ <ComplianceBadge status="compliant" size="md" />
61
+ </div>
62
+ <div className="text-muted-foreground text-xs font-mono uppercase tracking-widest mt-2">SOC 2</div>
63
+ <div className="flex flex-wrap items-center gap-2">
64
+ <ComplianceBadge product="soc2" status="ready" />
65
+ <ComplianceBadge product="soc2" status="compliant" />
66
+ <ComplianceBadge product="soc2" status="ready" size="md" />
67
+ <ComplianceBadge product="soc2" status="compliant" size="md" />
68
+ </div>
69
+ <div className="text-muted-foreground text-xs font-mono uppercase tracking-widest mt-2">Linked</div>
70
+ <div className="flex flex-wrap items-center gap-2">
71
+ <ComplianceBadge status="ready" href="/trust" />
72
+ <ComplianceBadge product="soc2" status="compliant" href="/trust" />
73
+ </div>
74
+ </div>
75
+ ),
76
+ }
@@ -0,0 +1,104 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { ComplianceBadge } from "./compliance-badge";
4
+
5
+ describe("ComplianceBadge", () => {
6
+ // The copy rule the component exists to enforce: there is no official HIPAA
7
+ // certification and the launch gate has not closed, so the unqualified claim
8
+ // must never render unless a caller asks for it explicitly.
9
+ it("defaults to HIPAA-Ready", () => {
10
+ render(<ComplianceBadge />);
11
+ expect(screen.getByLabelText("HIPAA-Ready")).toBeInTheDocument();
12
+ expect(screen.queryByText("HIPAA Compliant")).not.toBeInTheDocument();
13
+ });
14
+
15
+ it("renders HIPAA Compliant only with an explicit compliant status", () => {
16
+ render(<ComplianceBadge status="compliant" />);
17
+ expect(screen.getByLabelText("HIPAA Compliant")).toBeInTheDocument();
18
+ });
19
+
20
+ it("keeps the HIPAA copy exact when a product is given", () => {
21
+ render(<ComplianceBadge product="hipaa" status="ready" />);
22
+ expect(screen.getByLabelText("HIPAA-Ready")).toBeInTheDocument();
23
+ });
24
+
25
+ // SOC 2 Type II is an attained audit (compliance/evidence/2026-09-01-final-audit),
26
+ // so unlike HIPAA the filled claim is legitimate to render today — but still
27
+ // only under an explicit status, with plain "SOC 2" as the default.
28
+ it("defaults soc2 to plain SOC 2", () => {
29
+ render(<ComplianceBadge product="soc2" />);
30
+ expect(screen.getByLabelText("SOC 2")).toBeInTheDocument();
31
+ expect(screen.queryByText("SOC 2 Type II")).not.toBeInTheDocument();
32
+ });
33
+
34
+ it("renders SOC 2 Type II with an explicit compliant status", () => {
35
+ render(<ComplianceBadge product="soc2" status="compliant" />);
36
+ expect(screen.getByLabelText("SOC 2 Type II")).toBeInTheDocument();
37
+ });
38
+
39
+ it("fills the attained SOC 2 claim like any compliant status", () => {
40
+ const { container } = render(
41
+ <ComplianceBadge product="soc2" status="compliant" />,
42
+ );
43
+ const badge = container.firstElementChild as HTMLElement;
44
+ expect(badge.className).toContain("bg-[var(--surface-success-bg)]");
45
+ });
46
+
47
+ it("never says certified", () => {
48
+ const { container } = render(<ComplianceBadge status="compliant" />);
49
+ expect(container.textContent).not.toMatch(/certif/i);
50
+ });
51
+
52
+ it("outlines ready and fills compliant, each from one surface token triple", () => {
53
+ const { container: ready } = render(<ComplianceBadge status="ready" />);
54
+ const readyBadge = ready.firstElementChild as HTMLElement;
55
+ expect(readyBadge.className).toContain("bg-transparent");
56
+ expect(readyBadge.className).toContain("border-[var(--surface-info-border)]");
57
+ expect(readyBadge.className).toContain("text-[var(--surface-info-text)]");
58
+
59
+ const { container: compliant } = render(
60
+ <ComplianceBadge status="compliant" />,
61
+ );
62
+ const compliantBadge = compliant.firstElementChild as HTMLElement;
63
+ expect(compliantBadge.className).toContain(
64
+ "bg-[var(--surface-success-bg)]",
65
+ );
66
+ expect(compliantBadge.className).toContain(
67
+ "text-[var(--surface-success-text)]",
68
+ );
69
+ });
70
+
71
+ it("paints no hardcoded colours", () => {
72
+ const { container } = render(<ComplianceBadge status="compliant" />);
73
+ const badge = container.firstElementChild as HTMLElement;
74
+ expect(badge.className).not.toMatch(
75
+ /(?:bg|text|border)-(?:green|blue|red|amber|emerald)-/,
76
+ );
77
+ });
78
+
79
+ // The mark is decorative; the label carries the whole claim, so announcing
80
+ // both would say the same thing twice.
81
+ it("hides the brand mark from the accessibility tree", () => {
82
+ const { container } = render(<ComplianceBadge />);
83
+ const mark = container.querySelector("svg");
84
+ expect(mark).not.toBeNull();
85
+ expect(mark?.closest('[aria-hidden="true"]')).not.toBeNull();
86
+ });
87
+
88
+ it("renders as a link when href is given", () => {
89
+ render(<ComplianceBadge href="/trust" />);
90
+ const link = screen.getByRole("link", { name: "HIPAA-Ready" });
91
+ expect(link).toHaveAttribute("href", "/trust");
92
+ });
93
+
94
+ it("renders as a span without href", () => {
95
+ const { container } = render(<ComplianceBadge />);
96
+ expect(container.firstElementChild?.tagName).toBe("SPAN");
97
+ });
98
+
99
+ it.each(["sm", "md"] as const)("takes the %s size", (size) => {
100
+ const { container } = render(<ComplianceBadge size={size} />);
101
+ const badge = container.firstElementChild as HTMLElement;
102
+ expect(badge.className).toContain(size === "sm" ? "text-xs" : "text-sm");
103
+ });
104
+ });
@@ -0,0 +1,139 @@
1
+ import * as React from "react";
2
+ import { TangleKnot } from "@tangle-network/brand";
3
+ import { focusRing } from "../lib/focus";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * The one compliance badge every public surface uses, so the claim a page
8
+ * makes is chosen here and nowhere else.
9
+ *
10
+ * Copy discipline is the point of the component. There is NO official HIPAA
11
+ * certification a product can hold, so the badge never says "certified", and
12
+ * it renders "HIPAA Compliant" ONLY when a caller passes `status="compliant"`
13
+ * explicitly. The default — and the only HIPAA state surfaces may ship
14
+ * today — is `status="ready"`, which renders "HIPAA-Ready".
15
+ *
16
+ * For HIPAA, `status="compliant"` is reserved for use AFTER the company's
17
+ * documented launch gate closes: business associate agreements executed AND
18
+ * the infrastructure blockers resolved, per
19
+ * compliance/policies/hipaa-security-program.md. Do not pass it before that
20
+ * gate is confirmed closed in the policy record, whatever a page design
21
+ * suggests.
22
+ *
23
+ * That gate is HIPAA's. SOC 2 Type II is an ATTAINED audit, not a pending
24
+ * one — the final report is on file at
25
+ * compliance/evidence/2026-09-01-final-audit — so for `product="soc2"` the
26
+ * filled `status="compliant"` form, "SOC 2 Type II", is legitimate to render
27
+ * today.
28
+ *
29
+ * The claim is told three ways, matching StatusPill's doctrine: the LABEL
30
+ * text itself ("HIPAA-Ready" vs "HIPAA Compliant"), the FILL (outlined vs
31
+ * filled), and the TONE (the info triple while the gate is open, the success
32
+ * triple once it closes). Each status paints from one matched `--surface-*`
33
+ * token triple, so the text colour is solved against a known plane; the
34
+ * outlined "ready" form keeps the triple's text and border, which clear the
35
+ * 4.5:1 body floor on the page canvas, on a transparent fill.
36
+ *
37
+ * The Tangle mark is decorative: the label already carries the whole claim,
38
+ * so the mark is hidden from the accessibility tree and the badge's own
39
+ * `aria-label` states the claim.
40
+ */
41
+ export type ComplianceStatus = "ready" | "compliant";
42
+
43
+ /**
44
+ * The record is keyed by product so each framework's claim is reviewed copy
45
+ * in one place, not string interpolation at call sites. ISO 27001 slots in
46
+ * the same way when its audit lands.
47
+ */
48
+ export type ComplianceProduct = "hipaa" | "soc2";
49
+
50
+ export interface ComplianceBadgeProps {
51
+ /**
52
+ * Defaults to "ready". For HIPAA, pass "compliant" only after the launch
53
+ * gate closes; for SOC 2 the Type II audit is already attained, so
54
+ * "compliant" is legitimate today.
55
+ */
56
+ status?: ComplianceStatus;
57
+ product?: ComplianceProduct;
58
+ size?: "sm" | "md";
59
+ /** Renders the badge as a link, e.g. to the trust or compliance page. */
60
+ href?: string;
61
+ className?: string;
62
+ id?: string;
63
+ title?: string;
64
+ }
65
+
66
+ const PRODUCT_COPY: Record<
67
+ ComplianceProduct,
68
+ Record<ComplianceStatus, string>
69
+ > = {
70
+ hipaa: {
71
+ ready: "HIPAA-Ready",
72
+ compliant: "HIPAA Compliant",
73
+ },
74
+ soc2: {
75
+ ready: "SOC 2",
76
+ compliant: "SOC 2 Type II",
77
+ },
78
+ };
79
+
80
+ /**
81
+ * Outlined while a claim is still being earned (info: still moving), filled
82
+ * once it is attained (success: settled). Both draw from ONE matched token
83
+ * triple — no hardcoded colours — so themes repaint the badge like any other
84
+ * surface.
85
+ */
86
+ const STATUS_SURFACE: Record<ComplianceStatus, string> = {
87
+ ready:
88
+ "border-[var(--surface-info-border)] bg-transparent text-[var(--surface-info-text)]",
89
+ compliant:
90
+ "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]",
91
+ };
92
+
93
+ const SIZE_CLASSES = {
94
+ sm: "px-2 py-0.5 text-xs",
95
+ md: "px-2.5 py-1 text-sm",
96
+ } as const;
97
+
98
+ const MARK_SIZE = { sm: 12, md: 14 } as const;
99
+
100
+ function ComplianceBadge({
101
+ status = "ready",
102
+ product = "hipaa",
103
+ size = "sm",
104
+ href,
105
+ className,
106
+ ...props
107
+ }: ComplianceBadgeProps) {
108
+ const label = PRODUCT_COPY[product][status];
109
+ const classes = cn(
110
+ "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border font-medium",
111
+ SIZE_CLASSES[size],
112
+ STATUS_SURFACE[status],
113
+ href && focusRing,
114
+ className,
115
+ );
116
+ const mark = (
117
+ <span className="inline-flex shrink-0" aria-hidden="true">
118
+ <TangleKnot size={MARK_SIZE[size]} />
119
+ </span>
120
+ );
121
+
122
+ if (href) {
123
+ return (
124
+ <a href={href} aria-label={label} className={classes} {...props}>
125
+ {mark}
126
+ {label}
127
+ </a>
128
+ );
129
+ }
130
+
131
+ return (
132
+ <span aria-label={label} className={classes} {...props}>
133
+ {mark}
134
+ {label}
135
+ </span>
136
+ );
137
+ }
138
+
139
+ export { ComplianceBadge };
@@ -227,6 +227,13 @@ export type { PaginationProps } from "./pagination";
227
227
  export { StatusPill } from "./status-pill";
228
228
  export type { StatusPillProps, StatusTone } from "./status-pill";
229
229
 
230
+ export { ComplianceBadge } from "./compliance-badge";
231
+ export type {
232
+ ComplianceBadgeProps,
233
+ ComplianceProduct,
234
+ ComplianceStatus,
235
+ } from "./compliance-badge";
236
+
230
237
  export { Tag } from "./tag";
231
238
  export type { TagProps } from "./tag";
232
239
 
@@ -248,5 +255,26 @@ export type { CategoryTone, Tone, ToneClasses } from "./tone";
248
255
  export { MetricStrip, Metric } from "./metric-strip";
249
256
  export type { MetricStripProps, MetricProps } from "./metric-strip";
250
257
 
258
+ export { SeriesSparkline } from "./series-sparkline";
259
+ export type { SeriesSparklineProps } from "./series-sparkline";
260
+ export { MetricChart } from "./metric-chart";
261
+ export type { ChartReferenceLine, MetricChartProps } from "./metric-chart";
262
+ export {
263
+ normalizeSeries,
264
+ periodDelta,
265
+ seriesAreaPath,
266
+ seriesExtent,
267
+ seriesLinePath,
268
+ seriesMean,
269
+ seriesScale,
270
+ } from "../lib/series";
271
+ export type {
272
+ PeriodDelta,
273
+ SeriesExtent,
274
+ SeriesPoint,
275
+ SeriesPointInput,
276
+ SeriesScale,
277
+ } from "../lib/series";
278
+
251
279
  export { Toolbar, FilterField } from "./toolbar";
252
280
  export type { ToolbarProps, FilterFieldProps } from "./toolbar";
@@ -0,0 +1,55 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MetricChart } from "./metric-chart";
3
+
4
+ const meta = {
5
+ title: "Primitives/MetricChart",
6
+ component: MetricChart,
7
+ parameters: { layout: "padded" },
8
+ } satisfies Meta<typeof MetricChart>;
9
+ export default meta;
10
+ type Story = StoryObj<typeof meta>;
11
+
12
+ const DAY = 86_400_000;
13
+ const t0 = Date.parse("2026-08-24T07:00:00.000Z");
14
+
15
+ const bloodPressure = Array.from({ length: 12 }, (_, index) => ({
16
+ at: t0 + index * 5 * DAY,
17
+ value: 118 + ((index * 7) % 9),
18
+ value2: 74 + ((index * 5) % 8),
19
+ }));
20
+
21
+ const steps = Array.from({ length: 12 }, (_, index) => ({
22
+ at: t0 + index * 5 * DAY,
23
+ value: 6_200 + ((index * 1_937) % 4_800),
24
+ }));
25
+
26
+ export const DualSeries: Story = {
27
+ args: {
28
+ title: "Blood pressure",
29
+ unit: "mmHg",
30
+ decimals: 0,
31
+ points: bloodPressure,
32
+ referenceLines: [
33
+ { value: 120, label: "120 systolic" },
34
+ { value: 80, label: "80 diastolic", series: "secondary" },
35
+ ],
36
+ },
37
+ };
38
+
39
+ export const SingleSeries: Story = {
40
+ args: {
41
+ title: "Steps",
42
+ unit: "steps",
43
+ decimals: 0,
44
+ points: steps,
45
+ },
46
+ };
47
+
48
+ export const SingleReading: Story = {
49
+ args: {
50
+ title: "Weight",
51
+ unit: "kg",
52
+ decimals: 1,
53
+ points: [{ at: t0, value: 71.4 }],
54
+ },
55
+ };
@@ -0,0 +1,118 @@
1
+ import * as React from "react";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { MetricChart } from "./metric-chart";
5
+
6
+ afterEach(cleanup);
7
+
8
+ const DAY = 86_400_000;
9
+ const t0 = Date.parse("2026-09-01T07:00:00.000Z");
10
+ const points = Array.from({ length: 10 }, (_, index) => ({
11
+ at: t0 + index * 5 * DAY,
12
+ value: 118 + (index % 3) * 4,
13
+ value2: 76 + (index % 2) * 4,
14
+ }));
15
+
16
+ describe("MetricChart", () => {
17
+ it("renders an accessible svg with a generated summary description", () => {
18
+ const { container } = render(
19
+ <MetricChart points={points} title="Blood pressure" unit="mmHg" decimals={0} />,
20
+ );
21
+ const svg = screen.getByRole("img", { name: "Blood pressure" });
22
+ expect(svg.querySelector("title")!.textContent).toBe("Blood pressure");
23
+ const desc = svg.querySelector("desc")!.textContent!;
24
+ expect(desc).toContain("10 readings");
25
+ expect(desc).toContain("min 118");
26
+ expect(desc).toContain("max 126");
27
+ expect(desc).toContain("mmHg");
28
+ // Primary line, area and dotted secondary series.
29
+ expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(3);
30
+ });
31
+
32
+ it("honours a caller-provided description", () => {
33
+ const { container } = render(
34
+ <MetricChart points={points} title="Blood pressure" description="Custom summary." />,
35
+ );
36
+ expect(container.querySelector("desc")!.textContent).toBe("Custom summary.");
37
+ });
38
+
39
+ it("draws gridlines, date ticks and labelled reference lines on muted ink", () => {
40
+ const { container } = render(
41
+ <MetricChart
42
+ points={points}
43
+ title="Blood pressure"
44
+ referenceLines={[
45
+ { value: 120, label: "120 systolic" },
46
+ { value: 80, label: "80 diastolic", series: "secondary" },
47
+ ]}
48
+ />,
49
+ );
50
+ expect(screen.getByText("120 systolic")).toBeInTheDocument();
51
+ expect(screen.getByText("80 diastolic")).toBeInTheDocument();
52
+ // Four interior y ticks plus three x date ticks.
53
+ expect(screen.getByText("Sep 1")).toBeInTheDocument();
54
+ const dashed = Array.from(container.querySelectorAll("line")).filter(
55
+ (line) => line.getAttribute("stroke-dasharray") === "5 4",
56
+ );
57
+ expect(dashed).toHaveLength(2);
58
+ for (const line of dashed) expect(line.closest("g")!.getAttribute("class")).toContain("text-muted-foreground");
59
+ });
60
+
61
+ it("drops secondary reference lines when the series has no second reading", () => {
62
+ const single = points.map(({ at, value }) => ({ at, value }));
63
+ const { container } = render(
64
+ <MetricChart
65
+ points={single}
66
+ title="Heart rate"
67
+ referenceLines={[{ value: 80, label: "80 diastolic", series: "secondary" }]}
68
+ />,
69
+ );
70
+ expect(screen.queryByText("80 diastolic")).toBeNull();
71
+ expect(
72
+ Array.from(container.querySelectorAll("line")).filter(
73
+ (line) => line.getAttribute("stroke-dasharray") === "5 4",
74
+ ),
75
+ ).toHaveLength(0);
76
+ });
77
+
78
+ it("marks a single reading and renders axes only when data exists", () => {
79
+ const { container, rerender } = render(
80
+ <MetricChart points={[points[0]!]} title="Blood pressure" />,
81
+ );
82
+ expect(container.querySelectorAll("circle")).toHaveLength(1);
83
+ rerender(<MetricChart points={[]} title="Blood pressure" />);
84
+ expect(container.querySelectorAll("line")).toHaveLength(0);
85
+ expect(container.querySelectorAll("path")).toHaveLength(0);
86
+ expect(screen.getByRole("img", { name: "Blood pressure" })).toBeInTheDocument();
87
+ });
88
+
89
+ it("keeps every stroke and fill on currentColor", () => {
90
+ const { container } = render(<MetricChart points={points} title="Blood pressure" />);
91
+ for (const element of Array.from(container.querySelectorAll("[stroke], [fill]"))) {
92
+ const paint = element.getAttribute("stroke") ?? element.getAttribute("fill");
93
+ expect(["currentColor", "none"]).toContain(paint);
94
+ }
95
+ });
96
+
97
+ it("supports caller value and time formatting", () => {
98
+ render(
99
+ <MetricChart
100
+ points={points}
101
+ title="Blood pressure"
102
+ formatValue={(value) => `${value.toFixed(0)}!`}
103
+ formatTime={() => "sometime"}
104
+ />,
105
+ );
106
+ expect(screen.getAllByText("sometime")).toHaveLength(3);
107
+ expect(screen.getAllByText(/!$/).length).toBeGreaterThan(0);
108
+ });
109
+
110
+ it("preserves ref, caller attributes and caller classes", () => {
111
+ const ref = React.createRef<SVGSVGElement>();
112
+ const { container } = render(
113
+ <MetricChart ref={ref} points={points} title="Blood pressure" data-testid="bp" className="max-w-md" />,
114
+ );
115
+ expect(ref.current).toBe(container.querySelector("svg"));
116
+ expect(screen.getByTestId("bp").getAttribute("class")).toContain("max-w-md");
117
+ });
118
+ });