Skip to content

Draw every dashboard with the kit's metric tier, and refuse re-implementing it (#1041) - #1043

Merged
antoinetoussaint-byte merged 9 commits into
mainfrom
issue-1041-a-solution-s-dashboard-is-drawn-with-the
Oct 11, 2026
Merged

antoinetoussaint-byte merged 9 commits into
mainfrom
issue-1041-a-solution-s-dashboard-is-drawn-with-the

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1041.

Coordination, settled. The owner chose this PR over #958; #958 does not land and its overlapping half is rebuilt on top of whatever merges here. Main has been merged into this branch (not rebased).

Two obligations for whoever lands next, both about fields this PR adds to DashboardWidgetView:

  • Publish the kit's chart vocabulary as an A2UI catalog, so every consumer names the same graphs (#1050) #1051's generated catalog. Its obligation is now format, unit, higherIsBetter, failure?: "failed" | "refused" and the nullable series. failed?: boolean is gone: a boolean cannot distinguish a read that did not complete from one the viewer is not allowed to make. Beyond describing the fields, the schema needs explicit tests for the nullability and for the accepted failure values.
  • Percent needs one conversion boundary, deliberately chosen. types.ts defines format: "percent" as an already-scaled percentage: 45 renders "45%", and 0.4 renders "0.4%". fromDashboardData forwards the field unchanged off a structurally-typed ResolvedWidget, so a producer whose own percent means a 0-to-1 share would mis-render by a hundredfold with no type error. The SDK has no format field today (checked at 0.3.20), so nothing is wrong now and a test pins the kit's convention. Rebuilding feat: group a solution dashboard into declared sections a viewer can arrange #958's share semantics must put the conversion in exactly one place and cover charts, tiles and tables, with 0.4 → 40% regressions.

1. <Dashboard> renders the metric tier

packages/codefly-ui/src/dashboard/dashboard.tsx. Contract unchanged: it takes a resolved DashboardView, fetches nothing, reads no host context, imports no app code.

  • scalar (number) → StatTile, gathered with its adjacent scalars into a KPIRow: the series total in the widget's format and unit, a sparkline over the buckets and a delta against the previous bucket when the metric is bucketed over time, and a MetricStateBadge driven by series.coverage.
  • line / area / bar → MetricLineChart / MetricAreaChart / MetricBarChart over a ChartSeries, which brings the palette axes, the crosshair readout, the legend and the visually-hidden data table that is the chart's screen-reader representation.
  • table → key/value rows (the metric tier has no table component).

Three behaviours are the contract rather than presentation, and each has a test:

  • a null total is not a zero. Metric.value now admits null, so the dash is a property of the primitive and not of a state mapping, and a new no_total metric state says so in words when coverage is otherwise complete. partial outranks it, because a partial series is why its total is withheld.
  • an empty series is no data, not a zero — the aggregate omits a bucket rather than emitting an empty one. A real emitted zero still reads 0.
  • 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. A rise from zero gets no delta at all, because that is not a percentage.

Declared order is never rearranged: only adjacent scalars are gathered, so the page shows what the declaration says in the order it says it.

Tests: packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx, 31 cases — one per visualization (asserted on the accessible data table, which is what distinguishes the metric tier from the basic one), each one's empty and coverage: "partial" state, every scalar state above, the pending and failed states, the default layout, and each slot.

2. SolutionDashboard.tsx mounts it

It now draws nothing. What it keeps is what is genuinely the host's, composed around the renderer through four new presentational slots:

Host feature Slot
the + menu that adds a removed tile back actions (the dashboard's own header)
drag-to-reorder / arrow-key move, via the kit's SortableGrid layout
one audit query per widget, so a failing metric fails alone renderWidget
"No widgets are shown. Add one back with the + button." empty

The grip, the ⓘ (metric provenance, the principal directory, the recent-events drill-down) and the × go into each widget's actions, in the card header the kit draws.

The per-widget queries are why the kit also exports DashboardWidget — the same renderer for one widget. React fixes the number of hooks a component may call, so a varying set of per-widget queries cannot be hoisted into a renderer that takes the whole view; each widget resolves in its own component and mounts the kit's card. That is a composition of the kit's renderer, not a second one, and it is the only shape that keeps the tested failure-isolation property.

Its 16 existing tests pass. Three lines moved, each because the metric tier's markup differs from the basic tier's, with the reason written in:

  • the tileTitles() helper reads [data-slot="card-heading"], [data-slot="metric-label"] instead of card-title — a series widget is titled by its Card, a scalar by its StatTile.
  • the bar widget's buckets are asserted on the chart's accessible data table; the metric tier draws each bucket key as both an axis label and a row header, so a bare text query matched two nodes.
  • the line widget's title is asserted as appearing 3 times (card heading, table caption, the one series' column header) instead of once.

3. A gate that refuses re-implementing a kit component

src/components/__tests__/kit.test.ts, in the shape the kit's other consumer already uses, copied rather than reinvented. Three legs:

  • 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 ALLOWED saying what the two things are and what would retire the entry. Recognition is by the kit's own spelling of the import, so renaming an alias cannot turn a re-implementation into a wrapper.
  • class-variance-authority is no longer a dependency of this app. Nothing under src/ imported it (the kit declares it for itself), so this is a fact rather than a convention: a kit-shaped primitive cannot be built here at all. One line out of package.json, one out of package-lock.json.
  • the two chart surfaces stay re-exports (below).

Two deliberate departures from the upstream version of the rule, both because this tree proved them necessary:

  • module-local declarations count, not only exported ones. Both duplicates in this tree arrived as a helper at the bottom of a page, not as a file anyone would review as a component; a gate matching only export function walks past both.
  • Next's route convention is exempted narrowly — the one default export whose name the framework fixes in src/app/**/page.tsx and friends, since the kit also exports Page and Layout. A named kit-shaped component in such a file is still caught, and that has its own fixture.

Fixtures in the three directions asked for: must-catch (a Card of its own), must-not-catch (a wrapper, an alias, a re-export, a name the kit does not export, a route component), and the same duplication reached another way (module-local, arrow-function, and a Card built on the kit's CardRoot — how a duplicate escapes a plain name gate, since the kit and the consumer call the same thing by different names).

The gate is load-bearing: removing any ALLOWED entry reds it with the file and name.

The two files the issue asked about

Both are already right, and the gate now pins them:

  • src/components/charts/chart.tsx — one export { … } from "@codefly-dev/ui/dashboard" block.
  • src/features/dashboard/ui/charts/line-chart.tsx — one line, export { TrendLineChart as LineChart }.

Neither re-implements anything, so neither needs an exemption. What they do carry is a hazard the gate cannot see, recorded in the gate file rather than left for the next reader: both publish the name LineChart, and they publish different kit components under it — the first the metric-tier LineChart, the second TrendLineChart. An import of LineChart means one of two charts depending on which path it came from. Retiring the second alias belongs with the dashboard-DSL work that retires its only consumer (below).

What this does not fix

ALLOWED has six entries. Four are genuinely different things wearing a kit name (two host Dashboards that fetch, a page-rhythm Layout, a metric card that resolves its own metric). Two are real duplicates, labelled rather than fixed: the identical module-local MetricCard in event-operations-page.tsx and job-operations-page.tsx — a tile over a pre-formatted string plus a caption line. They are each other's duplicate as much as the kit's. They are not expressible as StatTile today, which takes a numeric Metric and has no caption slot; the entries say so and name what would have to change (StatTile carries a description, or those counts become Metrics). Folding them in would mean reworking two unrelated pages and their tests, which is outside this issue. Flagging for a follow-up decision rather than filing anything.

One other thing noticed and not changed: src/features/dashboard/ui/dashboard.tsx hand-rolls a header the kit's Section would draw. Same owner as the Dashboard/MetricCard/Layout entries; it goes with them.

Docs

  • module/AUDIT_METRICS.md — a new How a resolved series is drawn section: one renderer for the host's pages, a declared solution dashboard and a solution remote alike, plus a table of what each widget and each coverage state renders. The old sentence "Empty queries have no points and render No data yet" now points at it. This is the question downstream consumers were guessing at.
  • module/services/frontend/AGENTS.md — a new section saying SolutionDashboard.tsx mounts the kit and owns only the queries, the viewer's arrangement and the ⓘ, and naming the gate.
  • packages/codefly-ui/README.md — the ./dashboard entry-point row and the Dashboard bullet name the metric tier, DashboardWidget and the slots.

Review fixes (second commit)

A deep review of the first commit found eleven fixable defects; all are fixed at their cause with a test, and the three that rested on a mutation were re-mutated to show the fix bites. Two were guards that worked on main and this branch had dropped: compile-time exhaustiveness over WidgetVisualization (a sixth member passed tsc and all 351 tests, and would have drawn a titled card with an empty body), and a scalar widget's declared span and its dashboard's columns, both silently ignored. Three were its own: the bucket delta read array positions, so a series delivered newest-first painted a decline as a green rise; format: "percent" had no stated scale at a structurally-bound seam where a producer using share semantics would have rendered 0.4 as "0.4%"; and a failed refetch discarded values still on screen instead of marking them stale. One was in the gate, which rejected composition through src/shared/ui — the host's own documented import surface — so its verdict depended on the import path rather than on what a component composed.

Full findings, evidence and dispositions: review report r1, persisted via lazybox.

Independent review fixes (third commit)

An independent review at 998e17d5 returned one blocker, two majors and two minors. All five are fixed at their cause, each mutation-checked — the mutation that re-creates the defect is shown to red a test.

Blocker: a permission-denied refetch kept the audit values on screen. The retain-on-failure fix in the previous commit was right for a transient failure and wrong for a refusal: a viewer whose audit-read authority had just been revoked went on reading the counts, labelled "Stale", where before this branch they were withheld. Now classified at the host boundary with the policy that already exists for it — readOutcome + mayKeepRetainedRows, the same pair the admin tables use, whose own doc comment describes this exact trap. failed?: boolean became failure?: "failed" | "refused", MetricState gained forbidden, and the renderer answers a refusal before it looks at the series so a caller that forwards retained values cannot get them drawn. Regression: success → 403 discards and says "No access"; success → 503 retains and says "Stale".

Major: the duplication gate both permitted re-implementations and rejected real composition. Rebuilt on the TypeScript AST, checking composition within the declaration. The fixture that mattered needed a new leg: the renderer this gate exists to prevent declares WidgetBody, WidgetValues, WidgetCard — not one a kit name — so no name rule could catch it. A host file may now draw with at most one of the kit's dashboard marks; several is a widget dispatch, which is <Dashboard>'s job. The fixture reads the real deleted source out of 8590d329.

Major: a failed refresh turned a known-empty scalar into a reported zero. Availability is now read off the series and freshness off the badge.

Minors: the accent reaches --chart-1, the token the metric charts actually paint (asserted on the rendered mark); and a titled scalar is an <h3> again, via a new labelAs on the shared StatTile rather than a restored host card.

Independent re-review fixes (fourth commit)

The re-review at b82cc700 confirmed M3 and m5 fixed, B1/M2/m4 partly, and returned one blocker, three majors and one minor. All five fixed at their cause; every mechanism mutation-checked separately.

Blocker: refused values came back on the next ordinary failure. Hiding the snapshot for the refusal itself was not enough — it outlived the refusal, so 200 → 403 → 503 and 200 → 403 → dropped connection both restored the counts after "No access", with no authorized read in between. A refusal now latches, in the query cache beside the snapshot it guards: it outlives a remount, where state or a ref would forget, and expires with the values it protects. Ten regressions (five visualizations × two failure modes) plus the control where an authorized read lifts it.

Major: the gate read references as composition. Only rendered JSX counts now, so ComponentProps<typeof KitCard> no longer exempts a <div>. Namespace members, local aliases, shadowed names, all three default-export forms and aliasing cross-file re-exports resolve. The dispatch leg tests for branching instead of counting marks per file — counting rejected a page composing a KPI row above a chart, and a namespace import escaped it.

Major: the suite required this repository's history. The deleted-renderer fixture is committed as bytes instead of read with git show, which fails wherever the module is composed rather than cloned — something full-history CI cannot see.

Major: forbidden withheld the value but not the delta and trend beside it. Everything value-derived now goes with the value, asserted on the primitives directly, since only a direct consumer was exposed.

Minor: the accent missed scalar trends. StatTile takes a trend option colouring the sparkline from the chart token.

Review r2/x2 and this round's record are persisted in lazybox.

Third round (fifth commit): the latch goes, and the gate asks the compiler

Round three found both of the previous round's fixes right in intent and wrong in shape, and prescribed the shape of each. Both are now as prescribed.

Blocker: the latch and the values were two cache entries with two lifetimes. A thing that guards a snapshot has to stay in step with it, and this one could not: it was garbage-collected on its own five-minute clock while the values it protected survived, and a cached success for another revision of the graph — a different dashboard title is enough for another key — cleared it although no fresh grant had been given. So there is no latch. A refusal deletes the snapshots, for every cached revision of that widget's source, and nothing restorable is left for a later error to hand back. Three regressions: the cache's real 300 s expiry on a fake clock, a remount into the same QueryClient, and the cross-revision sequence.

Major: the gate's hand-rolled scope analysis kept misjudging nine new fixtures. Replaced, not extended: a ts.Program over the app's sources and the checker answer what an identifier refers to, which retires scope collection, shadowing and export-chain following all at once. Composition counts only JSX the component returns, and a dispatch is two return paths rendering different marks — so a layout drawing the same pair inside a <section> or a <main> passes, and choosing a line or an area by a widget's kind does not. No entry was added to ALLOWED or DISPATCHERS.

Fourth round (this commit): the read deletes, and the gate refuses what it cannot read

Round four confirmed the deletion right and its placement wrong, and gave the gate a different instruction: stop chasing evasions.

Blocker + major: deletion ran in a component effect, so it answered for the refusals a component happened to be mounted for. Switch graph revision mid-refresh and 42 stayed on screen; unmount mid-refresh and 37 came back on the next mount. And clearing the entry to drop its values restarted every active read of it, so a standing 403 never settled — one refresh made 14 refused requests here, against 1 for the shape before it. Both are one defect: the component is the wrong place. A refused read now deletes the snapshots in the query function, where readOutcome classifies the failure, which is the one thing that happens exactly once per refusal whoever is watching. The values go for every cached revision of that widget's source; the query, its error and its retry policy are untouched, so the same refresh now makes 1 request. Four regressions: the request count, a refusal completing after unmount before a 503 and before a dropped connection, and a refusal landing on the revision the viewer left while another is on screen. All 5 of the reviewer's reproducers pass.

Major: the gate now refuses what it cannot resolve. A kit name published from a module it cannot read is an offence rather than a skip; export chains are followed to their end with cycle detection instead of an eight-hop cap; the app's own @/ alias resolves. What a component returns is read by expanding the alternatives of its returned expression — conditionals wherever they sit (a dispatch one line inside returned JSX was passing), casts and parentheses unwrapped, a comma's right operand alone, unreachable returns dropped, attributes not counted — and a dispatch is two alternatives whose marks are each non-empty and neither contained in the other, which separates a choice between marks from a mark that is only sometimes added. A type that shares a kit name is a type, not a component. Its limits are now written in its doc comment: a duplicate under a new name, kit JSX handed through a prop, anything behind a call or a lookup table, a dispatch split across components. 17 fixtures were added, 14 of them the reviewer's; ALLOWED and DISPATCHERS are unchanged in content.

One deliberate divergence, reported as such. The prescription said removeQueries / setQueryData(key, undefined). Neither can express this: removeQueries destroys the entry, which silently cancels the very read 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; and setQueryData returns early when the new data is undefined (queryClient.js:92), so that call does nothing at all. The snapshot is deleted by writing the query's state instead, which notifies observers, cancels nothing, keeps the refusal, and starts no fetch.

Behaviour docs

module/AUDIT_METRICS.md (which tier a solution's dashboard is drawn with, how a resolved series becomes each mark, and what a refused widget keeps), module/services/frontend/AGENTS.md (drawing a declared dashboard, and where a refusal acts — the read, not the component), packages/codefly-ui/README.md.

Verification

Run locally on this head, all green: tsc --noEmit; eslint (0 errors, 39 pre-existing warnings, none in touched files); npm run test:pure — 318 files, 4236 tests, the 64 gate cases among them; biome check on every touched file; npm run generate:preview-stylesheet reproduces the committed CSS byte-for-byte. Repo gates: scripts/check-boundaries.sh, module/tools/naming-gate.mjs check (tree and record), scripts/ci/kit-version.mjs check, module/tools/module-verify.mjs, module/tools/interface-docs-gate.mjs check, module/tools/story-trace-gate.mjs tests, node --test scripts/ci/release-gates.test.mjs. Twenty-one mutations across the two fixes were each shown to red at least one test.

main is merged in (not rebased). Its release cut v0.0.94 while this branch was open, which published kit 0.12.6, so the kit moves to 0.12.7 — a patch, because the change is additive and a 0.x minor breaks every remote pinned to ^0.12.

Not verified: codefly ci run (the canonical service gate) was not run — it needs the Docker graph. The pipeline vitest project was not run for the same reason; nothing here touches it. scripts/test-published-ui.mjs and test-registry-ui.mjs were not run — they install from the package registry. The rendering has never been seen in a browser: codefly run solution boots the host but stops on five missing workspace configuration groups, so there is no screenshot of the new tier.

Suite flakiness, named rather than averaged. Two of five full local runs failed — 2 tests then 4 — in files this PR does not touch, including src/features/onboarding/ui/onboarding-reminder.test.tsx failing with "found multiple elements" for one landmark, which passes in isolation. Three runs were clean. The independent review reported the same class on the previous head (four timeouts in a generation file, an unhandled worker error), which predates these changes. I did not bisect it against a clean main worktree.

One source-compatibility note for reviewers. DashboardWidgetView.series is now WidgetSeries | null (null = still resolving, which is what lets a renderer tell "not yet" from "nothing matched" instead of taking a synthesised empty series). Constructing a view is unaffected; a consumer that reads view.widgets[i].series.total needs a ?.. In-tree that was one assertion in types.test.ts. fromDashboardData still only ever produces a non-null series.

For the #1051 rebuild. The obligation this leaves is failure?: "failed" | "refused" plus format, unit, higherIsBetter and a nullable series on the widget view, with schema tests for the nullability and for the accepted failure values; types.ts defines format: "percent" as an already-scaled percentage (0.4 renders as "0.4%"), which is the structurally-bound seam a producer using share semantics would get wrong.

🤖 Generated with Claude Code

…refuse re-implementing it (#1041)

`@codefly-dev/ui/dashboard` shipped two tiers. The host's own operations pages
used the metric one; a solution's dashboard got the older basic one — twice
over, because `src/solutions/SolutionDashboard.tsx` carried its own copy of the
widget-to-chart dispatch, the responsive grid, the card shell and the empty
state beside the kit's `<Dashboard>`, and both copies drew with `charts.tsx`.
So the same declaration rendered plainer inside the host than beside it, and
there were two renderers of one thing to keep in step.

**A fix at the owner.** `<Dashboard>` now paints with the metric tier, contract
unchanged (a resolved `DashboardView` in; no fetching, no host context, no app
imports). A scalar widget is a `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, and a `MetricStateBadge` from `series.coverage`.
A series widget is the kit's metric line/area/bar chart over a `ChartSeries`,
which brings the axes, the crosshair readout, the legend and the visually-hidden
data table that is the chart's screen-reader representation.

A withheld total renders as a dash and never as `0`: `Metric.value` now admits
`null` so that is a property of the primitive rather than of a state mapping,
and a new `no_total` state says which of partial / empty / no-total withheld it.
A series with no points reads as no data, not as 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 would assert an order the data does not have.

`SolutionDashboard.tsx` now mounts that renderer and draws nothing itself. It
keeps exactly what is the host's, composed through four new presentational
slots: the + menu as header `actions`, the drag-to-reorder `SortableGrid` as
`layout`, the per-widget audit query as `renderWidget`, and its own `empty`
line. The queries stay one per widget — a widget whose metric errors still
fails alone — which is why the kit also exports `DashboardWidget`, the same
renderer for one widget: React fixes the number of hooks a component may call,
so a varying set of per-widget queries cannot live in a renderer that takes the
whole view. Its 16 existing tests still pass; two assertions and one helper
moved onto the markup the metric tier produces, each with the reason in place.

**A gate**, in the shape the kit's other consumer already uses:
`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 saying what the two things are; `class-variance-authority` is
no longer a dependency of this app (nothing imported it), so a kit-shaped
primitive cannot be built here at all; and the two chart surfaces stay
re-exports. Module-local declarations count, not only exported ones — that is
how both duplicates in this tree arrived, and a gate matching only exports
walked past both. Nine fixtures cover must-catch, must-not-catch, and the same
duplication reached another way.

Docs: `module/AUDIT_METRICS.md` gains "How a resolved series is drawn" — the
tier, and a table of what each widget and each coverage state renders, which is
what downstream consumers were guessing at; `module/services/frontend/AGENTS.md`
says what the solution dashboard owns and what the kit draws; the kit's README
names the tier and the slots.

Refs #1041

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

Coordinating #1044: I found this PR before changing the shared dashboard files and will reuse commit 71b8749 for the metric-tier renderer, rather than implement it again. I am adding layout declarations, defaults and saved-layout regressions on top, with a self-contained PR against main; I will not merge or deploy. Please flag any pending renderer changes that should be included. I also found #958 and am checking its section contract for compatibility.

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

Coordination result: draft #1045 reuses 71b8749 and adds the layout/schema/persistence work for #1044. Its sortable view now calls the same kit layout through a renderLayout callback; the metric renderer and duplication gate are reused. The section vocabulary follows #958. No PR was merged or deployed. Full pure tests and responsive kit previews passed; the final native CI rerun ended with exit 137 before a report, documented in #1045.

antoinetoussaint-byte and others added 8 commits October 10, 2026 20:17
… and its gate (#1041)

A deep review of this branch found eleven fixable defects. Each is fixed at its
cause with a test, and the three that rested on a mutation were re-mutated to
show the fix bites.

**Two guards that worked on main had been dropped.**

Compile-time exhaustiveness over `WidgetVisualization`: main's dispatch ended
with `const _exhaustive: never = visualization`, and the kit's replacement ended
with `default: return null`. Proven by mutation — adding a sixth member passed
`tsc` and all 351 dashboard and solution tests, and would have drawn a titled
card with an empty body, indistinguishable from a widget whose query returned
nothing. The never-assignment is back; the same mutation now fails `tsc` at the
switch that must handle it.

A scalar widget's declared `span` and its dashboard's declared `columns` were
silently ignored: the span class skipped `number` widgets, and every scalar went
into a `KPIRow` whose geometry is fixed four-up. Both fields are documented on
the view model and `<Dashboard>` honoured them before. Now a scalar that
declares a span stays a grid item and keeps it — asking for a card's width is
exactly what the KPI row cannot grant — and the KPI row is capped at a declared
`columns`. A view that declares no columns keeps the four-up default, which is
what headline numbers want, rather than inheriting the card grid's two.

**Three defects of its own.**

The bucket delta read array positions. The resolver appends one point per bucket
in whatever order the aggregate returned, with no sort, and a delta's sign is
drawn as a colour — so a series delivered newest-first painted a decline as a
green rise. The timestamps were already being parsed and thrown away; now they
order both the delta and the sparkline, and a reversed series reads the same.

`DashboardWidgetView.format` is bound structurally, and the kit's `percent` is an
already-scaled percentage (45 → "45%"). A producer whose own `percent` means a
0-to-1 share would have had 0.4 rendered "0.4%" with no type error and no failing
test. The scale is now stated on the field itself and pinned by a test at the
`fromDashboardData` boundary, so such a producer fails a test instead of shipping
a hundredfold error in a displayed percentage.

A failed refetch threw away values still on screen. React Query keeps `data` when
a background refresh fails, and `failed` was read before the series, so a widget
that had resolved went to "Unable to load." and a number to a dash. Three states,
not two: a failure with nothing behind it is a failure; a failure with a series
in hand draws that series with the kit's own `stale` badge.

**The gate rejected the house style.**

Composition through `src/shared/ui` — which its own first line calls the host's
convenience surface, and which most of the app imports the kit through — was
invisible to the import check. Proven with a control: the same `Badge` wrapper
was rejected via `@/shared/ui` and accepted, otherwise unchanged, via
`@codefly-dev/ui/layout`. The verdict depended on the path rather than on what
the component composed, and the first consequence would have been an exception
entry for a file doing the right thing. The host's surfaces are now kit sources,
with their aliases resolved to the kit's own names: `src/shared/ui` publishes
`CardRoot` as `Card`, so importing `Card` from there composes `CardRoot` and a
declaration named `Card` is still a second `Card` — the same verdict the direct
import already got, instead of the opposite one.

The gate also could not see names re-exported with `export *`, and its
`kit.size` sanity check passes whether those names are counted or not. It now
refuses a star it cannot resolve, so the hole names itself rather than quietly
shrinking what the gate covers.

**Smaller.** A stat tile's truncated label carries its full text in `title`, as
`BarList` already did. The availability story shows the new state. `KPIRow` no
longer accepts `metrics` and `children` together and silently drops the first.
`preview.generated.css` is regenerated, which is where the new KPI column
utilities get their CSS — without it the cap would not have applied in previews.

Docs: AUDIT_METRICS.md states the percent scale, the time ordering and the stale
behaviour.

Refs #1041

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ate reads the AST (#1041)

An independent review of this branch at 998e17d found one blocker, two majors
and two minors. Each is fixed at its cause and mutation-checked: the mutation
that re-creates the defect is shown to red a test.

**BLOCKER — a permission-denied refetch kept the audit values on screen.**

`SolutionWidget` forwarded cached data with a failure flag for every query
error, so a viewer whose audit-read authority had just been revoked went on
reading the counts, now labelled "Stale". Before this branch the host's
`isError` branch withheld them, so this was a regression introduced by the
retain-on-failure fix — the right answer for a transient failure, the wrong one
for a refusal.

Fixed with the policy that already exists for it: `readOutcome` classifies the
error and `mayKeepRetainedRows` decides whether the series survives, exactly as
the admin tables do. `src/shared/lib/read-outcome.ts` documents this trap in its
own words — TanStack Query hands back the last successful data when a refetch
rejects — which is what makes it the owner rather than a new policy here.

The kit's `failed?: boolean` becomes `failure?: "failed" | "refused"`, because a
boolean cannot carry the distinction the blocker is about. `MetricState` gains
`forbidden` ("No access"), and the renderer answers a refusal before it looks at
the series in every branch, so a caller that forwards retained values by mistake
still cannot get them drawn. Reordering those branches to test the series first
— the version that reads more naturally — reds the test.

Regression: a success followed by a 403 discards the values and says "No
access"; the same success followed by a 503 keeps them and says "Stale"; both
assert the Connect code they classified on.

**MAJOR — the duplication gate permitted re-implementations and rejected real
composition.** It is rebuilt on the TypeScript AST and now checks composition
*within the declaration*, because a file-wide import proved nothing: an
unrelated `export { Card as KitCard } from "@codefly-dev/ui/layout"` exempted a
hand-rolled `Card` beside it, `export { LocalPanel as Card }` published a copy
under a kit name unseen, a namespace or relative import of a host surface was
rejected, and the root route's conventional `Page` was rejected because the
exemption only matched nested routes. All now have fixtures.

The fixture that mattered needed a new leg. The renderer this gate exists to
prevent — the one #1041 deleted — declares `WidgetBody`, `WidgetValues` and
`WidgetCard`, and not one of them is a name the kit exports, so no name-based
rule could ever have caught it. A host file may now draw with at most ONE of
the kit's dashboard marks; reaching for several is a widget dispatch, which is
`<Dashboard>`'s job. The fixture reads the real deleted source out of
`8590d329` rather than a paraphrase. Two live files draw with more than one, both
already named in ALLOWED for the same reason; they are listed in DISPATCHERS
with what would retire each, and removing one reds the gate.

**MAJOR — a failed refresh turned a known-empty scalar into a reported zero.**
Replacing the coverage state with `stale` also changed whether the value counted
as meaningful, so `{points: [], total: 0, coverage: "empty"}` went from "— / No
data" to "0 / Stale". Availability is now read off the series and freshness off
the badge: they are different questions about one tile and are answered apart.
A partial series keeps its withheld total dashed through a failed refresh too.

**MINOR — the chart migration had broken the public `accent` option.** The tier
change moved where a mark's colour comes from: a metric chart paints its first
series `var(--chart-1)`, while the basic tier used `--primary`. The accent now
sets both, asserted on the rendered mark rather than on the variable.

**MINOR — a titled scalar had lost its heading.** It was a `Card` with an
`<h3>`; a `StatTile`'s label is a span, so the title left the document outline.
`StatTile` gains `labelAs`, so the title is a heading again through the shared
primitive rather than a restored host card.

Refs #1041

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…refers to (#1041)

The re-review of the previous commit found one blocker, three majors and one
minor. Each is fixed at its cause, and each mechanism is mutation-checked
separately: the change that re-creates the defect is shown to red a test.

**BLOCKER — refused values came back on the next ordinary failure.**

Hiding the retained snapshot for the refusal itself was not enough, and that
was the bug: the snapshot outlived the refusal, so the NEXT error made it
eligible again and handed the refused values straight back, with no authorized
read in between. `200 → 403 → 503` and `200 → 403 → dropped connection` both
restored the counts after "No access".

A refusal now latches: the values stay ineligible until a successful read
replaces them. The latch lives in the query cache beside the snapshot it
guards, for two reasons — it outlives this component, where a ref or a piece of
state forgets on remount and the snapshot does not, and it expires with the
cache that holds the values, so neither can outlast the other. Ten regressions,
five visualizations times two failure modes, plus the control where an
authorized read lifts it.

The first version of that test was green for the wrong reason: a rejected
refetch reaches the component a tick after `refetchQueries` resolves, so
asserting immediately passed with the latch deleted. The wait is now a named
helper that says why it is there, and the mutation fails exactly the ten cases.

**MAJOR — the gate still read references as composition.**

It resolved bindings but not what they refer to. Only rendered JSX counts now,
so a parameter typed `ComponentProps<typeof KitCard>` no longer exempts a
`Card` whose body is a bare `<div>`. Namespace imports resolve through their
members, including a host surface's, where `UI.Card` is the kit's `CardRoot`.
Local aliases are chased to a fixed point. Names the declaration binds itself
shadow the import. All three default-export forms resolve, so
`export default Page` and `export { Page as default }` are the conventional
route components they look like. An aliasing cross-file re-export is caught,
while a same-name barrel re-export is not: that declaration was judged at its
own site, and flagging it again would demand a second exception for one
component.

The dispatch leg now tests for BRANCHING rather than counting marks per file.
Counting rejected a page that composed a KPI row above a chart, and was escaped
by a namespace import or a local alias. A surface may draw several marks; what
it may not do is choose between them by a widget's kind, which is the dispatch
`<Dashboard>` owns. The deleted renderer is still caught, and now also when its
imports are rewritten to a namespace or to local aliases.

**MAJOR — the suite required this repository's history.** The deleted-renderer
fixture ran `git show`, which fails wherever the module is composed rather than
cloned: a consuming repository carries the source tree, not these commits, and
full-history CI is structurally unable to see that. The renderer is committed as
a byte fixture instead, with a README on why it is `.txt` — as `.tsx` it would
be typechecked against today's imports and counted by `ui-inventory.json`.

**MAJOR — `forbidden` withheld the value but not what was derived from it.**
`StatTile` and `MetricCard` drew the supplied delta and trend beside "No
access", publishing the same figure twice over in the two forms hardest to
notice. Everything value-derived now goes with the value. The renderer avoided
this by building a fresh metric; a direct consumer of the exported primitives
did not, which is where the regressions sit.

**MINOR — the accent still missed scalar trends.** A sparkline strokes
`currentColor` under a muted utility, so neither override reached it and one
dashboard was drawn in two palettes. `StatTile` takes a `trend` option that
colours it from the chart token, as a token rather than a utility class so a
themed subtree's accent reaches it; `Sparkline` accepts a `style` for that.

Refs #1041

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e compiler (#1041)

Round three of the independent review found one blocker and one major, and
prescribed the shape of both fixes. Both prescriptions were better than what
they replaced, and both of mine had the same underlying fault: a rule enforced
by a second thing kept alongside the first, rather than by removing what the
rule is about.

**BLOCKER — the refusal latch could expire, or be cleared without a new grant.**

The latch and the values it guarded were separate cache entries with separate
lifetimes. The latch had no observer, so it was garbage-collected on its own
five-minute clock while the observed values stayed, and `200 → 403 → wait →
503` handed the refused numbers back on all five visualizations. Worse,
`!isError && data !== undefined` read cached availability as a successful read,
so a cached success for another revision of the same graph — a different
dashboard title is enough — cleared the latch for all of them.

There is no latch now. A refusal clears the snapshots themselves, for every
cached revision of that widget: the graph is the last part of the query key, so
a declaration that differs only in a title has its own entry holding the same
numbers. Nothing restorable is left, so there is no second thing to keep in
step, and only a read that succeeds can put values back.

`resetQueries`, not `removeQueries`: removing destroys the query the component
is observing, which leaves it with nothing to refetch and no way back even once
authority returns — the recovery control catches that. A reset discards the data
and keeps the query. Three regressions: past the default gcTime on the cache's
own clock, a remount into the same client, and the cross-revision sequence.

**MAJOR — the gate still misjudged composition and dispatch.**

Nine fixtures, in both directions: a guard-return dispatch and a destructured
shadow accepted, an unused nested wrapper counted as composition, a two-hop
barrel hiding a copy; and function-local aliases, namespace-member aliases,
nested shadows that never reach the outer JSX, layout branches, and a genuine
wrapper re-exported under the kit's name all rejected.

Every one of those is the same mistake — re-deriving by hand what the compiler
already knows. The detector now builds a `ts.Program` over the sources under
test and asks the checker what an identifier refers to. Relative specifiers
resolve between those files; the kit deliberately does not, because a kit
binding is evidenced by the import declaration in the source and needs no
installed package, so no resolution failure can weaken the rule. Scope,
destructuring, alias chains and export chains of any length all follow from
that one question.

Composition is judged only on JSX the component RETURNS, so an unused nested
wrapper and a render prop stop counting. Dispatch is two return paths that
render DIFFERENT marks, which is what separates a layout drawing the same pair
in two wrappers from a renderer choosing a line or an area by a widget's kind;
the ternary and guard-return spellings are both return paths and need no rule
of their own.

One consequence worth naming: an exception is recorded where a component is
DECLARED, not where it is published, so a barrel re-exporting the host's own
`Dashboard` no longer needs a second entry for one component. `ALLOWED` and
`DISPATCHERS` are unchanged in content — no allowlist grew to make this pass.

Six mutations, each shown to red a test: whole-declaration scanning instead of
return paths, flattened paths, no local-alias following, no export-chain
following, descending into nested functions, and for the blocker, no reset and a
reset narrowed to one revision.

Refs #1041

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… gate refuses what it cannot read (#1041)

A refused read is the one thing that happens exactly once per refusal,
whoever is watching. Moving the deletion of the retained snapshots into
the query function — where `readOutcome` classifies the failure — is what
makes the rule hold for a refusal that arrives after the viewer switched
dashboards or after the page unmounted, neither of which runs an effect.
The values go for every cached revision of that widget's source; the
query, its error and its retry policy are untouched, so a standing 403
settles in one request instead of restarting itself.

The duplication gate stops modelling more of JavaScript and starts
refusing what it cannot resolve. A kit name published from a module it
cannot read is an offence rather than a skip, export chains are followed
to their end with cycle detection instead of a hop cap, the app's own `@/`
alias resolves, and what a component RETURNS is read by expanding the
alternatives of its returned expression — conditionals wherever they sit,
casts and parentheses unwrapped, a comma's right operand alone,
unreachable returns dropped, attributes not counted. A dispatch is two
alternatives whose marks are each non-empty and neither contained in the
other. Its limits are now written in its doc comment.

The kit moves to 0.12.7: main's release cut v0.0.94 while this branch was
open, which published 0.12.6, and these bytes moved. Additive is a patch —
a 0.x minor breaks every remote pinned to ^0.12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte force-pushed the issue-1041-a-solution-s-dashboard-is-drawn-with-the branch from d89feca to 9fe2b36 Compare October 11, 2026 20:17
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 0f696ba Oct 11, 2026
24 checks passed
antoinetoussaint-byte added a commit to crisfiorini-oss/module-saas-starter that referenced this pull request Oct 12, 2026
main now carries codefly-dev#1043, which rewrote the kit's <Dashboard> onto the metric
tier and made SolutionDashboard compose it through slots. The owner chose
that renderer over this branch's, so every conflicted file is resolved to
main's side: the kit's dashboard.tsx, types.ts, metric-tiles.tsx, index.ts,
charts.tsx, SolutionDashboard.tsx and its test, and the package manifests
and lockfile.

This commit is the conflict resolution only. The branch's sections, viewer
arrangement, percent format, per-day zeros and two-row tiles are rebuilt on
top of codefly-dev#1043's renderer in the commits that follow; until they land this
commit does not type-check, because the branch's non-conflicting files
(widget-chart.tsx, the v2 layout) still name what the resolution dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
antoinetoussaint-byte added a commit to crisfiorini-oss/module-saas-starter that referenced this pull request Oct 12, 2026
…-dev#1043's

codefly-dev#1043 made the kit's <Dashboard> draw every widget with the metric tier and
the host compose it through slots, with a gate refusing a re-implementation.
This branch had drawn the same widgets through its own path, which goes:

- widget-chart.tsx (WidgetChart) dispatched a widget to a mark, which is
  <Dashboard>'s job; its inventory entry goes with it;
- the lone-series colour (chartSeriesColor(i, count) taking --chart-5)
  contradicts codefly-dev#1043's accent, which is the first series' --chart-1;
- formatShare and the formatValue props on the deprecated basic charts
  served WidgetChart's percent path; a widget's format now reaches every
  mark through the kit's formatMetricValue, and the share is scaled once
  in runDashboard (following commits);
- the audit page's bar list keeps main's own drawing; restyling it was part
  of matching the solution dashboard to the audit log, which codefly-dev#1043 did by
  moving both onto the metric tier.

The kit's dashboard and types tests are main's again here; this branch's
cases are rebuilt on codefly-dev#1043's renderer in the following commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
antoinetoussaint-byte added a commit to crisfiorini-oss/module-saas-starter that referenced this pull request Oct 12, 2026
…rd slots

SolutionDashboard rebuilds this branch's arrangement on codefly-dev#1043's file
instead of beside it. The viewer's layout (dashboard-layout.ts, version 2,
still the labelled browser-storage STOPGAP) is resolved into the view the
kit's <Dashboard> takes: the viewer's sections in their order under their
titles, each shown widget in the section the viewer put it in. The host's
controls go into the renderer's slots:

- `layout`: the kit's SortableBoard, one group per section, each headed by
  the kit's DashboardSectionHeader with the grip, the rename title, the
  section's + and its options in it; tiles move between sections by drag
  or arrow keys, and an arrow key that moves nothing saves nothing;
- `renderWidget`: SolutionWidget, still codefly-dev#1043's per-widget query mounting
  the kit's DashboardWidget; its query is now the shared useWidgetSeries,
  so a + menu asks the same key (and a refusal it meets deletes the same
  snapshots) only while it is open, and says "No data yet." or "Telemetry
  incomplete." beside a removed widget whose tile would have nothing;
- `actions`: the dashboard's + when it declares no sections.

A widget's format is bound from the resolved answer with its series,
because runDashboard is where a share became percentage points. A line,
area, bar or table tile spans two grid rows and every tile fills its cell,
so two number tiles stack beside a chart. "Add section" and the status
region sit after the renderer.

The branch's host tests are carried over onto codefly-dev#1043's file; those that read
its own card now read the kit's markup (section headings by their slot, a
tile's emptiness by the kit's "No data" / "Partial data" badges). A percent
ratio is asserted as "40%" on the tile, the bar chart's data table and a
table widget through the real SDK path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte deleted the issue-1041-a-solution-s-dashboard-is-drawn-with-the branch October 25, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant