Skip to content

feat: group a solution dashboard into declared sections a viewer can arrange - #958

Merged
antoinetoussaint-byte merged 32 commits into
codefly-dev:mainfrom
crisfiorini-oss:feat/dashboard-sections
Oct 12, 2026
Merged

antoinetoussaint-byte merged 32 commits into
codefly-dev:mainfrom
crisfiorini-oss:feat/dashboard-sections

Conversation

@crisfiorini-oss

@crisfiorini-oss crisfiorini-oss commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Important

Maintainer update: rebuilt on #1043's renderer (2026-10-11)

Latest maintainer head: d7b490183 (review fixes, directly below). The first round starts at "What happened".

Everything between this note and the horizontal rule below was added by a maintainer. The author's original description follows the rule, unchanged; it describes the branch before this update, so read its renderer sections (WidgetChart, MetricFigure, the BarList restyle, the lone-series colour, formatShare) as history.

Review fixes (second maintainer round, on top of 31cfe26d7)

An independent deep review at 31cfe26d7 returned LAND, 0 blockers. It found #1043 intact (kit.test.ts identical, refusal deletion holding on the new + menu path too), the percent share scaled exactly once, and nothing of the author's work lost. It asked for the fixes below. Each fix has tests, and each mechanism was removed in turn to confirm a test fails without it.

Commit Finding What changed
fe679cc43 m1 (offset) The audit RPC keys time buckets in UTC (postgres_audit.go), keyed …T00:00:00+00 in any session zone. A session-zone key's offset moves across daylight saving, so a day the SDK fills could not know its offset, and the kit, which labels in UTC, already read a +02 midnight as the previous day. A UTC session (the default) gets identical keys. TestTimeBucketKeysAreUTCInAnySessionZone runs against real Postgres under Europe/Paris across the 2026 change and fails with the old expression.
50322d093 M1, m2, m1 SDK fill.
• M1: a complete, time-grouped count / distinct count / sum is fillable, and a derived metric reads its missing day as that input's 0. A rate whose numerator starts later, or never happens, now reads 0 and stays complete. Keys go in time order, the days between two inputs' ranges are combined, and a sum or difference of fillable inputs can feed a rate.
• m2: the fill runs through the end of the range, the bucket holding to, or today for an open query.
• m1: keys that disagree about their offset are left unfilled rather than given a guessed one.
442896fc0 m3, m6, m7 Headings: a widget inside a section is <h4>, under the section's <h3>; outside sections it stays <h3>. Card gained titleAs, StatTile labelAs="h4", DashboardWidget heading, and renderWidget a second argument { heading }. Stories DashboardSections and SectionHeader were added, plus a kit README note of what a remote sees in 0.12.7.
0afad20aa m3, adopted tests The host passes the heading on. The ⓘ's data range ignores filled zeros (otherwise "latest event" became today). Adopted the review's + menu refusal tests and stale-layout tests.
6c96c133f m5 @codefly/saas-plugin-manifest 0.1.2, with the root pin and the lockfile.
41766e2a2 docs AUDIT_METRICS.md and docs/plugin-manifest-schema.md.

One deliberate divergence from the review. Its test "gives a filled day the offset the server would have given it" expects the SDK to infer +02 for 30 March from returned keys carrying +01 and +02. The keys alone cannot settle that: 29 March is +01, 30 March is +02, and nothing in them says where the change falls. So the fix is at the owner: UTC keys, constant by construction. The SDK refuses to fill mixed offsets, and a test pins that refusal. Run unchanged against this tree, the review's own suite passes 65 of 67. The 2 failures are this test, which exists in two of its files.

A behaviour change worth knowing. "Telemetry incomplete." no longer appears for a difference or rate whose count input simply had no events; that now reads 0. It still appears when an input is partial or has no value over no events, such as an average. The host test was moved to that case. A series now ends on the current, in-progress bucket, so its value there is the value so far.

Release note (kit 0.12.7, for remotes):

  • A widget's format, compact and currency included, now reaches chart axes, readouts, data tables and table cells; before, only scalars used it.
  • Widgets inside a section are titled <h4>.
  • renderWidget receives { heading }.
  • Card has titleAs.
  • SortableBoard is exported.

Still open, stated rather than fixed (m4, m7):

Verification at the new head (Node 24.21.0; frontend steps from module/services/frontend/code):

  • npm ci passed.
  • tsc --noEmit exited 0.
  • npm run lint reported 0 errors and 39 warnings, main's count.
  • npm run test:pure passed 322 files, 4,467 tests.
  • npm run build exited 0.
  • The kit suite passed 1,443 tests, the SDK 106, and the manifest 141.
  • The preview stylesheet --check passed.
  • test:published-ui and check:plugin-allowlist passed.
  • Repo gates passed: naming, boundaries, kit-version and its tests (16), release-gate tests (111), module-verify, interface-docs, and story-trace.
  • In accounts, go test -race ./pkg/infra/auditmetricstest passed against postgres:17-alpine with AUDIT_METRICS_REQUIRE_DB=1, as CI runs it, and go test -tags pure ./pkg/infra passed.
  • The review's .review-958 suite, run unchanged against this tree, passed 65 of 67; the 2 failures are the divergence above.

Not verified:

  • Still no browser run.
  • codefly ci run was not run, nor was the pipeline project.
  • Accounts suites other than auditmetricstest and the pure pkg/infra tests were not run locally. Their sessions run in UTC, where the keys are unchanged.
  • No deployed database's zone was checked.

What happened

#1043 merged as 0f696ba3f, by owner decision, instead of this PR's renderer. It rewrote the same files (the kit's dashboard.tsx, types.ts, metric-tiles.tsx, index.ts, charts.tsx, SolutionDashboard.tsx and its test, the package manifests). Its design is the base and stays: the kit's <Dashboard> draws the metric tier, SolutionDashboard composes it through slots, the checker-backed gate in src/components/__tests__/kit.test.ts refuses a second renderer, failure?: "failed" | "refused", refusals that delete retained snapshots at the read, availability apart from freshness, the accent on --chart-1, titled scalars as <h3>.

origin/main was merged into the branch (43f97c027); nothing was rebased or force-pushed, and the author's commits are untouched. Every conflict was resolved to main's side, and this PR's features were then rebuilt on #1043's renderer in five commits on top:

Commit What
43f97c027 merge of main, conflicts resolved to #1043 (does not type-check alone; says so)
c779cc963 drop this branch's second renderer
98564bf44 kit: sections in the view model, DashboardSectionHeader, every mark written in the widget's format
7e2b3faae SDK: the one percent conversion, sections carried
95766c125 host: the viewer's arrangement composed through the kit's slots
31cfe26d7 docs

Dropped, because #1043 already does it or contradicts it

Kept as the author wrote it

The manifest schema, validator and conformance tests (Dashboard.sections, MetricWidget.section, unique ids, a widget's section must be declared, no section on a dashboard without sections, metric format); the per-day zero fill in runMetric and the derived-metric handling of filled zeros; dashboard-layout.ts version 2 and its tests; the kit's DropdownMenuItem hint; the SortableGroups and MenuHints stories.

SortableBoard is kept in the kit. It does not duplicate #1043's renderer: it is the kit's own SortableGrid generalised to several groups (SortableGrid is now a one-group board with unchanged props), and it is reached through #1043's layout slot. One overlap a reviewer may want to rule on: the kit's ./board Board (#1014) also moves cards between groups, but it is unordered columns keyed by a field with no swap and no group reordering, so neither can stand in for the other today.

Rebuilt on #1043's renderer

  • Kit view model (types.ts): DashboardView.sections?: DashboardSectionView[] ({ id, title, description? }, drawn in the order listed) and DashboardWidgetView.section?: string; fromDashboardData carries both.
  • Kit renderer (dashboard.tsx): the default layout heads each section with the new exported DashboardSectionHeader (an <h3> under the dashboard's <h2>), lays each section's widgets out exactly as Draw every dashboard with the kit's metric tier, and refuse re-implementing it (#1041) #1043 does, draws a widget naming no section first and untitled, and keeps a declared section with no widgets ("No widgets in this section."). A view with sections never shows the "No widgets" empty state, and the layout slot is called with each tile's section. A view without sections renders as before (tested).
  • Host (SolutionDashboard.tsx): the viewer's layout is resolved into the view the kit draws (the viewer's sections, order and titles; each widget in the section the viewer put it in). The host's controls go into the slots: layout is the kit's SortableBoard, each group headed by DashboardSectionHeader with the grip, the rename title, the section's + and its options menu; renderWidget is still Draw every dashboard with the kit's metric tier, and refuse re-implementing it (#1041) #1043's SolutionWidget mounting DashboardWidget; actions is the dashboard's + when it declares no sections. Moving tiles across sections, renaming, reordering and removing sections, adding empty ones, per-section + menus offering only removed declared widgets, the muted "No data yet." / "Telemetry incomplete." hints, and the arrow-key announcements all work as the author described. The layout remains under the existing STOPGAP browser-storage label.
  • The + menu's query is useWidgetSeries, the same hook and key as the tile's (and so the same refusal handling that deletes snapshots), enabled only while the menu is open.

The percent contract (decided here)

There is one conversion boundary, and both meanings are now explicit:

  • A data graph's format: "percent" (manifest and SDK schema) means the metric's value is a share from 0 to 1, because that is what a ratio produces and the host cannot infer it.
  • The kit's DashboardWidgetView.format: "percent" means the value is already in percentage points (unchanged from Draw every dashboard with the kit's metric tier, and refuse re-implementing it (#1041) #1043; its test still pins 45 → "45%").
  • runDashboard is the only place they meet. A widget whose metric is declared percent carries its points and total multiplied by 100 and format: "percent" (ResolvedWidget.format: WidgetValueFormat); byMetric keeps the shares. runDashboard is the only producer of ResolvedWidget, so the host and a remote using fromDashboardData both receive scaled values, and nothing after it scales again.
  • Display goes through one function: the kit now writes a widget's format on a chart's axis, readout and data table and on a table's values through formatMetricValue, the function StatTile already uses. Before, only scalars honoured it.

The branch before this update put the share meaning on ResolvedWidget.format, which fromDashboardData forwards structurally, so a remote would have drawn 0.4 as "0.4%". This change fixes that. The scaling does not live in the kit because the kit's percent already means percentage points for every StatTile caller. It does not live in fromDashboardData because the host never calls it.

Regression tests: the SDK resolves a 2/5 ratio to 40 on the widget and 0.4 on byMetric. The kit writes "40%" in the line, area and bar data tables and in a table, and leaves a plain number alone. The host renders a declared percent ratio as "40%" on the tile, the bar chart's data table and a table widget through the real SDK path, a plain ratio as 2.5, and never "0.4" or "0.4%". Removing the SDK scaling reds the SDK test and the host test, and removing the kit formatter reds four kit tests and the host test.

Two-row tiles, and #1044 / #1045

The rule is a host-only itemClassName on the SortableBoard grid. A line, area, bar or table tile gets row-span-2 and every tile fills its cell, so two number tiles stack beside a chart; a stack layout is untouched. The kit's default layout geometry is unchanged, and this PR adds no span or columns to the manifest, so it does not touch #1044's layout model in code. Removing the class reds the host test.

Open PR #1045 (for #1044) proposes the same sections / section vocabulary plus Dashboard.columns, MetricWidget.span and per-section columns. It also states that a preference "cannot change membership" of a section, which contradicts this PR's viewer arrangement as specified. Whichever lands second has to reconcile that rule, its lenient-validator change against this strict validator, and the { id, title, description? } section shape, which both PRs already share.

Obligation for #1051 (the A2UI catalog)

This PR grows the kit's view model again. #1051's catalog.ts and data schema must describe:

  • DashboardView.sections?: { id, title, description? }[] and DashboardWidgetView.section?: string (new here);
  • the fields Draw every dashboard with the kit's metric tier, and refuse re-implementing it (#1041) #1043 left: failure?: "failed" | "refused", format (the kit's MetricFormat, with percent in percentage points and now applied to every mark, not just scalars), unit, higherIsBetter, and series: WidgetSeries | null. Its schema tests must cover the nullability and the accepted failure values.

The SDK side also gained ResolvedWidget.section, DashboardData.sections and WidgetValueFormat.

Classification

  • Percent conversion in runDashboard: a fix at the owner. The SDK is the only producer that knows the data graph's share meaning.
  • Sections in the kit's view model, renderer and header: a feature at the owner. The kit draws; the host composes through Draw every dashboard with the kit's metric tier, and refuse re-implementing it (#1041) #1043's slots.
  • Browser-storage layout: the existing labelled STOPGAP, unchanged in scope (ADR 0007 user_settings field still missing).
  • No hacks. The duplication gate is unchanged: no new ALLOWED or DISPATCHERS entries, and kit.test.ts is byte-identical to main.

Verification (Node 24.21.0, module/services/frontend/code, at 31cfe26d7)

  • npm ci passed.
  • npx tsc --noEmit exited 0.
  • npm run lint reported 0 errors and 39 warnings, the same count as main and none in changed files.
  • npm run test:pure passed 321 files, 4,444 tests. Among them, kit.test.ts passed 64 tests and the host's SolutionDashboard.test.tsx passed 67: Draw every dashboard with the kit's metric tier, and refuse re-implementing it (#1041) #1043's 36 plus the ported ones.
  • npm run build (next build) exited 0.
  • The codefly-ui package suite passed 1,441 tests, saas-sdk passed 95, and saas-plugin-manifest passed 141.
  • npm run test:published-ui and npm run check:plugin-allowlist passed.
  • Biome is clean on every file this update changed.
  • The kit's preview stylesheet was regenerated after a no-change control: the generator run on main's sources reproduced main's committed bytes exactly. The new classes are those of SortableBoard, the menu hint and the section layout.
  • Repo gates passed: node module/tools/naming-gate.mjs check, scripts/check-boundaries.sh, node scripts/ci/kit-version.mjs check, node --test scripts/ci/kit-version.test.mjs (16) and scripts/ci/release-gates.test.mjs (111), module/tools/module-verify.mjs, interface-docs-gate.mjs check, and story-trace-gate.mjs tests.
  • Versions: no bump was needed. main already carries the unreleased @codefly-dev/ui / @codefly-dev/saas-ui 0.12.7 and @codefly-dev/saas-sdk 0.3.25, above what v0.0.94 published, and the kit-version gate passes. @codefly/saas-plugin-manifest stays at main's 0.1.1.

Host tests ported from the author's branch were adapted where they read the old card markup. Section headings are now found by the kit's dashboard-section-title slot. A tile's emptiness is read from the kit's "No data" / "Partial data" badges rather than the old card's sentences. The test that titled a number tile like the audit log was dropped, because #1043's StatTile owns that title.

Not verified

  • No browser run. Nothing in this update was looked at in a browser. Dragging within and across sections, dragging a section, touch, focus return after a move, the two-row geometry and the SortableGroups story are covered only by happy-dom tests.
  • codefly ci run, the canonical service gate, was not run. Neither was the pipeline vitest project, which needs live services.
  • A percent line or area chart's axis ticks were not checked visually. The data tables are asserted; the axis gets the same formatter.

Docs updated

  • module/AUDIT_METRICS.md: the zero fill and its limits, the percent boundary, and how sections are drawn.
  • module/docs/plugin-manifest-schema.md: the percent boundary and the zero fill beside format. Sections were already described there.
  • module/services/frontend/AGENTS.md: the viewer's arrangement through the slots, and why a widget's format is bound from the resolved answer.
  • packages/codefly-ui/README.md: sections, DashboardSectionHeader, SortableBoard, and the format reaching every mark.

Summary

A solution can now group its dashboard's widgets into titled sections, declared in its manifest's data graph: Dashboard.sections ({ id, title, description? }[], drawn in declared order) and MetricWidget.section. Until now the validator refused the key (dashboard has unknown field 'sections'), so /api/solutions/register answered 422 invalid_manifest.

This fits ADR 0007. The solution declares the sections. A viewer only groups and arranges the tiles in their own view: they move tiles between sections, rename, reorder and remove sections, and add empty sections of their own that they fill by dragging tiles in. Each + menu (the dashboard's, and each section's) still offers only the declared widgets the viewer removed, and a section's + puts the widget back into that section. No preference adds a metric the dashboard has no widget for. Beside each removed widget, a + menu now says, muted, when its tile would have nothing to show ("No data yet.", or "Telemetry incomplete." for a derived metric missing an input), and it asks only once it is opened. The layout stays in browser storage under the existing STOPGAP label.

A metric may also name a format. percent reads its value as a share from 0 to 1 and writes it as a percentage, so a rate of 0.4 reads "40%" instead of "0.4". The host cannot infer this, because a ratio is not always a share ("versions per memo" is one). number, the default, writes the value as it is.

A per-day count now shows a 0 on each day with no events. Before, those days vanished and the line ran straight over them, so ten events on ten days of a 24-day span drew as a flat line at 1.

A chart, bar list or table tile is now two rows tall, so two number tiles stack beside it. Before, a number beside a chart left about 160px empty under it.

A solution dashboard now draws its tiles with the same charts as the audit log page. Before, a solution dashboard drew with the kit's first chart set: one colour, no readout when you hover, no legend. The audit log page draws with the metric charts. Both pages now draw with the same components.

A dashboard without sections, or a metric without a format, validates, renders and saves as before.

Changes

Manifest schema and validator (feat(manifest))

  • saas-plugin-manifest/src/data-graph.ts gains DashboardSection, Dashboard.sections and MetricWidget.section. The validator checks each section's shape and that section ids are unique within a dashboard. A widget must name a declared section when its dashboard declares sections, and must name none when it declares none. An empty sections list is refused.
  • plugin.codefly.schema.json carries the same field rules, including the cross-field rule (an if/then/else on sections). The conformance suite checks that the schema and the validator agree on acceptance and on five new rejections.
  • saas-sdk/src/schema.ts mirrors the same shapes, and both packages export the section types.
  • The host registry parse now has tests showing it carries a sectioned graph through and refuses a widget that names an undeclared section. That refusal is the 422 path.
  • docs/plugin-manifest-schema.md documents the new keys and cross-field rules.
  • Nothing else in the repo validates these keys. The Go accounts service reads only dashboard.events (for declared audit types), and the dashboard-authoring spec (features/dashboard/model/validate.ts) is a separate shape. No Python code reads the data graph.

Kit: SortableBoard (feat(ui))

  • codefly-ui/src/dashboard/sortable-grid.tsx: SortableBoard holds tiles in groups under one drag context. You can swap tiles across groups. With onMove, a slot at the end of each group appears during a drag. With onMoveGroup, a group is dragged by a handle in its header. The groups sit in one column and only the pointer's height counts, so a drop above the first group lands before it and a drop below the last lands after it. A group can be fixed, and the board glides groups and tiles into place when they move.
  • SortableGrid is now a board with one group; its props and behaviour are unchanged. It keeps main's renderOverlay and its item list keyed by content, and both now also apply to the board.
  • New SortableGroups story, listed in ui-inventory.json.

Host dashboard (feat(frontend))

  • src/solutions/dashboard-layout.ts, layout version 2: a list of sections, each with its tiles and, if the viewer renamed it, the viewer's title. It also stores the widgets and sections the layout was made against (seen, seenSections), so a section or widget the solution declares later still appears, while one the viewer removed stays removed.
    • A version-1 layout (the flat list main writes today) is read by putting each tile in its declared section.
    • Any id the dashboard does not declare is dropped. That includes a metric: tile saved before ADR 0007 applied.
    • A viewer's section id is custom: plus 16 hex characters. Titles are trimmed and capped at 60 characters, and a new section is titled "New section".
  • src/solutions/SolutionDashboard.tsx:
    • Section headers show the title (click it to rename), the description, a grip for dragging or arrow keys, a + menu and an options menu (rename, remove).
    • An "Add section" button adds a section, and screen-reader announcements report section changes.
    • The single-widget graph each tile resolves drops section, because its throwaway dashboard declares no sections.
    • Each + menu item for a removed widget runs the tile's own query, through the same useWidgetSeries hook and query key, and only while the menu is open. It shows the tile's no-values rule (withoutValues, now shared by the tile and the menu) as a muted hint: "No data yet.", "Telemetry incomplete." (the tile says "Telemetry unavailable or incomplete.", or "Incomplete telemetry; total unavailable."), or "Total unavailable." for a number with no total across groups. It shows nothing while loading, on a query error, or when there is data, and the item stays selectable. The hint is a new optional hint prop on the kit's DropdownMenuItem. The kit knows nothing about metrics: the item keeps its label as its accessible name and uses the hint as its description. There is a new MenuHints story, listed in ui-inventory.json.
  • docs/plugin-manifest-schema.md gains one paragraph on what viewers can do with sections.

Metric value format (feat(manifest))

  • data-graph.ts and plugin.codefly.schema.json allow format (number or percent) on source and derived metrics and refuse any other value. The conformance suite checks that both accept it and both refuse an unsupported value on either kind. saas-sdk/src/schema.ts mirrors the type as MetricValueFormat.
  • runDashboard puts each widget's metric format on the resolved widget, and the kit's fromDashboardData carries it to DashboardWidgetView.
  • The kit's StatChart, BarList, LineChart and AreaChart take an optional formatValue for the tile's number, each bar's value and the y-axis ticks. The new formatShare writes a share as a percentage, to one decimal (0.4 → "40%", 17/22 → "77.3%"). The kit's shared <Dashboard> and the host's SolutionDashboard use it for a percent widget, tables included, and pass nothing for a plain number, so each chart keeps its own default. They now draw through WidgetChart (see Same charts as the audit log), which passes it on; the formatValue props on the old charts remain.
  • docs/plugin-manifest-schema.md names the key.

Per-day zeros (fix(sdk))

  • saas-sdk/src/datagraph/run.ts: runMetric fills a time-grouped count, distinct count or sum with a 0 at every bucket between the first and the last the RPC returned. The RPC returns no bucket for a day with no rows, and the kit's line chart spaces points by position, so with every bucket present its spacing is by date. Week and month buckets step by a week and a month.
  • An avg, min, max or percentile has no value over no events, so its empty buckets stay out, as before. A bucket the RPC returned whose value was dropped as unknown stays out and keeps the series partial. A key that is not a date, or is off the bucket's step, leaves the points as they came. Totals do not change. Days before the first bucket and after the last are not filled.
  • A derived metric over the zeros reads a day no input had events on as it did before them, not as missing telemetry (fix(sdk)). Before this, a ratio of two per-day counts read 0/0 on such a day and marked the whole series "Partial telemetry", and a count combined with an avg did the same. combineDerived now treats a key where every operand is a filled zero, or missing, as a day with no events: a ratio there has no value and the series stays complete, and a sum or difference of the zeros is 0. A day with events in the denominator and none in the numerator reads 0.

Arrow keys on a tile's grip (fix(frontend))

  • A press that cannot move the tile (up on the first, down on the last) no longer saves the layout, so it does not pin the viewer to their own layout. A press that takes a tile into another section now tells a screen reader where it went ("Total logins moved to the start of Trends.").

Two-row tiles (feat(frontend))

  • The kit's SortableBoard gains an optional itemClassName(id): classes for one tile's list item, which is its group's grid item. It knows nothing about charts.
  • SolutionDashboard.tsx gives a line, area, bar or table tile row-span-2, and every card h-full. The grid works out the row heights from what the tiles hold, so no height is fixed. The grid does not pack later tiles into earlier holes, so the tiles keep the viewer's order, and a section with an odd cell count still ends on a half-empty row.

Same charts as the audit log (fix(frontend))

  • codefly-ui/src/dashboard/widget-chart.tsx: WidgetChart draws one resolved widget with the metric charts. A line widget draws with MetricLineChart, an area widget with MetricAreaChart, a bar widget with BarList, a number widget with MetricFigure, and a table as a table. The kit's <Dashboard> and the host's SolutionDashboard both draw a tile's values with it; each still says for itself what a tile with no values shows.
  • MetricFigure (metric-tiles.tsx) is StatTile's value, delta and sparkline without the card, for a tile that already has its own header. StatTile now draws its body with it.
  • BarList (charts.tsx) takes the audit log's look: the label and the value above the bar, a thinner bar. The audit page's top lists (src/components/dashboard.tsx) use it instead of a copy in the host, and that copy (Bars) is removed.
  • LineChart, AreaChart and StatChart stay exported, marked @deprecated, with a pointer to the chart that replaces each.
  • percent: the figure's percent expects 45 for 45%, and a percent metric is a share from 0 to 1. WidgetChart scales the share once, so a ratio of 0.4 reads "40%".
  • A number tile's title (SolutionDashboard.tsx) takes the size and weight the audit log's StatTile labels a figure with, the small body text, not a card heading, in the foreground colour rather than StatTile's muted grey, which read too faint beside the figure. Chart, bar and table tiles keep the card heading, as the audit log's chart cards do. A declared line still draws as a line and area as a filled area: the manifest makes that the solution's choice, so the host does not draw a line as an area to match the audit log's chart.
  • ui-inventory.json gains an entry for widget-chart.tsx, and the entry for the kit's dashboard.tsx imports it in place of charts.js.

Review fixes (fix(sdk), fix(frontend), fix(ui))

  • A per-day sum whose days with events all had an unknown value read a total of 0, because the filled zero on the day between them became the series' only point. toSeries (saas-sdk/src/datagraph/run.ts) now gives a series whose points are all filled zeros no total, as it had none before the fill.
  • When a solution retired the last section a viewer had kept, parseLayout (src/solutions/dashboard-layout.ts) fell back to the declared default and brought back every widget the viewer had removed. It now gets the default's first section back, puts the viewer's tiles there, and keeps their removals.
  • A dashboard's accent set only --primary, which the bar list colours from, while a line or area widget's metric chart colours its one series from a chart token. The kit's <Dashboard> now sets both from the accent (--primary and --chart-5, below).
  • A chart of one series drew it in --chart-1, the lightest grey in the default skin's light-to-dark palette, so a solution's line was faint beside the audit log's chart, whose dark outline is its topmost series in the last colour. chartSeriesColor (metric-chart.tsx) now gives a lone series the palette's last colour; two series or more keep the palette order. This applies to every metric chart with one series, the audit log's included when it is filtered to one category.

Versions: @codefly-dev/ui and @codefly-dev/saas-ui go to 0.12.5 (they share a version) and @codefly-dev/saas-sdk to 0.3.17, one above main's, with the root pins and lockfile updated to match. @codefly/saas-plugin-manifest stays at 0.1.0: the kit-version gate does not cover it, and earlier changes to it were not bumped either.

Testing

All runs used Node 24.17.0, from module/services/frontend/code unless another directory is named. The branch is rebased onto main at 7fecf40; the rebase had no conflicts.

  • npx vitest run --project pure: 294 files, 3742 tests passed. Two new host tests check that an arrow key that moves nothing leaves storage empty, and that an arrow-key move into another section is announced; both failed before the grip fix.
  • packages/codefly-ui: npx vitest run src/dashboard: 9 files, 104 tests passed. One checks that itemClassName lands on the tile's list item; the host checks that a line and a bar tile span two rows and a number does not. The board tests cover:
    • swapping tiles across groups, and dropping on a group's slot;
    • dropping a group above the first group, below the last, and off to the side;
    • fixed groups;
    • renderOverlay and its default.
  • packages/saas-plugin-manifest: npx vitest run: 141 tests passed. Cases added:
    • the validator accepts sections, including an empty one;
    • it refuses a section on a dashboard with none, a missing section, an unknown section, a duplicate id, a bad id, an empty title, an unknown field and an empty list;
    • the schema and the validator agree on each case;
    • both accept format on a source and a derived metric, and both refuse an unsupported format on either.
  • packages/saas-sdk: npx vitest run: 92 tests passed. They include one that checks runDashboard carries each widget's metric format, and none for a metric without one. Ten check the per-day zeros:
    • a count gets a 0 on each missing day, with its total unchanged;
    • a distinct count and a sum are filled too;
    • an avg, min, max and percentile are not;
    • a day the RPC returned with an unknown value gets no 0 and the series stays partial;
    • week and month buckets step by a week and a month;
    • a key off the step, or an impossible date, leaves the points as they came.
      With the fill switched off, the five tests that expect zeros fail and the rest pass.
      One more test checks that a per-day ratio and a difference over a day no input had events on stay complete, and that a day with events only in the denominator reads 0. It failed before the derived-metric fix ("partial"); main gives "complete" for the same graph.
  • The format tests check that:
    • formatShare writes 0.4 as "40%", 17/22 as "77.3%", 0 as "0%" and 1 as "100%";
    • StatChart and BarList write values with the caller's formatter, and LineChart hands it to its y axis;
    • fromDashboardData carries the format, and the kit's <Dashboard> writes a percent number and percent bars as percentages and a plain number as a number;
    • the host draws a percent ratio as "40%" and a plain ratio as 2.5, and never as "0.4";
    • the registry parse carries a metric's format through and refuses an unsupported one (the 422 path).
  • The layout and dashboard tests cover:
    • version-2 layouts written and read back, and migration from version 1 (main's current stored shape), including one that holds metric: tiles;
    • adding, renaming, removing and reordering sections;
    • dragging and arrow-key moves across sections;
    • dropping a section above the first or below the last;
    • each section's + offering only removed widgets and putting them back in that section;
    • a viewer's section adding nothing.
  • The + menu tests (SolutionDashboard.test.tsx, "what a + menu says about a removed widget", 6 tests) check that:
    • a removed widget whose metric returns no rows is described "No data yet.", and adding it back draws its tile at once from the answer the menu got;
    • a removed derived widget missing an input is described "Telemetry incomplete.", and its tile says "Telemetry unavailable or incomplete.";
    • a removed widget with data has no hint;
    • there is no hint and no spinner while the answer is loading, and the hint appears once the answer arrives;
    • there is no hint when the query fails;
    • nothing is asked about a removed widget until a menu opens.
      I broke the feature on purpose and ran the tests, then restored it:
    • with the feature removed, all 6 fail;
    • reading "loading or failed" as no data fails the loading and error tests;
    • labelling a widget that has data fails the with-data test;
    • giving a derived metric's gap the no-data words fails the derived test;
    • querying removed widgets on page load fails the before-open test;
    • giving the menu its own query key fails the two tests that add a tile back.
  • packages/codefly-ui: one new test in src/layout/__tests__/compound-controls.test.tsx checks that a hinted item keeps its label as its name and gets the hint as its description. It fails if the hint is not hidden from the name, or if the description link is removed. The same file had 7 tests pass.
  • For the format commit, npx eslint over its changed files: 0 errors. For the earlier commits, npm run lint: 0 errors. The 30 warnings are all patterns already on main (anonymous story default exports, react-hooks/incompatible-library on tables); none is in new code.
  • npm run typecheck: passed. After the format commit, npx tsc --noEmit with the packages rebuilt: passed.
  • npx biome check over the files the format commit changes: its new lines are clean. Every file with findings has the same count on the commit before it: the files named below, plus codefly-ui/src/dashboard/charts.tsx, dashboard.tsx, format.ts and __tests__/charts.test.tsx and __tests__/types.test.ts, which carry long lines and import order from main. For the earlier commits, over their changed files: 17 of 24 files are clean. The other seven (codefly-ui/src/dashboard/index.ts, saas-sdk/src/index.ts, plugin.codefly.schema.json, ui-inventory.json, src/solutions/__tests__/registry.test.ts, codefly-ui/src/layout/__tests__/compound-controls.test.tsx, codefly-ui/stories/primitives.stories.tsx) fail the same way on origin/main: whole-file import ordering or formatting, and in the last two, lint findings on lines this PR does not touch. The new lines follow each file's existing style.
  • For the chart commit, from module/services/frontend/code under Node 24:
    • npx vitest run --project pure: 294 files, 3747 tests passed. The host test that read a tile's title now reads the tile's own title, because a chart also names its hidden data table after it.
    • npm run typecheck: passed. npm run test:published-ui: passed.
    • npx eslint over the changed files: clean.
    • npx biome check: no new findings beyond those already on the unchanged files. One noArrayIndexKey finding moved with the bar list from the host into the kit.
    • The naming gate and the kit-version gate passed.
  • npm run check:plugin-allowlist: passed. npm run prepare:frontend leaves the tree clean.
  • npm run test:published-ui: passed ("Packed UI declarations and generic consumer rendering passed").
  • node --test scripts/ci/kit-version.test.mjs passed (16 tests), and node scripts/ci/kit-version.mjs check passed, reporting @codefly-dev/ui@0.11.5, @codefly-dev/saas-ui@0.11.5, @codefly-dev/saas-sdk@0.3.16.
  • scripts/check-boundaries.sh: clean. node module/tools/naming-gate.mjs check: clean. The message mode passed on each commit message and on this body.
  • No Go or Python validator of these keys exists, so no Go or Python tests were affected.
  • On a local stack running this branch as the host, with a solution whose graph declares sections:
    • every tile's number matched a SQL count over the same audit rows;
    • after the format commit, with six rates declared percent, the solution registered and the rates read 40%, 77.3%, 71.4%, 77.3%, 81.1% and 81.8%, while "versions per memo" read 2.227;
    • after the per-day fix, a per-day count with ten events on ten days of a 24-day span drops to 0 on the empty days, instead of drawing a flat line at 1;
    • after the two-row change, at 1440px wide, no tile is shorter than the space it sits in, and the only empty cells are the last cell of a section with an odd cell count. Before it, every number tile beside a chart or bar list had a gap under it.
  • Before the format commit, in the in-app browser (Chromium), with a mouse:
    • removing a tile and opening its section's + showed the tile with "No data yet." beside it when its metric had no rows, and with nothing beside it when it had data;
    • adding it back drew it with its data;
    • dragging one tile onto another in the same section swapped them;
    • the arrow keys on a grip moved a tile one place;
    • removing a section moved its tiles into the next one;
    • the arrangement survived a reload;
    • at phone width (375 px), the page did not scroll sideways.
  • After the chart commit, on a local stack, a reference solution's dashboard (a line, a bar list and a number) and the audit log page draw with the same charts, and dragging tiles still works.
  • After merging main (its own commit, plus one that regenerates the kit's committed preview stylesheet for the classes the dashboard components use), under Node 24:
    • npx vitest run --project pure: 310 files, 4072 tests passed. Before the stylesheet commit, the preview-stylesheet test failed twice (stale, and a missing opacity-70); it passes on main alone.
    • npm run typecheck, npm run test:published-ui, npm run check:plugin-allowlist, the kit-version gate, the naming gate and scripts/check-boundaries.sh passed.
    • eslint: no errors on the changed files. biome: the same findings as main's copies of those files, none new.
    • Versions: @codefly-dev/ui and @codefly-dev/saas-ui 0.12.5, @codefly-dev/saas-sdk 0.3.17, each one above main's; the lockfile is main's, regenerated.
  • After merging main again (its shared navigation and lifecycle primitives; the only extra conflict was ui-inventory.json, where both entries are kept), under Node 24: npx vitest run --project pure: 312 files, 4146 tests passed; typecheck, published-ui, plugin allowlist, kit-version gate, naming gate and scripts/check-boundaries.sh passed; eslint and biome report nothing new.
  • For the lone-series colour: a chart test checks a lone series strokes in var(--chart-5) and two series keep --chart-1 and --chart-2; it fails without the change. npx vitest run --project pure: 312 files, 4148 tests passed; typecheck and npm run test:published-ui passed; eslint is clean and biome reports nothing new. Checked on the local stack beside the audit log.
  • For the number tile titles: a host test checks that a number tile's title carries the metric label style, not muted, and a chart tile's does not. npx vitest run --project pure: 310 files, 4076 tests passed; npm run typecheck and the naming gate passed; eslint and biome are clean on the two changed files. Checked on the local stack at desktop width beside the audit log.
  • For the review fixes, under Node 24: each fix has a test that fails without it (a total of 0 where none was observed; the default layout, with the removed widget, where one section was expected; no --chart-1 on the accented dashboard) and passes with it. npx vitest run --project pure: 310 files, 4075 tests passed. npm run typecheck, npm run test:published-ui, the kit-version gate and the naming gate passed; eslint is clean on the changed files and biome reports nothing new on them.

Not verified

  • A review suspects the board's slot changes height when a drag starts, making the tiles jump; happy-dom cannot show it, so it needs a check in a real browser.
  • In a real browser I dragged only within one section, with a mouse. Dragging across sections, dragging a section, touch activation and the SortableGroups story in the explorer are unexercised outside happy-dom.
  • I did not check keyboard focus return after a move against a real browser. happy-dom keeps focus on a node React moves, where a browser drops it.
  • The 422 path for a malformed sectioned or formatted graph is covered only at the registry-parse level. The accepted path ran end to end on the local stack.
  • I did not check visual baselines under external skins, and did not look at a percent line or area chart's y axis in a browser; it is covered only by the test that the formatter reaches the axis.
  • I saw the hint in the in-app browser but did not check its colour when an item is focused. The app's query client keeps an answer fresh for 30 seconds, so a tile added back within that window draws without asking again. The tests use no fresh window: there, the tile draws at once from the cache and then refetches.
  • I did not run the pipeline test project; it needs live services.

Known behaviour

  • A viewer's saved layout wins over later declaration changes, on purpose. A widget the solution moves to another section stays where the viewer's saved layout has it. If the solution drops every section the viewer kept, the layout gets the declared default's first section back with the viewer's tiles in it, and the widgets the viewer removed stay removed.

Open questions

  • A dashboard with declared sections has no + in its own header, only one per section, as in the original design. Should the header also get a + that puts a removed widget back into its declared section?
  • The layout is still the labelled browser-storage stopgap. When the ADR 0007 user_settings field lands, the version-2 shape moves there as it is. There is no tracking issue for that field yet.

🤖 Generated with Claude Code

crisfiorini-oss and others added 8 commits October 2, 2026 14:41
A solution with many metrics wants an overview band above the detail, but
the validator refused any grouping: a dashboard with `sections` failed
`dashboard has unknown field 'sections'`, so the host answered the
registration with 422 invalid_manifest.

A dashboard may now declare `sections` (each an `id`, a `title` and an
optional `description`, in the order they are drawn), and each widget then
names its `section`. A section may hold no widgets. A dashboard without
sections is unchanged, and none of its widgets may name one.

- data-graph.ts: `DashboardSection`, `Dashboard.sections`,
  `MetricWidget.section`; the validator checks each section's shape, unique
  section ids per dashboard, and that a widget names a declared section
  exactly when its dashboard declares sections.
- plugin.codefly.schema.json carries the same field rules, including the
  cross-field one (if/then/else on `sections`); the conformance suite pins
  that the schema and the validator agree.
- saas-sdk's mirror of the shapes (src/schema.ts) and both packages' type
  exports gain the section types. saas-sdk 0.3.16.
- The host's registry parse carries a sectioned graph through and refuses a
  widget that names an undeclared section.
- docs/plugin-manifest-schema.md names the new keys and rules.

No other validator reads these keys: accounts parses only a dashboard's
`events` (for declared audit types), and the dashboard-authoring spec is a
separate shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SortableGrid rearranges one list of tiles. A dashboard with sections needs
the same drag across several groups, and groups that move themselves.
SortableBoard is that, and SortableGrid is now the board with one group, so
its behaviour and props are unchanged.

- One drag context over every group: a tile dropped on a tile in another
  group swaps with it, and previews and glides like a swap within a group.
- `onMove`: while a tile is dragged, every group ends with a tile-sized
  slot, and a drop there moves the tile to the end of that group. Without
  it the board offers no slot.
- `onMoveGroup`: a group is dragged by a handle its header spreads (for
  instance a grip by its title). The groups are one column, so a drop
  inserts: the nearer half of a group decides before or after it, above the
  first group is before it and below the last after it, and a line marks the
  place. No tile is a drop target meanwhile. A `fixed` group stays put and
  nothing lands before it.
- Every group and tile glides from where it was drawn when the order
  changes, a tile that moved to another group too; a tile glides with its
  group rather than on its own.
- Screen-reader announcements name the group a tile moves to, and where a
  group lands.
- `renderOverlay` and the content-keyed item list work as they do on
  SortableGrid.

A SortableGroups story shows it, and the UI inventory lists the story.
@codefly-dev/ui and @codefly-dev/saas-ui 0.11.5 (co-versioned).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd let a viewer arrange them

A solution dashboard that declares sections is drawn as one titled group
per section, in declared order, each with its description and its own +
menu. Sections come from the declaration; a viewer only groups and arranges
the tiles in their own view:

- drag a tile onto a tile in another section to swap the two, or onto the
  slot that ends every section during a drag to move it there; the grip's
  arrow keys cross a section boundary;
- rename a section (from its title or its options menu), remove one (its
  tiles move to the section above, or below for the first), reorder
  sections by dragging a section's grip or with its arrow keys, and add
  sections of their own after the last. A viewer's section starts empty
  and is filled by dragging tiles in.

Each + menu, the dashboard's and a section's, offers only the declared
widgets the viewer removed, and a section's + puts the widget back in that
section. No menu offers a metric the dashboard has no widget for, and no
section adds one (ADR 0007). A dashboard without sections draws and saves
as before, with its untitled group first.

The layout moves to version 2 in the same per-viewer key: sections, each
with its tiles and the viewer's title if they gave one, plus the declared
widgets and sections it was made against. So a section or widget the
solution declares later still appears, while one the viewer removed stays
removed. A version-1 layout (a flat list of tiles) is read by putting each
tile in its declared section; an id the dashboard does not declare, such as
a `metric:` tile saved before ADR 0007 applied, is dropped. A viewer's own
section id is `custom:` and 16 hex characters, and its title is trimmed and
capped at 60 characters.

The layout still lives in browser storage, under the existing STOPGAP
label. docs/plugin-manifest-schema.md says what a viewer can do with
sections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…no data yet

A + menu lists the declared widgets the viewer removed, but nothing in it
said whether a tile would have anything to show once added back. Each item
now says so, muted, beside the widget's title, with the tile's own rule:

- "No data yet." when the widget's metric returned no rows;
- "Telemetry incomplete." when a derived metric lacks an input, where the
  tile says "Telemetry unavailable or incomplete." (or, for a number,
  "Incomplete telemetry; total unavailable.");
- "Total unavailable." for a number with no total across groups;
- nothing when there is data, while the answer is loading, or when the
  query fails, so an error never reads as "no data".

The rule is one function the tile and the menu share. An item asks the
tile's own query, through the same hook and query key, and only while its
menu is open, as the tile's info popover does; so nothing is asked about a
removed widget on page load, and a tile added back draws at once from the
menu's answer. Items stay selectable either way, and the menus still offer
only removed declared widgets (ADR 0007).

The kit's DropdownMenuItem gains an optional `hint`: short muted text after
the label. It describes the item rather than naming it, so the label alone
stays the item's accessible name. A MenuHints story shows it, and the UI
inventory lists the story. @codefly-dev/ui stays at 0.11.5, already bumped
in this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rate on a solution dashboard read as a bare fraction: a win rate of four in
ten showed "0.4", and 17 of 22 showed "0.773". The host could not write it as
a percentage on its own, because a ratio is not always a share: "versions per
memo" is a ratio too, and reads 2.2.

A metric, source or derived, may now name a `format`. `number`, the default,
writes the value as it is. `percent` reads it as a share from 0 to 1 and writes
it as a percentage, to one decimal: 0.4 reads "40%", 17/22 reads "77.3%".

- data-graph.ts and plugin.codefly.schema.json allow `format` on both metric
  kinds and refuse any other value; the conformance suite pins that they agree.
  saas-sdk mirrors the type (`MetricValueFormat`).
- runDashboard puts each widget's metric format on the resolved widget, and
  the kit's fromDashboardData carries it to its view model.
- The kit's StatChart, BarList and Line/AreaChart take an optional
  `formatValue`: the tile's number, each bar's value and the y-axis ticks.
  `formatShare` writes a share as a percentage. The kit's shared <Dashboard>
  and the host's solution dashboard both use it for a percent widget, tables
  included, and leave a plain number to each chart's own default.
- docs/plugin-manifest-schema.md names the key.

A host that predates `format` refuses a graph that names it, as one that
predates sections refuses sections; both ship in the same release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A per-day line chart dropped every day with no events and drew a straight
line over it. The audit RPC returns no bucket for a day with no rows, and the
kit's line chart spaces its points evenly by position, not by date. So ten
closes on ten days across a 24-day span drew as a flat line at 1, which reads
as one close a day, and the x axis compressed the empty days away.

runMetric now puts a 0 at every day between the first and the last day the
RPC returned, for an aggregation that is 0 over no events: count,
count_distinct and sum. Week and month buckets step by a week and a month.
With every bucket present, the even spacing is spacing by date.

- avg, min, max and percentile have no value over no events, so a bucket
  with none stays out, as before.
- A bucket the RPC did return, whose value was dropped as unknown (events
  that day, none carrying the field), stays out and keeps the series
  partial. It is not a day with no events.
- A key that does not read as a date, or one off the bucket's step from the
  first, leaves the points as they came.
- Totals do not change: the zeros add nothing.
- Only the span between the first and the last returned bucket is filled,
  never the days before or after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… beside it

A section's grid lays tiles out in rows, and a row is as tall as its tallest
tile. A number tile is about 120px and a line chart about 282px, so a number
beside a chart left some 160px empty under it.

A line, area, bar or table tile now spans two rows of its section's grid, and
every card fills its cell. Two number tiles stack beside a chart, and the grid
works out the row heights from what the tiles hold, so no height is fixed. The
tiles keep the order the viewer gave them: the grid does not pack later tiles
into earlier holes. So a section with an odd number of cells still ends on a
half-empty row.

The kit's SortableBoard gains an optional `itemClassName(id)`: classes for one
tile's list item, which is its group's grid item. It knows nothing about
charts; the host says which tiles are tall.

Measured on a local host at 1440px wide: before, the page had a gap under
every number tile beside a chart or bar list; after, no tile is shorter than
the space it sits in, and the only empty cells are the last cell of a section
with an odd cell count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts on

The per-day zeros reached derived metrics. A ratio of two per-day counts
with a day on which neither had events read 0/0 there and marked the whole
series partial, so its tile said "Partial telemetry" where it said nothing
before the zeros. A derived metric over a count and an avg read the avg as
missing on the count's filled day and did the same.

Before the zeros no input returned such a day, so it was no missing
telemetry. runMetric now marks the zeros it puts in, and combineDerived
treats a key where every operand is one of them, or missing, as a key with
no events: a ratio there has no value and does not mark the series
partial, and a sum or difference of the zeros is 0, marked the same way.
A key where any input returned a bucket is combined as before, so a day
with events in the denominator and none in the numerator reads 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@crisfiorini-oss
crisfiorini-oss force-pushed the feat/dashboard-sections branch from 0fe584d to ccf0978 Compare October 2, 2026 18:55
…say where one crossed into another section

The arrow keys on a tile's grip saved the layout on every press, even one
that could not move the tile (up on the first tile, down on the last). A
save pins the viewer to their own layout, so a press that changed nothing
stopped the dashboard following its declared default. The grip now saves
only when the tile's section or place changes, as the section grip and
rename already do.

A press that took a tile into the next or previous section said nothing to
a screen reader, though a section move and a drag both do. It now says
"<tile> moved to the start of <section>." going down, or "to the end of"
going up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@crisfiorini-oss
crisfiorini-oss force-pushed the feat/dashboard-sections branch from 81d2fb9 to 0e84d23 Compare October 2, 2026 20:09
…udit log uses

The kit carried two chart sets. The audit log draws with the metric charts
(KPIRow and StatTile, MetricAreaChart): colour from the --chart palette, a
hover readout, a legend, days lined up by label. A declared dashboard, in the
host's solution page and in the kit's own Dashboard, still drew with the first
set (StatChart, LineChart, AreaChart), so the two pages looked unrelated.

WidgetChart draws one resolved widget with the metric charts, and both the kit
Dashboard and the host's solution page draw a tile's values with it.
MetricFigure is StatTile's value, delta and sparkline without the card, for a
tile that already has its own header. BarList takes the audit log's look, and
the audit log draws its top lists with it instead of a host copy. The old
LineChart, AreaChart and StatChart stay exported, marked deprecated.

A percent metric is a share from 0 to 1, and the figure's `percent` expects 45
for 45%, so WidgetChart scales the share once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crisfiorini-oss and others added 9 commits October 6, 2026 12:35
Conflicts were version numbers only. This branch changes the content of the
three published kit packages beyond main, so each takes main's version plus one
patch: @codefly-dev/ui 0.12.1 -> 0.12.2, @codefly-dev/saas-ui 0.12.1 -> 0.12.2,
@codefly-dev/saas-sdk 0.3.16 -> 0.3.17 (main is already at 0.3.16, the same
number this branch had bumped to, so it moves too). The host pins follow, and
the saas-plugin-contract pin takes main's 2.4.0 (this branch does not touch the
contract). package-lock.json is main's, regenerated from the resolved
package.json files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ard classes

The kit's stories and dashboard components on this branch use utility classes
(opacity-70, inset-x-0, -top-1.5, -bottom-1.5 and others) that main's committed
preview stylesheet does not define, so preview-stylesheet.test.tsx failed on the
merge ("is not stale" and "defines every class the kit's stories render"); it
passes on main alone. Ran generate-preview-stylesheet.mjs; --check is clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d zeros

A per-day sum whose days with events all had an unknown value had no total.
Filling the day between them with a 0 gave the series one point, so the
total became 0 and the number tile read 0 over amounts it never observed.
A total now counts only points something was observed on: a series of
filled zeros alone has none, as it had none before the fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y kept is retired

When a solution retired the last section a viewer had kept, the saved
layout fell back to the declared default, and every widget the viewer had
removed came back. The layout now gets the default's first section back
instead, the viewer's tiles go there, and a widget they removed stays
removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dashboard's accent set only `--primary`. The bar list colours from it,
but the metric charts a line or area widget now draws with colour their
series from `--chart-1`, so with an accent the bars took it and the lines
did not. The accent now sets both tokens on the dashboard's subtree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A solution dashboard's number tile was titled as a card heading: larger,
darker and in the heading font. The audit log's number tiles (the kit's
StatTile) label the figure in small, muted body text. A number tile's title
now reads the same way; chart, bar and table tiles keep the card heading,
as the audit log's chart cards do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts were version numbers and the UI inventory. This branch changes the
content of the published kit packages beyond main, so each takes main's version
plus one patch: @codefly-dev/ui 0.12.4 -> 0.12.5, @codefly-dev/saas-ui
0.12.4 -> 0.12.5, @codefly-dev/saas-sdk 0.3.16 -> 0.3.17 (main did not move it).
The host pins follow, and the contract, manifest and plugin-react pins take
main's 2.4.1, 0.1.1 and 0.4.2. package-lock.json is main's, regenerated from
the resolved package.json files.

ui-inventory.json keeps both sides: main's new accordion entry beside this
branch's widget-chart.tsx entry; the kit dashboard.tsx entry already imports
./widget-chart.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A chart of one series drew it in `--chart-1`. The default skin's palette
runs light to dark, so a solution dashboard's line was a faint grey (87%
lightness) beside the audit log's chart, which reads dark because its
topmost series, the total outline, takes the last colour. A lone series now
takes that last colour too; two series or more keep the palette order.
A dashboard's accent now sets `--chart-5`, the colour its one-series charts
draw in, instead of `--chart-1`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A number tile's title took StatTile's muted grey with its label size, and
beside the figure it read too faint to scan. It keeps the label size and
weight and now takes the foreground colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte antoinetoussaint-byte added the working Claimed by a lazybox agent; cleared when the session ends. label Oct 12, 2026
@antoinetoussaint-byte

antoinetoussaint-byte commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

lazybox has finished working on this. claude (Opus 5.5) held it from 2026-10-12 00:54 UTC until 2026-10-14 15:03 UTC. The working label has been removed; this comment is the record.

claim detail
Lazybox-Claim-Device: effacd542b611010656e
Lazybox-Claim-Session: 015d4a49b1
Lazybox-Claim-Workspace: github-codefly-dev-module-saas-starter-958
Lazybox-Claim-Agent: claude
Lazybox-Claim-Model: Opus 5.5
Lazybox-Claim-Started: 2026-10-12T00:54:14Z
Lazybox-Claim-Heartbeat: 2026-10-14T15:03:35Z
Lazybox-Claim-Expires: 2026-10-12T08:07:15Z
Lazybox-Claim-Released: 2026-10-14T15:03:35Z

antoinetoussaint-byte and others added 13 commits October 11, 2026 20:55
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>
… format

The kit's view model gains sections: DashboardView.sections ({id, title,
description?}[], drawn in the order listed) and DashboardWidgetView.section.
<Dashboard>'s default layout heads each section with the new exported
DashboardSectionHeader (an <h3> under the dashboard's <h2>), lays each
section's widgets out exactly as before, draws a widget naming no section
first and untitled, and keeps a section no widget names, saying it is
empty. A view with sections is never "no widgets": it goes to the layout,
and a host's `layout` slot receives each tile's section. A view without
sections draws as it did. fromDashboardData carries both fields.

DashboardSectionHeader takes the host's controls without the host drawing
a heading of its own: a leading handle, actions, an `editing` node drawn in
place of the heading, a `label` naming the heading when its title is a
control, and children under the description.

A widget's format now reaches a chart's axis, readout and data table and a
table's values, through formatMetricValue, the function StatTile writes a
scalar with; before, only the scalar honoured it. The kit's `percent`
contract is unchanged: the value is already a percentage.

The kit's preview stylesheet is regenerated; a no-change control on main's
sources reproduced main's committed bytes first. The new classes are those
of SortableBoard, the menu hint and the section layout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… sections

A data graph's `format: "percent"` reads a metric's value as a share from 0
to 1, because that is what a ratio produces. The kit's view reads its
`percent` as percentage points. This branch had put the graph's share
semantics on ResolvedWidget.format, which fromDashboardData forwards
structurally, so a remote drawing runDashboard's output would have written
0.4 as "0.4%".

runDashboard is now the one place the two meet: a widget whose metric is
declared percent carries its series multiplied by 100 and format "percent"
(WidgetValueFormat, the kit's convention); byMetric keeps the shares, and
nothing downstream scales again. A test pins 0.4 → 40 on the widget and
0.4 on byMetric, and fails with the scaling removed.

DashboardData also carries the dashboard's sections and each widget's
section, so a remote drawing its own dashboard with the kit gets them.

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>
AUDIT_METRICS.md says where a time-grouped count is zero-filled and what is
not, that a widget's format reaches every mark, that the data graph's
percent (a share) becomes the kit's (percentage points) once in
runDashboard, and how a view's sections are drawn. The manifest schema doc
names the same boundary and the zero fill beside the `format` key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AggregateAuditLog keyed a time bucket with to_char(date_trunc(grain,
created_at), '...OF') in the database session's zone. Under a zone with
daylight saving the offset moves ("+01" then "+02"), so:

- the SDK's zero fill, which has to name the buckets the aggregate did not
  return, could not know which offset a missing day takes (review m1: a
  filled day copied the first key's offset and could parse as the day
  before);
- the kit labels keys in UTC, so a "+02" midnight already read as 22:00
  on the previous day.

Buckets are now UTC days, weeks and months, keyed "...T00:00:00+00" by
construction, as the entitlements usage buckets already are. A session in
UTC, the default, gets the same keys as before.

TestTimeBucketKeysAreUTCInAnySessionZone sets the session to Europe/Paris
around the 2026 change to summer time and asserts day, week and month
keys; with the old expression it fails on "+01"/"+02" keys. Run against
postgres:17-alpine, as CI's audit-postgres service is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e range end

Review M1: each input of a derived metric was zero-filled only between
its own first and last bucket. A failure rate whose failures start after
the requests, or never happen, dropped those days and read "partial"
(the tile's "Partial data", the + menu's "Telemetry incomplete."), which
is the case this PR said it fixed. A complete, time-grouped count,
distinct count or sum is now marked fillable, and combineDerived reads a
fillable input's missing time bucket as that input's no-events 0. Keys
from every input are put in time order, the buckets between two inputs'
ranges are combined too, and a sum or difference of fillable inputs is
fillable in turn, so it can feed a rate. A partial input, an aggregation
with no value over no events, and a non-date key keep a missing operand
unknown. The prototype was the reviewer's; the union grid and the nested
case are added here.

Review m2: the fill stopped at the last returned bucket, so the days since
the last event vanished and the newest point and the bucket delta read an
old day as the latest. It now runs through the bucket holding the end of
the range: just before an exclusive `to`, or now for an open query.

Review m1: a fill copies the returned keys' suffix, which the RPC now
keys in UTC (previous commit). Keys that disagree about their offset are
left as they came rather than given a guessed one.

Tests: the reviewer's two M1 cases, the trailing-days case, and a shared
percent metric scaled once per widget, adopted; plus time order, union
grid, nested sum, partial input, exclusive `to`, week buckets, and the
offset refusal. Existing fill tests pin their clock before their own
days. Each mechanism was removed in turn and reds its tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review m3: a section's title and its widgets' titles were all <h3>, so
heading navigation read a section's widgets as its peers. A widget inside
a section is now titled <h4>; a widget drawn above every section, and
every widget of a view without sections, keeps <h3>.

- Card takes titleAs ("h3" default, "h4"); StatTile's labelAs takes "h4".
- DashboardWidget takes heading (WidgetHeading, "h3" default).
- <Dashboard> decides the level and passes it to renderWidget as a second
  argument, { heading }, so a host resolving its own widgets titles them
  the same way. A slot that ignores it keeps <h3>.

Also: stories for a sectioned <Dashboard> and DashboardSectionHeader,
listed in ui-inventory.json, and a README note of what a remote will see
in 0.12.7 (review m6): a widget's format, compact and currency included,
now reaches chart axes, readouts, data tables and tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…as no activity

- SolutionWidget passes renderWidget's heading to DashboardWidget, so a
  widget in a section is an <h4> on the host's page too.
- The ⓘ's data range and freshness read a day counted 0 as a day with no
  events: with the fill running through today, the last filled day had
  become "the latest event".
- The + menu's "Telemetry incomplete." test now misses an average, which
  has no value over no events; a count it missed before now reads 0, which
  is the review's M1 fix.
- Adopted from the review: a refusal met by a + menu deletes the tile's
  retained values (200, removed, 403 in the menu, then 503; and a 403
  landing after the menu closed), and stored layouts the manifest has
  moved past.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review m5: 0.1.1 is the version v0.0.92 to v0.0.94 carried, and this PR
adds exported types (DashboardSection, MetricValueFormat) and changes what
the validator accepts. A patch, as the kit's additive changes are. The
root pin and the lockfile follow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… inputs

AUDIT_METRICS.md states that time buckets are UTC, that the fill runs
through the end of the range (the current bucket being in progress), how a
derived metric reads an input's day with no events, and the heading level
of a section's widgets. The manifest schema doc says the same of format's
neighbour, the zero fill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The adopted stale-layout test dropped a widget's section by destructuring
it into an unused name, which added a lint warning (40 against main's 39).
It now sets the section to undefined.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 12, 2026
Merged via the queue into codefly-dev:main with commit 9e5622f Oct 12, 2026
25 checks passed
antoinetoussaint-byte added a commit that referenced this pull request Oct 13, 2026
… DashboardWidgetView gained

Merging main into this branch leaves `@codefly-dev/ui` unable to build:

    src/dashboard/catalog.ts(195,3): error TS1360
      Property 'sections' is missing in type … but required in type
      'SchemaProperties<DashboardView>'

That is the catalog's own invariant firing, not a merge artefact.
`SchemaProperties<T>` maps over `keyof Required<T>`, so the published A2UI data
model must name EVERY field of the view types — which is the point: a field
added to the renderer's types without a catalog entry does not compile, so the
published vocabulary cannot silently fall behind what the renderer draws.

Main added sections (#958's declared sections a viewer can arrange) and the
scalar/placement fields alongside them, while this branch was writing the
catalog, so the two met here. Declared now:

- `DashboardView.sections`, an array of a new `DashboardSectionView` `$def`
  (`id`, `title` required; `description` optional).
- `DashboardWidgetView.section`, naming the section a widget is drawn in.
- `DashboardWidgetView.failure`, as the `WidgetReadFailure` enum. Its
  description keeps the distinction the type's own comment insists on: "failed"
  says the read did not complete and says nothing about authority, so a series
  already in hand is still drawn as stale, while "refused" says this viewer may
  not see the number, which makes a previously fetched one wrong rather than
  old. They are different questions, not a severity ordering.
- `DashboardWidgetView.format`, `unit` and `higherIsBetter`, the scalar
  presentation fields.

`WIDGET_READ_FAILURES` and `METRIC_FORMATS` are declared through `closedSet`,
the way `DASHBOARD_LAYOUTS` already is, rather than as bare arrays. That is
load-bearing: `closedSet` refuses a list that has dropped a member, so a new
`WidgetReadFailure` or `MetricFormat` fails at the catalog instead of publishing
an enum that no longer spans its union.

`dashboard-data.schema.generated.json` is REGENERATED, not edited --
`npm run generate:dashboard-catalog --workspace @codefly-dev/ui` -- and the diff
is additive: +54 lines, the fields above and the new `$def`.
`check:dashboard-catalog` passes, which is what pins the published bytes to what
catalog.ts renders.

Also resolved in this merge: `src/dashboard/index.ts`, where main widened the
`dashboard.js` export with the section and widget components while this branch
added the `catalog.js` block. Neither side touched the other's, so the
resolution is the union.

Tests: frontend `--project pure` 4759/4759 across 336 files, `@codefly-dev/ui`
1478/1478 across 47 files, the dashboard and catalog suites 188/188,
`check:dashboard-catalog`, `tsc --noEmit` and `prepare:frontend` all exit 0,
eslint 0 errors. kit-version, module-verify, check-boundaries and naming-gate
pass. Not run: CI itself, codefly build phases, e2e.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
crisfiorini-oss pushed a commit to crisfiorini-oss/module-saas-starter that referenced this pull request Oct 13, 2026
…mer names the same graphs (codefly-dev#1050) (codefly-dev#1051)

* feat(frontend): publish the kit's chart vocabulary as an A2UI catalog (codefly-dev#1050)

`@codefly-dev/ui/dashboard` draws five visualizations, and that list was a
convention restated in three packages with nothing checking they agreed. The
kit now publishes it: an A2UI catalog naming the dashboard components, and the
data model those bindings resolve against, both generated from the kit's own
types and type-checked against them.

- `@codefly-dev/ui/dashboard-catalog.json` — `Dashboard`, `DashboardGrid` and
  the four standalone metric charts, pinned at `protocolVersion: "0.9"`
  because the published renderers expose no v1.0 client.
- `@codefly-dev/ui/dashboard-data.schema.json` — `DashboardView` and the
  shapes beneath it, bound by reference, carrying no numbers. A second
  document because A2UI closes a catalog's root keys.
- The component map is `Record<WidgetVisualization, string | null>` and the
  schema's properties `Record<keyof Required<DashboardView>, …>`, so a
  visualization or a view field the documents do not describe fails to build.
- `table` maps to `null`: `<Dashboard>` draws it and the kit exports no
  standalone table chart, so the gap is declared rather than silent.

No new chart components and no change to how anything draws.

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

* fix(kit): the dashboard catalog declares the fields DashboardView and DashboardWidgetView gained

Merging main into this branch leaves `@codefly-dev/ui` unable to build:

    src/dashboard/catalog.ts(195,3): error TS1360
      Property 'sections' is missing in type … but required in type
      'SchemaProperties<DashboardView>'

That is the catalog's own invariant firing, not a merge artefact.
`SchemaProperties<T>` maps over `keyof Required<T>`, so the published A2UI data
model must name EVERY field of the view types — which is the point: a field
added to the renderer's types without a catalog entry does not compile, so the
published vocabulary cannot silently fall behind what the renderer draws.

Main added sections (codefly-dev#958's declared sections a viewer can arrange) and the
scalar/placement fields alongside them, while this branch was writing the
catalog, so the two met here. Declared now:

- `DashboardView.sections`, an array of a new `DashboardSectionView` `$def`
  (`id`, `title` required; `description` optional).
- `DashboardWidgetView.section`, naming the section a widget is drawn in.
- `DashboardWidgetView.failure`, as the `WidgetReadFailure` enum. Its
  description keeps the distinction the type's own comment insists on: "failed"
  says the read did not complete and says nothing about authority, so a series
  already in hand is still drawn as stale, while "refused" says this viewer may
  not see the number, which makes a previously fetched one wrong rather than
  old. They are different questions, not a severity ordering.
- `DashboardWidgetView.format`, `unit` and `higherIsBetter`, the scalar
  presentation fields.

`WIDGET_READ_FAILURES` and `METRIC_FORMATS` are declared through `closedSet`,
the way `DASHBOARD_LAYOUTS` already is, rather than as bare arrays. That is
load-bearing: `closedSet` refuses a list that has dropped a member, so a new
`WidgetReadFailure` or `MetricFormat` fails at the catalog instead of publishing
an enum that no longer spans its union.

`dashboard-data.schema.generated.json` is REGENERATED, not edited --
`npm run generate:dashboard-catalog --workspace @codefly-dev/ui` -- and the diff
is additive: +54 lines, the fields above and the new `$def`.
`check:dashboard-catalog` passes, which is what pins the published bytes to what
catalog.ts renders.

Also resolved in this merge: `src/dashboard/index.ts`, where main widened the
`dashboard.js` export with the section and widget components while this branch
added the `catalog.js` block. Neither side touched the other's, so the
resolution is the union.

Tests: frontend `--project pure` 4759/4759 across 336 files, `@codefly-dev/ui`
1478/1478 across 47 files, the dashboard and catalog suites 188/188,
`check:dashboard-catalog`, `tsc --noEmit` and `prepare:frontend` all exit 0,
eslint 0 errors. kit-version, module-verify, check-boundaries and naming-gate
pass. Not run: CI itself, codefly build phases, e2e.

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

---------

Co-authored-by: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte antoinetoussaint-byte removed the working Claimed by a lazybox agent; cleared when the session ends. label Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants