diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md
index e1249bc64..02fdd4225 100644
--- a/module/AUDIT_METRICS.md
+++ b/module/AUDIT_METRICS.md
@@ -195,7 +195,8 @@ never the SDK binding input. Regenerate the export before generating the SDK.
## Unknown and partial results
-Empty queries have no points and render **No data yet**. Numeric sums with no
+Empty queries have no points and render as no data, in the form the widget's
+visualization has for it (see *How a resolved series is drawn*). Numeric sums with no
numeric observations omit their value, just like averages/percentiles; an emitted
zero stays zero. `samples[alias]` reports observations before reduction (so
repeated logical IDs are not mistaken for missing telemetry). Fewer samples than
@@ -213,6 +214,60 @@ The host DSL also withholds totals after top-N truncation. Authorization/RPC
errors remain errors, never empty successful results. Existing consumers of the
SDK must handle `total: null` when adopting this contract.
+## How a resolved series is drawn
+
+Every dashboard built from these series — the host's own pages, a solution's
+declared dashboard, and a solution remote that mounts the kit itself — is drawn
+by one renderer, `` from `@codefly-dev/ui/dashboard`, with the kit's
+**metric tier**. There is no second, plainer rendering: a consuming solution
+does not have to ask which half of the kit its dashboard will get.
+
+| Widget | Drawn as | Empty series | `coverage: "partial"` | `total: null` |
+| --- | --- | --- | --- | --- |
+| `number` | `StatTile`: the series total, in the widget's `format` and `unit`; a sparkline and a delta against the previous bucket when the metric is bucketed over **time** | `—` with a **No data** badge, never `0` | `—` with a **Partial data** badge | `—` with a **No total** badge when coverage is complete |
+| `line` / `area` / `bar` | the kit's metric chart over a `ChartSeries` — axes from the appearance palette, a crosshair readout, a legend for two or more series, and every exact value in a visually-hidden data table | the chart's own **No data** plot, plus a **No data** badge on the card | the observed points still plot, with a **Partial data** badge on the card | nothing: a series widget shows no total |
+| `table` | key/value rows | **No data yet.** | a **Partial data** badge on the card | — |
+
+A scalar widget's `format` names how its value is written, and `percent` takes a
+value that is **already a percentage**: 45 renders "45%". It is not a share —
+0.4 renders "0.4%". A producer whose own `percent` means a 0-to-1 share scales it
+before it reaches the view, because the field is bound structurally and nothing
+on the wire distinguishes the two conventions.
+
+Two of those cells are the contract, not presentation. A `null` total renders as
+a dash and never as `0`, because "cannot say" and "nothing happened" are
+different answers and only one of them is in the data; and a series with no
+points is no data rather than a zero, because the aggregate omits a bucket
+instead of emitting an empty one. A sparkline and a bucket delta are drawn
+**only** for a time-bucketed series: a line through counts grouped by event type
+or region would assert an order, and a delta between two of them a change over
+time, that a categorical grouping does not have. Both read the series in **time**
+order, taken from the parsed bucket keys rather than from the order the
+aggregate returned them in, so a series delivered newest-first reports the same
+change — a delta's sign is drawn as a colour, and getting it from an array
+position would paint a decline as a green rise.
+
+A widget whose resolution **failed** says so only when it has nothing else to
+say. With a series still in hand — a refresh that did not land — the tile draws
+that series and carries a **Stale** badge instead, because values that are old
+are still better than a blank card. Only a failure with nothing behind it reads
+as "Unable to load."
+
+A widget whose resolution was **refused** is the other case, and it keeps
+nothing. "No access" is drawn before the series is read, and the host that
+resolved the widget deletes the values it had cached — every revision of that
+widget's declaration, at the read that was refused rather than in the component
+displaying it, so a refusal arriving after the viewer moved on still lands.
+Numbers this viewer is no longer allowed to read are a wrong answer, not a stale
+one, and only a read that succeeds puts any back.
+
+A host composes its own features around that renderer — ``'s slots
+take header actions, a layout (the host's solution dashboard passes a
+drag-to-reorder grid), a per-widget renderer for a host that resolves each
+widget in its own query, and an empty state. A consumer re-implementing any part
+of the drawing is refused by a gate: `src/components/__tests__/kit.test.ts` in
+the frontend.
+
## Independent validation
Run `go test -race ./pkg/infra/auditmetricstest` from accounts/code for pure
diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md
index 00759fb49..0a257e52b 100644
--- a/module/services/frontend/AGENTS.md
+++ b/module/services/frontend/AGENTS.md
@@ -115,6 +115,40 @@ whether `/s/{id}` renders, and it stays installation-blind.
observation speaks for the present is the host's to decide, and a copy of that
window here would drift from it.
+## Drawing a solution's declared dashboard
+
+`src/solutions/SolutionDashboard.tsx` **mounts** the shared kit's
+`` (`@codefly-dev/ui/dashboard`); it draws nothing itself. Which
+tier each widget and each coverage state is drawn with is the module's
+[AUDIT_METRICS.md](../../AUDIT_METRICS.md) ("How a resolved series is drawn") —
+one renderer for the host's pages, a declared solution dashboard and a solution
+remote alike.
+
+What this file owns is only what is the host's, composed through that
+renderer's slots:
+
+- **the audit queries**, one per widget, so a widget whose metric errors fails
+ alone instead of blanking its siblings. Each lives in its own component
+ (`SolutionWidget`) that resolves the series and then mounts the kit's
+ `DashboardWidget`, because React fixes the number of hooks a component may
+ call and a renderer taking the whole view cannot hold a varying number of
+ queries;
+- **the viewer's arrangement** — reorder by drag or arrow keys, remove, add
+ back, saved per viewer and organization (`dashboard-layout.ts`), handed to
+ the renderer's `layout` slot as the kit's `SortableGrid`;
+- **the ⓘ** that says what a tile counts, from which audit events, over what
+ range and who caused the newest ones (`metric-info.ts`, the principal
+ directory, the recent-events drill-down), handed to each widget as its
+ header actions.
+
+Re-implementing any part of the drawing is refused by
+`src/components/__tests__/kit.test.ts`: a declaration taking a name
+`@codefly-dev/ui` exports must render the kit's component of that name or carry
+a reviewed entry, `class-variance-authority` is not a dependency of this app so
+a kit-shaped primitive cannot be built here, and the two chart surfaces
+(`src/components/charts/chart.tsx`,
+`src/features/dashboard/ui/charts/line-chart.tsx`) stay re-exports.
+
## Loading a remote, and its CSP
`/s/[solutionId]` (`src/app/(dashboard)/s/[solutionId]/page.tsx`) loads the
@@ -218,3 +252,18 @@ failures part company here, which is what `mayKeepRetainedRows` and
those very rows, so they go; a **transient failure** is not, so they stay with a
note that they may be out of date. Blanking a good table because one request did
not come back is worse than the staleness.
+
+**Where a refusal acts is the read, not the component.** Withholding rows at
+render answers for the error in hand and leaves the rows in the cache, so the
+next transient failure serves them again — authority is a property of the read,
+and so is what the read may leave behind. A refused read therefore deletes the
+retained snapshots for every cached key of what it was reading, in the query
+function where `readOutcome` classifies it, before the rejection reaches any
+component. `src/solutions/SolutionDashboard.tsx` is the worked example: one
+widget, several cached revisions of its declaration, and a refusal that may
+arrive after the viewer has navigated away or the page has unmounted, where no
+effect of theirs is left to run. Delete the data and nothing else: removing the
+entry cancels the read reporting the refusal and strands recovery, clearing it
+restarts every active read of it so a standing 403 never settles, and
+`setQueryData(key, undefined)` is a documented no-op. Recovery is then exactly
+one thing — a read that succeeds.
diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json
index 76aa8ac38..1cc6a8c4d 100644
--- a/module/services/frontend/code/package-lock.json
+++ b/module/services/frontend/code/package-lock.json
@@ -16,8 +16,8 @@
"@codefly-dev/saas-plugin-contract": "2.4.2",
"@codefly-dev/saas-plugin-react": "0.4.3",
"@codefly-dev/saas-sdk": "0.3.23",
- "@codefly-dev/saas-ui": "0.12.6",
- "@codefly-dev/ui": "0.12.6",
+ "@codefly-dev/saas-ui": "0.12.7",
+ "@codefly-dev/ui": "0.12.7",
"@codefly/saas-plugin-manifest": "0.1.1",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
@@ -34,7 +34,6 @@
"@simplewebauthn/browser": "^13.3.0",
"@tanstack/react-query": "^5.60.0",
"@tanstack/react-table": "^8.21.3",
- "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz",
"js-yaml": "^4.3.2",
@@ -15234,7 +15233,7 @@
},
"packages/codefly-ui": {
"name": "@codefly-dev/ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"license": "Apache-2.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -15382,7 +15381,7 @@
},
"packages/saas-ui": {
"name": "@codefly-dev/saas-ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"license": "Apache-2.0",
"devDependencies": {
"@bufbuild/protobuf": "^2.11.0",
diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json
index cfae0c638..ca15dbe96 100644
--- a/module/services/frontend/code/package.json
+++ b/module/services/frontend/code/package.json
@@ -39,8 +39,8 @@
"@codefly-dev/saas-plugin-contract": "2.4.2",
"@codefly-dev/saas-plugin-react": "0.4.3",
"@codefly-dev/saas-sdk": "0.3.23",
- "@codefly-dev/saas-ui": "0.12.6",
- "@codefly-dev/ui": "0.12.6",
+ "@codefly-dev/saas-ui": "0.12.7",
+ "@codefly-dev/ui": "0.12.7",
"@codefly/saas-plugin-manifest": "0.1.1",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
@@ -57,7 +57,6 @@
"@simplewebauthn/browser": "^13.3.0",
"@tanstack/react-query": "^5.60.0",
"@tanstack/react-table": "^8.21.3",
- "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz",
"js-yaml": "^4.3.2",
diff --git a/module/services/frontend/code/packages/codefly-ui/README.md b/module/services/frontend/code/packages/codefly-ui/README.md
index 070eee474..95543fdca 100644
--- a/module/services/frontend/code/packages/codefly-ui/README.md
+++ b/module/services/frontend/code/packages/codefly-ui/README.md
@@ -36,6 +36,15 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md).
(`Banner`, `EmptyState`, `ErrorState`) and overlays (`Dialog`, `AlertDialog`, `Notice`, `Tooltip`,
`DropdownMenu`); `dashboard` is ``, charts, `fromDashboardData`; `chat`
is ``. React only: no plugin runtime, no host context.
+ `` paints with the **metric tier** — `StatTile`/`KPIRow` for scalar
+ widgets, `metric-chart`'s line/area/bar over `ChartSeries` for series ones —
+ which is the tier the host's own operations pages use, so a solution's
+ dashboard and the host's own are the same picture. `DashboardWidget` is that
+ renderer for one widget, exported for a consumer that resolves each widget in
+ its own query; `slots` take header actions, a layout, a per-widget renderer
+ and an empty state, so a consumer composes its own features around the
+ renderer rather than beside it. What each widget and each coverage state
+ draws is tabulated in the module's `AUDIT_METRICS.md`.
This is the surface a solution fe-remote consumes. `` is fed by
`@codefly-dev/saas-sdk`'s `useChatStream` — the hook owns the SSE/WS transport, the
component stays pure, the same split as `runDashboard` → ``.
@@ -106,7 +115,7 @@ solution measured where it had to reach past the kit.
| `@codefly-dev/ui/plugin-host/ui` | Client UI adapters (`PluginErrorBoundary`) |
| `@codefly-dev/ui/skin` | `resolveSkin`, skin types |
| `@codefly-dev/ui/layout` | `Card`/`Section`/`Tabs` + shadcn primitives (React-only) |
-| `@codefly-dev/ui/dashboard` | `Dashboard`, charts, `fromDashboardData` (React-only) |
+| `@codefly-dev/ui/dashboard` | `Dashboard`, `DashboardWidget`, metric tiles + charts, `fromDashboardData` (React-only) |
| `@codefly-dev/ui/chat` | `Chat` (React-only) |
| `@codefly-dev/ui/content` | `Content`, `Markdown`, `JsonView`, `CodeBlock`, `TextBlock` (React-only) |
| `@codefly-dev/ui/board` | `Board` (React-only) |
diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json
index 18f4fc37c..056f89478 100644
--- a/module/services/frontend/code/packages/codefly-ui/package.json
+++ b/module/services/frontend/code/packages/codefly-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.",
"repository": {
"type": "git",
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/atoms.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/atoms.test.tsx
index fb6d0aeb2..36d76d9f8 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/atoms.test.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/atoms.test.tsx
@@ -2,18 +2,30 @@ import { describe, expect, it } from "vitest";
import { Axis, Gridline } from "../atoms.js";
import { linearScale, sparkPlot } from "../geometry.js";
-type ElementLike = { type?: unknown; props?: { children?: unknown } & Record };
+type ElementLike = {
+ type?: unknown;
+ props?: { children?: unknown } & Record;
+};
// Collect every element of a given SVG tag (a rendered component is a plain
// object tree, so no DOM is needed) with its props and text child. Recurses into
// child arrays — an axis holds one array of y labels and one of x labels.
-function byTag(node: unknown, tag: string): { props: Record; text: string }[] {
+function byTag(
+ node: unknown,
+ tag: string,
+): { props: Record; text: string }[] {
if (Array.isArray(node)) return node.flatMap((child) => byTag(child, tag));
if (!node || typeof node !== "object") return [];
const el = node as ElementLike;
const self =
el.type === tag && el.props
- ? [{ props: el.props, text: typeof el.props.children === "string" ? el.props.children : "" }]
+ ? [
+ {
+ props: el.props,
+ text:
+ typeof el.props.children === "string" ? el.props.children : "",
+ },
+ ]
: [];
return self.concat(byTag(el.props?.children, tag));
}
@@ -34,13 +46,18 @@ describe("Gridline", () => {
describe("Axis", () => {
it("labels each y tick with the formatted value", () => {
const scale = linearScale(0, 10, plot.bottom, plot.top);
- const labels = byTag(Axis({ plot, y: { ticks: [0, 5, 10], scale } }), "text").map((t) => t.text);
+ const labels = byTag(
+ Axis({ plot, y: { ticks: [0, 5, 10], scale } }),
+ "text",
+ ).map((t) => t.text);
expect(labels).toEqual(["0", "5", "10"]);
});
it("time-formats ISO bucket keys on the x axis", () => {
const keys = ["2026-09-01T00:00:00+00", "2026-09-02T00:00:00+00"];
- const labels = byTag(Axis({ plot, x: { keys } }), "text").map((t) => t.text);
+ const labels = byTag(Axis({ plot, x: { keys } }), "text").map(
+ (t) => t.text,
+ );
expect(labels).toEqual(["Sep 1", "Sep 2"]);
});
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/charts.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/charts.test.tsx
index 600a9200d..42a18a171 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/charts.test.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/charts.test.tsx
@@ -3,7 +3,10 @@ import { Axis, Svg } from "../atoms.js";
import { AreaChart, LineChart, StatChart } from "../charts.js";
import type { SeriesPoint } from "../types.js";
-type ElementLike = { type?: unknown; props?: { children?: unknown } & Record };
+type ElementLike = {
+ type?: unknown;
+ props?: { children?: unknown } & Record;
+};
// Walk a React element tree (no DOM needed — a rendered component is a plain
// object) collecting every 's props, so a test can assert what color the
@@ -36,7 +39,9 @@ const pts: SeriesPoint[] = [
// a text-color class (and the Dashboard accent behind `text-primary`) theme it.
describe("chart color inherits via currentColor", () => {
it("LineChart strokes with currentColor", () => {
- const strokes = pathProps(LineChart({ points: pts })).filter((p) => p.stroke && p.stroke !== "none");
+ const strokes = pathProps(LineChart({ points: pts })).filter(
+ (p) => p.stroke && p.stroke !== "none",
+ );
expect(strokes.length).toBeGreaterThan(0);
for (const p of strokes) expect(p.stroke).toBe("currentColor");
});
@@ -69,7 +74,9 @@ describe("axes gating", () => {
expect(tree.props.stretch).toBe(false);
expect(byType(tree, Axis)).toHaveLength(1);
// The data path still colors via currentColor, not a hardcoded hue.
- const strokes = pathProps(tree).filter((p) => p.stroke && p.stroke !== "none");
+ const strokes = pathProps(tree).filter(
+ (p) => p.stroke && p.stroke !== "none",
+ );
expect(strokes.length).toBeGreaterThan(0);
for (const p of strokes) expect(p.stroke).toBe("currentColor");
});
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx
index cd9ffb4a2..981a3be7b 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx
@@ -1,77 +1,719 @@
// @vitest-environment happy-dom
-import { cleanup, render, screen } from "@testing-library/react";
+import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Dashboard } from "../dashboard.js";
-import type { DashboardView } from "../types.js";
+import type {
+ DashboardView,
+ DashboardWidgetView,
+ WidgetSeries,
+} from "../types.js";
afterEach(cleanup);
-const emptySeries = { points: [], total: 0 };
+// Two daily buckets: a time-bucketed series, so it earns a trend.
+const daily: WidgetSeries = {
+ points: [
+ { key: "2026-09-01T00:00:00Z", value: 3 },
+ { key: "2026-09-02T00:00:00Z", value: 5 },
+ ],
+ total: 8,
+};
+
+// The same counts grouped by a category: an ordered line through these would
+// assert a direction the data does not have.
+const byCategory: WidgetSeries = {
+ points: [
+ { key: "identity", value: 3 },
+ { key: "security", value: 5 },
+ ],
+ total: 8,
+};
+
+const empty: WidgetSeries = { points: [], total: 0, coverage: "empty" };
+
+function view(...widgets: DashboardWidgetView[]): DashboardView {
+ return { widgets };
+}
+
+function show(...widgets: DashboardWidgetView[]) {
+ return render( );
+}
describe("Dashboard", () => {
- it("renders its header through Section and each widget through Card", () => {
- const data: DashboardView = {
- title: "Traffic",
- description: "last 7 days",
- widgets: [
- {
- id: "w1",
- title: "Visits",
- visualization: "number",
- series: emptySeries,
- },
- ],
- };
- const { container } = render( );
+ it("renders its header through Section and each series widget through Card", () => {
+ const { container } = render(
+ ,
+ );
- // Section owns the header markup — its root and heading classes.
- const section = container.querySelector("section");
- expect(section).not.toBeNull();
+ // Section owns the header markup — its root and heading.
+ expect(container.querySelector("section")).not.toBeNull();
expect(
screen.getByRole("heading", { name: "Traffic", level: 2 }),
).toBeTruthy();
expect(screen.getByText("last 7 days")).toBeTruthy();
- // Card owns the surface — one card per widget, painted by the primitive.
- const card = container.querySelector(
- ".rounded-lg.border.bg-card.p-4.text-card-foreground.shadow-sm",
- );
- expect(card).not.toBeNull();
+ // Card owns the surface — one card per series widget, painted by the
+ // primitive, with the widget's title as its heading.
+ expect(
+ container.querySelector(
+ ".rounded-lg.border.bg-card.p-4.text-card-foreground.shadow-sm",
+ ),
+ ).not.toBeNull();
expect(
screen.getByRole("heading", { name: "Visits", level: 3 }),
).toBeTruthy();
});
+ it("reaches the token the metric charts actually paint with its accent", () => {
+ // The tier change moved where a mark's colour comes from: a metric
+ // chart colours its first series `var(--chart-1)`, not `--primary`,
+ // so an accent that set only `--primary` stopped colouring the charts
+ // it exists for. Asserted on the rendered mark, not just the variable.
+ const { container } = render(
+ ,
+ );
+ expect(
+ container.querySelector('path[stroke="var(--chart-1)"]'),
+ ).not.toBeNull();
+ const section = container.querySelector("section") as HTMLElement;
+ expect(section.style.getPropertyValue("--chart-1")).toBe("hotpink");
+ expect(section.style.getPropertyValue("--primary")).toBe("hotpink");
+ });
+
+ it("reaches a scalar's own trend with the accent, not just the charts", () => {
+ // A declared accent that coloured the line charts but not the
+ // sparklines beside them drew one dashboard in two palettes.
+ const { container } = render(
+ ,
+ );
+ // The delta draws a lucide arrow, so the sparkline is not the first svg.
+ const [trend] = [...container.querySelectorAll("svg")].filter(
+ (svg) => !svg.classList.contains("lucide"),
+ );
+ expect((trend as SVGElement).style.color).toBe("var(--chart-1)");
+ expect(
+ (
+ container.querySelector("section") as HTMLElement
+ ).style.getPropertyValue("--chart-1"),
+ ).toBe("hotpink");
+ });
+
+ it("keeps a titled scalar reachable by heading navigation", () => {
+ // It was a Card with an ; a StatTile's label is a span, so the
+ // title left the document outline when the tier changed.
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { name: "Total events", level: 3 }),
+ ).toBeTruthy();
+ });
+
it("scopes the accent override onto the Section root", () => {
- const data: DashboardView = {
- accent: "hotpink",
- widgets: [{ id: "w1", visualization: "number", series: emptySeries }],
- };
- const { container } = render( );
+ const { container } = render(
+ ,
+ );
const section = container.querySelector("section") as HTMLElement;
expect(section.style.getPropertyValue("--primary")).toBe("hotpink");
});
+
+ it("says so, rather than drawing an empty grid, when the view has no widgets", () => {
+ show();
+ expect(screen.getByText("No widgets.")).toBeTruthy();
+ });
});
-it("preserves partial telemetry and unavailable totals from the SDK view", () => {
- render(
- {
+ it("draws a metric chart with an accessible data table", () => {
+ const { container } = render(
+ ,
+ );
+ const table = screen.getByRole("table", { name: "Events" });
+ expect(within(table).getByRole("row", { name: /identity/ })).toBeTruthy();
+ expect(within(table).getByText("3")).toBeTruthy();
+ expect(within(table).getByText("5")).toBeTruthy();
+ expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe(
+ "true",
+ );
+ });
+
+ it("draws the chart's own empty state instead of an axis with nothing on it", () => {
+ const { container } = render(
+ ,
+ );
+ expect(
+ container.querySelector("figure")?.getAttribute("aria-label"),
+ ).toBe("Events: no data");
+ expect(container.querySelector("svg")).toBeNull();
+ // Said twice on purpose, and in two registers: the card badges the
+ // coverage beside its title, and the plot says it where the geometry
+ // would have been.
+ expect(screen.getAllByText("No data")).toHaveLength(2);
+ });
+
+ it("badges a partial series on the card, from `series.coverage`", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Partial data")).toBeTruthy();
+ // A partial series keeps its observed points: the chart still draws.
+ expect(screen.getByRole("table", { name: "Events" })).toBeTruthy();
+ });
+ },
+);
+
+describe("a number widget", () => {
+ it("is a stat tile showing the series total in the widget's format", () => {
+ show({
+ id: "w",
+ title: "Revenue",
+ visualization: "number",
+ series: { points: [{ key: "all", value: 4_200_000 }], total: 4_200_000 },
+ format: "currency",
+ });
+ expect(screen.getByText("Revenue")).toBeTruthy();
+ expect(screen.getByText("$4.2M")).toBeTruthy();
+ });
+
+ it("appends the widget's unit", () => {
+ show({
+ id: "w",
+ title: "Throughput",
+ visualization: "number",
+ series: { points: [{ key: "all", value: 42 }], total: 42 },
+ unit: "req/s",
+ });
+ expect(screen.getByText("req/s")).toBeTruthy();
+ });
+
+ it("sparklines a time-bucketed metric and deltas its last bucket", () => {
+ const { container } = show({
+ id: "w",
+ title: "Logins",
+ visualization: "number",
+ series: daily,
+ });
+ expect(screen.getByText("8")).toBeTruthy();
+ expect(container.querySelector("svg")).not.toBeNull();
+ // 3 → 5 between the last two buckets.
+ expect(screen.getByText(/66\.7%/).textContent).toContain(
+ "vs previous bucket",
+ );
+ });
+
+ it("draws neither a trend nor a delta for a metric grouped by category", () => {
+ // A sparkline over "identity, security" would read as a direction, and a
+ // delta between them as a change over time. Neither is true of a
+ // categorical grouping, whose order is the aggregate's, not time's.
+ const { container } = show({
+ id: "w",
+ title: "Logins",
+ visualization: "number",
+ series: byCategory,
+ });
+ expect(screen.getByText("8")).toBeTruthy();
+ expect(container.querySelector("svg")).toBeNull();
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+
+ it("deltas the two latest buckets whatever order the series arrives in", () => {
+ // The resolver appends one point per bucket in the order the aggregate
+ // returned them, with no sort, and a delta's sign is drawn as a colour
+ // — so reading "previous" and "latest" off the last two array positions
+ // would paint a decline green for a series delivered newest-first.
+ const forward = [
+ { key: "2026-09-01T00:00:00Z", value: 10 },
+ { key: "2026-09-02T00:00:00Z", value: 5 },
+ ];
+ const reading = (points: typeof forward) => {
+ const { unmount } = show({
+ id: "w",
+ title: "Logins",
+ visualization: "number",
+ series: { points, total: 15 },
+ });
+ const text = screen.getByText(/%/).textContent;
+ unmount();
+ return text;
+ };
+ const ordered = reading(forward);
+ expect(ordered).toContain("50%");
+ // A halving, drawn as a fall, not a doubling drawn as a rise.
+ expect(ordered).toContain("−");
+ expect(reading([...forward].reverse())).toBe(ordered);
+ });
+
+ it("orders the sparkline by time too, so the trend and the delta agree", () => {
+ const { container } = show({
+ id: "w",
+ visualization: "number",
+ series: {
+ points: [
+ { key: "2026-09-03T00:00:00Z", value: 9 },
+ { key: "2026-09-01T00:00:00Z", value: 1 },
+ { key: "2026-09-02T00:00:00Z", value: 5 },
],
- }}
- />,
- );
- expect(screen.getByRole("status").textContent).toBe("Partial telemetry");
- expect(screen.getByText("Total unavailable")).toBeTruthy();
- expect(screen.queryByText("0")).toBeNull();
+ total: 15,
+ },
+ });
+ // The sparkline's path rises monotonically: 1, 5, 9 in time order, not
+ // 9, 1, 5 in array order.
+ const paths = [...container.querySelectorAll("svg path")];
+ const d = paths[paths.length - 1]?.getAttribute("d") ?? "";
+ const ys = [...d.matchAll(/[ML][\d.]+,([\d.]+)/g)].map((m) => Number(m[1]));
+ expect(ys).toHaveLength(3);
+ // Smaller y is higher on screen, so a rising series falls in y.
+ expect(ys[0]).toBeGreaterThan(ys[1]);
+ expect(ys[1]).toBeGreaterThan(ys[2]);
+ });
+
+ it("reads `format: percent` as an already-scaled percentage, not a share", () => {
+ // The field is bound structurally, so nothing on the wire distinguishes
+ // a producer whose own `percent` means a 0-to-1 share. This pins the
+ // kit's convention: a producer with the other one must scale first, and
+ // finds out here rather than by shipping a hundredfold error.
+ show({
+ id: "w",
+ title: "MFA adoption",
+ visualization: "number",
+ series: { points: [{ key: "all", value: 45 }], total: 45 },
+ format: "percent",
+ });
+ expect(screen.getByText("45%")).toBeTruthy();
+ expect(screen.queryByText("4,500%")).toBeNull();
+ });
+
+ it("gives no delta for a rise from zero, which is not a percentage", () => {
+ show({
+ id: "w",
+ visualization: "number",
+ series: {
+ points: [
+ { key: "2026-09-01T00:00:00Z", value: 0 },
+ { key: "2026-09-02T00:00:00Z", value: 5 },
+ ],
+ total: 5,
+ },
+ });
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+
+ it("shows a dash and says no total, never a 0, when the total is withheld", () => {
+ // `total: null` means the pipeline cannot name one number — a distinct
+ // count or an average across groups. Rendering it as 0 would report
+ // "nothing happened" where the answer is "cannot say".
+ show({
+ id: "w",
+ title: "Distinct actors",
+ visualization: "number",
+ series: { ...byCategory, total: null },
+ });
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.getByText("No total")).toBeTruthy();
+ expect(screen.queryByText("0")).toBeNull();
+ });
+
+ it("badges a partial series, which is why its total is withheld", () => {
+ show({
+ id: "w",
+ title: "Logins",
+ visualization: "number",
+ series: { ...byCategory, total: null, coverage: "partial" },
+ });
+ expect(screen.getByText("Partial data")).toBeTruthy();
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.queryByText("No total")).toBeNull();
+ });
+
+ it("reads an empty series as no data, not as a zero", () => {
+ // The audit aggregate omits a bucket rather than emitting a zero one, so
+ // a series with no points means nothing matched.
+ show({ id: "w", title: "Logins", visualization: "number", series: empty });
+ expect(screen.getByText("No data")).toBeTruthy();
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.queryByText("0")).toBeNull();
+ });
+
+ it("keeps a real zero a zero", () => {
+ show({
+ id: "w",
+ title: "Failures",
+ visualization: "number",
+ series: { points: [{ key: "all", value: 0 }], total: 0 },
+ });
+ expect(screen.getByText("0")).toBeTruthy();
+ expect(screen.queryByText("—")).toBeNull();
+ });
+});
+
+describe("a table widget", () => {
+ it("lists each point's key and value", () => {
+ show({
+ id: "w",
+ title: "By type",
+ visualization: "table",
+ series: byCategory,
+ });
+ const table = screen.getByRole("table", { name: "By type" });
+ expect(within(table).getByText("identity")).toBeTruthy();
+ expect(within(table).getByText("5")).toBeTruthy();
+ });
+
+ it("says there is nothing yet for an empty series", () => {
+ show({ id: "w", title: "By type", visualization: "table", series: empty });
+ expect(screen.getByText("No data yet.")).toBeTruthy();
+ });
+});
+
+describe("a widget still being resolved, or that failed", () => {
+ it("waits on a null series rather than drawing it as empty", () => {
+ const { container } = show(
+ { id: "chart", title: "Logins", visualization: "line", series: null },
+ { id: "tile", title: "Total", visualization: "number", series: null },
+ );
+ expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(
+ 2,
+ );
+ expect(screen.queryByText("No data")).toBeNull();
+ });
+
+ it("keeps a series a failed refresh left behind, and marks it stale", () => {
+ // A refresh that did not land is not an absence of data. Blanking the
+ // tile would throw away values that are still the best available.
+ const { container } = show(
+ {
+ id: "chart",
+ title: "Logins",
+ visualization: "line",
+ series: byCategory,
+ failure: "failed",
+ },
+ {
+ id: "tile",
+ title: "Total",
+ visualization: "number",
+ series: daily,
+ failure: "failed",
+ },
+ );
+ expect(screen.queryByText("Unable to load.")).toBeNull();
+ expect(screen.getAllByText("Stale")).toHaveLength(2);
+ expect(screen.getByRole("table", { name: "Logins" })).toBeTruthy();
+ expect(screen.getByText("8")).toBeTruthy();
+ expect(container.querySelector('[data-slot="skeleton"]')).toBeNull();
+ });
+
+ it("keeps an empty series valueless through a failed refresh", () => {
+ // The badge is one slot and freshness takes it, so deriving the value's
+ // presence from the state turned a known-empty scalar into a reported
+ // zero the moment its refresh failed.
+ const widget: DashboardWidgetView = {
+ id: "total",
+ visualization: "number",
+ series: empty,
+ };
+ const { rerender } = render( );
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.getByText("No data")).toBeTruthy();
+
+ rerender( );
+ expect(screen.getByText("Stale")).toBeTruthy();
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.queryByText("0")).toBeNull();
+ });
+
+ it("keeps a partial series identifiable through a failed refresh", () => {
+ show({
+ id: "w",
+ title: "Logins",
+ visualization: "number",
+ series: { ...byCategory, total: null, coverage: "partial" },
+ failure: "failed",
+ });
+ // Freshness takes the badge, but the withheld total still reads as one.
+ expect(screen.getByText("Stale")).toBeTruthy();
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.queryByText("8")).toBeNull();
+ });
+
+ it("discards a series the caller retained on a refused read", () => {
+ // A number this viewer may not see is a WRONG answer, not an old one.
+ // The host is expected to withhold it; the renderer that owns what a
+ // refusal looks like fails closed too, so a caller that forwards
+ // retained values by mistake cannot show them through this.
+ const { container } = show(
+ {
+ id: "chart",
+ title: "Logins",
+ visualization: "line",
+ series: byCategory,
+ failure: "refused",
+ },
+ {
+ id: "tile",
+ title: "Total",
+ visualization: "number",
+ series: daily,
+ failure: "refused",
+ },
+ );
+ expect(screen.queryByText("8")).toBeNull();
+ expect(container.querySelector("svg")).toBeNull();
+ expect(screen.queryByRole("table", { name: "Logins" })).toBeNull();
+ expect(screen.getAllByText("No access")).toHaveLength(2);
+ expect(
+ screen.getByText("You don't have permission to see this."),
+ ).toBeTruthy();
+ expect(screen.queryByText("Unable to load.")).toBeNull();
+ });
+
+ it("says a failed widget failed, and does not wait forever on its missing series", () => {
+ // A failed resolution leaves no series behind, so a renderer that tested
+ // for the series first would draw every failure as an endless wait.
+ const { container } = show(
+ {
+ id: "chart",
+ title: "Logins",
+ visualization: "line",
+ series: null,
+ failure: "failed",
+ },
+ {
+ id: "tile",
+ title: "Total",
+ visualization: "number",
+ series: null,
+ failure: "failed",
+ },
+ );
+ expect(screen.getByText("Unable to load.")).toBeTruthy();
+ expect(screen.getByText("Provider unavailable")).toBeTruthy();
+ expect(container.querySelector('[data-slot="skeleton"]')).toBeNull();
+ });
+});
+
+describe("the default layout", () => {
+ it("gathers adjacent scalars into one KPI row and keeps declared order", () => {
+ const { container } = show(
+ { id: "a", title: "A", visualization: "number", series: daily },
+ { id: "b", title: "B", visualization: "number", series: daily },
+ { id: "c", title: "C", visualization: "line", series: daily },
+ { id: "d", title: "D", visualization: "number", series: daily },
+ );
+ // Two tiles, then a chart, then a tile: three runs, the KPI row's own
+ // four-up geometry only around the adjacent pair.
+ const rows = [
+ ...container.querySelectorAll('section > div > div[class*="grid"]'),
+ ];
+ expect(rows).toHaveLength(3);
+ expect(rows[0].className).toContain("lg:grid-cols-4");
+ expect(rows[0].children).toHaveLength(2);
+ expect(rows[1].className).toContain("sm:grid-cols-2");
+ expect(rows[2].children).toHaveLength(1);
+ expect(
+ [...container.querySelectorAll('[data-slot="metric-label"], h3')].map(
+ (node) => node.textContent,
+ ),
+ ).toEqual(["A", "B", "C", "D"]);
+ });
+
+ it("keeps a scalar that declares a span in the grid, with its span", () => {
+ // A span is a request for grid width, which the KPI row's own geometry
+ // cannot grant, so a scalar that declares one stays a grid item rather
+ // than having the field silently dropped.
+ const { container } = render(
+ ,
+ );
+ const rows = [
+ ...container.querySelectorAll('section > div > div[class*="grid"]'),
+ ];
+ expect(rows).toHaveLength(2);
+ // The spanning scalar is a card in the grid...
+ expect(rows[0].className).toContain("sm:grid-cols-2");
+ expect(rows[0].firstElementChild?.className).toContain("sm:col-span-2");
+ // ...and only the one that asked for nothing becomes a headline number.
+ expect(rows[1].className).toContain("lg:grid-cols-2");
+ });
+
+ it("caps the KPI row at the dashboard's declared column count", () => {
+ const { container } = render(
+ ,
+ );
+ const row = container.querySelector(
+ 'section > div > div[class*="grid"]',
+ ) as HTMLElement;
+ expect(row.className).toContain("lg:grid-cols-2");
+ expect(row.className).not.toContain("lg:grid-cols-4");
+ });
+
+ it("stacks every widget in a row of its own for a stack layout", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("section > div")?.className).toContain(
+ "flex flex-col",
+ );
+ expect(container.querySelector('[class*="grid-cols-4"]')).toBeNull();
+ });
+});
+
+// The seams a host composes its own features through. They are what let the
+// host's solution dashboard keep its reorderable grid, its + menu and its
+// per-widget queries while this renderer still does the drawing.
+describe("slots", () => {
+ const widgets: DashboardWidgetView[] = [
+ { id: "a", title: "A", visualization: "line", series: daily },
+ { id: "b", title: "B", visualization: "number", series: daily },
+ ];
+
+ it("puts actions in the dashboard's own header", () => {
+ render(
+ Add }}
+ />,
+ );
+ expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
+ });
+
+ it("hands every widget to renderWidget, in the view's order", () => {
+ render(
+ {widget.id} }}
+ />,
+ );
+ expect(
+ screen.getAllByRole("listitem").map((item) => item.textContent),
+ ).toEqual(["a", "b"]);
+ });
+
+ it("hands the tiles, each with its widget, to a layout of the host's own", () => {
+ render(
+ (
+
+ {tiles.map((tile) => (
+
+ {tile.node}
+
+ ))}
+
+ ),
+ }}
+ />,
+ );
+ const items = screen.getAllByRole("listitem");
+ expect(items.map((item) => item.dataset.visualization)).toEqual([
+ "line",
+ "number",
+ ]);
+ // The nodes are the kit's own cards: the chart's data table is in them.
+ expect(within(items[0]).getByRole("table", { name: "A" })).toBeTruthy();
+ });
+
+ it("shows the host's own empty state in place of the layout", () => {
+ render(
+ Add one back with +.
}}
+ />,
+ );
+ expect(screen.getByText("Add one back with +.")).toBeTruthy();
+ expect(screen.queryByText("No widgets.")).toBeNull();
+ });
});
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/geometry.test.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/geometry.test.ts
index ff04c06ff..de9854fbb 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/geometry.test.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/geometry.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
-import { areaPath, linearScale, linePath, niceTicks, scaleX, sparkPlot } from "../geometry.js";
+import {
+ areaPath,
+ linearScale,
+ linePath,
+ niceTicks,
+ scaleX,
+ sparkPlot,
+} from "../geometry.js";
import type { SeriesPoint } from "../types.js";
// Path coordinates are emitted as "M x y L x y …"; pull them out as alternating
@@ -12,7 +19,8 @@ function coords(d: string): { xs: number[]; ys: number[] } {
};
}
-const pts = (...values: number[]): SeriesPoint[] => values.map((value, i) => ({ key: String(i), value }));
+const pts = (...values: number[]): SeriesPoint[] =>
+ values.map((value, i) => ({ key: String(i), value }));
describe("linePath", () => {
// Regression: scaleY used to close over the constant full viewBox height, so a
@@ -118,7 +126,8 @@ describe("niceTicks", () => {
const ticks = niceTicks(0, 95);
expect(ticks[0]).toBe(0);
expect(ticks[ticks.length - 1]).toBeGreaterThanOrEqual(95);
- for (let i = 1; i < ticks.length; i++) expect(ticks[i]).toBeGreaterThan(ticks[i - 1]);
+ for (let i = 1; i < ticks.length; i++)
+ expect(ticks[i]).toBeGreaterThan(ticks[i - 1]);
});
// A degenerate span (all-zero series, or a single point anchored at its own
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts
index 02f7d839d..397e29328 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts
@@ -25,8 +25,14 @@ describe("fromDashboardData", () => {
expect(view.columns).toBe(3);
expect(view.accent).toBe("#7c3aed");
expect(view.widgets).toHaveLength(1);
- expect(view.widgets[0]).toMatchObject({ id: "trend", visualization: "line" });
- expect(view.widgets[0].series.total).toBe(3);
+ expect(view.widgets[0]).toMatchObject({
+ id: "trend",
+ visualization: "line",
+ });
+ // `fromDashboardData` bridges an ALREADY-resolved result, so the series
+ // it produces is never the `null` the view model allows for a widget
+ // still being resolved.
+ expect(view.widgets[0].series?.total).toBe(3);
});
it("defaults layout to grid when the source omits it", () => {
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/charts.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/charts.tsx
index 003f3267e..325b130fe 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/charts.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/charts.tsx
@@ -14,17 +14,17 @@ import type * as React from "react";
import { Axis, Gridline, Svg } from "./atoms.js";
import { cn } from "./cn.js";
import {
- areaPath,
AXIS_MARGIN,
+ areaPath,
linearScale,
linePath,
niceTicks,
PAD,
plot,
sparkPlot,
- valueDomain,
VIEW_H,
VIEW_W,
+ valueDomain,
} from "./geometry.js";
import type { SeriesPoint } from "./types.js";
@@ -66,8 +66,21 @@ function cartesian({
const p = sparkPlot(height);
return (
- {fill && }
-
+ {fill && (
+
+ )}
+
);
}
@@ -86,10 +99,28 @@ function cartesian({
const singleY = points.length === 1 ? y(points[0].value) : undefined;
return (
-
+
{showY && }
- {fill && }
-
+ {fill && (
+
+ )}
+
{points.map((p) => (
-
+
{format ? format(p.key) : p.key}
- {p.value.toLocaleString()}
+
+ {p.value.toLocaleString()}
+
))}
@@ -154,7 +217,9 @@ export function StatChart({
const style: React.CSSProperties = { color: "var(--primary)" };
return (
-
{total.toLocaleString()}
+
+ {total.toLocaleString()}
+
{/* A trend needs two points. One point (a single all-time bucket, say)
would draw as a flat line that reads as "no change". */}
{points.length >= 2 && (
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx
index 978723a30..91ede8d7e 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx
@@ -6,13 +6,35 @@
// solution's Module-Federation remote render identical dashboards from the same
// package instance. Data resolution (metric → audit query) is the job of
// `@codefly-dev/saas-sdk`'s `runDashboard`; use `fromDashboardData` to bridge.
+//
+// It paints with the kit's METRIC tier — `StatTile`/`KPIRow` for scalars,
+// `metric-chart`'s line/area/bar over `ChartSeries` for series — which is the
+// tier the host's own operations pages are drawn with. Before, this renderer
+// and the host's solution dashboard both used the older `charts.tsx` tier, so a
+// solution's Dashboard tab read plainer than the host's own pages from the same
+// kit, and there were two renderers to keep in step rather than one.
import type * as React from "react";
+import { Fragment, type ReactNode } from "react";
import { Card } from "../layout/card.js";
import { Section } from "../layout/page.js";
-import { AreaChart, BarList, LineChart, StatChart } from "./charts.js";
+import { Skeleton } from "../layout/skeleton.js";
import { cn } from "./cn.js";
-import type { DashboardView, DashboardWidgetView } from "./types.js";
+import { parseTimeKey } from "./format.js";
+import {
+ AreaChart as MetricAreaChart,
+ BarChart as MetricBarChart,
+ LineChart as MetricLineChart,
+} from "./metric-chart.js";
+import type { ChartSeries } from "./metric-geometry.js";
+import { type MetricState, MetricStateBadge } from "./metric-state.js";
+import { KPIRow, type Metric, StatTile } from "./metric-tiles.js";
+import type {
+ DashboardView,
+ DashboardWidgetView,
+ SeriesPoint,
+ WidgetSeries,
+} from "./types.js";
// Column-span utility classes, matching the responsive grid below so a spanning
// card widens in step with it. A span is clamped to the grid's column count by
@@ -24,6 +46,19 @@ const COL_SPAN: Record<1 | 2 | 3 | 4, string> = {
4: "sm:col-span-2 lg:col-span-4",
};
+// The KPI row's own column count, capped at the dashboard's DECLARED one.
+// `KPIRow` is four across, which is what headline numbers want; a dashboard
+// that declared fewer columns did not ask for more, and one that declared none
+// did not ask for two either — so this is applied only when there is a
+// declaration to honour. `cn` resolves these against the row's own defaults
+// per breakpoint.
+const KPI_COLS: Record<1 | 2 | 3 | 4, string> = {
+ 1: "grid-cols-1 sm:grid-cols-1 lg:grid-cols-1",
+ 2: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-2",
+ 3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
+ 4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
+};
+
const GRID_COLS: Record<1 | 2 | 3 | 4, string> = {
1: "grid-cols-1",
2: "grid-cols-1 sm:grid-cols-2",
@@ -31,75 +66,480 @@ const GRID_COLS: Record<1 | 2 | 3 | 4, string> = {
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
};
-function WidgetBody({ widget }: { widget: DashboardWidgetView }) {
- const { series, visualization } = widget;
- if (series.points.length === 0) {
- return
No data yet.
;
+/** The widget's own title, or its id when the declaration gave none. */
+function widgetTitle(widget: DashboardWidgetView): string {
+ return widget.title ?? widget.id;
+}
+
+/**
+ * The series' values in time order, or `null` when it is not a trend at all.
+ *
+ * Only a series whose every key is a time bucket earns a sparkline and a
+ * bucket-over-bucket delta: drawing a line through counts grouped by event type
+ * or region would assert an order and a direction the data does not have.
+ *
+ * The order comes from the parsed timestamps rather than from the array,
+ * because nothing guarantees the array is chronological — the resolver appends
+ * one point per bucket in whatever order the aggregate returned them, and
+ * `ChartSeries` documents x-order as the producer's responsibility. Reading
+ * "previous" and "latest" off the last two positions would, for a series
+ * delivered newest-first, report the change with the wrong sign — and a delta's
+ * sign is drawn as a colour, so a decline would read as a green rise.
+ */
+function trendValues(points: SeriesPoint[]): number[] | null {
+ if (points.length < 2) return null;
+ const dated: { at: number; value: number }[] = [];
+ for (const point of points) {
+ const at = parseTimeKey(point.key);
+ if (at === null) return null;
+ dated.push({ at: at.getTime(), value: point.value });
+ }
+ dated.sort((a, b) => a.at - b.at);
+ return dated.map((point) => point.value);
+}
+
+/**
+ * The badge a resolved series earns, or `undefined` when it is simply ready.
+ *
+ * `partial` outranks a withheld total because it is the *reason* the total is
+ * withheld (a partial series keeps its observed points and gives up its total),
+ * and a reader who sees "No total" alone learns less than one who sees
+ * "Partial data". An empty series is `no_data`, never a zero: the audit
+ * aggregate omits a bucket rather than emitting one, so no points means nothing
+ * matched.
+ */
+function seriesState(series: WidgetSeries): MetricState | undefined {
+ if (series.coverage === "partial") return "partial";
+ if (series.coverage === "empty" || series.points.length === 0) {
+ return "no_data";
}
- switch (visualization) {
+ if (series.total === null) return "no_total";
+ return undefined;
+}
+
+/**
+ * Change from the second-latest bucket to the latest, as the signed fraction
+ * `StatTile` renders. A change from zero is not a percentage, so it is no
+ * delta at all rather than an infinite one.
+ */
+function lastBucketDelta(trend: number[]): number | undefined {
+ if (trend.length < 2) return undefined;
+ const previous = trend[trend.length - 2];
+ const latest = trend[trend.length - 1];
+ if (previous === 0) return undefined;
+ return (latest - previous) / Math.abs(previous);
+}
+
+/**
+ * Whether this series has a single number to show. An empty result and a
+ * withheld total are both "no number"; an emitted zero is a number.
+ */
+function hasScalar(series: WidgetSeries): boolean {
+ return (
+ series.total !== null &&
+ series.points.length > 0 &&
+ series.coverage !== "empty"
+ );
+}
+
+/** A resolved scalar widget as the `Metric` a tile binds to. */
+function widgetMetric(
+ widget: DashboardWidgetView,
+ series: WidgetSeries,
+): Metric {
+ const trend = trendValues(series.points);
+ const delta = trend ? lastBucketDelta(trend) : undefined;
+ return {
+ id: widget.id,
+ label: widgetTitle(widget),
+ // Whether a number exists is read off the SERIES, never off the badge.
+ // The badge is one slot and freshness can take it (a failed refresh
+ // says "Stale"), so deriving the value's presence from the state made
+ // an empty series render its 0 the moment it also went stale. The two
+ // are different questions about the same tile and are answered apart.
+ value: hasScalar(series) ? series.total : null,
+ format: widget.format,
+ unit: widget.unit,
+ higherIsBetter: widget.higherIsBetter,
+ series: trend ?? undefined,
+ delta,
+ // Only named when there is a delta: `MetricDelta` reads a label with no
+ // delta as "no data
", which is a different (and here untrue)
+ // statement about the comparison period.
+ deltaLabel: delta === undefined ? undefined : "vs previous bucket",
+ state: seriesState(series),
+ };
+}
+
+/** One series on the shared label axis the metric charts consume. */
+function chartSeries(
+ widget: DashboardWidgetView,
+ series: WidgetSeries,
+): ChartSeries[] {
+ return [
+ {
+ name: widgetTitle(widget),
+ data: series.points.map((p) => ({ label: p.key, value: p.value })),
+ },
+ ];
+}
+
+/** The resolved series drawn the way the widget's visualization asks. */
+function WidgetBody({
+ widget,
+ series,
+}: {
+ widget: DashboardWidgetView;
+ series: WidgetSeries;
+}) {
+ const title = widgetTitle(widget);
+ switch (widget.visualization) {
case "line":
- return ;
+ return (
+
+ );
case "area":
- return ;
+ return (
+
+ );
case "bar":
- return ;
- case "number":
- return series.total === null ? (
- Total unavailable
- ) : (
-
+ return (
+
);
+ case "number":
+ // A scalar is drawn as a tile by DashboardWidget, never as a card
+ // body. The case is here so the switch stays exhaustive.
+ return null;
case "table":
- return (
+ return series.points.length === 0 ? (
+ No data yet.
+ ) : (
+ {title}
{series.points.map((p) => (
{p.key}
- {p.value.toLocaleString()}
+
+ {p.value.toLocaleString()}
+
))}
);
- default:
- return null;
+ default: {
+ // Compile-time exhaustiveness: a new WidgetVisualization must be
+ // handled here or this assignment fails to type-check. Without it a
+ // visualization the metric tier has no mark for draws a titled card
+ // with an empty body, which is indistinguishable from a widget whose
+ // query returned nothing.
+ const unhandled: never = widget.visualization;
+ return unhandled;
+ }
}
}
-function WidgetCard({ widget, columns }: { widget: DashboardWidgetView; columns: 1 | 2 | 3 | 4 }) {
- const span = widget.span ? (Math.min(widget.span, columns) as 1 | 2 | 3 | 4) : 1;
+export interface DashboardWidgetProps {
+ widget: DashboardWidgetView;
+ /**
+ * Controls for this widget's header, opposite its title — a drag handle, a
+ * remove button, a panel that says where the number comes from. The
+ * renderer never invents them; a dashboard with nothing to do to a tile
+ * passes none.
+ */
+ actions?: ReactNode;
+ className?: string;
+}
+
+/**
+ * One widget of a dashboard, drawn with the metric tier: a scalar as a
+ * `StatTile` (value, format, a sparkline and a bucket delta when the metric is
+ * bucketed over time, and a `MetricStateBadge` from the series' coverage), a
+ * series as a metric chart inside a `Card`.
+ *
+ * Exported, and not merely an internal of {@link Dashboard}, because a host
+ * that resolves each widget through its own query — so one widget's failure
+ * cannot blank its siblings — must render each widget in its own component.
+ * React fixes the number of hooks a component may call, so those queries
+ * cannot be hoisted into one renderer that takes the whole view. Compose this
+ * from such a component; never redraw it.
+ */
+export function DashboardWidget({
+ widget,
+ actions,
+ className,
+}: DashboardWidgetProps) {
+ const title = widgetTitle(widget);
+
+ // A refusal is answered BEFORE the series is looked at, in every branch
+ // below. The host classifies its own transport and is expected to withhold
+ // the values too, but a number this viewer may not see is a wrong answer
+ // rather than an old one, so the renderer that owns what a refusal looks
+ // like fails closed as well: a caller that forwards retained values by
+ // mistake cannot get them drawn through this. Testing the series first
+ // here — the ordering that reads more naturally — is exactly what would
+ // put them back on screen.
+ const refused = widget.failure === "refused";
+ const { series } = widget;
+ // Otherwise three outcomes, not two. A failure with nothing behind it is a
+ // failure; a failure with a series still in hand is a refresh that did not
+ // land, and blanking the tile would throw away values that are still the
+ // best available. `stale` is the kit's own word for that.
+ const lost = widget.failure === "failed" && series === null;
+ const stale = widget.failure === "failed" && series !== null;
+
+ if (widget.visualization === "number") {
+ const metric: Metric = refused
+ ? { id: widget.id, label: title, value: null, state: "forbidden" }
+ : lost
+ ? {
+ id: widget.id,
+ label: title,
+ value: null,
+ state: "provider_unavailable",
+ }
+ : series === null
+ ? { id: widget.id, label: title, value: null, state: "loading" }
+ : {
+ ...widgetMetric(widget, series),
+ ...(stale && { state: "stale" }),
+ };
+ return (
+
+ );
+ }
+
+ // A chart or table card carries its coverage badge in the card's own
+ // description slot — the line under the title that says what this card is —
+ // so a reader meets "Partial data" where they meet the title, as they do on
+ // a tile, instead of as a sentence pushed above the geometry.
+ const state: MetricState | undefined = refused
+ ? "forbidden"
+ : series === null
+ ? undefined
+ : stale
+ ? "stale"
+ : seriesState(series);
return (
-
- {widget.series.coverage === "partial" && (
-
- Partial telemetry
+ : undefined}
+ actions={actions}
+ className={className}
+ >
+ {refused ? (
+
+ You don't have permission to see this.
+ ) : lost ? (
+ Unable to load.
+ ) : series === null ? (
+
+ ) : (
+
)}
-
);
}
+/** A widget and the node drawn for it, for a caller laying the tiles out itself. */
+export interface DashboardTile {
+ id: string;
+ /**
+ * The widget this tile draws. A layout that needs a second copy of a tile —
+ * a drag overlay, say — draws it from here rather than reusing the node,
+ * which is already mounted in the grid.
+ */
+ widget: DashboardWidgetView;
+ /** The widget as drawn: the kit's card, or `slots.renderWidget`'s. */
+ node: ReactNode;
+}
+
+/**
+ * Where a host composes its own features *around* this renderer, without
+ * taking over the drawing. Every slot is optional; a dashboard that needs none
+ * of them passes no `slots` at all and gets the full default rendering.
+ */
+export interface DashboardSlots {
+ /** Controls in the dashboard's own header, opposite its title. */
+ actions?: ReactNode;
+ /**
+ * Draws one widget in place of {@link DashboardWidget}. For a host that
+ * resolves each widget in its own query: return a component that resolves
+ * the series and then mounts `DashboardWidget` with it.
+ */
+ renderWidget?: (widget: DashboardWidgetView) => ReactNode;
+ /**
+ * Lays the tiles out in place of the default grid or stack — for a grid
+ * whose tiles the viewer can reorder. Tiles arrive in the view's order.
+ */
+ layout?: (tiles: DashboardTile[]) => ReactNode;
+ /** Shown in place of the layout when the view holds no widgets. */
+ empty?: ReactNode;
+}
+
+/**
+ * Runs of adjacent widgets that are laid out together: consecutive scalars
+ * become one `KPIRow`, everything else a grid of cards.
+ *
+ * Declared order is never rearranged — only adjacent scalars are gathered — so
+ * the page shows what the declaration says, in the order it says it. Hoisting
+ * every scalar to the top would read better on some dashboards and would also
+ * silently overrule a declaration the renderer does not own.
+ */
+function layoutRuns(tiles: DashboardTile[]): DashboardTile[][] {
+ const runs: DashboardTile[][] = [];
+ for (const tile of tiles) {
+ const previous = runs[runs.length - 1];
+ if (previous && inKpiRow(previous[0].widget) === inKpiRow(tile.widget)) {
+ previous.push(tile);
+ } else {
+ runs.push([tile]);
+ }
+ }
+ return runs;
+}
+
+/**
+ * Whether this widget belongs in a KPI row rather than in the card grid.
+ *
+ * A scalar that declares a `span` is asking for a card's width, which the KPI
+ * row's own geometry cannot give it, so it stays a grid item and keeps its
+ * span. Only a scalar that asked for nothing becomes a headline number.
+ */
+function inKpiRow(widget: DashboardWidgetView): boolean {
+ return widget.visualization === "number" && !widget.span;
+}
+
+function Tiles({ tiles }: { tiles: DashboardTile[] }) {
+ return (
+ <>
+ {tiles.map((tile) => (
+ {tile.node}
+ ))}
+ >
+ );
+}
+
+function DefaultLayout({
+ tiles,
+ columns,
+ declaredColumns,
+ grid,
+}: {
+ tiles: DashboardTile[];
+ columns: 1 | 2 | 3 | 4;
+ /** `data.columns` as declared, or undefined when the view named none. */
+ declaredColumns?: 1 | 2 | 3 | 4;
+ grid: boolean;
+}) {
+ if (!grid) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {layoutRuns(tiles).map((run) =>
+ inKpiRow(run[0].widget) ? (
+
+
+
+ ) : (
+
+
+
+ ),
+ )}
+
+ );
+}
+
/**
* Render a resolved dashboard. Pass a `DashboardView` (from your own data or via
* `fromDashboardData(runDashboard(...))`). `accent` overrides the `--primary`
* token for this dashboard's subtree only, so every chart picks it up.
*/
-export function Dashboard({ data, className }: { data: DashboardView; className?: string }) {
+export function Dashboard({
+ data,
+ className,
+ slots,
+}: {
+ data: DashboardView;
+ className?: string;
+ slots?: DashboardSlots;
+}) {
const columns = data.columns ?? 2;
- const style = data.accent ? ({ "--primary": data.accent } as React.CSSProperties) : undefined;
+ // The accent overrides BOTH tokens the marks paint from, because the tier
+ // change moved where the colour comes from: the metric charts colour a
+ // series from the categorical palette (`chartSeriesColor(0)` is
+ // `--chart-1`), while `BarList` and the sparkline still use `--primary`.
+ // Setting only `--primary`, as the basic tier needed, left a declared
+ // accent with no effect on the charts it exists to colour. A dashboard's
+ // accent is its FIRST series' colour; a chart with more keeps the rest of
+ // the palette so two series never collapse to one hue.
+ const style = data.accent
+ ? ({
+ "--primary": data.accent,
+ "--chart-1": data.accent,
+ } as React.CSSProperties)
+ : undefined;
const isGrid = (data.layout ?? "grid") === "grid";
+ const tiles: DashboardTile[] = data.widgets.map((widget) => ({
+ id: widget.id,
+ widget,
+ node: slots?.renderWidget?.(widget) ?? (
+ // A span is a request for grid width, so it applies to anything the
+ // grid lays out. A scalar that declares one is kept out of the KPI
+ // row for exactly that reason — see `inKpiRow`.
+
+ ),
+ }));
+
return (
-
-
- {data.widgets.map((widget) => (
-
- ))}
-
+
+ {tiles.length === 0 ? (
+ (slots?.empty ?? (
+ No widgets.
+ ))
+ ) : slots?.layout ? (
+ slots.layout(tiles)
+ ) : (
+
+ )}
);
}
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts
index 3b350a6f3..08f5d6e05 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts
@@ -7,7 +7,10 @@
// thousands. Bounding *significant* digits (not fraction digits) is what keeps a
// small fractional metric legible: `avg`/`ratio`/`percentile` series can tick in
// the hundredths, and a fraction-digit cap would round every such tick to "0".
-const valueFormat = new Intl.NumberFormat(undefined, { notation: "compact", maximumSignificantDigits: 3 });
+const valueFormat = new Intl.NumberFormat(undefined, {
+ notation: "compact",
+ maximumSignificantDigits: 3,
+});
export function formatAxisValue(value: number): string {
return valueFormat.format(value);
@@ -16,7 +19,8 @@ export function formatAxisValue(value: number): string {
// Series keys that are time buckets arrive as ISO timestamps from the audit RPC
// (`YYYY-MM-DDThh:mm:ss±hh`); anything else is a plain category key. Gate on the
// ISO shape so a non-date key ("US East", "42") is never coerced through Date.
-const ISO_TIME = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?([+-]\d{2}(:?\d{2})?|Z)?$/;
+const ISO_TIME =
+ /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?([+-]\d{2}(:?\d{2})?|Z)?$/;
// Postgres' `OF` offset emits a bare-hour zone ("+00", "-05") that Date.parse
// rejects; pad it to the "+00:00" the parser accepts. A "+0000" form gets its
@@ -24,7 +28,10 @@ const ISO_TIME = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?([+-]\d{2}(:?\d{2
// date-only "2026-12-25" would be misread as an offset and mangled.
function normalizeOffset(iso: string): string {
if (!iso.includes(":")) return iso;
- return iso.replace(/([+-]\d{2})(\d{2})?$/, (_m, hh: string, mm?: string) => `${hh}:${mm ?? "00"}`);
+ return iso.replace(
+ /([+-]\d{2})(\d{2})?$/,
+ (_m, hh: string, mm?: string) => `${hh}:${mm ?? "00"}`,
+ );
}
// Time buckets are day/week/month grains, so a key at exact UTC midnight reads as
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/geometry.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/geometry.ts
index fa3c53510..f60292a9d 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/geometry.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/geometry.ts
@@ -29,14 +29,29 @@ export interface Plot {
// The axis-less sparkline gutter: a hair of padding on every side so a
// non-scaling stroke isn't clipped at the frame, matching the pre-axis charts.
-export const SPARK_MARGIN: Margins = { top: PAD, right: PAD, bottom: PAD, left: PAD };
+export const SPARK_MARGIN: Margins = {
+ top: PAD,
+ right: PAD,
+ bottom: PAD,
+ left: PAD,
+};
// Extra left/bottom gutter an axed chart needs so the y value labels and the x
// time labels have room outside the plot; top/right stay tight.
-export const AXIS_MARGIN: Margins = { top: PAD, right: PAD, bottom: 22, left: 34 };
+export const AXIS_MARGIN: Margins = {
+ top: PAD,
+ right: PAD,
+ bottom: 22,
+ left: 34,
+};
export function plot(width: number, height: number, m: Margins): Plot {
- return { left: m.left, right: width - m.right, top: m.top, bottom: height - m.bottom };
+ return {
+ left: m.left,
+ right: width - m.right,
+ top: m.top,
+ bottom: height - m.bottom,
+ };
}
/** The full-width sparkline plot at a given height. */
@@ -47,7 +62,12 @@ export function sparkPlot(height: number): Plot {
// The Scale atom: a pure linear map from a domain onto a pixel range. A
// zero-width domain (a flat series) collapses to the range start instead of
// dividing by zero, so it draws one line rather than NaNs.
-export function linearScale(d0: number, d1: number, r0: number, r1: number): (v: number) => number {
+export function linearScale(
+ d0: number,
+ d1: number,
+ r0: number,
+ r1: number,
+): (v: number) => number {
const span = d1 - d0 || 1;
return (v) => r0 + ((v - d0) / span) * (r1 - r0);
}
@@ -96,7 +116,8 @@ export function niceTicks(min: number, max: number, count = 4): number[] {
const end = Math.ceil(max / step) * step;
const ticks: number[] = [];
// `+ step / 2` absorbs float drift so the final tick isn't dropped.
- for (let v = start; v <= end + step / 2; v += step) ticks.push(Number(v.toFixed(6)));
+ for (let v = start; v <= end + step / 2; v += step)
+ ticks.push(Number(v.toFixed(6)));
return ticks;
}
@@ -111,7 +132,10 @@ export function niceTicks(min: number, max: number, count = 4): number[] {
export function linePath(
points: SeriesPoint[],
p: Plot,
- y = scaleY(points.map((pt) => pt.value), p),
+ y = scaleY(
+ points.map((pt) => pt.value),
+ p,
+ ),
singleY = (p.top + p.bottom) / 2,
): string {
if (points.length === 0) return "";
@@ -120,7 +144,10 @@ export function linePath(
return `M ${p.left.toFixed(1)} ${yy} L ${p.right.toFixed(1)} ${yy}`;
}
return points
- .map((pt, i) => `${i === 0 ? "M" : "L"} ${scaleX(i, points.length, p).toFixed(1)} ${y(pt.value).toFixed(1)}`)
+ .map(
+ (pt, i) =>
+ `${i === 0 ? "M" : "L"} ${scaleX(i, points.length, p).toFixed(1)} ${y(pt.value).toFixed(1)}`,
+ )
.join(" ");
}
@@ -130,12 +157,19 @@ export function linePath(
export function areaPath(
points: SeriesPoint[],
p: Plot,
- y = scaleY(points.map((pt) => pt.value), p),
+ y = scaleY(
+ points.map((pt) => pt.value),
+ p,
+ ),
singleY = (p.top + p.bottom) / 2,
): string {
if (points.length === 0) return "";
const baseline = p.bottom.toFixed(1);
- const left = (points.length === 1 ? p.left : scaleX(0, points.length, p)).toFixed(1);
- const right = (points.length === 1 ? p.right : scaleX(points.length - 1, points.length, p)).toFixed(1);
+ const left = (
+ points.length === 1 ? p.left : scaleX(0, points.length, p)
+ ).toFixed(1);
+ const right = (
+ points.length === 1 ? p.right : scaleX(points.length - 1, points.length, p)
+ ).toFixed(1);
return `${linePath(points, p, y, singleY)} L ${right} ${baseline} L ${left} ${baseline} Z`;
}
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/index.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/index.ts
index c20a6015a..193fd4475 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/index.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/index.ts
@@ -13,7 +13,13 @@ export {
LineChart,
StatChart,
} from "./charts.js";
-export { Dashboard } from "./dashboard.js";
+export {
+ Dashboard,
+ type DashboardSlots,
+ type DashboardTile,
+ DashboardWidget,
+ type DashboardWidgetProps,
+} from "./dashboard.js";
export { formatAxisKey, formatAxisValue, parseTimeKey } from "./format.js";
export {
linearScale,
@@ -22,54 +28,50 @@ export {
scaleX,
scaleY,
} from "./geometry.js";
-export {
- type DashboardLayoutKind,
- type DashboardView,
- type DashboardWidgetView,
- fromDashboardData,
- type SeriesPoint,
- type WidgetSeries,
- type WidgetVisualization,
-} from "./types.js";
-
export {
AreaChart as MetricAreaChart,
- LineChart as MetricLineChart,
BarChart as MetricBarChart,
chartSeriesColor,
+ LineChart as MetricLineChart,
type MetricChartProps,
} from "./metric-chart.js";
export {
+ axisPositions,
type ChartDatum,
type ChartSeries,
- type ResolvedSeries,
type Point,
- unionLabels,
+ type ResolvedSeries,
resolveSeries,
- stackSeries,
- stackedExtent,
type StackedSeries,
+ stackedExtent,
+ stackSeries,
+ unionLabels,
valuesExtent,
- axisPositions,
} from "./metric-geometry.js";
-
-export { Sparkline } from "./sparkline.js";
-export { SortableGrid, type SortableGridProps } from "./sortable-grid.js";
-
export {
- MetricProvenance,
- MetricStateBadge,
assertSampleModeAllowed,
+ MetricProvenance,
type MetricState,
+ MetricStateBadge,
} from "./metric-state.js";
-
export {
- MetricCard,
- StatTile,
- KPIRow,
formatMetricValue,
+ KPIRow,
type Metric,
+ MetricCard,
type MetricFormat,
+ StatTile,
} from "./metric-tiles.js";
-
+export { SortableGrid, type SortableGridProps } from "./sortable-grid.js";
+export { Sparkline } from "./sparkline.js";
export { TrendLineChart } from "./trend-line-chart.js";
+export {
+ type DashboardLayoutKind,
+ type DashboardView,
+ type DashboardWidgetView,
+ fromDashboardData,
+ type SeriesPoint,
+ type WidgetReadFailure,
+ type WidgetSeries,
+ type WidgetVisualization,
+} from "./types.js";
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-state.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-state.tsx
index 0fb88d175..9d3943a7f 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-state.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-state.tsx
@@ -8,6 +8,17 @@ export type MetricState =
| "provider_unavailable"
| "not_configured"
| "sample"
+ // The read was refused: the viewer is not allowed to see this number.
+ // Distinct from every other state here, which are all about the data —
+ // this one is about authority, and it is the only one where showing a
+ // value that was fetched before would be a wrong answer rather than a
+ // stale one.
+ | "forbidden"
+ // The series resolved, but no single number summarises it: a distinct
+ // count, an average, a percentile or a ratio has no scalar total across
+ // several groups, and summing or averaging those summaries would
+ // misreport the result. Distinct from `no_data`, where nothing matched.
+ | "no_total"
| "ready";
const stateLabels: Record, string> = {
@@ -18,6 +29,8 @@ const stateLabels: Record, string> = {
provider_unavailable: "Provider unavailable",
not_configured: "Not configured",
sample: "Sample data",
+ no_total: "No total",
+ forbidden: "No access",
};
export function assertSampleModeAllowed(
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-tiles.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-tiles.tsx
index c4c73bd1a..1de676fdb 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-tiles.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-tiles.tsx
@@ -1,5 +1,5 @@
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
-import type { ComponentProps, KeyboardEvent } from "react";
+import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
import {
CardRoot as Card,
CardContent,
@@ -28,8 +28,14 @@ export interface Metric {
/** Stable identity for list keying; falls back to position when omitted. */
id?: string;
label: string;
- /** The computed value as a plain number — `Number(bigint)` a bigint source. */
- value: number;
+ /**
+ * The computed value as a plain number — `Number(bigint)` a bigint source.
+ * `null` when no number exists: it renders as a dash, never as `0`. A
+ * withheld total and a zero are different answers, and a tile that drew
+ * `null` as `0` would report the second when the pipeline said the first.
+ * {@link state} says which of partial / empty / no-total withheld it.
+ */
+ value: number | null;
/**
* How {@link value} is rendered. `"percent"` treats the value as an
* already-scaled percentage (45 → "45%"), not a fraction — unlike
@@ -70,6 +76,8 @@ const valuelessStates: MetricState[] = [
"no_data",
"not_configured",
"provider_unavailable",
+ "no_total",
+ "forbidden",
];
export function formatMetricValue(
@@ -110,13 +118,20 @@ function MetricValue({
if (metric.state === "loading") {
return ;
}
- const valueless = !!metric.state && valuelessStates.includes(metric.state);
+ // No number to show: either none exists, or the state is one for which none
+ // is meaningful. A unit with no value to qualify is noise, so it goes too.
+ if (
+ metric.value === null ||
+ (metric.state && valuelessStates.includes(metric.state))
+ ) {
+ return — ;
+ }
// Standalone figures use the font's proportional digits — tabular-nums is
// for columns that must align, and looks loose at display sizes.
return (
- {valueless ? "—" : formatMetricValue(metric.value, metric.format)}
- {!valueless && metric.unit && (
+ {formatMetricValue(metric.value, metric.format)}
+ {metric.unit && (
{metric.unit}
@@ -188,6 +203,20 @@ function hasTrend(series: number[] | undefined): series is number[] {
return !!series && series.length > 1;
}
+/**
+ * Whether this metric's value may be derived from at all.
+ *
+ * `forbidden` is not a state of the data, it is a statement about the reader's
+ * authority, so everything computed FROM the value goes with the value: a tile
+ * that dashed its number and still drew "+170%" beside a trend line would have
+ * published the same figure twice over, in the two forms that are hardest to
+ * notice. Every other state here is about the data — an average legitimately
+ * has no total and a meaningful delta — so only this one withholds.
+ */
+function withholdsValue(metric: Metric): boolean {
+ return metric.state === "forbidden";
+}
+
// The card props that turn a metric into a control. Both StatTile and
// MetricCard apply these, because `onSelect` lives on the Metric rather than on
// one component's props: a renderer that ignored it would silently drop the
@@ -225,14 +254,45 @@ function selectableCard(metric: Metric): {
* A single headline number — label, value, optional delta and trend sparkline,
* with a freshness badge when the metric isn't `ready`. The compact building
* block of a {@link KPIRow}.
+ *
+ * `actions` puts controls in the header beside the badge, for a caller that
+ * owns the tile as well as the number — a dashboard whose viewer can drag,
+ * remove or interrogate each tile. Without it the tile is purely presentational.
*/
+/** The colour a dashboard's accent reaches the first chart series through. */
+const ACCENT_TREND = "var(--chart-1)";
+
+function trendTone(trend: "muted" | "accent"): string | undefined {
+ return trend === "muted" ? "text-muted-foreground/70" : undefined;
+}
+
export function StatTile({
metric,
+ actions,
+ labelAs = "span",
+ trend = "muted",
className,
}: {
metric: Metric;
+ actions?: ReactNode;
+ /**
+ * The element the label is drawn as. A tile standing in for a titled
+ * region — a dashboard widget — passes `"h3"` so the title stays in the
+ * document outline and reachable by heading navigation, which a card with
+ * a heading gives for free and a tile otherwise takes away.
+ */
+ labelAs?: "span" | "h3";
+ /**
+ * How the trend sparkline is coloured. `"muted"` is a tile reading beside
+ * other content; `"accent"` takes the dashboard's own accent, which a
+ * scalar widget wants for the same reason its charts do — a declared
+ * accent that coloured the line charts and not the sparklines beside them
+ * left one dashboard drawn in two palettes.
+ */
+ trend?: "muted" | "accent";
className?: string;
}) {
+ const Label = labelAs;
const selectable = selectableCard(metric);
return (
-
+ {/* Truncated because the header also carries a badge and, for a
+ caller that owns the tile, its controls — with the full text
+ in `title`, as BarList does, so clipping it does not lose it. */}
+
{metric.label}
-
+
{metric.state &&
}
+ {actions != null && (
+
{actions}
+ )}
-
+ {!withholdsValue(metric) && (
+
+ )}
- {hasTrend(metric.series) && (
+ {!withholdsValue(metric) && hasTrend(metric.series) && (
)}
@@ -281,7 +357,10 @@ export function MetricCard({
return (
-
+
{metric.label}
{metric.state && }
@@ -289,13 +368,15 @@ export function MetricCard({
-
+ {!withholdsValue(metric) && (
+
+ )}
- {hasTrend(metric.series) && (
+ {!withholdsValue(metric) && hasTrend(metric.series) && (
- {metrics.map((metric, i) => (
-
- ))}
+ {children ??
+ metrics?.map((metric, i) => (
+
+ ))}
);
}
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/sparkline.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/sparkline.tsx
index 005664923..72ea0380e 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/sparkline.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/sparkline.tsx
@@ -18,6 +18,7 @@
* dividing by zero; the visual "no change" answer is correct.
*/
+import type { CSSProperties } from "react";
import { useId } from "react";
import { linearScale } from "./geometry.js";
@@ -26,6 +27,12 @@ interface SparklineProps {
width?: number;
height?: number;
className?: string;
+ /**
+ * Inline style for the svg. The line strokes `currentColor`, so this is how
+ * a caller gives it a colour from a token rather than from a utility class
+ * — which is what lets a themed subtree's own accent reach it.
+ */
+ style?: CSSProperties;
// strokeWidth defaults to 1.5 — looks crisp at 1× and 2× DPR.
strokeWidth?: number;
}
@@ -36,6 +43,7 @@ export function Sparkline({
height = 24,
strokeWidth = 1.5,
className,
+ style,
}: SparklineProps) {
const gradId = `sparkline-grad-${useId().replaceAll(":", "")}`;
@@ -46,6 +54,7 @@ export function Sparkline({
height={height}
viewBox={`0 0 ${width} ${height}`}
className={className}
+ style={style}
aria-hidden
/>
);
@@ -81,6 +90,7 @@ export function Sparkline({
height={height}
viewBox={`0 0 ${width} ${height}`}
className={className}
+ style={style}
aria-hidden
>
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/types.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/types.ts
index 55be73394..9d1b1c7ec 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/types.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/types.ts
@@ -8,6 +8,15 @@
// own types here is what lets `@codefly-dev/ui` stay a pure component library with no
// dependency on the SDK's transport stack.
+import type { MetricFormat } from "./metric-tiles.js";
+
+/**
+ * How a widget's read ended when it did not succeed. Presentational: a caller
+ * classifies its own transport (the host app maps its `ReadOutcome` onto this)
+ * and the renderer only decides what to draw.
+ */
+export type WidgetReadFailure = "failed" | "refused";
+
/** How a widget draws its series. */
export type WidgetVisualization = "line" | "bar" | "area" | "number" | "table";
@@ -32,9 +41,43 @@ export interface DashboardWidgetView {
id: string;
visualization: WidgetVisualization;
title?: string;
- series: WidgetSeries;
+ /**
+ * The resolved series, or `null` while it is still being resolved. A
+ * renderer draws a placeholder for `null` rather than an empty series,
+ * because "not yet" and "nothing matched" are different answers and the
+ * second one is a fact about the data.
+ */
+ series: WidgetSeries | null;
+ /**
+ * How the read ended, when it did not succeed. A caller that resolves each
+ * widget separately sets it per widget, so one failure says so in its own
+ * card instead of blanking its siblings.
+ *
+ * The two are not a severity ordering, they are different questions.
+ * `"failed"` says the read did not complete and says nothing about
+ * authority, so a series already in hand is still the best answer and is
+ * drawn as stale. `"refused"` says this viewer is not allowed to see the
+ * number, which makes a previously fetched one a WRONG answer rather than
+ * an old one — so the renderer discards it, whatever the caller passed.
+ * A boolean cannot express that difference, which is why this is not one.
+ */
+ failure?: WidgetReadFailure;
/** Grid columns this widget spans (1–4); ignored in a stack. */
span?: 1 | 2 | 3 | 4;
+ /**
+ * How a scalar widget's value is rendered. Defaults to a plain number.
+ *
+ * `"percent"` takes a value that is ALREADY a percentage: 45 renders "45%".
+ * It does not take a share — 0.4 renders "0.4%", not "40%". A producer whose
+ * own `percent` means a 0-to-1 share must scale it before it reaches here,
+ * because this field is bound structurally (see `fromDashboardData`) and
+ * nothing on the wire can tell the two conventions apart.
+ */
+ format?: MetricFormat;
+ /** Suffix after a scalar widget's value, e.g. "req/s". */
+ unit?: string;
+ /** When false a rising value reads as bad (an error count). Defaults true. */
+ higherIsBetter?: boolean;
}
/** The fully-resolved dashboard the renderer paints. */
@@ -57,6 +100,9 @@ interface ResolvedWidgetLike {
visualization: WidgetVisualization;
title?: string;
series: WidgetSeries;
+ format?: MetricFormat;
+ unit?: string;
+ higherIsBetter?: boolean;
}
interface DashboardDataLike {
title?: string;
@@ -84,6 +130,9 @@ export function fromDashboardData(
visualization: widget.visualization,
title: widget.title,
series: widget.series,
+ format: widget.format,
+ unit: widget.unit,
+ higherIsBetter: widget.higherIsBetter,
})),
};
}
diff --git a/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css b/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css
index 79c8e1603..20b57248a 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css
+++ b/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css
@@ -4438,6 +4438,10 @@
max-width: var(--container-sm);
}
+ .sm\:grid-cols-1 {
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ }
+
.sm\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -4553,6 +4557,14 @@
grid-column: span 4 / span 4;
}
+ .lg\:grid-cols-1 {
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ }
+
+ .lg\:grid-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
diff --git a/module/services/frontend/code/packages/codefly-ui/stories/metrics.stories.tsx b/module/services/frontend/code/packages/codefly-ui/stories/metrics.stories.tsx
index f5066f219..70cbd65e5 100644
--- a/module/services/frontend/code/packages/codefly-ui/stories/metrics.stories.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/stories/metrics.stories.tsx
@@ -1,12 +1,12 @@
-import { TrendLineChart } from "../src/dashboard/index.js";
import {
- MetricCard,
- StatTile,
KPIRow,
MetricAreaChart,
MetricBarChart,
+ MetricCard,
MetricLineChart,
type MetricState,
+ StatTile,
+ TrendLineChart,
} from "../src/dashboard/index.js";
export default { title: "Shared UI/Metrics" };
@@ -56,6 +56,7 @@ export const Availability = {
"stale",
"provider_unavailable",
"not_configured",
+ "no_total",
] satisfies MetricState[]
).map((state) => (
diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json
index 505377c0c..d62870fe0 100644
--- a/module/services/frontend/code/packages/saas-ui/package.json
+++ b/module/services/frontend/code/packages/saas-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/saas-ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"description": "Reusable SaaS-domain frontend components (datasources, …) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.",
"repository": {
"type": "git",
diff --git a/module/services/frontend/code/src/components/__tests__/fixtures/README.md b/module/services/frontend/code/src/components/__tests__/fixtures/README.md
new file mode 100644
index 000000000..ddb89f971
--- /dev/null
+++ b/module/services/frontend/code/src/components/__tests__/fixtures/README.md
@@ -0,0 +1,17 @@
+# Fixtures
+
+`deleted-solution-dashboard.txt` is `src/solutions/SolutionDashboard.tsx` as it
+stood at `8590d329`, before #1041 replaced its hand-rolled widget dispatch with
+a mount of the kit's renderer. The duplication gate asserts that it would be
+refused today, so the rule is pinned against the real duplication rather than a
+paraphrase of it.
+
+It is committed rather than read with `git show` because the suite ships inside
+an immutable module package: a consuming repository carries this source tree and
+not this repository's history, so a fixture that reaches for an upstream commit
+fails there with `fatal: not a git repository` while passing in full-history CI
+— a portability failure that CI is structurally unable to see.
+
+The `.txt` extension is deliberate. The file is a byte fixture, not a module:
+as `.tsx` it would be typechecked against today's imports, linted, and counted
+by `ui-inventory.json`.
diff --git a/module/services/frontend/code/src/components/__tests__/fixtures/deleted-solution-dashboard.txt b/module/services/frontend/code/src/components/__tests__/fixtures/deleted-solution-dashboard.txt
new file mode 100644
index 000000000..526feeade
--- /dev/null
+++ b/module/services/frontend/code/src/components/__tests__/fixtures/deleted-solution-dashboard.txt
@@ -0,0 +1,695 @@
+"use client";
+
+import type {
+ Dashboard,
+ DataGraph,
+ MetricWidget,
+} from "@codefly/saas-plugin-manifest";
+import {
+ createSaasClient,
+ type ResolvedWidget,
+ runDashboard,
+} from "@codefly-dev/saas-sdk";
+// Charts come from the shared kit, not host-internal components: the same
+// primitives a solution's own remote would render with, so host-rendered and
+// solution-rendered dashboards look identical and there is one charting
+// implementation to maintain.
+import {
+ AreaChart,
+ BarList,
+ LineChart,
+ SortableGrid,
+ StatChart,
+} from "@codefly-dev/ui/dashboard";
+import { useQuery } from "@tanstack/react-query";
+import { GripVertical, Info, Plus, X } from "lucide-react";
+import { type ReactNode, useState, useSyncExternalStore } from "react";
+import { flushSync } from "react-dom";
+import { resolveActor } from "@/features/audit/model/transforms";
+import {
+ useAuditEventTypes,
+ usePrincipalDirectory,
+} from "@/features/audit/service/queries";
+import { scopedDashboardDraftKey } from "@/features/dashboard/service/use-dashboard-authoring";
+import { useAuth } from "@/lib/auth";
+import { apiTransport } from "@/lib/connect/transport";
+import {
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Popover,
+ PopoverContent,
+ PopoverTitle,
+ PopoverTrigger,
+ Skeleton,
+} from "@/shared/ui";
+import {
+ addableTiles,
+ addTile,
+ layoutKey,
+ moveBy,
+ parseLayout,
+ readSavedLayout,
+ removeTile,
+ serializeLayout,
+ swapTiles,
+ tileWidget,
+ writeSavedLayout,
+} from "./dashboard-layout";
+import {
+ ACTIVITY_DASHBOARD,
+ activityGraph,
+ activitySummary,
+ describeMetric,
+ formatDay,
+ formatMoment,
+ newestEvents,
+ recentEventsQueries,
+} from "./metric-info";
+
+// One audit-backed SDK client for every solution dashboard, built on the host's
+// shared Connect transport so a solution's declared graph resolves through the
+// same auth'd, org-scoped gateway path the rest of the app uses. The host owns
+// the client, the bearer token, and the org scope; the solution supplies only
+// the DataGraph — it can never widen a query past the viewer's own org.
+const sdk = createSaasClient(apiTransport);
+
+// Dashboard id of the throwaway single-widget graph each card resolves. The card
+// resolves its own widget in isolation, so this id is never surfaced.
+const SINGLE_WIDGET_DASHBOARD = "widget";
+
+// A graph carrying every event and metric but exactly one widget, so runDashboard
+// resolves only that widget's metric (and its derived inputs). Resolving each
+// widget through its own call is what isolates failures: a widget whose metric's
+// audit query errors fails alone, instead of blanking every sibling that shares
+// the dashboard's single batched resolution.
+function singleWidgetGraph(graph: DataGraph, widget: MetricWidget): DataGraph {
+ return {
+ events: graph.events,
+ metrics: graph.metrics,
+ dashboards: [
+ { id: SINGLE_WIDGET_DASHBOARD, layout: "grid", widgets: [widget] },
+ ],
+ };
+}
+
+// Render a resolved widget's series in the shape its visualization asks for. A
+// series with no points is "no data yet" for every kind — the audit RPC omits a
+// bucket rather than emitting a zero, so an empty series means nothing matched,
+// not a real zero worth plotting.
+function WidgetBody({ widget }: { widget: ResolvedWidget }) {
+ return (
+ <>
+ {widget.series.coverage === "partial" && (
+
+ Partial telemetry
+
+ )}
+
+ >
+ );
+}
+
+function WidgetValues({ widget }: { widget: ResolvedWidget }) {
+ const { series, visualization } = widget;
+ if (series.points.length === 0) {
+ return (
+
+ {series.coverage === "partial"
+ ? "Telemetry unavailable or incomplete."
+ : "No data yet."}
+
+ );
+ }
+ switch (visualization) {
+ case "line":
+ return (
+
+ );
+ case "area":
+ return (
+
+ );
+ case "bar":
+ return ;
+ case "number":
+ return series.total === null ? (
+
+ {series.coverage === "partial"
+ ? "Incomplete telemetry; total unavailable."
+ : "Total unavailable across groups."}
+
+ ) : (
+
+ );
+ case "table":
+ return (
+
+
+ {series.points.map((point) => (
+
+ {point.key}
+
+ {point.value.toLocaleString()}
+
+
+ ))}
+
+
+ );
+ default: {
+ // Compile-time exhaustiveness: a new WidgetVisualization must be
+ // handled here or this assignment fails to type-check.
+ const _exhaustive: never = visualization;
+ return _exhaustive;
+ }
+ }
+}
+
+function WidgetCard({
+ graph,
+ widget,
+ solutionId,
+ dashboardId,
+ orgId,
+ grip,
+ info,
+ remove,
+}: {
+ graph: DataGraph;
+ widget: MetricWidget;
+ solutionId: string;
+ dashboardId: string;
+ orgId: string;
+ grip: ReactNode;
+ info: ReactNode;
+ remove: ReactNode;
+}) {
+ // The graph is part of the key so a solution that redeploys with a changed
+ // declaration refetches instead of serving another graph's cached series that
+ // happens to share the same solution/dashboard/widget/org ids. Disabled until
+ // the org resolves so the pre-org window reads as loading, never as empty.
+ const { data, isPending, isError } = useQuery({
+ queryKey: [
+ "solution-widget",
+ solutionId,
+ dashboardId,
+ widget.id,
+ orgId,
+ graph,
+ ],
+ queryFn: () =>
+ runDashboard(
+ sdk.audit,
+ singleWidgetGraph(graph, widget),
+ SINGLE_WIDGET_DASHBOARD,
+ { orgId },
+ ).then((resolved) => resolved.widgets[0]),
+ enabled: orgId !== "",
+ });
+
+ return (
+
+
+ {grip}
+
+ {widget.title ?? widget.metric}
+
+ {info}
+ {remove}
+
+
+ {isError ? (
+ Unable to load.
+ ) : isPending || !data ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+// Reading `window.localStorage` can itself throw: blocked site data or a
+// sandboxed frame raise a SecurityError from the getter, before any getItem. No
+// storage means every viewer sees the declared default.
+function browserStorage(): Storage | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+// The saved layout is read from storage on each render, so there is nothing to
+// subscribe to; a change made on this page is held in state instead.
+function noSubscription() {
+ return () => {};
+}
+
+/**
+ * One viewer's layout of one dashboard, kept in this browser. The server
+ * cannot know what the browser saved, so it renders the declared default and
+ * React swaps in the saved layout after hydration; `useSyncExternalStore` is
+ * what makes that a defined re-render rather than a hydration mismatch.
+ *
+ * The layout is saved only on the viewer's own change, so a viewer who never
+ * customizes keeps following the declared default.
+ *
+ * STOPGAP (labelled): browser storage is an interim home, so a layout does not
+ * follow the viewer to another browser or device. ADR 0007 puts per-user
+ * dashboard preferences in a composed `user_settings` field keyed by dashboard
+ * id; that field and its typed settings-catalog entry do not exist yet (no
+ * tracking issue yet). When they land, this hook's read and write move there
+ * and nothing else changes.
+ */
+function useDashboardLayout(
+ storageKey: string,
+ dashboard: Dashboard,
+): [string[], (next: readonly string[]) => void] {
+ const stored = useSyncExternalStore(
+ noSubscription,
+ () => readSavedLayout(browserStorage(), storageKey),
+ () => null,
+ );
+ // The viewer's latest change on this page. It holds even when storage
+ // refuses the write; it just will not outlive a reload.
+ const [edited, setEdited] = useState<{ key: string; raw: string } | null>(
+ null,
+ );
+ const layout = parseLayout(
+ edited?.key === storageKey ? edited.raw : stored,
+ dashboard,
+ );
+ const save = (next: readonly string[]) => {
+ const raw = serializeLayout(next, dashboard);
+ setEdited({ key: storageKey, raw });
+ writeSavedLayout(browserStorage(), storageKey, raw);
+ };
+ return [layout, save];
+}
+
+const ARROW_STEP: Partial> = {
+ ArrowUp: -1,
+ ArrowLeft: -1,
+ ArrowDown: 1,
+ ArrowRight: 1,
+};
+
+// How many of the newest events the ⓘ lists.
+const RECENT_EVENTS = 5;
+
+// Where a tile's number comes from: what it counts and from which audit
+// events, read from the solution's declaration; how many events there are and
+// the days they span, from per-day counts; and the newest events themselves,
+// from the audit log. Nothing is asked of the audit trail until the viewer
+// opens it.
+function MetricInfo({
+ graph,
+ widget,
+ orgId,
+}: {
+ graph: DataGraph;
+ widget: MetricWidget;
+ orgId: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const title = widget.title ?? widget.metric;
+ const { counts, sources, note } = describeMetric(
+ graph,
+ widget.metric,
+ widget.visualization,
+ );
+ const { data: registered } = useAuditEventTypes({ enabled: open });
+ const activity = useQuery({
+ queryKey: ["solution-metric-activity", widget.metric, orgId, graph],
+ queryFn: () =>
+ runDashboard(
+ sdk.audit,
+ activityGraph(graph, widget.metric),
+ ACTIVITY_DASHBOARD,
+ { orgId },
+ ).then((resolved) =>
+ activitySummary(resolved.widgets.map((w) => w.series)),
+ ),
+ enabled: open && orgId !== "",
+ });
+ const searches = recentEventsQueries(graph, widget.metric);
+ const recent = useQuery({
+ queryKey: ["solution-metric-recent", widget.metric, orgId, graph],
+ queryFn: async () =>
+ newestEvents(
+ await Promise.all(
+ (searches ?? []).map((search) =>
+ sdk.audit
+ .queryAuditLog({ orgId, pageSize: RECENT_EVENTS, ...search })
+ .then((response) => response.events),
+ ),
+ ),
+ RECENT_EVENTS,
+ ),
+ enabled: open && orgId !== "" && searches !== null,
+ });
+ const { directory } = usePrincipalDirectory(
+ orgId,
+ recent.data?.map((event) => event.actorId) ?? [],
+ );
+ const who = (actorId: string) => resolveActor(actorId, directory).label;
+
+ const summary = activity.data;
+ const counted = activity.isPending
+ ? "Loading…"
+ : activity.isError
+ ? "Unavailable"
+ : null;
+ const dayFreshness =
+ counted ??
+ (summary ? `Latest event on ${formatDay(summary.last)}` : "No events yet");
+ // A metric the audit log search cannot narrow like the metric does (by
+ // collection) keeps the day its per-day counts give.
+ const newest = recent.data?.[0];
+ const freshness =
+ searches === null || recent.isError
+ ? dayFreshness
+ : recent.isPending
+ ? "Loading…"
+ : newest
+ ? `${formatMoment(newest.at)}, by ${who(newest.actorId)}`
+ : "No events yet";
+
+ return (
+
+
+ }
+ >
+
+
+
+ {title}
+
+ Counts
+ {counts}
+ Counted from
+
+ {sources.map(({ type, declared }) => {
+ // The audit service's type list holds only the platform's own
+ // types, so a type a module registers falls back to what the
+ // solution's declaration says about it.
+ const description =
+ registered?.find((t) => t.name === type)?.description ||
+ declared;
+ return (
+
+ {description && {description} }
+
+ {type}
+
+
+ );
+ })}
+
+ Scope
+ Your organization
+ Period
+ All time
+ Based on
+
+ {counted ??
+ (summary
+ ? `${summary.events.toLocaleString()} ${summary.events === 1 ? "event" : "events"}`
+ : "No events yet")}
+
+ Data range
+
+ {counted ??
+ (summary
+ ? formatDay(summary.first) === formatDay(summary.last)
+ ? formatDay(summary.first)
+ : `${formatDay(summary.first)} – ${formatDay(summary.last)}`
+ : "No events yet")}
+
+ Freshness
+ {freshness}
+ {recent.data && recent.data.length > 0 && (
+ <>
+ Recent events
+
+
+ {recent.data.map((event) => (
+
+ {formatMoment(event.at, { year: false })} ·{" "}
+ {who(event.actorId)}
+
+ ))}
+
+
+ >
+ )}
+ {note && (
+ <>
+ Note
+ {note}
+ >
+ )}
+
+
+
+ );
+}
+
+// Lists what the viewer can put back on the dashboard: the declared widgets
+// they removed. A preference never adds a metric the dashboard does not draw
+// (ADR 0007).
+function AddTileMenu({
+ label,
+ addable,
+ onAdd,
+}: {
+ label: string;
+ addable: { id: string; label: string }[];
+ onAdd: (tileId: string) => void;
+}) {
+ return (
+
+
+ }
+ >
+
+
+
+ {addable.length === 0 ? (
+
+ Every widget is on this dashboard
+
+ ) : (
+ addable.map((tile) => (
+ onAdd(tile.id)}>
+ {tile.label}
+
+ ))
+ )}
+
+
+ );
+}
+
+// A dashboard the viewer can arrange: drag a tile to reorder (or focus its grip
+// and use the arrow keys), × to remove one, + to add one back.
+function SolutionDashboard({
+ graph,
+ dashboard,
+ solutionId,
+}: {
+ graph: DataGraph;
+ dashboard: Dashboard;
+ solutionId: string;
+}) {
+ const { user, organizationId } = useAuth();
+ const orgId = organizationId ?? "";
+ const storageKey = scopedDashboardDraftKey(
+ layoutKey(solutionId, dashboard.id),
+ { organizationId, userId: user?.id },
+ );
+ const [layout, save] = useDashboardLayout(storageKey, dashboard);
+ const shown = layout.flatMap((tileId) => {
+ const widget = tileWidget(dashboard, tileId);
+ return widget ? [{ tileId, widget }] : [];
+ });
+ const widgets = new Map(shown.map(({ tileId, widget }) => [tileId, widget]));
+ const titleOf = (tileId: string) => {
+ const widget = widgets.get(tileId);
+ return widget?.title ?? widget?.metric ?? tileId;
+ };
+ const renderTile = (tileId: string) => {
+ const widget = widgets.get(tileId);
+ if (!widget) return null;
+ const title = titleOf(tileId);
+ return (
+ {
+ const step = ARROW_STEP[event.key];
+ if (step === undefined) return;
+ event.preventDefault();
+ const grip = event.currentTarget;
+ // Moving a tile can move its DOM node, which drops focus;
+ // commit first so the grip can take focus back.
+ flushSync(() => save(moveBy(layout, tileId, step)));
+ grip.focus();
+ }}
+ >
+
+
+ }
+ info={ }
+ remove={
+ save(removeTile(layout, tileId))}
+ >
+
+
+ }
+ />
+ );
+ };
+ // The floating copy while a tile is dragged: the same card, but with no
+ // info popover (so no second panel or audit query exists mid-drag) and no
+ // controls a keyboard or screen reader could reach. The icons stay so the
+ // copy is drawn the same size as the tile it follows.
+ const renderDragged = (tileId: string) => {
+ const widget = widgets.get(tileId);
+ if (!widget) return null;
+ const glyph = (icon: ReactNode) => (
+
+ {icon}
+
+ );
+ return (
+
+ )}
+ info={glyph( )}
+ remove={glyph( )}
+ />
+
+ );
+ };
+
+ return (
+
+
+ {dashboard.title && (
+
+ {dashboard.title}
+
+ )}
+
save(addTile(layout, tileId))}
+ />
+
+ {shown.length === 0 ? (
+
+ No widgets are shown. Add one back with the + button.
+
+ ) : (
+ // The kit's SortableGrid rather than its Grid/Stack: the tiles are
+ // dragged onto each other to swap. The classes are those Grid
+ // cols={2} and Stack draw with.
+ tileId)}
+ onSwap={(dragged, target) => save(swapTiles(layout, dragged, target))}
+ itemLabel={titleOf}
+ renderItem={renderTile}
+ renderOverlay={renderDragged}
+ className={
+ dashboard.layout === "stack"
+ ? "flex flex-col gap-4"
+ : "grid grid-cols-1 gap-4 sm:grid-cols-2"
+ }
+ />
+ )}
+
+ );
+}
+
+/**
+ * Renders every dashboard a registered solution declares in its data-graph slot.
+ * Each widget resolves against the audit trail through its own query, so one
+ * widget's failure never blanks its siblings. A solution ships only the
+ * declaration; all charting lives here in the host. Each viewer arranges each
+ * dashboard for themselves, starting from the declared widgets in declared
+ * order.
+ */
+export function SolutionDashboards({
+ graph,
+ solutionId,
+}: {
+ graph: DataGraph;
+ solutionId: string;
+}) {
+ return (
+
+ {graph.dashboards.map((dashboard) => (
+
+ ))}
+
+ );
+}
diff --git a/module/services/frontend/code/src/components/__tests__/kit.test.ts b/module/services/frontend/code/src/components/__tests__/kit.test.ts
new file mode 100644
index 000000000..755cc806a
--- /dev/null
+++ b/module/services/frontend/code/src/components/__tests__/kit.test.ts
@@ -0,0 +1,1875 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import ts from "typescript";
+import { describe, expect, it } from "vitest";
+import packageJson from "../../../package.json" with { type: "json" };
+
+/**
+ * The host app composes `@codefly-dev/ui`; it does not re-implement it.
+ *
+ * The audit that opened issue #1041 found a solution's Dashboard tab drawn with
+ * the kit's older basic chart tier while the host's own operations pages used
+ * its metric tier — because `src/solutions/SolutionDashboard.tsx` had its own
+ * copy of the widget-to-chart dispatch, the responsive grid, the card shell and
+ * the empty state beside the kit's ``. Two renderers of one thing do
+ * not stay equal: one gets the sparkline, the crosshair readout and the
+ * screen-reader data table a release later, and the other does not. The owner
+ * asked for a linter.
+ *
+ * The rule is not invented here: another module that composes this kit already
+ * carries it, in a gate of the same shape, and that one is copied rather than
+ * replaced. Three legs, because no single one of them is sufficient:
+ *
+ * - **A name the kit exports** may be declared here only by a wrapper that
+ * renders the kit's component of that name, or with a reviewed entry in
+ * {@link ALLOWED}. Both forms of the declaration count — exported and
+ * module-local — because a duplicate hidden as a file-local helper is the
+ * commonest way one arrives (two of them were sitting here when this gate
+ * was written, and the upstream version of this rule, which looked only at
+ * exported declarations, would have walked past both).
+ * - **`class-variance-authority`** — the library the kit builds its variants
+ * with — is not a dependency of this package, so a kit-shaped primitive
+ * cannot be built here at all. This leg is a fact, not a convention, which
+ * is why it is worth more than the first.
+ * - **The chart surfaces stay re-exports.** `src/components/charts/chart.tsx`
+ * and `src/features/dashboard/ui/charts/line-chart.tsx` are each a single
+ * `export … from "@codefly-dev/ui/dashboard"`. Issue #1041 asked what they
+ * were; they are already right, and this keeps them so.
+ *
+ * It resolves names with the TypeScript checker, so every spelling that reaches
+ * the same component reaches the same verdict, and it reads what a component
+ * RETURNS rather than what a file mentions. Where it cannot resolve a name the
+ * kit owns — published from a third-party module, or from a chain whose end it
+ * cannot read — it refuses rather than passing: "could not tell" is not a
+ * verdict it is allowed to reach, and four rounds of adversarial fixtures each
+ * found the next way through the version that skipped instead.
+ *
+ * WHAT IT CANNOT SEE. This is a tripwire for the common shape, not a proof, and
+ * these are limits rather than bugs — each is a place where deciding would mean
+ * following the program further than reading one component's returned
+ * expression:
+ *
+ * - **A duplicate under a NEW name**, built by hand, passes every leg.
+ * {@link ALLOWED} and {@link DISPATCHERS} are the backstop — labels a
+ * reviewer reads and asks about, not detectors.
+ * - **Kit JSX handed to another component through an attribute.** What the
+ * recipient does with a prop is beyond this; a kit element in one is no
+ * evidence that this component renders it. Children are not attributes and
+ * do count.
+ * - **Anything behind a call.** A mark rendered by a helper function, or
+ * chosen from a lookup table (`MARKS[kind]`), is a dispatch this cannot
+ * read. The table is plain to a reviewer and invisible here.
+ * - **A dispatch split across components**, one visualization each, composed
+ * by a parent that only forwards.
+ *
+ * Classifying files by their names instead would fail on the next file anyone
+ * adds, and a gate that cries wolf is a gate that gets deleted.
+ */
+
+const here = dirname(fileURLToPath(import.meta.url));
+const appRoot = join(here, "..", "..", "..");
+
+/**
+ * A name this package declares although the kit exports it, and why. Each entry
+ * says what the two things are, why neither can stand in for the other, and
+ * what would have to change for the entry to go.
+ */
+const ALLOWED: Record = {
+ "Dashboard in src/features/dashboard/ui/dashboard.tsx":
+ "the host's dashboard DSL surface: it reads the viewer's organization from host auth and resolves each declared metric through `useMetric` as it draws. The kit's `` is pure and takes an already-resolved `DashboardView`, so neither can stand in for the other. Goes when the DSL resolves its metrics outside the renderer and hands the kit a view, as `src/solutions/SolutionDashboard.tsx` now does.",
+ "Dashboard in src/components/dashboard.tsx":
+ "the widget-slot renderer: a discriminated union of host-supplied widgets (sparkline, bars, series, arbitrary node), each carrying its own loading/error/empty state, rather than a declared metric view. Goes when the audit page's widgets are expressible as a `DashboardView` and it mounts the kit's renderer instead.",
+ "Layout in src/features/dashboard/ui/layout.tsx":
+ "page rhythm for a dashboard page — an `` and a vertical stack. The kit's `Layout` is the application shell (sidebar, header, content region); this is what goes inside one. Goes when it is re-expressed on the kit's `Page`/`PageHeader`.",
+ "MetricCard in src/features/dashboard/ui/metric-card.tsx":
+ "resolves one declared metric through `useMetric` and then paints whichever chart the declaration asked for; the kit's `MetricCard` takes an already-resolved `Metric`. Goes with the DSL's resolution moving outside the card, the same change that retires the `Dashboard` entry above.",
+ "MetricCard in src/features/event-operations/ui/event-operations-page.tsx":
+ "a tile over an already-formatted string value plus a caption line. The kit's `StatTile`/`MetricCard` take a numeric `Metric` and have no caption slot, so this is not yet expressible as one. Goes when `StatTile` carries a description, or when these counts are expressed as `Metric`s with a `format`.",
+ "MetricCard in src/features/job-operations/ui/job-operations-page.tsx":
+ "the same tile as the event-operations page's, over the job queue's counts, and it goes with it — the two are each other's duplicate as much as the kit's, and both should become one call to the kit's tile.",
+};
+
+/**
+ * Chart surfaces that are, and stay, thin re-exports of the kit.
+ *
+ * Issue #1041 asked what these two are, since both carry a kit export's name.
+ * They are each a single `export … from "@codefly-dev/ui/dashboard"`: they ship
+ * the kit's component, not another one, so neither is a duplicate and neither
+ * needs an entry in {@link ALLOWED}. What they do carry is a hazard this gate
+ * cannot see, recorded here rather than left for the next reader to find: both
+ * publish the name `LineChart`, and they publish DIFFERENT kit components under
+ * it — `src/components/charts` re-exports the metric-tier `LineChart`, while
+ * `src/features/dashboard/ui/charts` re-exports `TrendLineChart`. An import of
+ * `LineChart` therefore means one of two charts depending on which path it came
+ * from. Retiring the second alias belongs with the dashboard-DSL work that
+ * retires `MetricCard in src/features/dashboard/ui/metric-card.tsx` below, its
+ * only consumer.
+ */
+const CHART_RE_EXPORTS = [
+ "src/components/charts/chart.tsx",
+ "src/features/dashboard/ui/charts/line-chart.tsx",
+];
+
+function walk(dir: string, out: string[] = []): string[] {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== "__tests__" && entry.name !== "node_modules") {
+ walk(path, out);
+ }
+ continue;
+ }
+ if (/\.tsx?$/.test(entry.name) && !/\.(test|stories)\./.test(entry.name)) {
+ out.push(path);
+ }
+ }
+ return out;
+}
+
+/**
+ * `export *` clauses in the kit's entry points, by the module they name.
+ *
+ * The export scan below reads `export { … }` blocks, so a star re-export is
+ * invisible to it: the kit's root and plugin-host entry points use one, and
+ * four capitalised names reach consumers only that way. None collides with a
+ * host declaration today, but a partial miss is indistinguishable from success
+ * — the `kit.size` sanity check passes at 309 whether those four are in it or
+ * not — so the gate refuses a star it cannot resolve rather than quietly
+ * covering less than it claims.
+ */
+function starReExports(
+ entryPoints: { path: string; source: string }[],
+): string[] {
+ const unresolved: string[] = [];
+ for (const { path, source } of entryPoints) {
+ for (const star of source.matchAll(
+ /export\s+\*\s+from\s*["']([^"']+)["']/g,
+ )) {
+ // A star onto another of the kit's own entry points resolves: that
+ // file is itself in this list and its named exports are read there.
+ if (star[1].startsWith("./") || star[1].startsWith("../")) continue;
+ unresolved.push(`${path} → ${star[1]}`);
+ }
+ }
+ return unresolved;
+}
+
+/** Every capitalised name the kit's entry points export. */
+function kitExports(entryPoints: string[]): Set {
+ const names = new Set();
+ for (const source of entryPoints) {
+ for (const block of source.matchAll(/export\s*\{([^}]*)\}/g)) {
+ for (const raw of block[1].split(",")) {
+ const name =
+ raw
+ .trim()
+ .replace(/^type\s+/, "")
+ .split(/\s+as\s+/)
+ .pop()
+ ?.trim() ?? "";
+ if (/^[A-Z][A-Za-z0-9]*$/.test(name)) names.add(name);
+ }
+ }
+ }
+ return names;
+}
+
+/**
+ * Modules of this app that exist only to re-export the kit — the host's own
+ * import surface. `src/shared/ui/index.ts` says so in its first line ("Host
+ * convenience imports; presentation is implemented by the shared kit"), and
+ * most of the app reaches the kit through it rather than through
+ * `@codefly-dev/ui` directly.
+ *
+ * They are kit sources for the purposes of the rule below. Without this, the
+ * gate's verdict depended on which path a wrapper imported through: the same
+ * `Badge` wrapper passed when it imported `@codefly-dev/ui/layout` and failed
+ * when it imported the identical component from `@/shared/ui`. A gate that
+ * rejects the house style is one that gets an exception entry per correct file,
+ * and then gets deleted.
+ */
+const KIT_SURFACES = [
+ "src/shared/ui/index.ts",
+ "src/components/charts/chart.tsx",
+ "src/components/charts/index.ts",
+ "src/components/sparkline.tsx",
+ "src/components/metric-state.tsx",
+];
+
+const KIT_SPECIFIER = /^@codefly-dev\/(?:ui|saas-ui)(?:\/|$)/;
+
+/**
+ * Whether a module specifier names one of the host's kit surfaces, by alias
+ * (`@/shared/ui`) or by a relative path that lands on the same file. Both
+ * spellings reach the same module, so judging only the alias rejected a real
+ * wrapper for how it spelled its import.
+ */
+function isHostSurface(specifier: string, fromPath: string): boolean {
+ const resolved = specifier.startsWith("@/")
+ ? `src/${specifier.slice(2)}`
+ : specifier.startsWith(".")
+ ? join(dirname(fromPath), specifier).replaceAll("\\", "/")
+ : null;
+ if (resolved === null) return false;
+ return KIT_SURFACES.some(
+ (surface) => surface === resolved || surface.startsWith(`${resolved}/`),
+ );
+}
+
+/**
+ * What a local name in a file refers to.
+ *
+ * `{ kit }` is a kit component, under the kit's OWN name whatever the local
+ * spelling. `{ namespace }` is a whole module imported as one object, so
+ * ` ` has to be resolved through it at the use site.
+ */
+/**
+ * A program over just these sources, so a symbol can be resolved the way the
+ * compiler resolves it.
+ *
+ * Hand-rolled scope analysis is what kept misjudging this: it collected names
+ * across scopes (so a nested parameter shadowed an outer tag), missed
+ * destructured bindings (so a parameter did NOT shadow when it should have),
+ * and could not follow an export chain at all. The checker answers all of
+ * those from one question — what does this identifier refer to — so the rules
+ * below ask it instead of re-deriving it.
+ *
+ * Only these files are in the program: relative specifiers resolve between
+ * them and everything else (`react`, the kit itself) stays unresolved on
+ * purpose. A kit binding is recognised from the import declaration in the
+ * source, which is present whether or not the package is installed, so the
+ * gate needs no `node_modules` and cannot be weakened by a resolution failure.
+ */
+function programOver(files: { path: string; source: string }[]): {
+ program: ts.Program;
+ checker: ts.TypeChecker;
+ sources: Map;
+} {
+ const sources = new Map(
+ files.map(({ path, source }) => [path, parse(path, source)]),
+ );
+ const host: ts.CompilerHost = {
+ fileExists: (name) => sources.has(name),
+ readFile: (name) => sources.get(name)?.text,
+ getSourceFile: (name) => sources.get(name),
+ getDefaultLibFileName: () => "lib.d.ts",
+ writeFile: () => {},
+ getCurrentDirectory: () => "",
+ getCanonicalFileName: (name) => name,
+ useCaseSensitiveFileNames: () => true,
+ getNewLine: () => "\n",
+ resolveModuleNames: (names, containingFile) =>
+ names.map((name) => {
+ // The app's own two spellings of its own modules: a relative path
+ // and the `@/` alias its tsconfig maps to `src/`. Resolving only
+ // the first let a copy hide behind an export chain written in the
+ // second — the gate read the barrel and then stopped.
+ if (!name.startsWith(".") && !name.startsWith("@/")) return undefined;
+ const base = name.startsWith("@/")
+ ? `src/${name.slice(2)}`
+ : join(dirname(containingFile), name).replaceAll("\\", "/");
+ for (const candidate of [
+ base,
+ `${base}.tsx`,
+ `${base}.ts`,
+ `${base}/index.tsx`,
+ `${base}/index.ts`,
+ ]) {
+ if (sources.has(candidate)) {
+ return {
+ resolvedFileName: candidate,
+ extension: candidate.endsWith(".tsx")
+ ? ts.Extension.Tsx
+ : ts.Extension.Ts,
+ };
+ }
+ }
+ return undefined;
+ }),
+ };
+ const program = ts.createProgram(
+ [...sources.keys()],
+ {
+ jsx: ts.JsxEmit.Preserve,
+ noLib: true,
+ noResolve: false,
+ allowJs: false,
+ target: ts.ScriptTarget.Latest,
+ module: ts.ModuleKind.ESNext,
+ },
+ host,
+ );
+ return { program, checker: program.getTypeChecker(), sources };
+}
+
+const KIT_NAMESPACE = "*kit";
+const SURFACE_NAMESPACE = "*surface";
+
+/**
+ * An alias chain from a symbol to its end, every hop included.
+ *
+ * Cycle-detected, not hop-capped. A chain that is long is not a chain that is
+ * suspicious, and abandoning one at an arbitrary depth was itself an escape:
+ * nine re-exports in a row resolved to nothing and the copy at the end of them
+ * went unexamined. A cycle is the only thing that can stop this, and it stops
+ * it without a verdict.
+ */
+function aliasChain(symbol: ts.Symbol, checker: ts.TypeChecker): ts.Symbol[] {
+ const chain = [symbol];
+ const seen = new Set([symbol]);
+ let current = symbol;
+ while (current.flags & ts.SymbolFlags.Alias) {
+ const next = checker.getImmediateAliasedSymbol(current);
+ if (!next || seen.has(next)) break;
+ seen.add(next);
+ chain.push(next);
+ current = next;
+ }
+ return chain;
+}
+
+/**
+ * Where a declaration comes from, when it comes from another module.
+ *
+ * `kit` is the kit's own component under the kit's own name, however the local
+ * spelling renamed it, whether it arrived by `import` or by `export … from`.
+ * `namespace` is a whole kit or surface module, to be resolved at the use site.
+ * `external` is a module that is neither — including a third-party one, which
+ * is as much a second implementation of a kit name as a hand-written copy.
+ * `undefined` means the declaration is local, so the chain continues.
+ */
+type KitBinding =
+ | { kind: "kit"; name: string }
+ | { kind: "namespace"; marker: string }
+ | { kind: "type" }
+ | { kind: "external"; from: string };
+
+function kitBindingOf(
+ declaration: ts.Node,
+ surface: Map,
+): KitBinding | undefined {
+ let from: ts.ImportDeclaration | ts.ExportDeclaration | undefined;
+ for (let node: ts.Node | undefined = declaration; node; node = node.parent) {
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
+ from = node;
+ break;
+ }
+ }
+ const specifier = from?.moduleSpecifier;
+ if (!from || !specifier || !ts.isStringLiteral(specifier)) return undefined;
+ if (
+ (ts.isImportDeclaration(from) && from.importClause?.isTypeOnly) ||
+ (ts.isExportDeclaration(from) && from.isTypeOnly)
+ ) {
+ return { kind: "type" };
+ }
+ const fromKit = KIT_SPECIFIER.test(specifier.text);
+ const fromSurface = isHostSurface(
+ specifier.text,
+ from.getSourceFile().fileName,
+ );
+ if (!fromKit && !fromSurface) {
+ return { kind: "external", from: specifier.text };
+ }
+ if (ts.isNamespaceImport(declaration) || ts.isNamespaceExport(declaration)) {
+ return {
+ kind: "namespace",
+ marker: fromKit ? KIT_NAMESPACE : SURFACE_NAMESPACE,
+ };
+ }
+ if (ts.isImportSpecifier(declaration) || ts.isExportSpecifier(declaration)) {
+ // A type is not a component. It cannot be rendered, it cannot be a
+ // second implementation of one, and treating a type-only binding as
+ // something unreadable would offend on every `export type` that shares
+ // a kit name.
+ if (declaration.isTypeOnly || typeOnlyClause(declaration)) {
+ return { kind: "type" };
+ }
+ const published = (declaration.propertyName ?? declaration.name).text;
+ const name = fromKit ? published : surface.get(published);
+ return name
+ ? { kind: "kit", name }
+ : { kind: "external", from: specifier.text };
+ }
+ return { kind: "external", from: specifier.text };
+}
+
+/** Whether the clause a specifier sits in is type-only. */
+function typeOnlyClause(
+ declaration: ts.ImportSpecifier | ts.ExportSpecifier,
+): boolean {
+ const clause = declaration.parent.parent;
+ return ts.isImportClause(clause)
+ ? clause.isTypeOnly
+ : ts.isExportDeclaration(clause) && clause.isTypeOnly;
+}
+
+/**
+ * The kit component an identifier refers to, by the kit's OWN name, or a
+ * namespace marker for a whole kit module, or undefined for anything else —
+ * including anything that cannot be resolved, which is the conservative
+ * answer: unresolved is not composition.
+ *
+ * The checker answers this, so every spelling that reaches the same component
+ * reaches the same verdict: an import alias, a local variable alias, a chain of
+ * re-exports through the app's own barrels, a member of a namespace import of
+ * either. Hand-rolled scope analysis is what kept misjudging it — it collected
+ * names across scopes, missed destructured bindings, and could not follow an
+ * export chain at all.
+ */
+function kitComponentOf(
+ node: ts.Expression | ts.JsxTagNameExpression,
+ checker: ts.TypeChecker,
+ surface: Map,
+ seen = new Set(),
+): string | undefined {
+ if (seen.has(node)) return undefined;
+ seen.add(node);
+
+ // The whole expression first, which is what resolves `UI.Card` when `UI` is
+ // a namespace of one of the app's own barrels: the checker walks the barrel's
+ // export to the kit import behind it.
+ const symbol = checker.getSymbolAtLocation(node);
+ if (symbol) {
+ for (const hop of aliasChain(symbol, checker)) {
+ const declaration = hop.declarations?.[0];
+ if (!declaration) continue;
+ const binding = kitBindingOf(declaration, surface);
+ if (binding?.kind === "kit") return binding.name;
+ if (binding?.kind === "namespace") return binding.marker;
+ // Another module of the app's own: the chain walks through it. Only
+ // its end can say the name is not the kit's, which is why nothing
+ // here stops at the first hop that is not.
+ if (binding) continue;
+ if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
+ return kitComponentOf(declaration.initializer, checker, surface, seen);
+ }
+ }
+ }
+ // `UI.Card` where `UI` is the kit itself: the kit is not in the program, so
+ // there is no symbol for the member and the namespace is the evidence.
+ if (ts.isPropertyAccessExpression(node)) {
+ const base = kitComponentOf(node.expression, checker, surface, seen);
+ if (base === KIT_NAMESPACE) return node.name.text;
+ if (base === SURFACE_NAMESPACE) return surface.get(node.name.text);
+ }
+ return undefined;
+}
+
+/**
+ * What a component renders, as the alternative pictures it can draw: one set of
+ * kit marks per branch the renderer can take.
+ *
+ * Expression forms, and nothing beyond them. A conditional is two
+ * alternatives wherever it sits — nested inside returned JSX included, which
+ * is where a dispatch is usually written. `&&` adds the empty alternative,
+ * because the mark may be absent. `||` and `??` are their two sides. A cast,
+ * a parenthesis and a non-null assertion are their inner expression. A comma
+ * expression is its RIGHT operand alone: the others are evaluated and thrown
+ * away. Children of an element render together, so they multiply out.
+ *
+ * Nothing else is descended into, which is how two things end up not counting
+ * as rendered — both limits rather than oversights. A function body is not what
+ * the expression holding it draws, so a render prop and a callback in children
+ * are values, not pictures. And JSX handed to another component through an
+ * attribute is a value passed too: what the recipient does with it is beyond
+ * anything this can see, so a kit element in a prop is no evidence that this
+ * component renders it. Children are not attributes and do count.
+ */
+function alternatives(
+ node: ts.Node,
+ checker: ts.TypeChecker,
+ surface: Map,
+ seen = new Set(),
+): Set[] {
+ const NOTHING = [new Set()];
+ if (seen.has(node)) return NOTHING;
+ seen.add(node);
+ const of = (next: ts.Node) => alternatives(next, checker, surface, seen);
+
+ if (
+ ts.isParenthesizedExpression(node) ||
+ ts.isAsExpression(node) ||
+ ts.isSatisfiesExpression(node) ||
+ ts.isTypeAssertionExpression(node) ||
+ ts.isNonNullExpression(node)
+ ) {
+ return of(node.expression);
+ }
+ if (ts.isJsxExpression(node)) {
+ return node.expression ? of(node.expression) : NOTHING;
+ }
+ if (ts.isConditionalExpression(node)) {
+ return distinct([...of(node.whenTrue), ...of(node.whenFalse)]);
+ }
+ if (ts.isBinaryExpression(node)) {
+ switch (node.operatorToken.kind) {
+ case ts.SyntaxKind.CommaToken:
+ return of(node.right);
+ case ts.SyntaxKind.AmpersandAmpersandToken:
+ return distinct([new Set(), ...of(node.right)]);
+ case ts.SyntaxKind.BarBarToken:
+ case ts.SyntaxKind.QuestionQuestionToken:
+ return distinct([...of(node.left), ...of(node.right)]);
+ default:
+ return NOTHING;
+ }
+ }
+ if (ts.isJsxElement(node)) {
+ return together([
+ [markOf(node.openingElement.tagName, checker, surface)],
+ ...node.children.map(of),
+ ]);
+ }
+ if (ts.isJsxSelfClosingElement(node)) {
+ return [markOf(node.tagName, checker, surface)];
+ }
+ if (ts.isJsxFragment(node)) {
+ return together(node.children.map(of));
+ }
+ if (ts.isArrayLiteralExpression(node)) {
+ return together(node.elements.map(of));
+ }
+ // A local name holding a picture: `const content = ; return content`.
+ if (ts.isIdentifier(node)) {
+ const symbol = checker.getSymbolAtLocation(node);
+ for (const hop of symbol ? aliasChain(symbol, checker) : []) {
+ const declaration = hop.declarations?.[0];
+ if (
+ declaration &&
+ ts.isVariableDeclaration(declaration) &&
+ declaration.initializer
+ ) {
+ return of(declaration.initializer);
+ }
+ }
+ }
+ return NOTHING;
+}
+
+/** Whether a node is a function, whose body is not this component's picture. */
+function isFunctionLike(node: ts.Node): boolean {
+ return (
+ ts.isFunctionDeclaration(node) ||
+ ts.isFunctionExpression(node) ||
+ ts.isArrowFunction(node) ||
+ ts.isMethodDeclaration(node)
+ );
+}
+
+/** The kit mark a JSX tag draws, as a set of none or one. */
+function markOf(
+ tag: ts.JsxTagNameExpression,
+ checker: ts.TypeChecker,
+ surface: Map,
+): Set {
+ const kit = kitComponentOf(tag, checker, surface);
+ return kit && !kit.startsWith("*") ? new Set([kit]) : new Set();
+}
+
+/** One alternative per distinct set of marks. */
+function distinct(paths: Set[]): Set[] {
+ const byShape = new Map>();
+ for (const path of paths) {
+ byShape.set([...path].sort().join("+"), path);
+ }
+ return [...byShape.values()];
+}
+
+/**
+ * Parts that all render: every combination of one alternative from each. Kept
+ * distinct at each step, so the count is bounded by the mark sets that can
+ * actually occur rather than by the size of the tree.
+ */
+function together(parts: Set[][]): Set[] {
+ let combined: Set[] = [new Set()];
+ for (const part of parts) {
+ const next: Set[] = [];
+ for (const left of combined) {
+ for (const right of part) {
+ next.push(new Set([...left, ...right]));
+ }
+ }
+ combined = distinct(next);
+ }
+ return combined;
+}
+
+/**
+ * The alternatives a declaration's body returns.
+ *
+ * Every `return` in the body, plus a concise arrow body, which is one. A
+ * statement after a `return` or a `throw` in the same block is unreachable and
+ * is not one: a picture no execution reaches is not drawn.
+ */
+function returnedAlternatives(
+ declaration: ts.Node,
+ checker: ts.TypeChecker,
+ surface: Map,
+): Set[] {
+ const body = functionBodyOf(declaration);
+ if (!body) return [];
+ if (!ts.isBlock(body)) {
+ return alternatives(body, checker, surface);
+ }
+ const paths: Set[] = [];
+ const visit = (node: ts.Node) => {
+ if (isFunctionLike(node) && node !== body) return;
+ if (ts.isBlock(node) || ts.isCaseClause(node) || ts.isDefaultClause(node)) {
+ for (const statement of node.statements) {
+ visit(statement);
+ if (ts.isReturnStatement(statement) || ts.isThrowStatement(statement)) {
+ return;
+ }
+ }
+ return;
+ }
+ if (ts.isReturnStatement(node)) {
+ if (node.expression) {
+ paths.push(...alternatives(node.expression, checker, surface));
+ }
+ return;
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(body);
+ return paths;
+}
+
+/** Every kit component this declaration returns, on any alternative. */
+function returnedKitComponents(
+ declaration: ts.Node,
+ checker: ts.TypeChecker,
+ surface: Map,
+): Set {
+ return new Set(
+ returnedAlternatives(declaration, checker, surface).flatMap((path) => [
+ ...path,
+ ]),
+ );
+}
+
+/** The body of the function a declaration is, if it is one. */
+function functionBodyOf(declaration: ts.Node): ts.Node | undefined {
+ if (ts.isFunctionDeclaration(declaration)) return declaration.body;
+ if (
+ ts.isVariableDeclaration(declaration) &&
+ declaration.initializer &&
+ isFunctionLike(declaration.initializer)
+ ) {
+ return (declaration.initializer as ts.ArrowFunction).body;
+ }
+ if (isFunctionLike(declaration)) {
+ return (declaration as ts.ArrowFunction).body;
+ }
+ return undefined;
+}
+
+/**
+ * What a published name resolves to.
+ *
+ * `declaration` is code in this set, to be examined. `kit` is the kit's own
+ * component, however renamed — a re-export ships the kit's component rather
+ * than a second one. `unresolved` is everything else, and it is an OFFENCE
+ * rather than a skip: a name the kit owns, published here from somewhere this
+ * cannot read, is a second implementation of it as surely as a hand-written
+ * copy, and "could not tell" was the hole that four rounds of fixtures each
+ * found the next way through.
+ */
+type Resolution =
+ | { kind: "declaration"; node: ts.Node }
+ | { kind: "kit"; name: string }
+ | { kind: "type" }
+ | { kind: "unresolved"; why: string };
+
+/**
+ * Every component a file publishes, by published name — following an export
+ * chain of any length into whichever file actually declares it, so splitting a
+ * copy from its kit-named export hides nothing and a genuine wrapper
+ * re-exported under the kit's name is not mistaken for one. Declarations the
+ * file does not export are still declarations here: a duplicate hidden as a
+ * file-local helper is the commonest way one arrives.
+ */
+function publishedComponents(
+ file: ts.SourceFile,
+ checker: ts.TypeChecker,
+ surface: Map,
+): Map {
+ const published = new Map();
+ const resolve = (symbol: ts.Symbol): Resolution => {
+ const chain = aliasChain(symbol, checker);
+ for (const [hop, resolved] of chain.entries()) {
+ const declarations = resolved.declarations ?? [];
+ const value = declarations.find(
+ (declaration) =>
+ ts.isFunctionDeclaration(declaration) ||
+ ts.isClassDeclaration(declaration) ||
+ ts.isVariableDeclaration(declaration),
+ );
+ if (value) return { kind: "declaration", node: value };
+ if (
+ declarations.length > 0 &&
+ declarations.every(
+ (declaration) =>
+ ts.isTypeAliasDeclaration(declaration) ||
+ ts.isInterfaceDeclaration(declaration),
+ )
+ ) {
+ return { kind: "type" };
+ }
+ const binding = declarations[0]
+ ? kitBindingOf(declarations[0], surface)
+ : undefined;
+ if (binding?.kind === "kit" || binding?.kind === "namespace") {
+ return {
+ kind: "kit",
+ name: binding.kind === "kit" ? binding.name : binding.marker,
+ };
+ }
+ if (binding?.kind === "type") return { kind: "type" };
+ // An `external` hop in the middle is another of the app's own
+ // modules, which the chain walks through. At the END of the chain it
+ // is a module this cannot read, and a kit name coming out of one is
+ // a second implementation of it as much as a hand-written copy.
+ if (hop === chain.length - 1) {
+ return binding?.kind === "external"
+ ? { kind: "unresolved", why: `published from ${binding.from}` }
+ : { kind: "unresolved", why: "the chain reaches no declaration" };
+ }
+ }
+ return { kind: "unresolved", why: "the chain reaches no declaration" };
+ };
+
+ const moduleSymbol = checker.getSymbolAtLocation(file);
+ if (moduleSymbol) {
+ for (const exported of checker.getExportsOfModule(moduleSymbol)) {
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(exported.name)) continue;
+ published.set(exported.name, resolve(exported));
+ }
+ }
+ const visit = (node: ts.Node) => {
+ const name =
+ (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) &&
+ node.name
+ ? node.name.text
+ : ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)
+ ? node.name.text
+ : undefined;
+ if (name && /^[A-Z][A-Za-z0-9]*$/.test(name) && !published.has(name)) {
+ published.set(name, { kind: "declaration", node });
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(file);
+ return published;
+}
+
+/**
+ * The kit's dashboard marks. A surface may compose several of these on one
+ * page — a KPI row above a chart is a page, not a renderer. What it may not do
+ * is BRANCH between them: choosing a mark by a widget's kind is a dispatch over
+ * visualizations, and that is ``'s whole job.
+ *
+ * Branching is the test, not the count, because counting file-wide imports
+ * caught a page that composed two marks side by side and could be escaped by a
+ * namespace import or a local alias. This is also the only leg that catches a
+ * duplicate under a NEW name: the renderer #1041 deleted declared
+ * `WidgetBody`, `WidgetValues` and `WidgetCard` — not one of them a name the
+ * kit exports — so every name-based leg passed it while it was the duplication
+ * itself.
+ */
+const DASHBOARD_MARKS = new Set([
+ "LineChart",
+ "AreaChart",
+ "BarList",
+ "StatChart",
+ "TrendLineChart",
+ "MetricLineChart",
+ "MetricAreaChart",
+ "MetricBarChart",
+ "StatTile",
+ "MetricCard",
+ "KPIRow",
+ "Sparkline",
+]);
+
+/**
+ * Whether this declaration CHOOSES between dashboard marks: two alternatives
+ * whose marks are each non-empty and neither contained in the other.
+ *
+ * Containment is what separates the two shapes that look alike. A page that
+ * always draws a chart and sometimes adds a KPI row has `{chart}` and
+ * `{row, chart}` — one is the other plus an optional extra, and it is one
+ * picture with a variation. A renderer that draws a line for one kind and an
+ * area for another has `{line}` and `{area}` — neither is the other, and that
+ * is the dispatch over visualizations `` owns.
+ *
+ * Choosing is the test, not branching and not counting: counting file-wide
+ * imports flagged a page that composed two marks side by side, and comparing
+ * only top-level returns walked past the same dispatch written one line deeper,
+ * inside the JSX it returns.
+ */
+function dispatchedMarks(
+ declaration: ts.Node,
+ checker: ts.TypeChecker,
+ surface: Map,
+): string[] {
+ const paths = distinct(
+ returnedAlternatives(declaration, checker, surface).map(
+ (path) => new Set([...path].filter((mark) => DASHBOARD_MARKS.has(mark))),
+ ),
+ ).filter((path) => path.size > 0);
+ const within = (small: Set, large: Set) =>
+ [...small].every((mark) => large.has(mark));
+ for (const left of paths) {
+ for (const right of paths) {
+ if (left === right) continue;
+ if (!within(left, right) && !within(right, left)) {
+ return [...new Set([...left, ...right])].sort();
+ }
+ }
+ }
+ return [];
+}
+
+const DISPATCHERS: Record = {
+ "src/components/dashboard.tsx":
+ "the widget-slot renderer: a union of host-supplied widgets, each with its own loading/error/empty state. Goes when the audit page's widgets become a DashboardView and it mounts the kit's renderer.",
+ "src/features/dashboard/ui/metric-card.tsx":
+ "the dashboard DSL's card: it resolves one declared metric through useMetric and then draws whichever chart the declaration asked for. Goes with the DSL resolving outside the card.",
+};
+
+function appSources(): { path: string; source: string }[] {
+ return walk(join(appRoot, "src"))
+ .map((file) => relative(appRoot, file).replaceAll("\\", "/"))
+ .filter(
+ (path) =>
+ !path.startsWith("src/gen/") && !path.startsWith("src/generated/"),
+ )
+ .map((path) => ({
+ path,
+ source: readFileSync(join(appRoot, path), "utf8"),
+ }));
+}
+
+function kitEntryPoints(): { path: string; source: string }[] {
+ return walk(join(appRoot, "packages/codefly-ui/src"))
+ .filter((file) => /[/\\]index\.ts$/.test(file))
+ .map((file) => ({
+ path: relative(appRoot, file).replaceAll("\\", "/"),
+ source: readFileSync(file, "utf8"),
+ }));
+}
+
+function kitNames(): Set {
+ return kitExports(kitEntryPoints().map((entry) => entry.source));
+}
+
+/** Each host surface's published name → the kit name behind it. */
+function hostSurface(): Map {
+ const surface = new Map();
+ for (const path of KIT_SURFACES) {
+ const file = parse(path, readFileSync(join(appRoot, path), "utf8"));
+ for (const statement of file.statements) {
+ if (
+ !ts.isExportDeclaration(statement) &&
+ !ts.isImportDeclaration(statement)
+ ) {
+ continue;
+ }
+ const specifier = statement.moduleSpecifier;
+ if (!specifier || !ts.isStringLiteral(specifier)) continue;
+ if (!KIT_SPECIFIER.test(specifier.text)) continue;
+ const clause = ts.isExportDeclaration(statement)
+ ? statement.exportClause
+ : statement.importClause?.namedBindings;
+ if (!clause) continue;
+ if (ts.isNamedExports(clause) || ts.isNamedImports(clause)) {
+ for (const element of clause.elements) {
+ surface.set(
+ element.name.text,
+ (element.propertyName ?? element.name).text,
+ );
+ }
+ }
+ }
+ }
+ return surface;
+}
+
+function parse(path: string, source: string): ts.SourceFile {
+ return ts.createSourceFile(
+ path,
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX,
+ );
+}
+
+interface Offence {
+ name: string;
+ path: string;
+ /**
+ * Why it is an offence: a name the kit owns, a widget dispatch, or a name
+ * the kit owns published from somewhere this cannot read.
+ */
+ kind: "name" | "dispatch" | "unresolved";
+}
+
+/**
+ * Files whose default export's NAME is fixed by the framework, not chosen:
+ * Next's route conventions make every route component a `Page`, every nested
+ * shell a `Layout`, and so on, and the kit exports primitives called `Page` and
+ * `Layout`. A route component is not a second copy of either — it is the thing
+ * that mounts them — so its default export is not read as a declaration taking
+ * the kit's name. A NAMED declaration in such a file still is.
+ *
+ * The root route (`src/app/page.tsx`) counts: it is as conventional as a nested
+ * one, and requiring a directory between `app/` and `page.tsx` rejected it.
+ */
+const ROUTE_CONVENTION =
+ /^src\/app\/(?:.*\/)?(page|layout|template|default|error|global-error|loading|not-found|forbidden|unauthorized)\.tsx?$/;
+
+/** What a file's default export resolves to, in every spelling of it. */
+function defaultExportNames(
+ file: ts.SourceFile,
+ checker: ts.TypeChecker,
+): Set {
+ const names = new Set();
+ const moduleSymbol = checker.getSymbolAtLocation(file);
+ const exported = moduleSymbol
+ ? checker
+ .getExportsOfModule(moduleSymbol)
+ .find((symbol) => symbol.name === "default")
+ : undefined;
+ for (const symbol of exported ? aliasChain(exported, checker) : []) {
+ for (const declaration of symbol.declarations ?? []) {
+ if (
+ (ts.isFunctionDeclaration(declaration) ||
+ ts.isClassDeclaration(declaration) ||
+ ts.isVariableDeclaration(declaration)) &&
+ declaration.name &&
+ ts.isIdentifier(declaration.name)
+ ) {
+ names.add(declaration.name.text);
+ }
+ if (
+ ts.isExportAssignment(declaration) &&
+ ts.isIdentifier(declaration.expression)
+ ) {
+ names.add(declaration.expression.text);
+ }
+ }
+ }
+ return names;
+}
+
+/**
+ * Declarations that take a kit export's name without RETURNING the kit's
+ * component of that name, names the kit owns that this cannot resolve at all,
+ * and declarations that choose between the kit's dashboard marks. Pure, so the
+ * fixtures below can exercise it on sources that are not in the tree.
+ */
+function findOffences(
+ files: { path: string; source: string }[],
+ kit: Set,
+ allowed: Record = ALLOWED,
+ /** Published name → kit name for the host's own surfaces. */
+ surface: Map = new Map(),
+ dispatchers: Record = DISPATCHERS,
+): Offence[] {
+ const { checker, sources } = programOver(files);
+ const offences: Offence[] = [];
+
+ for (const [path, file] of sources) {
+ if (KIT_SURFACES.includes(path)) continue;
+ const routeDefaults = ROUTE_CONVENTION.test(path)
+ ? defaultExportNames(file, checker)
+ : new Set();
+ const components = publishedComponents(file, checker, surface);
+
+ for (const [name, resolution] of components) {
+ if (!kit.has(name) || routeDefaults.has(name)) continue;
+ if (resolution.kind === "kit" || resolution.kind === "type") continue;
+ // An exception is recorded where the component is DECLARED, so a
+ // barrel that re-exports it does not need a second one for the
+ // same component. The published path is checked too, for a name a
+ // file publishes without declaring anywhere in the set.
+ const declaredIn =
+ resolution.kind === "declaration"
+ ? resolution.node.getSourceFile().fileName
+ : path;
+ if (`${name} in ${path}` in allowed) continue;
+ if (`${name} in ${declaredIn}` in allowed) continue;
+ if (resolution.kind === "unresolved") {
+ offences.push({ name, path, kind: "unresolved" });
+ continue;
+ }
+ if (returnedKitComponents(resolution.node, checker, surface).has(name)) {
+ continue;
+ }
+ offences.push({ name, path, kind: "name" });
+ }
+
+ if (path in dispatchers) continue;
+ for (const [, resolution] of components) {
+ if (resolution.kind !== "declaration") continue;
+ const marks = dispatchedMarks(resolution.node, checker, surface);
+ if (marks.length > 1) {
+ offences.push({ name: marks.join("+"), path, kind: "dispatch" });
+ break;
+ }
+ }
+ }
+ return offences;
+}
+
+describe("the host app composes the kit rather than re-implementing it", () => {
+ it("declares no component the kit already exports", () => {
+ const kit = kitNames();
+ expect(
+ kit.size,
+ "the kit's entry points were not readable, so this gate proves nothing",
+ ).toBeGreaterThan(100);
+
+ const offences = findOffences(
+ appSources(),
+ kit,
+ ALLOWED,
+ hostSurface(),
+ ).map(({ name, path, kind }) => {
+ if (kind === "name") {
+ return `${name} in ${path} — @codefly-dev/ui already exports it. Compose the kit's component, or add "${name} in ${path}" to ALLOWED with what the two things are and why neither can stand in for the other.`;
+ }
+ if (kind === "unresolved") {
+ return `${name} in ${path} — @codefly-dev/ui exports that name and this publishes it from somewhere this gate cannot read, so it cannot tell the kit's component from a second one. Publish the kit's own, or declare it here where it can be read.`;
+ }
+ return `${path} draws with ${name} — more than one of the kit's dashboard marks, which is a widget dispatch and 's job. Mount or , or add ${path} to DISPATCHERS with what it is and what would retire it.`;
+ });
+ expect(offences).toEqual([]);
+ });
+
+ it("reads every name the kit exports, star re-exports included", () => {
+ // The gate covers what it can parse. A star onto a package outside the
+ // kit's own tree is not parsed, so it is refused rather than missed.
+ expect(starReExports(kitEntryPoints())).toEqual([
+ "packages/codefly-ui/src/plugin-host/index.ts → @codefly-dev/saas-plugin-react",
+ ]);
+ });
+
+ it("cannot build a kit-shaped primitive: the library for building them is not a dependency", () => {
+ // The strongest leg, because it is a fact rather than a convention: `cva`
+ // is how the kit composes its variants, and it left this package's
+ // dependencies with issue #1041. The kit declares it for itself.
+ expect(Object.keys(packageJson.dependencies)).not.toContain(
+ "class-variance-authority",
+ );
+ const importers = appSources()
+ .filter(({ source }) => /["']class-variance-authority["']/.test(source))
+ .map(({ path }) => path);
+ expect(importers).toEqual([]);
+ });
+
+ it.each(CHART_RE_EXPORTS)(
+ "%s stays a re-export of the kit's charts",
+ (path) => {
+ const source = readFileSync(join(appRoot, path), "utf8");
+ expect(source).toContain('from "@codefly-dev/ui/dashboard"');
+ // No component of its own: no JSX, no class strings, no variants.
+ expect(source).not.toMatch(/\bfunction\b|=>|cva\(|cn\(/);
+ },
+ );
+
+ it("keeps every entry in ALLOWED a real one", () => {
+ const kit = kitNames();
+ for (const [key, reason] of Object.entries(ALLOWED)) {
+ const [name, path] = key.split(" in ");
+ expect(
+ kit.has(name),
+ `${key}: ${name} is not a kit export any more, so this entry is stale — delete it`,
+ ).toBe(true);
+ expect(
+ new RegExp(
+ `^(?:export\\s+)?(?:function|const|class)\\s+${name}\\b`,
+ "m",
+ ).test(readFileSync(join(appRoot, path), "utf8")),
+ `${key} is not declared there any more, so this entry is stale — delete it`,
+ ).toBe(true);
+ expect(
+ reason.length,
+ `${key} needs what the two things are and why, not a listing`,
+ ).toBeGreaterThan(80);
+ }
+ });
+});
+
+// Three directions, because a gate is only as good as what it is known to
+// catch, to let through, and to catch when the same duplication arrives by
+// another route. Each fixture is a source that is not in the tree, so these
+// hold whatever the tree happens to contain.
+describe("the detector", () => {
+ const kit = new Set(["Card", "CardRoot", "StatTile", "Page", "Badge"]);
+ // What the host's surfaces publish, as `src/shared/ui` really does it: one
+ // name straight through, one renaming a kit primitive to another kit
+ // component's name.
+ const surface = new Map([
+ ["Badge", "Badge"],
+ ["Card", "CardRoot"],
+ ]);
+ const deletedRenderer = () =>
+ readFileSync(
+ join(
+ appRoot,
+ "src/components/__tests__/fixtures/deleted-solution-dashboard.txt",
+ ),
+ "utf8",
+ );
+ const detect = (path: string, source: string) =>
+ findOffences([{ path, source }], kit, {}, surface, {}).map(
+ (o) => `${o.name} in ${o.path}`,
+ );
+
+ it("catches a component taking a kit export's name", () => {
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `export function Card({ children }: { children: ReactNode }) {
+ return {children}
;
+ }`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("lets a wrapper that renders the kit's own component through", () => {
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+ export function Card(props: ComponentProps) {
+ return ;
+ }`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("catches the same duplication declared module-locally instead of exported", () => {
+ // How both duplicates in this tree arrived: not as a new file anyone
+ // would review as a component, but as a helper at the bottom of a page.
+ // The upstream version of this rule matched only `export function`, and
+ // let this shape past.
+ expect(
+ detect(
+ "src/features/x/ui/page.tsx",
+ `function Card({ title }: { title: string }) {
+ return {title}
;
+ }
+ export function Deliveries() { return ; }`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/page.tsx"]);
+ });
+
+ it("catches an arrow-function component, which is the same declaration", () => {
+ expect(
+ detect(
+ "src/features/x/ui/tile.tsx",
+ `const StatTile = ({ value }: { value: number }) => {value} ;`,
+ ),
+ ).toEqual(["StatTile in src/features/x/ui/tile.tsx"]);
+ });
+
+ it("catches a declaration that imports a DIFFERENT kit component under the name it takes", () => {
+ // Importing `CardRoot` and calling the result `Card` is not a wrapper
+ // around `Card`: it is a second `Card` built on a kit primitive. This is
+ // how a duplicate escapes a plain name gate — the kit and the consumer
+ // call the same thing by different names, so neither name collides.
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { CardRoot } from "@codefly-dev/ui/layout";
+ export function Card({ title }: { title: string }) {
+ return {title} ;
+ }`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("is not fooled by an alias: a wrapper is recognised by the kit's own spelling", () => {
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { StatTile as Tile } from "@codefly-dev/ui/dashboard";
+ export const StatTile = (props: ComponentProps) => ;`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("ignores a re-export, which ships the kit's component rather than another one", () => {
+ expect(
+ detect(
+ "src/components/charts/chart.tsx",
+ `export { StatTile, Card } from "@codefly-dev/ui/dashboard";`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("lets a Next route component keep the name the framework gives it", () => {
+ expect(
+ detect(
+ "src/app/admin/users/page.tsx",
+ `export default function Page() { return ; }`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("still catches a NAMED kit-shaped component inside a route file", () => {
+ // The exemption is for the one export whose name Next fixes, not for the
+ // file: a second `Card` beside the route component is still a second Card.
+ expect(
+ detect(
+ "src/app/admin/users/page.tsx",
+ `function Card({ title }: { title: string }) { return {title}
; }
+ export default function Page() { return ; }`,
+ ),
+ ).toEqual(["Card in src/app/admin/users/page.tsx"]);
+ });
+
+ it("lets a wrapper compose the kit through the host's own import surface", () => {
+ // The path a wrapper imports through must not change the verdict. This
+ // is the same component, and the same wrapper, as the kit-direct case
+ // above; `@/shared/ui` publishes `Badge` straight from the kit.
+ expect(
+ detect(
+ "src/features/x/ui/badge.tsx",
+ `import { Badge as KitBadge } from "@/shared/ui";
+ export function Badge(props: ComponentProps) {
+ return ;
+ }`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("resolves the surface's alias, so one import is judged one way either way", () => {
+ // `@/shared/ui` publishes the kit's `CardRoot` under the name `Card`, so
+ // importing `Card` from there composes `CardRoot` — and a declaration
+ // named `Card` is a second `Card`, exactly as the kit-direct form of
+ // this is caught above. Without resolving the alias the two spellings
+ // of one import would get opposite verdicts.
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { Card as HostCard } from "@/shared/ui";
+ export function Card({ title }: { title: string }) {
+ return {title} ;
+ }`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("does not read the host's surfaces themselves as duplicates", () => {
+ // A surface file re-exports under a kit name by construction.
+ expect(
+ detect(
+ "src/shared/ui/index.ts",
+ `export { CardRoot as Card, Badge } from "@codefly-dev/ui/layout";`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("catches a same-name copy sitting beside an unrelated kit re-export", () => {
+ // The shape a duplicate actually arrives in: somebody re-exports the
+ // kit's component for a neighbour and writes their own beside it. A
+ // file-wide import check read that re-export as proof of composition
+ // and exempted the copy.
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `export { Card as KitCard } from "@codefly-dev/ui/layout";
+export const Card = () => copy
;`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("catches an implementation published under a kit name by an export alias", () => {
+ // The declared name is not the shipped name. Reading only declarations
+ // let this straight through.
+ expect(
+ detect(
+ "src/features/x/ui/panel.tsx",
+ `const LocalPanel = () => copy
;
+export { LocalPanel as Card };`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/panel.tsx"]);
+ });
+
+ it("allows a wrapper that reaches the kit through a namespace import", () => {
+ expect(
+ detect(
+ "src/features/x/ui/badge.tsx",
+ `import * as UI from "@/shared/ui";
+export const Badge = (props) => ;`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("allows a wrapper that reaches a host surface by relative path", () => {
+ // `@/shared/ui` and `../../../shared/ui` are the same module; judging
+ // only the alias rejected a real wrapper for how it spelled its import.
+ expect(
+ detect(
+ "src/features/x/ui/badge.tsx",
+ `import { Badge as KitBadge } from "../../../shared/ui";
+export const Badge = (props) => ;`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("applies the Next route exception to the ROOT route, not only nested ones", () => {
+ expect(
+ detect(
+ "src/app/page.tsx",
+ "export default function Page() { return ; }",
+ ),
+ ).toEqual([]);
+ });
+
+ it("rejects the exact renderer this gate was written to prevent", () => {
+ // The case that shows the name legs are not enough on their own: the
+ // renderer #1041 deleted declared WidgetBody, WidgetValues and
+ // WidgetCard — not one of them a name the kit exports — so every
+ // name-based leg passed it while it was the duplication itself. The
+ // dispatch leg is what catches it.
+ //
+ // Read from a committed fixture, not with `git show`: this suite ships
+ // inside an immutable module package, where a consuming repository
+ // carries the source tree and not this repository's history, so a
+ // fixture reaching for an upstream commit fails there while passing in
+ // full-history CI.
+ expect(
+ detect("src/solutions/SolutionDashboard.tsx", deletedRenderer()).length,
+ ).toBeGreaterThan(0);
+ });
+
+ it("still rejects that renderer when its imports become a namespace", () => {
+ // Changing only how the marks are imported, and qualifying the JSX,
+ // must not change the verdict: a gate that reads local names rather
+ // than what they resolve to is escaped by one edit.
+ const named =
+ /import \{\s*AreaChart,\s*BarList,\s*LineChart,\s*SortableGrid,\s*StatChart,\s*\} from "@codefly-dev\/ui\/dashboard";/;
+ const original = deletedRenderer();
+ expect(named.test(original)).toBe(true);
+ const namespaced = original
+ .replace(named, 'import * as Charts from "@codefly-dev/ui/dashboard";')
+ .replace(
+ /<(\/?)(AreaChart|BarList|LineChart|SortableGrid|StatChart)\b/g,
+ "<$1Charts.$2",
+ );
+ expect(
+ detect("src/solutions/SolutionDashboard.tsx", namespaced).length,
+ ).toBeGreaterThan(0);
+ });
+
+ it("rejects that renderer when its marks are local aliases", () => {
+ expect(
+ detect(
+ "src/features/x/ui/widget.tsx",
+ `import { LineChart as L, AreaChart as A } from "@codefly-dev/ui/dashboard";
+export function WidgetBody({ kind }) {
+ return kind === "line" ? : ;
+}`,
+ ).length,
+ ).toBeGreaterThan(0);
+ });
+
+ it("does not read a type reference as composition", () => {
+ // A parameter typed ComponentProps mentions the kit and
+ // draws nothing; reading plain identifiers exempted a Card whose body
+ // was a bare .
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export const Card = (props: React.ComponentProps
) => (
+ {props.children}
+);`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("does not let a local shadow stand in for the kit's component", () => {
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card() {
+ const KitCard = () =>
;
+ return ;
+}`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("resolves a surface namespace member to the kit name behind it", () => {
+ // `@/shared/ui` publishes CardRoot as Card, so composes
+ // CardRoot and a declaration named Card is still a second Card.
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import * as UI from "@/shared/ui";
+export const Card = () => copy ;`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("follows a local alias of a kit import", () => {
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+const Local = KitCard;
+export const Card = (props) => ;`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("allows a wrapper that renders through a kit namespace", () => {
+ expect(
+ detect(
+ "src/features/x/ui/badge.tsx",
+ `import * as UI from "@codefly-dev/ui/layout";
+export default function Badge(props) { return ; }`,
+ ),
+ ).toEqual([]);
+ });
+
+ it.each([
+ [
+ "a default identifier",
+ "const Page = () => ; export default Page;",
+ ],
+ [
+ "an aliased default",
+ "const Page = () => ; export { Page as default };",
+ ],
+ ])("applies the route exception to %s", (_name, source) => {
+ expect(detect("src/app/page.tsx", source)).toEqual([]);
+ });
+
+ it("does not read two marks composed side by side as a dispatch", () => {
+ // A KPI row above a chart is a page. What a page may not do is BRANCH
+ // between marks, which is the dispatch owns.
+ expect(
+ detect(
+ "src/features/x/ui/overview.tsx",
+ `import { KPIRow, MetricLineChart } from "@codefly-dev/ui/dashboard";
+export const Overview = () => (
+ <>
+
+
+ >
+);`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("catches a copy split across two files by an aliasing re-export", () => {
+ expect(
+ findOffences(
+ [
+ {
+ path: "src/features/x/ui/local.tsx",
+ source: "export const LocalPanel = () => copy
;",
+ },
+ {
+ path: "src/features/x/ui/index.ts",
+ source: 'export { LocalPanel as Card } from "./local";',
+ },
+ ],
+ kit,
+ {},
+ surface,
+ {},
+ ).length,
+ ).toBeGreaterThan(0);
+ });
+
+ it("lets a page draw ONE kit mark, which is composition and not a dispatch", () => {
+ expect(
+ detect(
+ "src/features/x/ui/page.tsx",
+ `import { MetricLineChart } from "@codefly-dev/ui/dashboard";
+export const Trend = () => ;`,
+ ),
+ ).toEqual([]);
+ });
+
+ it.each([
+ [
+ "a guard-return dispatch",
+ `import { LineChart as L, AreaChart as A } from "@codefly-dev/ui/dashboard";
+export function WidgetBody({ kind }) {
+ if (kind === "line") return ;
+ return ;
+}`,
+ true,
+ ],
+ [
+ "a destructured parameter shadowing the import",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card({ KitCard }) { return ; }`,
+ true,
+ ],
+ [
+ "an unused nested wrapper beside a plain return",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card() {
+ const Unused = () => ;
+ return copy
;
+}`,
+ true,
+ ],
+ [
+ "a function-local alias of the kit component",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card(props) {
+ const Local = KitCard;
+ return ;
+}`,
+ false,
+ ],
+ [
+ "an alias of a namespace member",
+ `import * as UI from "@codefly-dev/ui/layout";
+const Local = UI.Card;
+export const Card = (props) => ;`,
+ false,
+ ],
+ [
+ "a nested shadow that does not reach the outer JSX",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card(props) {
+ const helper = (KitCard) => KitCard;
+ return ;
+}`,
+ false,
+ ],
+ [
+ "two layout branches drawing the same pair of marks",
+ `import { KPIRow, MetricLineChart } from "@codefly-dev/ui/dashboard";
+export function Overview({ compact }) {
+ return compact ? (
+
+ ) : (
+
+ );
+}`,
+ false,
+ ],
+ ])("%s", (_name, source, rejected) => {
+ expect(detect("src/features/x/ui/example.tsx", source).length > 0).toBe(
+ rejected,
+ );
+ });
+
+ it("does not count kit JSX inside a callback the component passes down", () => {
+ // A render prop is not what this component returns, and the skip that
+ // keeps a nested function out of a returned expression is what says so.
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card() {
+ return } />;
+}`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("accepts a genuine wrapper re-exported under the kit's name", () => {
+ // Its implementation composes the kit, which is the question — the name
+ // it is published under is not evidence either way.
+ expect(
+ findOffences(
+ [
+ {
+ path: "src/features/x/ui/local.tsx",
+ source: `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export const LocalPanel = (props) => ;`,
+ },
+ {
+ path: "src/features/x/ui/index.ts",
+ source: 'export { LocalPanel as Card } from "./local";',
+ },
+ ],
+ kit,
+ {},
+ surface,
+ {},
+ ),
+ ).toEqual([]);
+ });
+
+ it("catches a copy published under a kit name two hops away", () => {
+ expect(
+ findOffences(
+ [
+ {
+ path: "src/features/x/ui/local.tsx",
+ source: "export const LocalPanel = () => copy
;",
+ },
+ {
+ path: "src/features/x/ui/bridge.ts",
+ source: 'export { LocalPanel } from "./local";',
+ },
+ {
+ path: "src/features/x/ui/index.ts",
+ source: 'export { LocalPanel as Card } from "./bridge";',
+ },
+ ],
+ kit,
+ {},
+ surface,
+ {},
+ ).length,
+ ).toBeGreaterThan(0);
+ });
+
+ // What the gate must see through, and what it must not mistake for a copy.
+ // Each of these was found by an independent review of an earlier shape of
+ // this detector, and each named a rule rather than a trick: these say what
+ // the rule now is.
+ it.each([
+ [
+ "a dispatch written one line inside the JSX it returns",
+ `import { LineChart as L, AreaChart as A } from "@codefly-dev/ui/dashboard";
+export function WidgetBody({ kind }) {
+ return ;
+}`,
+ true,
+ ],
+ [
+ "the same dispatch behind a cast",
+ `import { LineChart as L, AreaChart as A } from "@codefly-dev/ui/dashboard";
+export function WidgetBody({ kind }) {
+ return (kind === "line" ? : ) as React.ReactNode;
+}`,
+ true,
+ ],
+ [
+ "kit JSX in a comma operand, which is evaluated and discarded",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card() { return ( , copy
); }`,
+ true,
+ ],
+ [
+ "kit JSX in a return no execution reaches",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card() {
+ return copy
;
+ return ;
+}`,
+ true,
+ ],
+ [
+ "kit JSX handed to another component through a prop",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+function Ignore(props) { return copy
; }
+export function Card() { return } />; }`,
+ true,
+ ],
+ [
+ "a dispatch whose arms are fragments",
+ `import { LineChart as L, AreaChart as A } from "@codefly-dev/ui/dashboard";
+export function WidgetBody({ kind }) {
+ return kind === "line" ? <> > : <> >;
+}`,
+ true,
+ ],
+ [
+ "kit JSX returned by a nested function the component only declares",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card() {
+ function Unused() { return ; }
+ return copy
;
+}`,
+ true,
+ ],
+ [
+ "kit JSX in a callback the component puts in its children",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card() { return {() => } ; }`,
+ true,
+ ],
+ [
+ "returned kit JSX in an array, which React renders",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card(props) { return [ ]; }`,
+ false,
+ ],
+ [
+ "returned kit JSX held in a local first",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+export function Card(props) {
+ const content = ;
+ return content;
+}`,
+ false,
+ ],
+ [
+ "a mark drawn always beside one drawn only sometimes",
+ `import { KPIRow, MetricLineChart } from "@codefly-dev/ui/dashboard";
+export function Overview({ withRow }) {
+ return (
+
+ );
+}`,
+ false,
+ ],
+ ])("%s", (_name, source, rejected) => {
+ expect(detect("src/features/x/ui/example.tsx", source).length > 0).toBe(
+ rejected,
+ );
+ });
+
+ it("catches a copy re-exported through the app's own path alias", () => {
+ // `@/…` and `./…` reach the same module. Resolving only the second read
+ // the barrel and stopped, so the copy at the end of it went unexamined.
+ expect(
+ findOffences(
+ [
+ {
+ path: "src/features/x/ui/local.tsx",
+ source: "export const LocalPanel = () => copy
;",
+ },
+ {
+ path: "src/features/x/ui/index.ts",
+ source:
+ 'export { LocalPanel as Card } from "@/features/x/ui/local";',
+ },
+ ],
+ kit,
+ {},
+ surface,
+ {},
+ ).map(({ name, kind }) => `${kind}:${name}`),
+ // `name`, not `unresolved`: the copy was READ. An alias the gate
+ // cannot follow would also be refused, but for not knowing, and that
+ // is a weaker answer than the one available here.
+ ).toEqual(["name:Card"]);
+ });
+
+ it("catches a copy however many re-exports stand between", () => {
+ // A chain is followed to its end, cycle-detected rather than cut off at
+ // a depth: ten hops is not more suspicious than two, and a gate that
+ // gave up at eight could be escaped by writing a ninth.
+ const sources = [
+ {
+ path: "src/features/x/ui/local.tsx",
+ source: "export const LocalPanel = () => copy
;",
+ },
+ ];
+ for (let hop = 0; hop < 9; hop += 1) {
+ sources.push({
+ path: `src/features/x/ui/bridge${hop}.ts`,
+ source: `export { LocalPanel } from "./${hop ? `bridge${hop - 1}` : "local"}";`,
+ });
+ }
+ sources.push({
+ path: "src/features/x/ui/index.ts",
+ source: 'export { LocalPanel as Card } from "./bridge8";',
+ });
+ expect(
+ findOffences(sources, kit, {}, surface, {}).map(
+ ({ name, kind }) => `${kind}:${name}`,
+ ),
+ ).toEqual(["name:Card"]);
+ });
+
+ it.each([
+ [
+ "a named import",
+ 'export { Card as Panel } from "@codefly-dev/ui/layout";',
+ 'import { Panel } from "./bridge";\nexport const Card = (props) => ;',
+ ],
+ [
+ "a namespace import",
+ 'export { Card } from "@codefly-dev/ui/layout";',
+ 'import * as UI from "./bridge";\nexport const Card = (props) => ;',
+ ],
+ ])(
+ "accepts a wrapper reaching the kit through the app's own barrel by %s",
+ (_name, bridge, local) => {
+ // The barrel is the app's, the component behind it is the kit's. A
+ // gate that stopped at the barrel rejected a genuine wrapper for how
+ // it spelled its import.
+ expect(
+ findOffences(
+ [
+ { path: "src/features/x/ui/bridge.ts", source: bridge },
+ { path: "src/features/x/ui/local.tsx", source: local },
+ ],
+ kit,
+ {},
+ surface,
+ {},
+ ),
+ ).toEqual([]);
+ },
+ );
+
+ it("refuses a kit name published from outside anything it can read", () => {
+ // Not a hand-written copy and not the kit either: a second Card from a
+ // third-party package is the same two-renderers problem, and "could not
+ // tell" is not a verdict this gate is allowed to reach.
+ expect(
+ findOffences(
+ [
+ {
+ path: "src/features/x/ui/index.ts",
+ source: 'export { Panel as Card } from "some-other-ui-kit";',
+ },
+ ],
+ kit,
+ {},
+ surface,
+ {},
+ ).length,
+ ).toBeGreaterThan(0);
+ });
+
+ it("does not refuse a TYPE that shares a kit name", () => {
+ // A type cannot be rendered and cannot stand in for a component, so it
+ // is neither a copy nor something unreadable.
+ expect(
+ findOffences(
+ [
+ {
+ path: "src/features/x/model/types.ts",
+ source: "export interface Card { id: string }",
+ },
+ {
+ path: "src/features/x/ui/index.ts",
+ source: 'export type { Card } from "../model/types";',
+ },
+ ],
+ kit,
+ {},
+ surface,
+ {},
+ ),
+ ).toEqual([]);
+ });
+
+ it("ignores a name the kit does not export", () => {
+ expect(
+ detect(
+ "src/features/x/ui/panel.tsx",
+ `export function PayloadBlock({ title }: { title: string }) { return {title}
; }`,
+ ),
+ ).toEqual([]);
+ });
+});
diff --git a/module/services/frontend/code/src/features/webhooks/ui/webhook-deliveries-panel.tsx b/module/services/frontend/code/src/features/webhooks/ui/webhook-deliveries-panel.tsx
index 972c11c46..dc57752f5 100644
--- a/module/services/frontend/code/src/features/webhooks/ui/webhook-deliveries-panel.tsx
+++ b/module/services/frontend/code/src/features/webhooks/ui/webhook-deliveries-panel.tsx
@@ -295,14 +295,14 @@ function DeliveryDetail({
{/* Request payload */}
-
{/* Response body */}
- {
expect(screen.getByText("No data")).toBeTruthy();
});
+ it.each([
+ ["StatTile", StatTile],
+ ["MetricCard", MetricCard],
+ ])(
+ "%s withholds everything derived from a forbidden value, not just the value",
+ (_name, Tile) => {
+ // "No access" beside "+170%" and a trend line publishes the figure
+ // twice over, in the two forms hardest to notice. The renderer
+ // builds a fresh metric and avoids this; a direct consumer of the
+ // exported primitive does not.
+ const { container } = render(
+ ,
+ );
+ expect(screen.getByText("No access")).toBeTruthy();
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.queryByText("37")).toBeNull();
+ expect(screen.queryByText(/170%/)).toBeNull();
+ expect(container.querySelector("svg")).toBeNull();
+ },
+ );
+
+ it("colours its trend from the chart token when asked, so an accent reaches it", () => {
+ // The sparkline strokes currentColor under a muted utility, so neither
+ // of a dashboard's accent overrides could reach it.
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector("svg") as SVGElement;
+ expect(svg.style.color).toBe("var(--chart-1)");
+ expect(svg.getAttribute("class")).not.toContain("text-muted-foreground");
+ });
+
it("shows a skeleton instead of a value while loading", () => {
const { container } = render(
,
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index 526feeade..330d45656 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -1,27 +1,30 @@
"use client";
import type {
- Dashboard,
+ Dashboard as DashboardDeclaration,
DataGraph,
MetricWidget,
} from "@codefly/saas-plugin-manifest";
+import { createSaasClient, runDashboard } from "@codefly-dev/saas-sdk";
+// The dashboard is DRAWN by the shared kit's `` — the same renderer,
+// from the same package instance, that a solution's own remote mounts and that
+// the host's operations pages are built from. Nothing here re-implements it:
+// this file owns only what is genuinely the host's (the per-widget audit
+// queries, the viewer's saved arrangement of the tiles, and the ⓘ that says
+// where a number comes from) and composes it AROUND the renderer through the
+// renderer's own slots.
import {
- createSaasClient,
- type ResolvedWidget,
- runDashboard,
-} from "@codefly-dev/saas-sdk";
-// Charts come from the shared kit, not host-internal components: the same
-// primitives a solution's own remote would render with, so host-rendered and
-// solution-rendered dashboards look identical and there is one charting
-// implementation to maintain.
-import {
- AreaChart,
- BarList,
- LineChart,
+ Dashboard,
+ DashboardWidget,
+ type DashboardWidgetView,
SortableGrid,
- StatChart,
+ type WidgetReadFailure,
} from "@codefly-dev/ui/dashboard";
-import { useQuery } from "@tanstack/react-query";
+import {
+ type QueryClient,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
import { GripVertical, Info, Plus, X } from "lucide-react";
import { type ReactNode, useState, useSyncExternalStore } from "react";
import { flushSync } from "react-dom";
@@ -33,12 +36,13 @@ import {
import { scopedDashboardDraftKey } from "@/features/dashboard/service/use-dashboard-authoring";
import { useAuth } from "@/lib/auth";
import { apiTransport } from "@/lib/connect/transport";
+import {
+ mayKeepRetainedRows,
+ type ReadOutcome,
+ readOutcome,
+} from "@/shared/lib/read-outcome";
import {
Button,
- Card,
- CardContent,
- CardHeader,
- CardTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -47,7 +51,6 @@ import {
PopoverContent,
PopoverTitle,
PopoverTrigger,
- Skeleton,
} from "@/shared/ui";
import {
addableTiles,
@@ -99,144 +102,160 @@ function singleWidgetGraph(graph: DataGraph, widget: MetricWidget): DataGraph {
};
}
-// Render a resolved widget's series in the shape its visualization asks for. A
-// series with no points is "no data yet" for every kind — the audit RPC omits a
-// bucket rather than emitting a zero, so an empty series means nothing matched,
-// not a real zero worth plotting.
-function WidgetBody({ widget }: { widget: ResolvedWidget }) {
- return (
- <>
- {widget.series.coverage === "partial" && (
-
- Partial telemetry
-
- )}
-
- >
- );
-}
-
-function WidgetValues({ widget }: { widget: ResolvedWidget }) {
- const { series, visualization } = widget;
- if (series.points.length === 0) {
- return (
-
- {series.coverage === "partial"
- ? "Telemetry unavailable or incomplete."
- : "No data yet."}
-
- );
- }
- switch (visualization) {
- case "line":
- return (
-
- );
- case "area":
- return (
-
- );
- case "bar":
- return ;
- case "number":
- return series.total === null ? (
-
- {series.coverage === "partial"
- ? "Incomplete telemetry; total unavailable."
- : "Total unavailable across groups."}
-
- ) : (
-
- );
- case "table":
- return (
-
-
- {series.points.map((point) => (
-
- {point.key}
-
- {point.value.toLocaleString()}
-
-
- ))}
-
-
- );
- default: {
- // Compile-time exhaustiveness: a new WidgetVisualization must be
- // handled here or this assignment fails to type-check.
- const _exhaustive: never = visualization;
- return _exhaustive;
- }
- }
-}
-
-function WidgetCard({
+// One declared widget, resolved and drawn.
+//
+// The query lives here, in a component of its own, because each widget is
+// resolved in isolation: a widget whose metric's audit query errors fails
+// alone instead of blanking every sibling that would share one batched
+// resolution. React fixes the number of hooks a component may call, so these
+// queries cannot be hoisted into the renderer that takes the whole view —
+// which is why the kit exports `DashboardWidget` beside `` and this
+// mounts that, rather than drawing a card of its own.
+function SolutionWidget({
graph,
widget,
solutionId,
dashboardId,
orgId,
- grip,
- info,
- remove,
+ actions,
}: {
graph: DataGraph;
widget: MetricWidget;
solutionId: string;
dashboardId: string;
orgId: string;
- grip: ReactNode;
- info: ReactNode;
- remove: ReactNode;
+ actions: ReactNode;
}) {
// The graph is part of the key so a solution that redeploys with a changed
// declaration refetches instead of serving another graph's cached series that
// happens to share the same solution/dashboard/widget/org ids. Disabled until
// the org resolves so the pre-org window reads as loading, never as empty.
- const { data, isPending, isError } = useQuery({
- queryKey: [
- "solution-widget",
- solutionId,
- dashboardId,
- widget.id,
- orgId,
- graph,
- ],
+ const client = useQueryClient();
+ const source = widgetSource({ solutionId, dashboardId, widget, orgId });
+ const { data, isError, error } = useQuery({
+ queryKey: [...source, graph],
queryFn: () =>
runDashboard(
sdk.audit,
singleWidgetGraph(graph, widget),
SINGLE_WIDGET_DASHBOARD,
{ orgId },
- ).then((resolved) => resolved.widgets[0]),
+ )
+ .then((resolved) => resolved.widgets[0])
+ .catch((cause: unknown) => {
+ // The read itself answers for what it leaves behind. A refused
+ // read deletes every snapshot of this widget's source before the
+ // rejection reaches anyone, so there is nothing retained for a
+ // later error to hand back and nothing to keep in step with it.
+ if (!mayKeepRetainedRows(readOutcome(true, cause))) {
+ discardSnapshots(client, source);
+ }
+ throw cause;
+ }),
enabled: orgId !== "",
});
+ const outcome = readOutcome(isError, error);
+ // Whatever the cache still holds. A refusal has already emptied it, which is
+ // why this reads the snapshot without asking whether the viewer may keep it:
+ // the question was put at the read boundary, where the answer could be acted
+ // on once for every revision instead of per mounted component.
+ const retained = data?.series ?? null;
+
return (
-
-
- {grip}
-
- {widget.title ?? widget.metric}
-
- {info}
- {remove}
-
-
- {isError ? (
- Unable to load.
- ) : isPending || !data ? (
-
- ) : (
-
- )}
-
-
+
);
}
+/**
+ * Every cached revision of one widget's source — the query key without the
+ * declaration that produced it.
+ *
+ * A dashboard whose declaration has changed since keeps its own cache entry: a
+ * different title is enough for another key, holding the same numbers. So a
+ * rule about this widget's values is a rule about the whole prefix, never about
+ * the one entry a render happens to have resolved.
+ */
+function widgetSource({
+ solutionId,
+ dashboardId,
+ widget,
+ orgId,
+}: {
+ solutionId: string;
+ dashboardId: string;
+ widget: MetricWidget;
+ orgId: string;
+}): readonly unknown[] {
+ return ["solution-widget", solutionId, dashboardId, widget.id, orgId];
+}
+
+/**
+ * Delete the retained snapshots under a widget's source: the values go, the
+ * queries and their states stay.
+ *
+ * This is what `mayKeepRetainedRows` says must happen to rows a refused read
+ * leaves behind, done where the refusal is classified rather than in a
+ * component. The difference is not tidiness. A component effect runs only for
+ * the revision that is mounted and only while it is: a refusal that arrives
+ * after the viewer switched dashboards, or after the page unmounted, left the
+ * numbers in the cache for the next render to serve. The read is the one thing
+ * that happens exactly once per refusal, whoever is watching.
+ *
+ * Writing the state is the whole of it, deliberately:
+ *
+ * - `removeQueries` destroys the entry, which silently cancels the very read
+ * that is reporting the refusal (`Query.destroy` → `cancel({silent:true})`)
+ * and leaves a mounted observer holding a detached query that no longer
+ * re-renders — the values stay on screen and recovery has nothing to
+ * refetch into.
+ * - `setQueryData(key, undefined)` cannot express this at all: the client
+ * returns early when the new data is `undefined`, so the call does nothing.
+ * - `resetQueries` clears the error with the data and refetches every active
+ * entry, so a standing refusal re-asks and re-clears without end.
+ *
+ * Setting `data` notifies the observers, cancels nothing, keeps the refusal,
+ * and starts no fetch. An entry left with no data renders as a wait and is
+ * stale, so the next read of it is a real read — which is the only way values
+ * come back.
+ */
+function discardSnapshots(client: QueryClient, source: readonly unknown[]) {
+ for (const query of client
+ .getQueryCache()
+ .findAll({ queryKey: source, exact: false })) {
+ if (query.state.data === undefined) continue;
+ query.setState({ data: undefined, dataUpdatedAt: 0 });
+ }
+}
+
+// The host's read classification as the renderer's presentational one. A read
+// that succeeded is no failure at all, which is what `undefined` says.
+const WIDGET_FAILURE: Record = {
+ empty: undefined,
+ forbidden: "refused",
+ failed: "failed",
+};
+
+// A declared widget as the renderer's view model, with no series yet. The
+// series is bound by `SolutionWidget`, the one place that has resolved it; a
+// `null` series is the honest value here and the renderer draws it as a wait
+// rather than as an empty result.
+function declaredWidget(widget: MetricWidget): DashboardWidgetView {
+ return {
+ id: widget.id,
+ visualization: widget.visualization,
+ title: widget.title ?? widget.metric,
+ series: null,
+ };
+}
+
// Reading `window.localStorage` can itself throw: blocked site data or a
// sandboxed frame raise a SecurityError from the getter, before any getItem. No
// storage means every viewer sees the declared default.
@@ -273,7 +292,7 @@ function noSubscription() {
*/
function useDashboardLayout(
storageKey: string,
- dashboard: Dashboard,
+ dashboard: DashboardDeclaration,
): [string[], (next: readonly string[]) => void] {
const stored = useSyncExternalStore(
noSubscription,
@@ -525,7 +544,7 @@ function SolutionDashboard({
solutionId,
}: {
graph: DataGraph;
- dashboard: Dashboard;
+ dashboard: DashboardDeclaration;
solutionId: string;
}) {
const { user, organizationId } = useAuth();
@@ -544,52 +563,45 @@ function SolutionDashboard({
const widget = widgets.get(tileId);
return widget?.title ?? widget?.metric ?? tileId;
};
- const renderTile = (tileId: string) => {
- const widget = widgets.get(tileId);
- if (!widget) return null;
+ // The grip, the ⓘ and the × — the three host-only controls — go into the
+ // renderer's own per-widget actions slot, so they sit in the card header the
+ // kit draws instead of a header this file draws.
+ const controls = (tileId: string) => {
const title = titleOf(tileId);
+ const widget = widgets.get(tileId);
return (
- {
- const step = ARROW_STEP[event.key];
- if (step === undefined) return;
- event.preventDefault();
- const grip = event.currentTarget;
- // Moving a tile can move its DOM node, which drops focus;
- // commit first so the grip can take focus back.
- flushSync(() => save(moveBy(layout, tileId, step)));
- grip.focus();
- }}
- >
-
-
- }
- info={ }
- remove={
- save(removeTile(layout, tileId))}
- >
-
-
- }
- />
+ <>
+ {
+ const step = ARROW_STEP[event.key];
+ if (step === undefined) return;
+ event.preventDefault();
+ const grip = event.currentTarget;
+ // Moving a tile can move its DOM node, which drops focus;
+ // commit first so the grip can take focus back.
+ flushSync(() => save(moveBy(layout, tileId, step)));
+ grip.focus();
+ }}
+ >
+
+
+ {widget && }
+ save(removeTile(layout, tileId))}
+ >
+
+
+ >
);
};
// The floating copy while a tile is dragged: the same card, but with no
@@ -612,56 +624,82 @@ function SolutionDashboard({
);
return (
- )}
- info={glyph( )}
- remove={glyph( )}
+ actions={
+ <>
+ {glyph( )}
+ {glyph( )}
+ {glyph( )}
+ >
+ }
/>
);
};
return (
-
-
- {dashboard.title && (
-
- {dashboard.title}
-
- )}
-
save(addTile(layout, tileId))}
- />
-
- {shown.length === 0 ? (
-
- No widgets are shown. Add one back with the + button.
-
- ) : (
- // The kit's SortableGrid rather than its Grid/Stack: the tiles are
- // dragged onto each other to swap. The classes are those Grid
- // cols={2} and Stack draw with.
- tileId)}
- onSwap={(dragged, target) => save(swapTiles(layout, dragged, target))}
- itemLabel={titleOf}
- renderItem={renderTile}
- renderOverlay={renderDragged}
- className={
- dashboard.layout === "stack"
- ? "flex flex-col gap-4"
- : "grid grid-cols-1 gap-4 sm:grid-cols-2"
- }
- />
- )}
-
+ declaredWidget(widget)),
+ }}
+ slots={{
+ actions: (
+ save(addTile(layout, tileId))}
+ />
+ ),
+ renderWidget: (view) => {
+ const widget = widgets.get(view.id);
+ if (!widget) return null;
+ return (
+
+ );
+ },
+ // The kit's SortableGrid rather than its default grid: the tiles
+ // are dragged onto each other to swap. The classes are those the
+ // kit's grid and stack draw with.
+ layout: (tiles) => {
+ const nodes = new Map(tiles.map((tile) => [tile.id, tile.node]));
+ return (
+ tile.id)}
+ onSwap={(dragged, target) =>
+ save(swapTiles(layout, dragged, target))
+ }
+ itemLabel={titleOf}
+ renderItem={(id) => nodes.get(id) ?? null}
+ renderOverlay={renderDragged}
+ className={
+ dashboard.layout === "stack"
+ ? "flex flex-col gap-4"
+ : "grid grid-cols-1 gap-4 sm:grid-cols-2"
+ }
+ />
+ );
+ },
+ empty: (
+
+ No widgets are shown. Add one back with the + button.
+
+ ),
+ }}
+ />
);
}
diff --git a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
index 8a64814bb..e1376b1f3 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -1,5 +1,12 @@
import type { DataGraph } from "@codefly/saas-plugin-manifest";
-import { cleanup, fireEvent, screen, within } from "@testing-library/react";
+import { Code, ConnectError } from "@connectrpc/connect";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ screen,
+ within,
+} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderInApp, rpc } from "@/test/container";
@@ -114,15 +121,25 @@ function aggregateHandler(
);
}
+// A rejected refetch reaches the component a tick after `refetchQueries`
+// resolves. Without this wait an assertion can run while the previous state is
+// still rendered, which makes the retention tests pass whatever the code does.
+const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
+
const DEFAULT_ORDER = ["Logins over time", "Top event types", "Total logins"];
-// The tile titles, in the order the dashboard shows them.
+// The tile titles, in the order the dashboard shows them. The kit's renderer
+// titles a series widget through its Card (`card-heading`) and a scalar one
+// through its StatTile (`metric-label`), so a tile is named by whichever of
+// the two it carries.
function tileTitles(): string[] {
return screen
.getAllByRole("listitem")
.map(
(tile) =>
- tile.querySelector('[data-slot="card-title"]')?.textContent ?? "",
+ tile.querySelector(
+ '[data-slot="card-heading"], [data-slot="metric-label"]',
+ )?.textContent ?? "",
);
}
@@ -155,13 +172,22 @@ describe("SolutionDashboards", () => {
renderInApp( );
- // Bar widget: each event_type bucket becomes a labelled bar.
- expect(await screen.findByText("saas.auth.login")).toBeTruthy();
- expect(screen.getByText("42")).toBeTruthy();
+ // Bar widget: each event_type bucket becomes a bar, and every exact
+ // value is reachable in the chart's accessible data table — which is
+ // what a reader who cannot see the geometry gets.
+ const bars = await screen.findByRole("table", { name: "Top event types" });
+ expect(
+ within(bars).getByRole("row", { name: /saas\.auth\.login/ }),
+ ).toBeTruthy();
+ expect(within(bars).getByText("42")).toBeTruthy();
// Number widget: the total-logins stat sums the time buckets (3 + 5).
expect(screen.getByText("8")).toBeTruthy();
- // Each widget's title is rendered from the declaration.
- expect(screen.getByText("Logins over time")).toBeTruthy();
+ // Each widget's title is rendered from the declaration. A chart widget
+ // says it three times: once visibly as its card's heading, and twice in
+ // the accessible data table that is the chart's screen-reader
+ // representation — as the table's caption and as its one series' column
+ // header, there being no other name for the series than the widget's.
+ expect(screen.getAllByText("Logins over time")).toHaveLength(3);
expect(screen.getByText("Total logins")).toBeTruthy();
});
@@ -196,6 +222,449 @@ describe("SolutionDashboards", () => {
expect(await screen.findByText("Unable to load.")).toBeTruthy();
});
+ // The sequence that matters: a read that SUCCEEDED, then one that did not.
+ // TanStack Query hands back the last successful data when a refetch
+ // rejects, so a cold failure cannot show this — there is nothing retained
+ // to leak.
+ describe.each([
+ [403, "permission_denied", Code.PermissionDenied],
+ [503, "unavailable", Code.Unavailable],
+ ] as const)("a %d on a refetch", (status, code, expected) => {
+ const refused = status === 403;
+
+ it(`${refused ? "discards" : "keeps"} the values already on screen`, async () => {
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ buckets: [{ key: "2026-09-01", count: "37" }] }),
+ ),
+ );
+ const { client } = renderDashboards();
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code, message: "read rejected" }, { status }),
+ ),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ });
+
+ const query = client
+ .getQueryCache()
+ .find({ queryKey: ["solution-widget"], exact: false });
+ expect(ConnectError.from(query?.state.error).code).toBe(expected);
+
+ if (refused) {
+ // A number this viewer is no longer allowed to read is a wrong
+ // answer, not a stale one.
+ expect(await screen.findAllByText("No access")).not.toHaveLength(0);
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+ expect(screen.queryByText("Stale")).toBeNull();
+ } else {
+ // A transient failure says nothing about authority, so the
+ // values stay and read as out of date.
+ expect(await screen.findAllByText("Stale")).not.toHaveLength(0);
+ expect(screen.getAllByText("37").length).toBeGreaterThan(0);
+ }
+ });
+ });
+
+ // The longer sequence, and the one that matters most: a refusal followed by
+ // an ordinary failure, with no authorized read in between. A snapshot that
+ // outlived the refusal would be eligible again at the next error, which
+ // hands those values straight back.
+ describe.each(["line", "area", "bar", "number", "table"] as const)(
+ "a refused %s widget",
+ (visualization) => {
+ const oneWidget: DataGraph = {
+ ...graph,
+ dashboards: [
+ {
+ ...graph.dashboards[0],
+ widgets: [{ ...graph.dashboards[0].widgets[0], visualization }],
+ },
+ ],
+ };
+
+ it.each([
+ [
+ "a 503",
+ () => HttpResponse.json({ code: "unavailable" }, { status: 503 }),
+ ],
+ ["a dropped connection", () => HttpResponse.error()],
+ ])("does not resurrect its values on %s", async (_name, fail) => {
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({
+ buckets: [{ key: "2026-09-01", count: "37" }],
+ }),
+ ),
+ );
+ const { client } = renderInApp(
+ ,
+ );
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code: "permission_denied" }, { status: 403 }),
+ ),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ await settle();
+ });
+ expect(await screen.findByText("No access")).toBeTruthy();
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+
+ server.use(http.post(rpc("AuditService", "AggregateAuditLog"), fail));
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ await settle();
+ });
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+ });
+ },
+ );
+
+ it("does not resurrect them once the caches have had time to expire", async () => {
+ // The shape this replaced marked the snapshot ineligible from a second
+ // cache entry, which had its own garbage-collection clock: five
+ // minutes later the mark was gone and the values were not, so a retry
+ // handed them back. Deleting the snapshot leaves nothing with a
+ // lifetime of its own to fall out of step.
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ buckets: [{ key: "2026-09-01", count: "37" }] }),
+ ),
+ );
+ const { client } = renderDashboards();
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code: "permission_denied" }, { status: 403 }),
+ ),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ await settle();
+ });
+ expect(await screen.findAllByText("No access")).not.toHaveLength(0);
+
+ // Past the default five-minute gcTime, on the cache's own clock.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ try {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300_001);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code: "unavailable" }, { status: 503 }),
+ ),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ await settle();
+ });
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+ });
+
+ it("does not resurrect them on a remount", async () => {
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ buckets: [{ key: "2026-09-01", count: "37" }] }),
+ ),
+ );
+ const { client, unmount } = renderDashboards();
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code: "permission_denied" }, { status: 403 }),
+ ),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ await settle();
+ });
+ expect(await screen.findAllByText("No access")).not.toHaveLength(0);
+
+ unmount();
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code: "unavailable" }, { status: 503 }),
+ ),
+ );
+ renderInApp(
+ ,
+ client,
+ );
+ await act(async () => {
+ await settle();
+ });
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+ });
+
+ it("does not leave the same numbers behind under another graph revision", async () => {
+ // The graph is the last part of the query key, so a declaration that
+ // differs only in a dashboard title has its own cache entry holding
+ // the same numbers. A refusal has to reach every revision of them.
+ const renamed: DataGraph = {
+ ...graph,
+ dashboards: [{ ...graph.dashboards[0], title: "Activity (renamed)" }],
+ };
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ buckets: [{ key: "2026-09-01", count: "37" }] }),
+ ),
+ );
+ const { client, unmount } = renderDashboards();
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+ unmount();
+
+ // A second revision, cached under its own key.
+ const second = renderInApp(
+ ,
+ client,
+ );
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code: "permission_denied" }, { status: 403 }),
+ ),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ await settle();
+ });
+ expect(await screen.findAllByText("No access")).not.toHaveLength(0);
+ second.unmount();
+
+ // Back to the first revision, whose refresh fails transiently.
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code: "unavailable" }, { status: 503 }),
+ ),
+ );
+ renderInApp(
+ ,
+ client,
+ );
+ await act(async () => {
+ await settle();
+ });
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+ });
+
+ it("lets a successful authorized read lift the refusal", async () => {
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ buckets: [{ key: "2026-09-01", count: "37" }] }),
+ ),
+ );
+ const { client } = renderDashboards();
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code: "permission_denied" }, { status: 403 }),
+ ),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ });
+ expect(await screen.findAllByText("No access")).not.toHaveLength(0);
+
+ // Authority restored: a read succeeds and the NEW values are drawn.
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ buckets: [{ key: "2026-09-01", count: "42" }] }),
+ ),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ });
+ expect(await screen.findAllByText("42")).not.toHaveLength(0);
+ expect(screen.queryByText("No access")).toBeNull();
+ });
+
+ // Everything above drives the refusal through a mounted component. These
+ // drive it where no mounted component can answer for it: the refusal is a
+ // property of the READ, and the read outlives whoever started it.
+ describe("a refusal with nobody watching it", () => {
+ // A handler that holds its request open until the test releases it, so a
+ // refusal can be in flight across an unmount or a change of dashboard.
+ function heldRefusal() {
+ let enter!: () => void;
+ let release!: () => void;
+ const entered = new Promise((resolve) => {
+ enter = resolve;
+ });
+ const released = new Promise((resolve) => {
+ release = resolve;
+ });
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), async () => {
+ enter();
+ await released;
+ return HttpResponse.json(
+ { code: "permission_denied" },
+ { status: 403 },
+ );
+ }),
+ );
+ return { entered, release };
+ }
+
+ it.each([
+ [
+ "a 503",
+ () => HttpResponse.json({ code: "unavailable" }, { status: 503 }),
+ ],
+ ["a dropped connection", () => HttpResponse.error()],
+ ])(
+ "deletes the values when it completes after the page is gone, then %s",
+ async (_name, fail) => {
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({
+ buckets: [{ key: "2026-09-01", count: "37" }],
+ }),
+ ),
+ );
+ const { client, unmount } = renderDashboards();
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ const { entered, release } = heldRefusal();
+ const refusal = client.refetchQueries({
+ queryKey: ["solution-widget"],
+ });
+ await entered;
+ // The viewer leaves while the request is still open, so no effect
+ // of theirs is left to run when the answer comes back.
+ unmount();
+ release();
+ await refusal;
+
+ // A retry nobody is watching either. It must not hand the values
+ // back to the next mount.
+ server.use(http.post(rpc("AuditService", "AggregateAuditLog"), fail));
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+
+ renderInApp(
+ ,
+ client,
+ );
+ await act(async () => {
+ await settle();
+ });
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+ },
+ );
+
+ it("deletes the revision on screen when it lands on the one the viewer left", async () => {
+ // The refusal is for the revision the viewer has navigated away from;
+ // the one mounted is another revision of the same widget, with its own
+ // cached values on screen. Both are this widget's source, and nothing
+ // in React connects the refused read to the component drawing them.
+ const renamed: DataGraph = {
+ ...graph,
+ dashboards: [{ ...graph.dashboards[0], title: "Activity (renamed)" }],
+ };
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ buckets: [{ key: "2026-09-01", count: "37" }] }),
+ ),
+ );
+ const { client, rerender } = renderDashboards();
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ // The second revision reads its own values, under its own key.
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ buckets: [{ key: "2026-09-01", count: "42" }] }),
+ ),
+ );
+ rerender( );
+ expect(await screen.findAllByText("42")).not.toHaveLength(0);
+ rerender( );
+ await act(async () => {
+ await settle();
+ });
+
+ const { entered, release } = heldRefusal();
+ const refusal = client.refetchQueries({
+ queryKey: ["solution-widget"],
+ type: "active",
+ });
+ await entered;
+
+ // Mid-refusal the viewer switches back, where the other revision's
+ // values are cached and its refresh fails transiently: 42, stale.
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ code: "unavailable" }, { status: 503 }),
+ ),
+ );
+ rerender( );
+ expect(await screen.findAllByText("Stale")).not.toHaveLength(0);
+ expect(screen.getAllByText("42").length).toBeGreaterThan(0);
+
+ await act(async () => {
+ release();
+ await refusal;
+ await settle();
+ });
+ expect(screen.queryAllByText("42")).toHaveLength(0);
+ });
+
+ it("asks once, rather than clearing its own error and asking again", async () => {
+ // Clearing the entry to drop its values restarts every active read of
+ // it, whose next answer is the same refusal: a standing 403 became an
+ // unbounded loop. Deleting the values touches neither the error nor
+ // the fetch, so the retry policy is the client's alone.
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), () =>
+ HttpResponse.json({ buckets: [{ key: "2026-09-01", count: "37" }] }),
+ ),
+ );
+ const { client } = renderDashboards();
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ let refusals = 0;
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), async () => {
+ refusals += 1;
+ // Answered a tick late, so the component sees the pending state
+ // in between — the window a self-restarting clear re-entered.
+ await new Promise((resolve) => setTimeout(resolve, 15));
+ return HttpResponse.json(
+ { code: "permission_denied" },
+ { status: 403 },
+ );
+ }),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ });
+ expect(await screen.findAllByText("No access")).not.toHaveLength(0);
+ for (let round = 0; round < 8; round += 1) {
+ await act(async () => {
+ await settle();
+ });
+ }
+ // Three widgets, one refresh, `retry: false` in tests.
+ expect(refusals).toBe(3);
+ });
+ });
+
it("reads as loading until the org context resolves, never as empty", () => {
authState.organizationId = undefined;
diff --git a/module/services/frontend/code/src/test/container.tsx b/module/services/frontend/code/src/test/container.tsx
index af7459594..acf8a8e7e 100644
--- a/module/services/frontend/code/src/test/container.tsx
+++ b/module/services/frontend/code/src/test/container.tsx
@@ -10,16 +10,35 @@ export function rpc(service: string, method: string): string {
// Render an admin feature container with the single provider its data hooks
// need. retry is off so a rejected ancillary query surfaces at once rather
// than stalling the test.
-export function renderInApp(ui: React.ReactElement) {
- const client = new QueryClient({
- defaultOptions: { queries: { retry: false } },
- });
+export function renderInApp(ui: React.ReactElement, reuse?: QueryClient) {
+ // `reuse` mounts into an EXISTING cache, which is what a remount or a
+ // second revision of the same declaration actually is: the component goes
+ // away and the cached answers do not. A test that built a fresh client
+ // could not tell a snapshot that was deleted from one that was never
+ // there.
+ const client =
+ reuse ??
+ new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
// The client comes back alongside the render result so a test can drive a
// SECOND read of the same query — the success-then-refused sequence, which is
// the one that proves a denial is not hidden behind rows already on screen.
// A cold denied read cannot show that, because there is nothing retained.
+ const mounted = render(
+ {ui} ,
+ );
return {
- ...render({ui} ),
+ ...mounted,
client,
+ // Re-render under the SAME provider. React Testing Library's own
+ // `rerender` replaces the whole tree with what it is given, so a test
+ // that passed the bare container would lose the client — which is the
+ // one thing a test of a second revision of the same declaration needs
+ // to keep.
+ rerender: (next: React.ReactElement) =>
+ mounted.rerender(
+ {next} ,
+ ),
};
}