Repository navigation
Draw every dashboard with the kit's metric tier, and refuse re-implementing it (#1041) - #1043
Conversation
…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>
|
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. |
|
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. |
… 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>
…-s-dashboard-is-drawn-with-the
…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>
…-s-dashboard-is-drawn-with-the
… 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>
d89feca to
9fe2b36
Compare
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>
…-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>
…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>
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:format,unit,higherIsBetter,failure?: "failed" | "refused"and the nullableseries.failed?: booleanis 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 acceptedfailurevalues.types.tsdefinesformat: "percent"as an already-scaled percentage: 45 renders "45%", and 0.4 renders "0.4%".fromDashboardDataforwards the field unchanged off a structurally-typedResolvedWidget, so a producer whose ownpercentmeans a 0-to-1 share would mis-render by a hundredfold with no type error. The SDK has noformatfield 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, with0.4 → 40%regressions.1.
<Dashboard>renders the metric tierpackages/codefly-ui/src/dashboard/dashboard.tsx. Contract unchanged: it takes a resolvedDashboardView, fetches nothing, reads no host context, imports no app code.number) →StatTile, gathered with its adjacent scalars into aKPIRow: the series total in the widget'sformatandunit, a sparkline over the buckets and a delta against the previous bucket when the metric is bucketed over time, and aMetricStateBadgedriven byseries.coverage.line/area/bar→MetricLineChart/MetricAreaChart/MetricBarChartover aChartSeries, 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:
nulltotal is not a zero.Metric.valuenow admitsnull, so the dash is a property of the primitive and not of a state mapping, and a newno_totalmetric state says so in words when coverage is otherwise complete.partialoutranks it, because a partial series is why its total is withheld.0.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 andcoverage: "partial"state, every scalar state above, the pending and failed states, the default layout, and each slot.2.
SolutionDashboard.tsxmounts itIt now draws nothing. What it keeps is what is genuinely the host's, composed around the renderer through four new presentational slots:
actions(the dashboard's own header)SortableGridlayoutrenderWidgetemptyThe 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:
tileTitles()helper reads[data-slot="card-heading"], [data-slot="metric-label"]instead ofcard-title— a series widget is titled by its Card, a scalar by its StatTile.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:ALLOWEDsaying 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-authorityis no longer a dependency of this app. Nothing undersrc/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 ofpackage.json, one out ofpackage-lock.json.Two deliberate departures from the upstream version of the rule, both because this tree proved them necessary:
export functionwalks past both.src/app/**/page.tsxand friends, since the kit also exportsPageandLayout. 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
Cardof 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 aCardbuilt on the kit'sCardRoot— 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
ALLOWEDentry 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— oneexport { … } 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-tierLineChart, the secondTrendLineChart. An import ofLineChartmeans 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
ALLOWEDhas six entries. Four are genuinely different things wearing a kit name (two hostDashboards that fetch, a page-rhythmLayout, a metric card that resolves its own metric). Two are real duplicates, labelled rather than fixed: the identical module-localMetricCardinevent-operations-page.tsxandjob-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 asStatTiletoday, which takes a numericMetricand has no caption slot; the entries say so and name what would have to change (StatTilecarries a description, or those counts becomeMetrics). 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.tsxhand-rolls a header the kit'sSectionwould draw. Same owner as theDashboard/MetricCard/Layoutentries; 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 sayingSolutionDashboard.tsxmounts the kit and owns only the queries, the viewer's arrangement and the ⓘ, and naming the gate.packages/codefly-ui/README.md— the./dashboardentry-point row and the Dashboard bullet name the metric tier,DashboardWidgetand 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
mainand this branch had dropped: compile-time exhaustiveness overWidgetVisualization(a sixth member passedtscand all 351 tests, and would have drawn a titled card with an empty body), and a scalar widget's declaredspanand its dashboard'scolumns, 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 throughsrc/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
998e17d5returned 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?: booleanbecamefailure?: "failed" | "refused",MetricStategainedforbidden, 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 of8590d329.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 newlabelAson the sharedStatTilerather than a restored host card.Independent re-review fixes (fourth commit)
The re-review at
b82cc700confirmed 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 → 503and200 → 403 → dropped connectionboth 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:
forbiddenwithheld 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.
StatTiletakes atrendoption colouring the sparkline from the chart token.Review
r2/x2and 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.Programover 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 toALLOWEDorDISPATCHERS.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
42stayed on screen; unmount mid-refresh and37came 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, wherereadOutcomeclassifies 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;ALLOWEDandDISPATCHERSare unchanged in content.One deliberate divergence, reported as such. The prescription said
removeQueries/setQueryData(key, undefined). Neither can express this:removeQueriesdestroys 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; andsetQueryDatareturns early when the new data isundefined(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 checkon every touched file;npm run generate:preview-stylesheetreproduces 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.mainis merged in (not rebased). Its release cutv0.0.94while this branch was open, which published kit0.12.6, so the kit moves to 0.12.7 — a patch, because the change is additive and a0.xminor breaks every remote pinned to^0.12.Not verified:
codefly ci run(the canonical service gate) was not run — it needs the Docker graph. Thepipelinevitest project was not run for the same reason; nothing here touches it.scripts/test-published-ui.mjsandtest-registry-ui.mjswere not run — they install from the package registry. The rendering has never been seen in a browser:codefly run solutionboots 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.tsxfailing 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 cleanmainworktree.One source-compatibility note for reviewers.
DashboardWidgetView.seriesis nowWidgetSeries | 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 readsview.widgets[i].series.totalneeds a?.. In-tree that was one assertion intypes.test.ts.fromDashboardDatastill only ever produces a non-null series.For the #1051 rebuild. The obligation this leaves is
failure?: "failed" | "refused"plusformat,unit,higherIsBetterand a nullableserieson the widget view, with schema tests for the nullability and for the accepted failure values;types.tsdefinesformat: "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