zz-meridian 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/payload/CHANGELOG.md +32 -0
  3. package/payload/app/system/preview/[section]/[card]/keys.ts +2 -0
  4. package/payload/docs/atlas-scenario-coverage.md +4 -3
  5. package/payload/docs/data-display.md +14 -2
  6. package/payload/docs/testing.md +1 -0
  7. package/payload/package.json +1 -1
  8. package/payload/scripts/brand.ts +1 -1
  9. package/payload/scripts/lib/budgets.ts +1 -1
  10. package/payload/scripts/shot.ts +65 -40
  11. package/payload/scripts/verify.config.ts +1 -1
  12. package/payload/src/components/charts/bar-list/README.md +39 -5
  13. package/payload/src/components/charts/bar-list/index.tsx +85 -16
  14. package/payload/src/components/charts/bar-list/levels.tsx +33 -0
  15. package/payload/src/components/charts/bar-list/preview.tsx +78 -4
  16. package/payload/src/components/charts/column-chart/README.md +40 -21
  17. package/payload/src/components/charts/column-chart/grouped.tsx +205 -0
  18. package/payload/src/components/charts/column-chart/index.tsx +43 -137
  19. package/payload/src/components/charts/column-chart/preview.tsx +64 -2
  20. package/payload/src/components/charts/column-chart/shared.tsx +171 -0
  21. package/payload/src/components/charts/diverging-bars/README.md +93 -0
  22. package/payload/src/components/charts/diverging-bars/index.tsx +151 -0
  23. package/payload/src/components/charts/diverging-bars/preview.tsx +141 -0
  24. package/payload/src/components/charts/scale.ts +45 -0
  25. package/payload/src/components/charts/sparkline/README.md +14 -2
  26. package/payload/src/components/charts/sparkline/index.tsx +18 -8
  27. package/payload/src/components/charts/sparkline/preview.tsx +10 -0
  28. package/payload/src/components/charts/trend-chart/README.md +17 -6
  29. package/payload/src/components/charts/trend-chart/index.tsx +71 -22
  30. package/payload/src/components/charts/trend-chart/preview.tsx +39 -0
  31. package/payload/src/components/charts/use-size.ts +4 -5
  32. package/payload/src/components/ui/dialog/README.md +4 -1
  33. package/payload/src/components/ui/dialog/index.tsx +2 -2
  34. package/payload/src/components/ui/dialog/preview.tsx +62 -3
  35. package/payload/src/components/ui/disclosure/README.md +89 -0
  36. package/payload/src/components/ui/disclosure/index.tsx +19 -0
  37. package/payload/src/components/ui/disclosure/preview.tsx +34 -0
  38. package/payload/src/styles/base.css +7 -0
  39. package/payload/src/system/preview-loaders.ts +2 -0
  40. package/payload/src/system/registry.ts +2 -0
  41. package/payload/tests/bar-list-axis-words.test.tsx +42 -0
  42. package/payload/tests/bar-list-forms.test.tsx +152 -0
  43. package/payload/tests/chart-scale-held.test.ts +99 -0
  44. package/payload/tests/chart-semantics.test.tsx +19 -0
  45. package/payload/tests/column-chart-grouped.test.tsx +149 -0
  46. package/payload/tests/column-chart-stacked.test.tsx +1 -1
  47. package/payload/tests/dialog-xl.test.tsx +25 -0
  48. package/payload/tests/disclosure.test.tsx +35 -0
  49. package/payload/tests/diverging-bars.test.tsx +116 -0
  50. package/payload/tests/feel-bar-list.test.tsx +22 -0
  51. package/payload/tests/feel-column-chart.test.tsx +27 -0
  52. package/payload/tests/feel-sparkline.test.tsx +21 -0
  53. package/payload/tests/hatch.test.ts +45 -0
  54. package/payload/tests/review-held-scales.test.tsx +81 -0
  55. package/payload/tests/sparkline-forms.test.tsx +57 -0
  56. package/payload/tests/trend-chart-curve.test.tsx +57 -0
  57. package/payload/tests/trend-chart-forms.test.tsx +137 -0
  58. package/payload/tests/truncation-keeps-text.test.tsx +2 -0
  59. package/payload/tests/use-size-late.test.tsx +64 -0
  60. package/payload/tests/verify-sizes.test.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,38 @@
2
2
 
3
3
  Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
4
4
 
5
+ ## [0.33.0] · 2026-10-28
6
+
7
+ The charts and parts a monitoring page needs (issue #99): bars in the order given, on a held and labelled scale, two values a row, signed rows about zero, straight and step lines with a dot per reading, grouped columns, a hatch for an estimate, a section that opens in place and a dialog wide enough for a table.
8
+
9
+ ### Breaking
10
+
11
+ - **A stacked Column chart's caption names its highlight as ringed.** Its table read `Accented:` for a highlighted column that wears the `ink` ring; it reads `Ringed:`, as the grouped form does. A plain column still reads `Accented:`. A test or a reader that looked for `Accented:` on a stacked chart looks for `Ringed:`.
12
+ - **A stacked column's parts are filled with `background-color`.** They were filled with the `background` shorthand, which would clear the estimate's hatch. The colours are unchanged; a test that read a part's `style.background` reads `style.backgroundColor`.
13
+ - **`useSize` returns a callback ref.** It returned a ref object and observed only the element present at mount, so a Trend chart's widening line that appeared later, when live data first crossed its `domain`, was never measured. `useSize()[0]` is now a callback that measures whatever element it is attached to, whenever it is attached, and stops when that element leaves: pass it only to a `ref` prop, and read the element some other way if code read its `.current`.
14
+
15
+ ### Added
16
+
17
+ - **A Bar list keeps the order given, holds its scale and reads two values a row.** `order="given"` keeps the rows in the order passed, for readings in time or cumulative shares. `max` holds the scale at a stated top, and a row past it widens the scale and is named: `Scale widened to 112% to show Beta`. `axis` draws the levels and their gridlines under the bars, in `text-2xs` and `ink-3`; a level word that would touch the word beside it is not drawn, and its gridline stays. `pair` reads each row against its `compare` value, drawn as an outline on the same scale under a legend. `estimate: true` on a row hatches its bar and writes `Hatched: estimate` once.
18
+ - **Diverging bars: signed rows about one zero line.** A new chart card for a change by category where some rose and some fell, on a scale symmetric about zero: `domain` holds it and a value past it widens it in words, the figure stands beyond each bar on the side away from zero, one `highlight` row with its `highlightLabel`, linked labels, hatched estimates, and every figure as text in a labelled list, so no table is needed behind it.
19
+ - **A Trend chart draws straight and step lines, a dot per reading, on a held scale.** `curve="linear"` or `"step"` draws a series (its stacked parts and its running stretch too) straight or held level between readings; `points` draws a dot on each reading, none on a running point; `domain={[min, max]}` holds the plot to a stated range so charts side by side share it, and a value outside it widens the levels and says so above the plot and in the table's caption: `Scale widened from 90.0%–100.0% to 80.0%–100.0%`. A line wrapped to two on a phone card moves the plot down. `domain` and `floor` are not passed together.
20
+ - **A Sparkline takes `curve` and `domain`.** `curve="linear"` or `"step"` draws it straight or in steps, as the Trend chart does. `domain` holds its scale so sparklines side by side compare, and the title names the range drawn: the domain, `0 to 100`, while every value is inside it; widened, when a value is outside it, `0 to 150, scale widened from 0–100`.
21
+ - **A Column chart groups its parts.** `grouped` with two or three `parts` stands each column's parts side by side, each an identity in its categorical slot: a legend, a value above each bar of 36px or more, a part under zero hanging from the zero line, a part with no data keeping its slot with the dashed no-data hairline, and no Total in the readout or the table. A highlighted or pointed group wears the `ink` ring. The grouped form is loaded only by a chart that passes `grouped`, so no other route's first load grows.
22
+ - **A Column chart marks an estimate.** `estimate: true` on a plain, stacked or grouped column hatches its fill, says "estimate" in the readout, the announcement and the table, and writes `Hatched: estimate` once under the labels.
23
+ - **The hatch: an estimate without a hue.** `[data-estimate]` is a base role: diagonal stripes in the surface's colour over the mark's own fill, so an estimated mark spends no colour or tone. Under forced colours (Windows' contrast themes) it keeps its stripes in `CanvasText` over `Canvas`, so an estimate never reads as a measured figure.
24
+ - **A section opens in place: the Disclosure.** `Disclosure` (`summary`, `defaultOpen`, `className`) is the native `<details>` with a summary row and a chevron that turns, so the browser owns the press, the keyboard and the open state and no client code ships. Use it for what a reader opens on purpose, such as the readings behind a figure; never for the figure itself.
25
+ - **A dialog wide enough for a table.** `DialogContent size="xl"` is 960px on a desk and the same bottom sheet on a phone. A table of up to six columns goes in an `xl` dialog; past that, or when the reader needs the page beside it, it goes on its own page.
26
+ - **The scale helpers behind them.** `heldTicks(min, max)` in `src/components/charts/scale.ts` gives levels that end exactly at both ends of a held range, and `linearPath` and `stepPath` draw the straight and step lines beside `monotonePath`.
27
+ - **`docs/data-display.md` names each new form** in "Choose the form first": bars in the order given, two values a row, signed rows, grouped columns, straight and step lines, a held scale and an estimate, each with its rule.
28
+
29
+ ### Changed
30
+
31
+ - **The first-load cap is 830 KiB.** It was 820 KiB; `/engineering`, which draws the Trend chart, Column chart, Bar list and Sparkline together, is 823 KiB with this release's forms. `FIRST_LOAD_CAP_KB` in `scripts/lib/budgets.ts` and the `## Budgets` row of `docs/testing.md` say 830; a project that set its own cap keeps it.
32
+
33
+ ### Fixed
34
+
35
+ - **A page too tall to capture whole says so.** `node scripts/shot.ts --full` died on a bare `Page.captureScreenshot timed out` when a page was taller than Chrome captures at once (the Column chart's preview, 12,047px at 1440 and 2x), and took the rest of the run with it. The shot prints the route, width, theme and the page's height in CSS and device pixels, and says to shoot it without `--full` or one part with `--press` or a narrower route; the browser stuck on that capture is closed, the run goes on in a fresh one, and it exits 1.
36
+
5
37
  ## [0.32.0] · 2026-10-27
6
38
 
7
39
  ### Added
@@ -19,6 +19,7 @@ export const PREVIEW_KEYS: string[] = [
19
19
  'components/copy-field',
20
20
  'components/delta',
21
21
  'components/dialog',
22
+ 'components/disclosure',
22
23
  'components/empty-state',
23
24
  'components/field',
24
25
  'components/icon-button',
@@ -68,6 +69,7 @@ export const PREVIEW_KEYS: string[] = [
68
69
  'patterns/bar-list',
69
70
  'patterns/column-chart',
70
71
  'patterns/composition-bar',
72
+ 'patterns/diverging-bars',
71
73
  'patterns/heatmap',
72
74
  'patterns/meridian',
73
75
  'patterns/sparkline',
@@ -79,12 +79,13 @@ This is the inventory of the whole Meridian sample: every job the product is mea
79
79
  | shared | Pattern: Shell tools | search pill and alerts — `src/components/patterns/shell-tools/index.tsx` | L3 | `src/components/patterns/shell-tools` | keep | Used by the dashboard layout and the states layout. The alerts panel lists what needs a person across the product and links to where each is handled; the layout hands it alerts worked out from the sample's records, and the card's own preview keeps its fixture. It is the closest thing to A7. | `src/components/patterns/shell-tools` | `/system/patterns/shell-tools` | all console pages | normal, empty | `tests/clock.test.tsx`, `tests/shell-alerts.test.ts` |
80
80
  | S5 | Pattern: Status list | every service, its 90-day history and, when asked, how long it has been operational — `src/components/patterns/status-list/index.tsx` | L3 | `src/components/patterns/status-list` | keep | Used by Now, by Services and by the Now embed. With `run` each row says how long the service has been operational, and that the run is its longest when no run among the days kept is longer; Now and Services ask for it. It is also the nearest composition for release lifecycle (S8) and run verdicts (S7). | `src/components/patterns/status-list` | `/system/patterns/status-list` | Now, Services, Now embed | normal, empty | `tests/status-list.test.tsx`, `tests/plural.test.ts` |
81
81
  | shared | Chart: Bar list | rank items by one measure — `src/components/charts/bar-list/index.tsx` | L3 | `src/components/charts/bar-list` | keep | Used by Usage, Revenue, One customer and Now. | `src/components/charts/bar-list` | `/system/patterns/bar-list` | Usage, Revenue, One customer, Now | normal, empty | `scripts/audit.ts` |
82
- | shared | Chart: Column chart | compare counts side by side, or read each period as a whole split into its parts — `src/components/charts/column-chart/index.tsx` | L3 | `src/components/charts/column-chart` | keep | Used by Usage for the hours of the day, which are sums over the heatmap's rows, with the busiest hour highlighted. On that chart the card writes a value above each column while a column is at least 36px wide, is one Tab stop whose arrow keys move the cursor the pointer moves, and reads the column pointed at to a screen reader. The card also draws each column as its parts, in one hue or in tones when the parts are outcomes, with a legend, a fold into Other past four parts, a readout and a table of every part and total; marks a last column that is a period still running and says so far; and takes its card's height when asked. Home draws requests and revenue a period with the latest highlighted, Revenue draws its periods as parts in one hue, and Now its verification runs as parts in tones with the day in progress running; no page takes its card's height yet, and the card's preview does. | `src/components/charts/column-chart` | `/system/patterns/column-chart` | Main (`/`), Usage, Revenue, Now | normal, empty, partial | `tests/column-chart.test.tsx`, `tests/column-chart-stacked.test.tsx`, `scripts/audit.ts` |
82
+ | shared | Chart: Column chart | compare counts side by side, or read each period as a whole split into its parts — `src/components/charts/column-chart/index.tsx` | L3 | `src/components/charts/column-chart` | keep | Used by Usage for the hours of the day, which are sums over the heatmap's rows, with the busiest hour highlighted. On that chart the card writes a value above each column while a column is at least 36px wide, is one Tab stop whose arrow keys move the cursor the pointer moves, and reads the column pointed at to a screen reader. The card also draws each column as its parts, in one hue or in tones when the parts are outcomes, with a legend, a fold into Other past four parts, a readout and a table of every part and total; marks a last column that is a period still running and says so far; stands two or three identities side by side as a grouped form, with no Total, loaded only on a page that draws one; hatches an estimated column in every form and says estimate; and takes its card's height when asked. Home draws requests and revenue a period with the latest highlighted, Revenue draws its periods as parts in one hue, and Now its verification runs as parts in tones with the day in progress running; no page takes its card's height yet, and the card's preview does. | `src/components/charts/column-chart` | `/system/patterns/column-chart` | Main (`/`), Usage, Revenue, Now | normal, empty, partial | `tests/column-chart.test.tsx`, `tests/column-chart-stacked.test.tsx`, `tests/column-chart-grouped.test.tsx`, `scripts/audit.ts` |
83
83
  | shared | Chart: Composition bar | split one whole into parts — `src/components/charts/composition-bar/index.tsx` | L3 | `src/components/charts/composition-bar` | keep | Used by Usage for how the last 30 complete days were answered, by status class in status colours, and by Home and Revenue for what is sold and who pays. Single use is not a reason to remove a card the Atlas teaches. | `src/components/charts/composition-bar` | `/system/patterns/composition-bar` | Main (`/`), Revenue, Usage | normal | `scripts/audit.ts` |
84
+ | shared | Chart: Diverging bars | signed rows either side of one zero line — `src/components/charts/diverging-bars/index.tsx` | L3 | `src/components/charts/diverging-bars` | keep | Added for issue #99: a signed change by category on a scale symmetric about zero, held at a domain or widened past it in words, the neutral population with one accent row, linked labels and hatched estimates. No page of the sample draws it yet; the Atlas teaches it, and its preview draws every state from the sample's customers against the company. | `src/components/charts/diverging-bars` | `/system/patterns/diverging-bars` | none: no page draws it yet; the Atlas preview does | normal, empty, partial | `tests/diverging-bars.test.tsx`, `tests/chart-semantics.test.tsx`, `scripts/audit.ts` |
84
85
  | shared | Chart: Heatmap | weekday by hour load, or a year of days — `src/components/charts/heatmap/index.tsx` | L3 | `src/components/charts/heatmap` | keep | One consumer, Usage, which draws the rows of the `hours` collection, requests for each UTC weekday and hour, and every day kept. It answers when traffic comes, which is Usage's job, in Business. Usage also draws a year of days as a calendar, a cell a date and a column a week, joined to the page's Meridian, and its weekday-and-hour grid ranked by quantile. | `src/components/charts/heatmap` | `/system/patterns/heatmap` | Usage | normal, empty | `tests/usage-figures.test.ts`, `tests/heatmap-calendar.test.tsx`, `tests/heatmap-calendar-timezone.test.tsx`, `tests/heatmap-scale.test.tsx`, `scripts/audit.ts` |
85
86
  | shared | Chart: Meridian | one time cursor shared by every chart on a page — `src/components/charts/meridian/index.tsx` | L3 | `src/components/charts/meridian` | keep | Joined by the trend and column charts, the featured metric, the metric tile, the Home, Revenue, Usage and Now, the home embed and the sign-in screen. It is the system's namesake interaction. | `src/components/charts/meridian` | `/system/patterns/meridian` | Main (`/`), Revenue, Usage, Now, home embed, sign-in | normal | `tests/meridian.test.tsx` |
86
- | shared | Chart: Sparkline | trend shape beside a figure — `src/components/charts/sparkline/index.tsx` | L3 | `src/components/charts/sparkline` | keep | Used by the metric tile and the Customers table column. It marks the day the page points at only when it has one value for each of the Meridian's dates. | `src/components/charts/sparkline` | `/system/patterns/sparkline` | Customers, metric tile consumers | normal | `tests/sparkline.test.tsx` |
87
- | shared | Chart: Trend chart | measures over time on one axis — `src/components/charts/trend-chart/index.tsx` | L3 | `src/components/charts/trend-chart` | keep | Used by the Home, Revenue, Usage, Now, Services, the home embed and the sign-in screen. Its partial state is a day with no measurement, drawn as a gap, and a last point that is a period still running: the stretch into it is dashed with nothing filled under it, and its readout and its row of the table say so far. Home, Revenue, Usage and Services set a running point. | `src/components/charts/trend-chart` | `/system/patterns/trend-chart` | Main (`/`), Revenue, Usage, Now, Services, home embed, sign-in | normal, partial | `tests/trend-chart.test.tsx`, `tests/trend-chart-running.test.tsx` |
87
+ | shared | Chart: Sparkline | trend shape beside a figure — `src/components/charts/sparkline/index.tsx` | L3 | `src/components/charts/sparkline` | keep | Used by the metric tile and the Customers table column. It marks the day the page points at only when it has one value for each of the Meridian's dates. `curve` draws it smooth, straight or in steps; `domain` holds its scale and widens, saying so in its title, for a value outside it. | `src/components/charts/sparkline` | `/system/patterns/sparkline` | Customers, metric tile consumers | normal | `tests/sparkline.test.tsx`, `tests/sparkline-forms.test.tsx` |
88
+ | shared | Chart: Trend chart | measures over time on one axis — `src/components/charts/trend-chart/index.tsx` | L3 | `src/components/charts/trend-chart` | keep | Used by the Home, Revenue, Usage, Now, Services, the home embed and the sign-in screen. Its partial state is a day with no measurement, drawn as a gap, and a last point that is a period still running: the stretch into it is dashed with nothing filled under it, and its readout and its row of the table say so far. Home, Revenue, Usage and Services set a running point. | `src/components/charts/trend-chart` | `/system/patterns/trend-chart` | Main (`/`), Revenue, Usage, Now, Services, home embed, sign-in | normal, partial | `tests/trend-chart.test.tsx`, `tests/trend-chart-running.test.tsx`, `tests/trend-chart-forms.test.tsx`, `tests/cli-trend-chart-forms.test.tsx` |
88
89
  | shared | Chart: Uptime bars | recent days of one service as one SVG — `src/components/charts/uptime-bars/index.tsx` | L3 | `src/components/charts/uptime-bars` | keep | Used by the status list, on Now and Services, and by Now directly for the days inside the band. | `src/components/charts/uptime-bars` | `/system/patterns/uptime-bars` | Now, Services | normal | `tests/uptime-svg.test.tsx` |
89
90
  | A1 | Act on several rows at once (revoke or export many keys or tickets) | none | L3 | `src/components/patterns/data-table` | defer | Nothing was built. The Data table has an optional selection column, but no page turns it on, and the only bulk write the spec could use (revoke many) has no backend operation. An agent's bulk action is already a Proposal. It comes back when a page has a bulk write with a backend operation to call. | none | none: nothing was built, so there is no entry to specify | none | none | none |
90
91
  | A2 | Return to a filtered view without rebuilding it | none | L3 | `src/components/patterns/filter-bar`, `app/(dashboard)/requests` | defer | Nothing was built. The filtered Requests view is already a link, because the filters live in the address, and a bookmark meets the job today. A saved, named view needs per-person storage the sample does not have; it comes back when a record for a person's views exists. | none | none: nothing was built, so there is no entry to specify | none | none | none |
@@ -11,17 +11,25 @@ How Meridian shows numbers: which form to use, how colour is assigned, how figur
11
11
  | A number with its change and shape | A tile | Metric tile |
12
12
  | The same number for many things at once (eight customers, six regions), each with its change and shape | A wall of compact tiles: the name, the change, one line and the shape | Metric tile |
13
13
  | Change over time | A line or an area on one axis | Trend chart |
14
+ | A few readings over a long time (weekly figures, a handful of samples), one a period | Straight segments with a point on each reading (`curve="linear"`, `points`); a period with no reading is `null`, since a reading stands at its index, not its time | Trend chart |
15
+ | A level that changes at events (a balance, a limit, seats on a plan) | Steps: each value held until the next reading (`curve="step"`) | Trend chart |
14
16
  | The shape of a trend beside a figure | A line without axes | Sparkline |
15
17
  | A ranking | Horizontal bars, the longest first | Bar list |
18
+ | A ranking whose order is its meaning (the hours of a day, the steps of a funnel) | Horizontal bars in the order given (`order="given"`) | Bar list |
19
+ | A ranking beside an earlier reading (this week against last) | Horizontal bars, each with its earlier value as an outline under it (`pair`) | Bar list |
20
+ | A signed change by category (a net change, a customer ahead of or behind the company) | Rows either side of one zero line | Diverging bars |
16
21
  | Parts of a whole, at one moment | One stacked bar | Composition bar |
17
22
  | Counts per category or per period | Columns | Column chart |
18
23
  | Parts of a whole over periods, each period a total to read (weeks, months, a handful of days) | Stacked columns, each column a whole | Column chart |
24
+ | Two or three identities a period that are never summed (money in and money out, this year and last) | Columns side by side in each period, with no total (`grouped`) | Column chart |
19
25
  | Parts of a whole over a long run of days, where the shape is the story | Stacked bands | Trend chart |
20
26
  | Two dimensions of time (weekday by hour, or a year of days as week by weekday) | A grid of cells | Heatmap |
21
27
  | A service's history | Day bars | Uptime bars |
22
28
  | How long a service has been operational | One line under its day bars: its run | Status list |
23
29
  | An incident, step by step | A timeline, newest first, each update timed from the incident's start | Incident card |
24
30
  | Many records | A table | Data table |
31
+ | The same measure for several things, compared row by row (one chart a service, a plan or a region) | Charts on one held scale (`domain`, `max`) | Trend chart, Sparkline, Bar list, Diverging bars |
32
+ | A value derived rather than measured (a forecast, a day not yet reconciled) | Its mark hatched, with "Hatched: estimate" under the chart | Bar list, Column chart, Diverging bars |
25
33
 
26
34
  Sometimes the answer is not a chart: one number and a sentence beat a chart with one interesting point.
27
35
 
@@ -29,10 +37,12 @@ Sometimes the answer is not a chart: one number and a sentence beat a chart with
29
37
 
30
38
  Two measures of different size are two charts on one Meridian, not one chart with a second scale. A dashed comparison series is allowed only when it is the same unit, such as the previous period. Never scale one series to fit another ("Errors × 20"): the tooltip would print a number that is not true.
31
39
 
40
+ The same measure for several things, one chart each, is drawn on one held scale so the charts compare row by row: `domain` on a Trend chart, a Sparkline and Diverging bars, `max` on a Bar list. A held scale widens rather than clips. A value outside it takes the scale to the levels that hold it, and the chart says so in words: a Trend chart in a line above its plot and at the end of its table's caption ("Scale widened from 90.0%–100.0% to 80.0%–100.0%"), a Bar list in a line under its rows ("Scale widened to 140 to show Gamma"), Diverging bars in a line above theirs ("Scale widened to +35 to show Delta") and a Sparkline, which has no room for a line, in its title. A range is never chosen tighter than the data to make a change look larger: the chart widens and says so.
41
+
32
42
  ## Colour by job
33
43
 
34
- - **Identity** (which series): the six categorical slots in their fixed order, `series-1` to `series-6`. Assign in order; never skip; a seventh series folds into Other. The order is validated for colour-vision deficiency in both themes (`node scripts/contrast.ts`).
35
- - **The one that matters**: the accent, with every other mark in the neutral population (`chart-neutral`). One highlighted bar, one highlighted line.
44
+ - **Identity** (which series): the six categorical slots in their fixed order, `series-1` to `series-6`. Assign in order; never skip; a seventh series folds into Other. The order is validated for colour-vision deficiency in both themes (`node scripts/contrast.ts`). A grouped Column chart's parts are identities side by side, never the parts of a whole: they take the slots in order and never the accent, a pointed or highlighted group wears the `ink` ring instead, and nothing is summed. Past three identities, or over a long run of periods, the form is a Trend chart with a line a series.
45
+ - **The one that matters**: the accent, with every other mark in the neutral population (`chart-neutral`). One highlighted bar, one highlighted line. Diverging bars are the neutral population with one accent row, never coloured by their sign: which side of the zero line a bar stands on, and the figure's sign, already say it. Values all of one sign are a Bar list.
36
46
  - **Magnitude**: one hue, light to dark, built from the accent and the surface (the heatmap).
37
47
  - **Magnitude by rank**: a Heatmap's step is a cell's share of the largest cell (`max`) unless one outlier would leave every other cell at the lowest step. Then it is ranked (`scale="quantile"`): the cells above zero take five steps of equal count. A step no longer says how much, so the legend's upper end reads the value the top step starts at ("900 or more") in place of "More", and the readout and the hidden table give each cell's exact value.
38
48
  - **State**: positive, warning, critical, always with a word or an icon.
@@ -48,6 +58,8 @@ Text never wears a series colour; values and labels stay in ink.
48
58
  - Gridlines recessive (`chart-grid`), one baseline (`chart-axis`), axis labels at `text-2xs` in `ink-3`. Axis labels are a scale: `$12`, `1.2M`; the exact value is in the tooltip and the table.
49
59
  - A legend is always present for two or more series or parts; a single series is named by its title.
50
60
  - A mark that encodes something says what, and the component says it: on the pointer, and to a screen reader where the mark is the only carrier. A Bar list row names its row and its share, an accented column says why it is the accent (`highlightLabel`), a marked day says what it is marked for, a Sparkline says the range it draws, a Delta says whether its colour reads better or worse, and a Status dot or an activity's tone carries its word as its label. When the meaning is structural — a dashed stretch into a period still running, a delta's direction — the component draws the key or the sentence itself. A new chart is held to this by a reviewer: a colour, a fill or a size a reader is meant to decode, with nothing to decode it with, is not finished.
61
+ - A compare is the earlier reading (last week, a target) drawn as an unfilled outline on the same scale as the value, never a second fill: a Bar list's `pair` draws it as a 1px `chart-neutral-strong` outline under each bar, the figure reads the value and then the compare in `ink-3`, and a legend names both.
62
+ - An estimate, a value derived rather than measured, is told by a hatch and words, never by a colour: its mark keeps its own fill with stripes of the surface over it, it says "estimate" wherever it is read (a row's title, the readout, the announcement, the table), and the chart writes "Hatched: estimate" once. A Bar list, a Column chart and Diverging bars take `estimate`. A period with no data is not an estimate: there is no figure to hatch.
51
63
  - A column's value stands above it at rest, in `ink-3`, while a column is at least 36px wide; narrower, it shows for the column pointed at. Stacked, the value is the column's total.
52
64
 
53
65
  ## A period still in progress
@@ -108,6 +108,7 @@ A limit that moved, with what it was set from (`## Rules`, Limits).
108
108
  | Limit | Old | New | Run | Machine | Commit | Samples | p95 | Margin |
109
109
  |---|---|---|---|---|---|---|---|---|
110
110
  | `/system/start/start-a-dashboard` phone warm shell, its own `budget` in `scripts/verify.config.ts` | 300 ms | 250 ms, the shared phone warm shell: the route's own figure is withdrawn | weekly `perf` shard 5/5, run 39145209850 | GitHub's `ubuntu-24.04` runner, the phone at 3.2x | `dfdad67` (withdrawn in `55b7ece`) | 20 | 58 ms (median 43, max 59) | none: a withdrawal returns to the shared figure, which holds the p95 with room |
111
+ | First-load JS cap, every route (`FIRST_LOAD_CAP_KB` in `scripts/lib/budgets.ts`) | 820 KiB | 830 KiB, the owner's decision for 0.33.0: the cap is not a limit `## Rules` lets move, so it went to them with the count | `node scripts/sizes.ts` after `pnpm build` of Phase 1 wave 1 of issue #99 | local, macOS | `bf0fda4` plus wave 1 | 1 (a byte count does not vary between builds of one tree) | n/a | `/engineering` 823 KiB, 7 KiB under: its headroom went to growth since `09d9e0b` (5 to 23 KiB on every large route) and 5 KiB to the #99 forms |
111
112
 
112
113
  ## The classification
113
114
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
@@ -219,7 +219,7 @@ if (has('--product')) {
219
219
  for (const f of walkDirs('src/components')) if (/\/(README\.md|preview\.tsx)$/.test(f)) fs.rmSync(file(f));
220
220
  for (const f of walkDirs('app')) if (/\/README\.md$/.test(f)) fs.rmSync(file(f));
221
221
  // A test of a module removed here goes with it (tests/cli-product-prune.test.ts holds the two lists together).
222
- for (const f of ['src/system/specimen.tsx', 'tests/specimen-still.test.tsx', 'tests/atlas-product-slice.test.tsx', 'tests/atlas-template-contracts.test.tsx', 'src/system/registry.ts', 'CONTRIBUTING.md', 'CHANGELOG.md']) fs.rmSync(file(f), { force: true });
222
+ for (const f of ['src/system/specimen.tsx', 'tests/specimen-still.test.tsx', 'tests/atlas-product-slice.test.tsx', 'tests/atlas-template-contracts.test.tsx', 'tests/dialog-xl.test.tsx', 'src/system/registry.ts', 'CONTRIBUTING.md', 'CHANGELOG.md']) fs.rmSync(file(f), { force: true });
223
223
  for (const d of ['docs', 'decisions', 'skills']) fs.rmSync(file(d), { recursive: true, force: true });
224
224
  const p = json('package.json');
225
225
  delete p.scripts?.registry;
@@ -49,7 +49,7 @@ const COLD: ReadinessBudget = {
49
49
  };
50
50
 
51
51
  /** The first-load cap, in KiB, for a route the baseline does not hold, and for every route unless `firstLoadKb` is null. */
52
- export const FIRST_LOAD_CAP_KB = 820;
52
+ export const FIRST_LOAD_CAP_KB = 830;
53
53
 
54
54
  /** Each page's HTML is held to its own baseline; an absolute HTML cap is a project's route, so the defaults set none. */
55
55
  export const DEFAULT_BUDGETS: Budgets = {
@@ -10,6 +10,11 @@
10
10
  *
11
11
  * Motion is reduced, so every page shows its final state. Files land in out/shots/<route>-<width>-<theme>.png.
12
12
  *
13
+ * A capture Chrome does not finish in time fails that shot, not the run: the line names the page's height in CSS and device
14
+ * pixels, and a `--full` page that tall says what to shoot instead. Chrome keeps working on the capture it abandoned, and
15
+ * the next shot timed out behind it, so that browser is closed and the run goes on in a fresh one. Any other error stops
16
+ * the run.
17
+ *
13
18
  * `--as` signs every page in as one persona declared in scripts/verify.config.ts, through the cookie scripts/lib/chrome.ts
14
19
  * sets before the first request, and puts the persona in the name: <route>-<persona>-<width>-<theme>.png. Without it no
15
20
  * cookie is sent and every request is signed in as whoever the API answers a request with no cookie as.
@@ -87,52 +92,72 @@ async function press(page: Page, name: string): Promise<boolean> {
87
92
  return true;
88
93
  }
89
94
 
90
- const page = await launch();
95
+ let page = await launch();
91
96
  let failed = false;
92
97
  try {
93
98
  for (const r of routes) {
94
99
  for (const w of widths) {
95
100
  for (const t of themes) {
96
- // Each capture starts from a blank page. Without it, the first full-page capture after a navigation that follows a
97
- // change of theme returned a frame painted before the page had settled: charts unmeasured, a chart missing, text
98
- // at another breakpoint's size, while the DOM already read as settled and waiting longer changed nothing.
99
- // Measured on the Heatmap's preview at 1440px: dark then light in one run drew light wrong every time, light
100
- // alone and light after a blank page drew it right, pixel for pixel.
101
- await page.send('Page.navigate', { url: 'about:blank' });
102
- await sleep(300);
103
- await page.open(base + r, { width: w, height: w < 600 ? 844 : 900, theme: t, cookie });
104
- // A picture of an error page under the route's name reads as the route: refused, as a press that matched nothing is.
105
- const answer = answerProblem(r, page.status(), await page.eval<boolean>(DRAWS_NOT_FOUND), MISSING);
106
- if (answer) { console.log(`FAIL ${r} at ${w}px ${t}: ${answer}`); failed = true; continue; }
107
- const missing = [];
108
- for (const p of presses) if (!(await press(page, p))) { missing.push(p); break; }
109
- if (missing.length) { console.log(`FAIL ${r} at ${w}px ${t}: nothing to press named "${missing[0]}"`); failed = true; continue; }
110
- if (point) {
111
- const location = await page.eval<{ x: number; y: number } | null>(`(() => {
112
- const el = [...document.querySelectorAll('svg[role="img"][tabindex="0"]')].find((el) => el.getAttribute('aria-label')?.startsWith(${JSON.stringify(point)}));
113
- if (!el) return null;
114
- el.scrollIntoView({ block: 'center' });
115
- const r = el.getBoundingClientRect();
116
- return { x: r.left + r.width * ${at}, y: r.top + r.height / 2 };
117
- })()`);
118
- if (!location) { console.log(`FAIL ${r}: no chart named "${point}"`); failed = true; continue; }
119
- if (w < 600) await page.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [location] });
120
- else await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...location });
121
- await sleep(500);
122
- const readout = await page.eval<{ fits: boolean; detail: string }>(`(() => {
123
- const el = [...document.querySelectorAll('svg[role="img"][tabindex="0"]')].find((el) => el.getAttribute('aria-label')?.startsWith(${JSON.stringify(point)}));
124
- const tip = el?.parentElement?.querySelector('div[aria-hidden]');
125
- const r = tip?.getBoundingClientRect();
126
- const chart = el?.getBoundingClientRect();
127
- const fits = !!r && !!chart && getComputedStyle(tip).opacity > 0.05 && r.left >= Math.max(0, chart.left) - 1 && r.right <= Math.min(document.documentElement.clientWidth, chart.right) + 1;
128
- return { fits, detail: JSON.stringify({ viewport: document.documentElement.clientWidth, chart: chart && { left: chart.left, right: chart.right }, readout: r && { left: r.left, right: r.right } }) };
129
- })()`);
130
- if (!readout.fits) { console.log(`FAIL ${r} at ${w}px ${t}: readout missing or outside its chart: ${readout.detail}`); failed = true; }
101
+ // The height the capture asks Chrome for, read before it: once a capture times out, Chrome answers nothing.
102
+ let tall: { h: number; page: number } | undefined;
103
+ try {
104
+ // Each capture starts from a blank page. Without it, the first full-page capture after a navigation that follows a
105
+ // change of theme returned a frame painted before the page had settled: charts unmeasured, a chart missing, text
106
+ // at another breakpoint's size, while the DOM already read as settled and waiting longer changed nothing.
107
+ // Measured on the Heatmap's preview at 1440px: dark then light in one run drew light wrong every time, light
108
+ // alone and light after a blank page drew it right, pixel for pixel.
109
+ await page.send('Page.navigate', { url: 'about:blank' });
110
+ await sleep(300);
111
+ await page.open(base + r, { width: w, height: w < 600 ? 844 : 900, theme: t, cookie });
112
+ // A picture of an error page under the route's name reads as the route: refused, as a press that matched nothing is.
113
+ const answer = answerProblem(r, page.status(), await page.eval<boolean>(DRAWS_NOT_FOUND), MISSING);
114
+ if (answer) { console.log(`FAIL ${r} at ${w}px ${t}: ${answer}`); failed = true; continue; }
115
+ const missing = [];
116
+ for (const p of presses) if (!(await press(page, p))) { missing.push(p); break; }
117
+ if (missing.length) { console.log(`FAIL ${r} at ${w}px ${t}: nothing to press named "${missing[0]}"`); failed = true; continue; }
118
+ if (point) {
119
+ const location = await page.eval<{ x: number; y: number } | null>(`(() => {
120
+ const el = [...document.querySelectorAll('svg[role="img"][tabindex="0"]')].find((el) => el.getAttribute('aria-label')?.startsWith(${JSON.stringify(point)}));
121
+ if (!el) return null;
122
+ el.scrollIntoView({ block: 'center' });
123
+ const r = el.getBoundingClientRect();
124
+ return { x: r.left + r.width * ${at}, y: r.top + r.height / 2 };
125
+ })()`);
126
+ if (!location) { console.log(`FAIL ${r}: no chart named "${point}"`); failed = true; continue; }
127
+ if (w < 600) await page.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [location] });
128
+ else await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...location });
129
+ await sleep(500);
130
+ const readout = await page.eval<{ fits: boolean; detail: string }>(`(() => {
131
+ const el = [...document.querySelectorAll('svg[role="img"][tabindex="0"]')].find((el) => el.getAttribute('aria-label')?.startsWith(${JSON.stringify(point)}));
132
+ const tip = el?.parentElement?.querySelector('div[aria-hidden]');
133
+ const r = tip?.getBoundingClientRect();
134
+ const chart = el?.getBoundingClientRect();
135
+ const fits = !!r && !!chart && getComputedStyle(tip).opacity > 0.05 && r.left >= Math.max(0, chart.left) - 1 && r.right <= Math.min(document.documentElement.clientWidth, chart.right) + 1;
136
+ return { fits, detail: JSON.stringify({ viewport: document.documentElement.clientWidth, chart: chart && { left: chart.left, right: chart.right }, readout: r && { left: r.left, right: r.right } }) };
137
+ })()`);
138
+ if (!readout.fits) { console.log(`FAIL ${r} at ${w}px ${t}: readout missing or outside its chart: ${readout.detail}`); failed = true; }
139
+ }
140
+ const name = (r.replace(/^\//, '').replace(/[/?=&#]+/g, '-') || 'home') + (as ? `-${slug(as)}` : '') + (presses.length ? `-${slug(presses.at(-1)!)}` : '') + (point ? `-${slug(point)}-point-${at}` : '') + `-${w}-${t}.png`;
141
+ // Read the way scripts/lib/chrome.ts reads it: a shell page scrolls in its scroll region, not the document.
142
+ tall = await page.eval<{ h: number; page: number }>(
143
+ `(() => { const s = document.querySelector('[data-scroll-region]'); return { h: innerHeight, page: innerHeight + (s ? s.scrollHeight - s.clientHeight : Math.max(0, document.documentElement.scrollHeight - innerHeight)) }; })()`,
144
+ );
145
+ const h = await page.shot(path.join(out, name), { full });
146
+ console.log(`${path.join(out, name)} ${w}x${h}`);
147
+ if (point && w < 600) await page.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
148
+ } catch (e) {
149
+ const message = (e as Error).message;
150
+ if (message !== 'Page.captureScreenshot timed out' || !tall) throw e;
151
+ const css = full ? tall.page : tall.h;
152
+ const size = `${css}px tall (${css * Number(process.env.DPR)} device px at ${process.env.DPR}x)`;
153
+ console.log(full && tall.page > tall.h
154
+ ? `FAIL ${r} at ${w}px ${t}: the page is ${size}, and a full-page capture that tall passes what Chrome takes at once (${message}); shoot it without --full, or one part with --press "<name>" or a narrower route`
155
+ : `FAIL ${r} at ${w}px ${t}: ${message} on a capture ${size}`);
156
+ failed = true;
157
+ // Chrome is still working on the capture it abandoned: the next shot runs in a fresh browser.
158
+ page.close();
159
+ page = await launch();
131
160
  }
132
- const name = (r.replace(/^\//, '').replace(/[/?=&#]+/g, '-') || 'home') + (as ? `-${slug(as)}` : '') + (presses.length ? `-${slug(presses.at(-1)!)}` : '') + (point ? `-${slug(point)}-point-${at}` : '') + `-${w}-${t}.png`;
133
- const h = await page.shot(path.join(out, name), { full });
134
- console.log(`${path.join(out, name)} ${w}x${h}`);
135
- if (point && w < 600) await page.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
136
161
  }
137
162
  }
138
163
  }
@@ -111,7 +111,7 @@ type VerifyConfig = {
111
111
  */
112
112
  requestDependentRoutes?: { path: string; reason: string }[];
113
113
  /**
114
- * Limits verify enforces. Leave a key out for Meridian's default: first-load JS 820 KiB per route and 5% growth over
114
+ * Limits verify enforces. Leave a key out for Meridian's default: first-load JS 830 KiB per route and 5% growth over
115
115
  * `scripts/verify.baseline.json`, the warm, cold and after-live navigation times, and no prefetch on a closed phone drawer.
116
116
  * Every page's HTML, gzipped through the end of the streamed response, is held to `htmlGrowthPct` (5%) over its own
117
117
  * figure in the baseline's `html`; `htmlKb` adds an absolute cap in KiB, the same gzipped figure, for a page a project
@@ -8,9 +8,14 @@ Status: beta
8
8
 
9
9
  1. **Row**: label on the left (`text-sm`, `ink-2`; the highlighted row `ink` at `weight-medium`), an optional meta line, the value on the right (`ink`, tabular, `weight-medium`).
10
10
  2. **Track**: 6px tall, `fill-track`, `radius-full`.
11
- 3. **Bar**: the value's share of the largest, `chart-neutral`; the one highlighted row in `accent`.
11
+ 3. **Bar**: the value against the scale's top (the largest row, `total`, `max` or the axis's last level), `chart-neutral`; the one highlighted row in `accent`, and its row's title adds `highlightLabel`, what makes it the one ("The busiest endpoint"), so the accent is never a colour left to guess.
12
12
  4. **Others**: past `limit`, the remaining rows fold into "N others", summed, in `ink-3`; a single extra row shows as itself, since "1 others" would hide a name to save no space. It is never a link, whatever the rows it holds.
13
13
  5. **Linked row**: a row with an `href` names a record that has a page, and its label is the one link to it, named by the label. The bar, the meta line and the figure stay text: they say what the label already opens, so they are never a second way there. At rest the row looks as an unlinked one does.
14
+ 6. **Compare** (under `pair`): 2px under the value bar, the row's `compare` on the same scale, a 1px `chart-neutral-strong` outline with no fill; none for a missing compare. The figure reads `{value} · {compare}`, the compare in `ink-3`, `—` for one with no data.
15
+ 7. **Legend** (under `pair`): above the rows, a filled and an outlined swatch named `pair.label` and `pair.compareLabel`, at `text-xs` in `ink-2`.
16
+ 8. **Axis** (under `axis`): the levels at `text-2xs` in `ink-3` under the rows, and a 1px `chart-grid` gridline through every row at each. No two level words that show stand closer than 4px: from the top inward, a word that would touch the nearest one shown above it, or the first, is hidden (`invisible`, still in the row), and its gridline stays. The first level and the top always show.
17
+ 9. **Estimate**: an estimated row's bar is hatched (`data-estimate`, the base hatch over its own fill), its title adds "estimate", and the list writes "Hatched: estimate" once. A folded row holding one is one; a compare outline never is.
18
+ 10. **Lines under the list**, in this order, each at `text-2xs` in `ink-3`: the axis levels, the widening line ("Scale widened to 140 to show Gamma"), the estimate key.
14
19
 
15
20
  ## Variants
16
21
 
@@ -20,6 +25,11 @@ Status: beta
20
25
  | All neutral | A ranking with no finding to point at |
21
26
  | With meta | A short fact per row (a plan, an error rate), between label and value |
22
27
  | Linked | Each row names a record that has a page (a customer): its label opens it |
28
+ | In the order given | `order="given"`: the order is the meaning (the hours of a day, the stages of a funnel) |
29
+ | Held scale | `max`: every bar against a stated top (a capacity, 100%), so cards of one measure compare row by row |
30
+ | With an axis | `axis`: a reader reads lengths against levels, not only against each other |
31
+ | Paired | `pair`: each row against its own earlier figure or target |
32
+ | With estimates | `estimate` on a row whose figure is projected or partial (the hour still running) |
23
33
 
24
34
  ## Sizes
25
35
 
@@ -39,11 +49,27 @@ Rows are 14px apart; label to bar 6px. The list takes its container's width.
39
49
 
40
50
  Rows are not interactive by default. Give a row an `href` when it names a record that has a page, and its label becomes the link to it (`row-link`, with the `hit` target: 24px under a mouse, 44px under a finger). Give every row of a list one or none: a list where some names open and others do not reads as broken.
41
51
 
52
+ Rows are sorted by value, largest first. Pass `order="given"` when the order is the meaning (hours, weekdays, the stages of a funnel): the rows keep the order passed.
53
+
42
54
  Bars are drawn against the largest row. Pass `total` when the rows are a share of a stated whole (the top 8 tools out of every call, or rows hidden by `limit`): each bar is then its value over `total`, and hovering a row shows its share ("12.4% of 3.1M").
43
55
 
56
+ Pass `max` instead when the scale is held: a capacity, a ceiling, 100%, or the same top across several cards so they compare row by row. Every bar is drawn against it and no share is printed; `max` and `total` are one or the other, in the type. A value (or a drawn compare) past `max` widens the scale to it, and the list writes "Scale widened to {top} to show {row}", naming the first row that stands at the top. A `max` not above zero draws against 1; a `max` under 1, a share held at 50% as 0.5, is held like any other.
57
+
58
+ Pass `axis` when a reader reads lengths against levels: under `max`, the held levels and the widened top; without it, the nice levels of the largest row, and the bars are drawn against the last of them. The levels row measures its width, at 7px a character, and hides a word that would touch one that shows (a widened 112% beside the held 100%), keeping its gridline; until it is measured every word shows.
59
+
60
+ Pass `pair` (`{ label, compareLabel }`) and a `compare` on each row to read each row against its own earlier figure or target. Both share one scale.
61
+
62
+ Mark a row `estimate` when its figure is projected or partial: its bar is hatched and the list writes the key.
63
+
64
+ Under `order="given"`, `max` or `pair` no row folds: each is read one by one, and a sum of the rest would hide that.
65
+
66
+ With none of these the root is the `ul`. Once the list draws a legend, levels, a widening line or a key, its root is a `div` taking `className` around the legend, the `ul` (which keeps `aria-label`) and the lines.
67
+
68
+ Every row is an `li` carrying `data-row` with its key (`__others` for the folded row); its bar carries `data-bar` (and `data-estimate` when estimated), its figure `data-figure`, its compare `data-compare`. The legend carries `data-legend`, a level `data-level`, a gridline `data-gridline`, the widening line `data-widened` and the key `data-estimate-key`.
69
+
44
70
  ## Surfaces
45
71
 
46
- - **Console**: up to `limit` rows (default 6).
72
+ - **Console**: up to `limit` rows (default 6); every row under `order="given"`, `max` or `pair`, which fold none.
47
73
  - **Mobile**: the meta line hides; everything else holds. A linked label answers a 44px target.
48
74
  - **Embed**: the top five, then Expand.
49
75
 
@@ -53,7 +79,7 @@ The ranking is shared as structured context by the view (`useShareView`), so an
53
79
 
54
80
  ## Accessibility
55
81
 
56
- A labelled list (`aria-label`); each row's label and value are text, so the bar is reinforcement, never the only signal. A linked row is one link a row, named by its label, so a keyboard has one Tab stop a row and a screen reader hears each name once. The accent bar holds 3.71:1 dark, 5.71:1 light on `surface`.
82
+ A labelled list (`aria-label`); each row's label and value are text, so the bar is reinforcement, never the only signal. A compare is text in the figure and the legend names both; an estimate is said in its row's title and the key, never by the hatch alone; a widened scale is said in a sentence. A linked row is one link a row, named by its label, so a keyboard has one Tab stop a row and a screen reader hears each name once. The accent bar holds 3.71:1 dark, 5.71:1 light on `surface`.
57
83
 
58
84
  ## Content
59
85
 
@@ -61,7 +87,9 @@ Labels are the items' own names (routes in mono); values use one formatter for t
61
87
 
62
88
  ## Do and do not
63
89
 
64
- - Do sort by value, largest first.
90
+ - Do rank the rows by value, the largest first; when the order is the meaning (hours, stages), pass `order="given"` and keep it.
91
+ - Do hold the scale with `max` when cards of one measure stand side by side; do not pass it and `total` together.
92
+ - Do pass `highlightLabel` beside the highlighted row's key: an accent with nothing to decode it with is not finished.
65
93
  - Do not colour more than one row; do not colour rows by their value.
66
94
 
67
95
  ## Implementation
@@ -69,7 +97,13 @@ Labels are the items' own names (routes in mono); values use one formatter for t
69
97
  ```tsx
70
98
  import { BarList } from '@/components/charts/bar-list';
71
99
 
72
- <BarList label="Requests by endpoint" items={items} highlight="/v1/messages" limit={5} format={formatCompact} />
100
+ <BarList label="Requests by endpoint" items={items} highlight="/v1/messages" highlightLabel="The busiest endpoint" limit={5} format={formatCompact} />
101
+
102
+ // The hours of a day, in their order, against a held scale with its levels.
103
+ <BarList label="Requests by hour" order="given" max={5000} axis items={hours} format={formatCompact} />
104
+
105
+ // This week against last week.
106
+ <BarList label="Requests by region" pair={{ label: 'This week', compareLabel: 'Last week' }} items={regions.map((r) => ({ ...r, compare: r.lastWeek }))} />
73
107
 
74
108
  // Rows that open a record: the label is the link.
75
109
  <BarList label="Revenue by customer" items={customers.map((c) => ({ key: c.id, label: c.name, value: c.revenue, href: `/customers/${c.id}` }))} format={formatCost} />
@@ -1,8 +1,20 @@
1
1
  import Link from 'next/link';
2
2
  import { cn } from '@/lib/cn';
3
3
  import { plural } from '@/lib/format';
4
+ import { heldTicks, niceTicks } from '@/components/charts/scale';
5
+ import { Levels } from './levels';
4
6
 
5
- export type BarItem = { key: string; label: React.ReactNode; value: number; meta?: React.ReactNode; href?: string };
7
+ export type BarItem = {
8
+ key: string;
9
+ label: React.ReactNode;
10
+ value: number;
11
+ meta?: React.ReactNode;
12
+ href?: string;
13
+ /** The figure the value is read against under `pair`: last week's, the target. `null` is one with no data. */
14
+ compare?: number | null;
15
+ /** The value is projected or partial, not measured: its bar is hatched and its row says so. */
16
+ estimate?: boolean;
17
+ };
6
18
 
7
19
  /**
8
20
  * A ranked list with a bar for each row: "by endpoint", "by customer". The bars are the neutral population and one
@@ -18,6 +30,10 @@ export function BarList({
18
30
  highlightLabel,
19
31
  limit = 6,
20
32
  total,
33
+ max,
34
+ order = 'value',
35
+ axis = false,
36
+ pair,
21
37
  label,
22
38
  className,
23
39
  }: {
@@ -29,29 +45,55 @@ export function BarList({
29
45
  * not a colour a reader has to guess (issue #94). */
30
46
  highlightLabel?: string;
31
47
  limit?: number;
32
- /** The whole the rows are a share of: each bar's length is its value over this, and its share shows on hover. Left
33
- * out, bars are drawn against the largest row. */
34
- total?: number;
48
+ /** `given` keeps the rows in the order passed, for an order that is the meaning (hours, stages): nothing folds. */
49
+ order?: 'value' | 'given';
50
+ /** Draws levels under the rows and a gridline through them at each. */
51
+ axis?: boolean;
52
+ /** Reads each row's `compare` beside its value, as an outline on the same scale, under a legend of the two. */
53
+ pair?: { label: string; compareLabel: string };
35
54
  label: string;
36
55
  className?: string;
37
- }) {
38
- const sorted = [...items].sort((a, b) => b.value - a.value);
39
- // One row past the limit is shown as itself: "1 other" hides a name to save no space.
40
- const cut = sorted.length === limit + 1 ? limit + 1 : limit;
56
+ } & (
57
+ | {
58
+ /** The whole the rows are a share of: each bar's length is its value over this, and its share shows on hover. Left
59
+ * out, bars are drawn against the largest row. */
60
+ total?: number;
61
+ max?: undefined;
62
+ }
63
+ /** A held scale: every bar is drawn against `max` (a capacity, 100%), so lists compare card to card. A row past it
64
+ * widens the scale to itself, and the list says so. No share is printed. */
65
+ | { max: number; total?: undefined }
66
+ )) {
67
+ const held = max !== undefined;
68
+ // A held top not above zero is no scale to draw against, so the bars are drawn against one.
69
+ const cap = held ? (max > 0 ? max : 1) : 0;
70
+ const sorted = order === 'given' ? items : [...items].sort((a, b) => b.value - a.value);
71
+ // One row past the limit is shown as itself: "1 other" hides a name to save no space. A held scale, a pair and an
72
+ // order that is the meaning show every row: a fold would sum what they are read one by one for.
73
+ const cut = order === 'given' || held || pair ? sorted.length : sorted.length === limit + 1 ? limit + 1 : limit;
41
74
  const shown = sorted.slice(0, cut);
42
75
  const rest = sorted.slice(cut);
43
- if (rest.length) shown.push({ key: '__others', label: `${rest.length} ${plural(rest.length, 'other')}`, value: rest.reduce((a, b) => a + b.value, 0) });
44
- const max = Math.max(1, total ?? 0, ...shown.map((i) => i.value));
45
- return (
46
- <ul aria-label={label} className={cn('flex flex-col gap-3.5', className)}>
76
+ if (rest.length) shown.push({ key: '__others', label: `${rest.length} ${plural(rest.length, 'other')}`, value: rest.reduce((a, b) => a + b.value, 0), estimate: rest.some((r) => r.estimate) });
77
+ const data = shown.flatMap((i) => (pair && i.compare != null ? [i.value, i.compare] : [i.value]));
78
+ const ticks = !axis ? null : held ? heldTicks(0, cap) : niceTicks(Math.max(total ?? 0, ...data));
79
+ const top = held ? Math.max(cap, ...data) : ticks ? ticks.at(-1)! : Math.max(1, total ?? 0, ...data);
80
+ // A held scale keeps its levels and adds the widened top as one more, so the levels still read as the held ones.
81
+ const levels = ticks && top > ticks.at(-1)! ? [...ticks, top] : ticks;
82
+ // The first row in drawing order that stands at the widened top is named, so the reader knows which row moved it.
83
+ const past = held && top > cap ? shown.find((i) => i.value === top || (pair && i.compare === top)) : undefined;
84
+ const named = (it: BarItem) => (typeof it.label === 'string' || typeof it.label === 'number' ? String(it.label) : null);
85
+ const estimated = shown.some((i) => i.estimate);
86
+ // Once the list draws anything beside its rows, its root is a frame around them that takes `className`.
87
+ const framed = !!(levels || past || estimated || pair);
88
+ const list = (
89
+ <ul aria-label={label} className={cn('flex flex-col gap-3.5', !framed && className)}>
47
90
  {shown.map((it, n) => {
48
91
  const hot = it.key === highlight;
49
92
  // The row says what it is as well as its share (issue #94): "42.1% of 1,204" on its own never named the row, and
50
93
  // a list with no total had no title at all. The label is the row's own word, so it is used when it is text.
51
- const named = typeof it.label === 'string' || typeof it.label === 'number' ? String(it.label) : null;
52
94
  const share = total ? `${((it.value / Math.max(1, total)) * 100).toFixed(1)}% of ${format(total)}` : null;
53
95
  return (
54
- <li key={it.key} className="group/bar" title={[named, format(it.value), share, hot ? highlightLabel : null].filter(Boolean).join(' · ')}>
96
+ <li key={it.key} data-row={it.key} className="group/bar" title={[named(it), format(it.value), share, it.estimate ? 'estimate' : null, hot ? highlightLabel : null].filter(Boolean).join(' · ')}>
55
97
  <div className="mb-1.5 flex items-baseline gap-3 text-sm">
56
98
  {it.href ? (
57
99
  // The row's look at rest, the accent under the pointer and the focus ring on the keyboard. The hit area is
@@ -63,17 +105,44 @@ export function BarList({
63
105
  <span className={cn('min-w-0 flex-1 truncate', hot ? 'font-medium text-ink' : 'text-ink-2', it.key === '__others' && 'text-ink-3')}>{it.label}</span>
64
106
  )}
65
107
  {it.meta ? <span className="t-caption hidden sm:inline">{it.meta}</span> : null}
66
- <span className="t-num font-medium text-ink">{format(it.value)}</span>
108
+ <span data-figure className="t-num font-medium text-ink">
109
+ {format(it.value)}
110
+ {pair ? <span className="text-ink-3"> · {it.compare == null ? '—' : format(it.compare)}</span> : null}
111
+ </span>
67
112
  </div>
68
113
  <div className="h-1.5 overflow-hidden rounded-full bg-fill-track">
69
114
  <div
115
+ data-bar
116
+ data-estimate={it.estimate || undefined}
70
117
  className={cn('grow-x h-full rounded-full transition-colors duration-(--dur-hover)', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/bar:bg-chart-neutral-strong')}
71
- style={{ width: `${(it.value / max) * 100}%`, ['--i' as string]: n }}
118
+ style={{ width: `${(it.value / top) * 100}%`, ['--i' as string]: n }}
72
119
  />
73
120
  </div>
121
+ {pair && it.compare != null ? <div data-compare className="mt-0.5 h-1.5 rounded-full border border-chart-neutral-strong" style={{ width: `${(it.compare / top) * 100}%` }} /> : null}
74
122
  </li>
75
123
  );
76
124
  })}
77
125
  </ul>
78
126
  );
127
+ if (!framed) return list;
128
+ return (
129
+ <div className={className}>
130
+ {pair ? (
131
+ <ul data-legend className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-2">
132
+ <li className="flex items-center gap-1.5"><span aria-hidden className="h-1.5 w-3 rounded-full bg-chart-neutral" />{pair.label}</li>
133
+ <li className="flex items-center gap-1.5"><span aria-hidden className="h-1.5 w-3 rounded-full border border-chart-neutral-strong" />{pair.compareLabel}</li>
134
+ </ul>
135
+ ) : null}
136
+ {levels ? (
137
+ // The gridlines stand behind the rows, through all of them, at the levels named under them.
138
+ <div className="relative">
139
+ {levels.map((l) => <span key={l} data-gridline aria-hidden className={cn('absolute inset-y-0 w-px bg-chart-grid', l === top && '-translate-x-full')} style={{ left: `${(l / top) * 100}%` }} />)}
140
+ {list}
141
+ </div>
142
+ ) : list}
143
+ {levels ? <Levels levels={levels.map((l) => [format(l), l / top])} /> : null}
144
+ {past ? <p data-widened className="mt-1.5 text-2xs text-ink-3">Scale widened to {format(top)} to show {named(past) ?? past.key}</p> : null}
145
+ {estimated ? <p data-estimate-key className="mt-1.5 text-2xs text-ink-3">Hatched: estimate</p> : null}
146
+ </div>
147
+ );
79
148
  }