zz-meridian 0.25.1 → 0.27.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.
- package/dist/migrations.js +8 -0
- package/package.json +1 -1
- package/payload/CHANGELOG.md +42 -0
- package/payload/app/embed/now/view.tsx +7 -1
- package/payload/docs/distribution.md +2 -2
- package/payload/docs/testing.md +1 -0
- package/payload/package.json +1 -1
- package/payload/scripts/check.ts +1 -1
- package/payload/scripts/lib/slots.ts +34 -0
- package/payload/scripts/verify.config.ts +8 -4
- package/payload/scripts/verify.ts +31 -12
- package/payload/skills/zz-meridian/references/update.md +3 -2
- package/payload/skills/zz-meridian/references/validation.md +4 -2
- package/payload/src/components/charts/column-chart/README.md +6 -3
- package/payload/src/components/charts/column-chart/index.tsx +33 -15
- package/payload/src/components/charts/column-chart/preview.tsx +10 -0
- package/payload/src/components/charts/scale.ts +9 -4
- package/payload/src/components/charts/trend-chart/README.md +6 -2
- package/payload/src/components/charts/trend-chart/index.tsx +25 -8
- package/payload/src/components/charts/trend-chart/preview.tsx +20 -0
- package/payload/src/components/patterns/data-table/README.md +2 -2
- package/payload/src/components/patterns/data-table/index.tsx +26 -16
- package/payload/src/components/patterns/rail/index.tsx +3 -2
- package/payload/src/components/ui/table/README.md +1 -1
- package/payload/src/components/ui/table/index.tsx +59 -20
- package/payload/src/views/members.tsx +3 -2
- package/payload/tests/chart-scale.test.ts +14 -0
- package/payload/tests/column-chart-signed.test.tsx +59 -0
- package/payload/tests/rail-name-title.test.tsx +24 -0
- package/payload/tests/table-cut-align.test.tsx +2 -1
- package/payload/tests/table-cut-cell.test.tsx +3 -2
- package/payload/tests/table-grid.test.tsx +49 -0
- package/payload/tests/trend-chart-signed.test.tsx +54 -0
- package/payload/tests/verify-browsers.test.ts +34 -0
package/dist/migrations.js
CHANGED
|
@@ -470,6 +470,14 @@ export const RELEASE_MIGRATIONS = [
|
|
|
470
470
|
instructions: 'Bring `tests/column-chart-no-data.test.tsx` over from the release as it has it. Since 0.19.0 `src/components/charts/column-chart/index.tsx` draws no column for a `null` period: its box has no height, a dashed hairline (`data-no-data-mark`) keeps the slot, and the word stands over it, because the track was the tallest mark on the chart. A test of your own that finds `.bg-fill-track` or `[data-track]` in a no-data column finds `[data-no-data-mark]` instead.',
|
|
471
471
|
checks: ['gate'],
|
|
472
472
|
},
|
|
473
|
+
{
|
|
474
|
+
id: 'table-grid',
|
|
475
|
+
since: '0.26.0',
|
|
476
|
+
applies: (root) => matching(root, (rel, src) => importsOf(rel, src).some(({ from }) => from === 'src/components/ui/table' || from === 'src/components/patterns/data-table') && (/@min-\[672px\]:min-w-64/.test(src) || /<TableCell\b[^>]*\bcolSpan\s*=/.test(src))),
|
|
477
|
+
summary: 'A table is a grid since 0.26.0: its spare width sits in the gaps, a `grow` column\'s floor is 160px at every width, and a cell spans no columns.',
|
|
478
|
+
instructions: 'Bring `tests/table-cut-cell.test.tsx` over from the release as it has it. Since 0.26.0 `src/components/ui/table/index.tsx` lays a table out as a grid whose rows are subgrids: every column is as wide as its content, the spare is shared equally between the gaps, and a `grow` column\'s floor is `min-w-40` at every width, without the `@min-[672px]:min-w-64` it had from a 672px table. A test of your own that expects that class expects `min-w-40` alone. A `TableCell` of your own with a `colSpan` takes one track of the grid, so it draws in the first column only: a row that should run the width of the table, such as an empty state, is drawn outside the table, or as a `DataTable` `empty`.',
|
|
479
|
+
checks: ['gate'],
|
|
480
|
+
},
|
|
473
481
|
];
|
|
474
482
|
/**
|
|
475
483
|
* A test of the rail that stands over a fake `ResizeObserver` answering `observe` with nothing (0.16.2). The lookbehind
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
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.27.0] · 2026-10-23
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- **A chart's scale holds every value it draws, zero among them** ([#80](https://github.com/zhixuan312/zz-meridian/issues/80)). `ticksBetween` counts a floor under zero down to a step under it, where it used to clamp it to zero and draw every value below zero under the axis and out of the card.
|
|
10
|
+
- `TrendChart` without a `floor` now holds zero and every value: a series that goes under zero takes the axis down with it. An `area` is filled to the zero line from either side, not down to the lowest level.
|
|
11
|
+
- `TrendChart`'s top is the data's own. It was at least 1, so a ratio that peaks at 0.4% was drawn along the floor of an axis to 100%. A chart whose values are all under 1 now draws to a step over its highest value.
|
|
12
|
+
- Levels are rounded to a digit under their step, not to six places, so a ratio a millionth apart keeps one level per tick.
|
|
13
|
+
- **`ColumnChart` draws a value under zero as a fall** ([#78](https://github.com/zhixuan312/zz-meridian/issues/78)). In the plain form, a value under zero used to draw nothing. Now the scale goes under zero, zero is drawn as the axis, and the column hangs from the zero line, rounded at its lower end, with its value under it. While no value is under zero the chart is drawn as before. Stacked, a part under zero is still no contribution: parts make a whole.
|
|
14
|
+
|
|
15
|
+
### New
|
|
16
|
+
|
|
17
|
+
- **`TrendChart`'s `format` may be a function, with an `axisFormat`** ([#78](https://github.com/zhixuan312/zz-meridian/issues/78)). From a client component, pass `(n: number) => string` for a figure no named format says exactly: a price to four decimals, a ratio to six, or an amount in a currency of its own. It reads the readout and the table, and the axis unless `axisFormat` says otherwise. A server component still passes a name, because a function cannot cross into a client component.
|
|
18
|
+
- The Trend chart's Atlas page draws "Below zero" and "A format of its own". The Column chart's page draws "Rises and falls".
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **The rail keeps the whole product name and workspace in a `title`** ([#79](https://github.com/zhixuan312/zz-meridian/issues/79)). A name too long for the rail, such as 23 characters in three words, was cut with an ellipsis and nothing held the whole of it, so `scripts/audit.ts` failed every route at every width.
|
|
23
|
+
- **The Now embed loads its body only when it draws it.** The inline view a chat shows first draws the open incident and the services; Now's whole body, with its charts, tables and feed, is the fullscreen view, now a chunk of its own (`next/dynamic`). In a created project, `/embed/now`'s first-load JS falls from 820 KiB, at the cap, to 712 KiB. `app/embed/now/view.tsx` is a sample file the team owns, so `update` leaves a project's own copy as it is: one whose embed nears the cap can take the release's.
|
|
24
|
+
|
|
25
|
+
## [0.26.0] · 2026-10-22
|
|
26
|
+
|
|
27
|
+
### Breaking
|
|
28
|
+
|
|
29
|
+
- **A table puts its spare width in the gaps between its columns, an equal share in each.** `Table` is laid out as a CSS grid now, not by the table algorithm. Every column is as wide as its content. `justify-content: space-between` spreads the spare across the gaps, so every gap is the same whatever each column's alignment. In 0.25 the table algorithm put a column's spare inside the column, after a left-aligned value and before a right-aligned one. So a gap between a text column and a number held both columns' shares, and the gap after the number held none. Measured from the ink of one column to the ink of the next at 1920px, the gaps on /customers, /members and /tests now vary by 0 to 3% (a coefficient of variation of 0.00 to 0.03), against 42 to 53% in 0.25.1.
|
|
30
|
+
- **Rows are subgrids** of the table's columns, so a row's border, its hover and its selection run across the gaps. The header row carries the sunk background and the rules.
|
|
31
|
+
- **The table counts its columns** from the cells of its first row as its own JSX writes them: the head's `TableHeader` cells when it has a head, otherwise the first row's `TableCell` cells. It counts one track per column at each `hideBelow` step. Write that row's cells in the table's own JSX, not inside a component of your own, or the table cannot count them.
|
|
32
|
+
- **Cells are blocks in a grid.** A cell no longer sets the row height or centres itself: the row is at least `--row-height` tall and centres its cells. A cell you styled as a table cell (`align-middle`, a fixed height, a `colSpan`) is now laid out by its row; drop those classes.
|
|
33
|
+
- **The `grow` floor is 160px at every width.** It was 256px from a 672px table. A floor is also a width the column claims when its names are shorter, so the gap after the lead was half as wide again as every other gap.
|
|
34
|
+
- **A text column after a number no longer takes extra left padding** (`pl-8`). With equal gaps it made one gap wider than the rest.
|
|
35
|
+
- **`DataTable`'s selection checkbox sits over the lead column's left padding**, not in a column of its own, so no gap stands between a checkbox and the name it selects. It is still its own cell, first in the row, marked `data-check`. The table does not count it.
|
|
36
|
+
- **`update` lists `migration:table-grid`** for a project whose own files expect the old floor (`@min-[672px]:min-w-64`, as `tests/table-cut-cell.test.tsx` did in a project created before 0.26.0) or give a `TableCell` a `colSpan`, which a grid row draws in one track. Bring the release's `tests/table-cut-cell.test.tsx` over, and draw a row that spans the table, such as an empty state, outside it or as a `DataTable` `empty`.
|
|
37
|
+
- Rows, cells and row groups carry their ARIA roles (`row`, `cell`, `columnheader`, `rowgroup`, `table`), since a table laid out as a grid can lose its native semantics in some browsers.
|
|
38
|
+
|
|
39
|
+
### New
|
|
40
|
+
|
|
41
|
+
- **`pnpm verify --browsers <n>`: one run holds at most `n` browsers at once** ([#77](https://github.com/zhixuan312/zz-meridian/issues/77)).
|
|
42
|
+
- Without the flag, the ceiling is one browser per two CPUs and per 4 GiB of memory, whichever is fewer. `--full`'s audit, presses, keyboard walk and product checks start under the ceiling, not all at once.
|
|
43
|
+
- A check in `browserChecks` holds one browser, or the number its entry names: `{ script, browsers }`, the whole ceiling at most. It starts once that many are free and is told in `VERIFY_BROWSERS` how many it holds, so a check that pools browsers sizes its pool from that number, not from the machine. The sample's journey drives three people at once and declares `browsers: 3`.
|
|
44
|
+
- The trace header prints the ceiling used, for example `browsers: at most 5 at once (10 CPUs and 32 GiB)`.
|
|
45
|
+
- Without a ceiling, one run on a 32 GB laptop held about 70 Chrome processes and pushed the machine about 17 GB into swap.
|
|
46
|
+
|
|
5
47
|
## [0.25.1] · 2026-10-21
|
|
6
48
|
|
|
7
49
|
### Fixed
|
|
@@ -8,10 +8,16 @@ import { Freshness } from '@/components/patterns/freshness';
|
|
|
8
8
|
import { AskAbout } from '@/components/patterns/ask-about';
|
|
9
9
|
import { StatusList } from '@/components/patterns/status-list';
|
|
10
10
|
import { ShareContext } from '@/components/base/use-share-view';
|
|
11
|
-
import
|
|
11
|
+
import dynamic from 'next/dynamic';
|
|
12
12
|
import { nothingArrived, nowContext, unpackNow, type PackedNowData } from '@/views/now-context';
|
|
13
13
|
import { formatRelative } from '@/lib/format-date';
|
|
14
14
|
|
|
15
|
+
// Now's whole body is the fullscreen view, and the inline one only when nothing has arrived: its charts, tables and
|
|
16
|
+
// feed are a chunk of their own, so the inline view a chat shows first downloads the banner and the list alone. In one
|
|
17
|
+
// created project the embed's first-load JS was 820 KiB, at the cap, with the body in it.
|
|
18
|
+
const NowBody = dynamic(() => import('@/views/now').then((m) => m.NowBody));
|
|
19
|
+
const NoServices = dynamic(() => import('@/views/now').then((m) => m.NoServices));
|
|
20
|
+
|
|
15
21
|
/**
|
|
16
22
|
* Inline, the services and the open incident, the most severe first; fullscreen, and inline when nothing has arrived
|
|
17
23
|
* yet, Now's whole body. With the services unread or none monitored, inline says so where the list would be, and never
|
|
@@ -148,10 +148,10 @@ Two environment variables exist for the updater's own tests and are used only wh
|
|
|
148
148
|
reports as `migration:<id>` because it cannot make them. An entry applies when the project's recorded version is older
|
|
149
149
|
than its `since` and the target is not, and its `applies(root)` returns the paths that still have the old shape, or
|
|
150
150
|
null. It only reads, and only team-owned files (the ownership rule's managed paths are skipped); a file it cannot read is
|
|
151
|
-
absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers
|
|
151
|
+
absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers`, `no-data-mark` and `table-grid`, `gate` alone), and its
|
|
152
152
|
instructions name the section of a reference (`cache.md`, `live.md`, `validation.md`, `agents.md`, `customize.md`), of the Start guide or of `docs/assistant.md` to follow.
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
Seventeen arrived after 0.5.0 and are resolved as the skill's `references/update.md` says: `share-view-context` and `agent-reads-section` (0.9.0), `assistant-view-tools` (0.10.0), `nav-needs` (0.13.0), `agent-reads-own-group` (0.15.0), `refusal-declared`, `managed-exports-removed`, `view-tool-page-explore`, `agent-does-here-section` and `sample-pages-renamed` (0.16.0), `marker-measured-on-observe` (0.16.2), `view-page-matched-as-next`, `journey-moved` and `today-in-timezone` (0.16.3), `verify-tiers` (0.17.0), `no-data-mark` (0.19.0), and `table-grid` (0.26.0). The ten of 0.5.0 are:
|
|
155
155
|
|
|
156
156
|
| Id | Reported when a team-owned file still has | Completion step |
|
|
157
157
|
|----|-------------------------------------------|-----------------|
|
package/payload/docs/testing.md
CHANGED
|
@@ -34,6 +34,7 @@ A tier is when a check runs, ordered by speed and scope. The first four are one
|
|
|
34
34
|
- **on request**: a command no tier runs, run at the moment `## When each runs` gives it. The combinations that exist:
|
|
35
35
|
- `pnpm verify --perf` is the 20-sample navigation protocol, and the whole of it runs only in CI, split by route with `--shard <i>/<n>`, each shard on its own runner. It refuses to run elsewhere, and a shard runs alone or beside the other tiers. Locally, perf runs only narrowed, `node scripts/perf.ts --routes <route>`, to diagnose one route or to sample a recalibration.
|
|
36
36
|
- `pnpm verify --as <persona>` signs the browser in as each persona it names (a comma-separated list), alone or beside `--full`. It is refused beside `--timed` or `--perf`, before anything runs.
|
|
37
|
+
- `pnpm verify --browsers <n>` holds at most `n` browsers at once in the complete tier. Without it the ceiling is one browser per two CPUs and per 4 GiB of memory, whichever is fewer, and the trace header prints the one a run used (`browsers: at most 5 at once (10 CPUs and 32 GiB)`). The audit, the presses and the keyboard walk take one each; a product check in `browserChecks` takes one, or the `browsers` its entry names (`{ script, browsers: 3 }`), the whole ceiling at most, and is told in `VERIFY_BROWSERS` how many it holds.
|
|
37
38
|
- `node scripts/audit.ts --atlas` and `node scripts/vitals.ts --atlas` run against a served app, outside `pnpm verify`, over every Design Atlas entry.
|
|
38
39
|
- The browser-fixture tests of the checks, `pnpm exec vitest run tests/deep- --no-file-parallelism`, run beside `pnpm verify --full` after a change to a check script. Meridian's repository only.
|
|
39
40
|
- `node cli/scripts/smoke.ts --tarball <tarball>` is the consumer smoke, a packed tarball adopted, created and updated end to end. Meridian's repository only.
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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.",
|
package/payload/scripts/check.ts
CHANGED
|
@@ -57,7 +57,7 @@ function walk(dir: string, ext: RegExp, out: string[] = []) {
|
|
|
57
57
|
const css = read('src/styles/tokens.css') + read('src/styles/base.css') + read('src/styles/motion.css');
|
|
58
58
|
const defined = new Set([...css.matchAll(/--([a-z0-9-]+)\s*:/g)].map((m) => m[1]));
|
|
59
59
|
/** Local custom properties a component sets for itself, and the host's bridged variables. */
|
|
60
|
-
const LOCAL = /^(i|w|m-len|readout-(cursor|width)|safe-(top|right|bottom|left)|font-face-(sans|mono)|color-[a-z-]+|border-radius-[a-z]+|tw-.*|radix-.*)$/;
|
|
60
|
+
const LOCAL = /^(i|w|m-len|cols(-(xl|lg|md|sm))?|readout-(cursor|width)|safe-(top|right|bottom|left)|font-face-(sans|mono)|color-[a-z-]+|border-radius-[a-z]+|tw-.*|radix-.*)$/;
|
|
61
61
|
|
|
62
62
|
// ── Cards ────────────────────────────────────────────────────────────────────────────────────────────
|
|
63
63
|
const CARD_SECTIONS = ['## Surfaces', '## Agents', '## Accessibility'];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ceiling on how many browsers one verify run holds at once (#77). Each suite asks for the browsers it will open and
|
|
3
|
+
* starts when that many are free; one that asks for more than the ceiling is given the whole ceiling. Without it the
|
|
4
|
+
* browser suites all started together, and a product check that pools its own browsers added its pool on top.
|
|
5
|
+
*/
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
|
|
8
|
+
/** What a machine can hold: one browser for every two CPUs and every 4 GiB of memory, whichever is fewer, and at least one. */
|
|
9
|
+
export function browserCeiling(cpus = os.availableParallelism(), memory = os.totalmem()) {
|
|
10
|
+
return Math.max(1, Math.min(Math.floor(cpus / 2), Math.floor(memory / 2 ** 32)));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function slots(ceiling: number) {
|
|
14
|
+
let free = ceiling;
|
|
15
|
+
const waiting: { want: number; go: () => void }[] = [];
|
|
16
|
+
const next = () => {
|
|
17
|
+
while (waiting.length && waiting[0].want <= free) {
|
|
18
|
+
const w = waiting.shift()!;
|
|
19
|
+
free -= w.want;
|
|
20
|
+
w.go();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/** Run `job` once `want` browsers are free, and free them when it ends, however it ends. */
|
|
24
|
+
return async function hold<T>(want: number, job: () => Promise<T>): Promise<T> {
|
|
25
|
+
const n = Math.max(1, Math.min(want, ceiling));
|
|
26
|
+
await new Promise<void>((go) => { waiting.push({ want: n, go }); next(); });
|
|
27
|
+
try {
|
|
28
|
+
return await job();
|
|
29
|
+
} finally {
|
|
30
|
+
free += n;
|
|
31
|
+
next();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -73,9 +73,12 @@ type VerifyConfig = {
|
|
|
73
73
|
allowRemoteData?: true;
|
|
74
74
|
/**
|
|
75
75
|
* The product's own browser checks, beside Meridian's audit, presses and keyboard walk: scripts verify runs with
|
|
76
|
-
* `--base <url>` against the built app, failing when one exits non-zero.
|
|
76
|
+
* `--base <url>` against the built app, failing when one exits non-zero. An entry is the script's path, for a check that
|
|
77
|
+
* opens one browser, or `{ script, browsers }` for one that opens more at once. Verify holds that many of the run's
|
|
78
|
+
* browsers (`--browsers`), the whole ceiling at most, while it runs, and tells it in `VERIFY_BROWSERS` how many that
|
|
79
|
+
* is: a check that pools browsers sizes its pool from it, not from the machine.
|
|
77
80
|
*/
|
|
78
|
-
browserChecks?: string[];
|
|
81
|
+
browserChecks?: (string | { script: string; browsers: number })[];
|
|
79
82
|
/**
|
|
80
83
|
* The product's own browser checks that judge a time, run by `pnpm verify --timed` alone and one at a time, after the
|
|
81
84
|
* live data and before Web Vitals, each with `--base <url>` like `browserChecks`. A check that judges a time under the
|
|
@@ -154,8 +157,9 @@ const config: VerifyConfig = {
|
|
|
154
157
|
'/customers/missing', // no customer has this id: the not-found screen in the shell
|
|
155
158
|
],
|
|
156
159
|
smokeRoutes: ['/', '/requests', '/settings'],
|
|
157
|
-
// The sample's own journeys across its three workspaces, run beside the audit, the presses and the keyboard walk
|
|
158
|
-
|
|
160
|
+
// The sample's own journeys across its three workspaces, run beside the audit, the presses and the keyboard walk; three
|
|
161
|
+
// people at once, so three browsers.
|
|
162
|
+
browserChecks: [{ script: 'journeys/workspaces.ts', browsers: 3 }],
|
|
159
163
|
navigationChecks: [
|
|
160
164
|
{ path: '/', title: 'Home', readySelector: 'main table tbody tr', probe: 'toggle', controlSelector: 'main [role="radio"][aria-checked="false"]', resultSelector: 'main [role="radio"]' },
|
|
161
165
|
{ path: '/members', title: 'Members', readySelector: 'main table tbody tr', probe: 'toggle', controlSelector: 'main button[aria-haspopup="menu"]', resultSelector: '[role="menu"]' },
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* and through the phone drawer, warm, cold and right after a live refresh, the p95 reported
|
|
17
17
|
* The whole protocol runs only where CI is `true`, a share of it with `--shard <i>/<n>` (with --perf alone)
|
|
18
18
|
* pnpm verify --full --perf the deep suites together; the gate and the build are shared, so each runs once
|
|
19
|
+
* --browsers <n> the most browsers the run holds at once (default: one per two CPUs and per 4 GiB of memory,
|
|
20
|
+
* whichever is fewer); --full's browser suites start under it, and a product check is told in
|
|
21
|
+
* VERIFY_BROWSERS how many it holds
|
|
19
22
|
*
|
|
20
23
|
* It prints one line per phase (`ok`, `warn`, `FAIL` or `not run`, and the seconds), then one coverage line saying what
|
|
21
24
|
* ran and what did not, then the outcome. Nothing that did not run is reported as passed. The trace, with the commit, the
|
|
@@ -41,6 +44,7 @@ import net from 'node:net';
|
|
|
41
44
|
import os from 'node:os';
|
|
42
45
|
import path from 'node:path';
|
|
43
46
|
|
|
47
|
+
import { browserCeiling, slots } from './lib/slots.ts';
|
|
44
48
|
import { bin } from './lib/bin.ts';
|
|
45
49
|
import { launch } from './lib/chrome.ts';
|
|
46
50
|
import type { BudgetsConfig, NavigationCheck } from './lib/budgets.ts';
|
|
@@ -59,7 +63,7 @@ type VerifyConfig = BudgetsConfig & {
|
|
|
59
63
|
noLiveApi?: true;
|
|
60
64
|
dataUrls?: string[];
|
|
61
65
|
allowRemoteData?: true;
|
|
62
|
-
browserChecks?: string[];
|
|
66
|
+
browserChecks?: (string | { script: string; browsers: number })[];
|
|
63
67
|
timedChecks?: string[];
|
|
64
68
|
fakeModel?: FakeModelStep[];
|
|
65
69
|
navigationChecks?: NavigationCheck[];
|
|
@@ -70,17 +74,19 @@ type VerifyConfig = BudgetsConfig & {
|
|
|
70
74
|
const config = verifyConfig as VerifyConfig;
|
|
71
75
|
|
|
72
76
|
const ROOT = path.resolve(import.meta.dirname, '..');
|
|
73
|
-
const OPTIONS = ['--full', '--timed', '--perf', '--as'];
|
|
77
|
+
const OPTIONS = ['--full', '--timed', '--perf', '--as', '--browsers'];
|
|
74
78
|
|
|
75
|
-
// `--as`
|
|
76
|
-
//
|
|
79
|
+
// `--as` and `--browsers` consume the argument after them: a comma-separated persona list, a number of browsers. Every
|
|
80
|
+
// other argument must be a known option. An unknown option is refused here, before anything runs.
|
|
77
81
|
const argv = process.argv.slice(2);
|
|
78
82
|
const unknown: string[] = [];
|
|
79
83
|
let asList = '';
|
|
80
84
|
let shardText: string | undefined;
|
|
85
|
+
let browsersText: string | undefined;
|
|
81
86
|
for (let i = 0; i < argv.length; i++) {
|
|
82
87
|
const a = argv[i];
|
|
83
88
|
if (a === '--as') { asList = argv[++i] ?? ''; continue; }
|
|
89
|
+
if (a === '--browsers') { browsersText = argv[++i] ?? ''; continue; }
|
|
84
90
|
if (a === '--shard') { shardText = argv[++i] ?? ''; continue; }
|
|
85
91
|
if (!OPTIONS.includes(a)) unknown.push(a);
|
|
86
92
|
}
|
|
@@ -88,6 +94,13 @@ if (unknown.length) {
|
|
|
88
94
|
console.error(`verify: ${unknown.join(', ')} ${unknown.length === 1 ? 'is not an option' : 'are not options'}. pnpm verify takes ${OPTIONS.join(', ')}.`);
|
|
89
95
|
process.exit(1);
|
|
90
96
|
}
|
|
97
|
+
if (browsersText !== undefined && !/^[1-9]\d*$/.test(browsersText)) {
|
|
98
|
+
console.error(`verify: --browsers takes a whole number of browsers, at least 1, not "${browsersText}".`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
/** The most browsers this run holds at once: `--browsers`, or what the machine can hold. */
|
|
102
|
+
const ceiling = browsersText ? Number(browsersText) : browserCeiling();
|
|
103
|
+
const ceilingFrom = browsersText ? '--browsers' : `${os.availableParallelism()} CPUs and ${Math.round(os.totalmem() / 2 ** 30)} GiB`;
|
|
91
104
|
const full = argv.includes('--full');
|
|
92
105
|
const timed = argv.includes('--timed');
|
|
93
106
|
const perf = argv.includes('--perf');
|
|
@@ -145,7 +158,8 @@ const hasAssistant = fs.existsSync(path.join(ROOT, APP_DIR, 'api/assistant/route
|
|
|
145
158
|
// The stream itself is Meridian's, proven on the sample in Meridian's own runs; a product's own live pages prove
|
|
146
159
|
// themselves through `browserChecks`.
|
|
147
160
|
const hasLiveSample = sampleSurfaces(ROOT, APP_DIR).members;
|
|
148
|
-
|
|
161
|
+
/** Each product check with the browsers it opens at once: one, unless its entry says more. */
|
|
162
|
+
const own = (config.browserChecks ?? []).map((c) => (typeof c === 'string' ? { script: c, browsers: 1 } : c));
|
|
149
163
|
const ownTimed = config.timedChecks ?? [];
|
|
150
164
|
const absent = new Set<string>([...(hasAssistant ? [] : ['assistant']), ...(hasLiveSample ? [] : ['live']), ...(own.length ? [] : ['browserChecks']), ...(ownTimed.length ? [] : ['timedChecks']), ...(perf ? [] : ['perf'])]);
|
|
151
165
|
const SUITES = SUITE_NAMES.filter((name) => !absent.has(name));
|
|
@@ -203,6 +217,7 @@ function traceHeader(code: number): string[] {
|
|
|
203
217
|
`commit: ${sha ? `${sha}${dirty ? ' (working tree has changes)' : ''}` : 'not a git checkout'}`,
|
|
204
218
|
`toolchain: node ${process.version}; next ${nextVersion()}; ${process.env.npm_config_user_agent?.split(' ')[0] ?? 'no package manager'}`,
|
|
205
219
|
`browser: ${chrome}; profile: ${os.platform()} ${os.arch()}, ${cpus.length} CPUs (${cpus[0]?.model ?? 'unknown'}), local, not the reference profile`,
|
|
220
|
+
`browsers: at most ${ceiling} at once (${ceilingFrom})`,
|
|
206
221
|
`selected routes: ${selected.length ? selected.join(', ') : 'none'}`,
|
|
207
222
|
`gate runs: ${gates}`,
|
|
208
223
|
`build runs: ${builds}`,
|
|
@@ -620,20 +635,24 @@ if (full && browser.ran && baseUrl) {
|
|
|
620
635
|
if (suiteOutcome(on.status) === 'FAIL') ok = false;
|
|
621
636
|
}
|
|
622
637
|
|
|
623
|
-
// The audit, the presses and the keyboard walk each run
|
|
638
|
+
// The audit, the presses and the keyboard walk each run one browser of their own, side by side against the one built
|
|
639
|
+
// app, as many at once as the ceiling allows (#77). A product check holds the browsers its entry says it opens, the
|
|
640
|
+
// whole ceiling at most, and is told in VERIFY_BROWSERS how many that is, so a check that pools browsers sizes its pool
|
|
641
|
+
// from that and not from the machine; it starts once that many are free.
|
|
624
642
|
current = 'the browser suites';
|
|
625
|
-
announce('the audit, every control and link, the keyboard path' + (own.length ? ', and the product\'s own checks' : ''));
|
|
643
|
+
announce('the audit, every control and link, the keyboard path' + (own.length ? ', and the product\'s own checks' : '') + `, at most ${ceiling} browsers at once`);
|
|
626
644
|
const here = `http://127.0.0.1:${server!.port}`;
|
|
645
|
+
const hold = slots(ceiling);
|
|
627
646
|
const [audit, presses, keys, ...extras] = await Promise.all([
|
|
628
|
-
run('scripts/audit.ts', ['--base', here]),
|
|
629
|
-
run('scripts/interactions.ts', ['--base', here]),
|
|
630
|
-
run('scripts/keyboard.ts', ['--base', here]),
|
|
631
|
-
...own.map((
|
|
647
|
+
hold(1, () => run('scripts/audit.ts', ['--base', here])),
|
|
648
|
+
hold(1, () => run('scripts/interactions.ts', ['--base', here])),
|
|
649
|
+
hold(1, () => run('scripts/keyboard.ts', ['--base', here])),
|
|
650
|
+
...own.map((c) => hold(c.browsers, () => run(c.script, ['--base', here], { ...process.env, VERIFY_BROWSERS: String(Math.min(c.browsers, ceiling)) }))),
|
|
632
651
|
]);
|
|
633
652
|
reportSuite('audit', 'audit: every page at 5 widths in both themes', audit);
|
|
634
653
|
reportSuite('presses', 'every control pressed and every link followed', presses);
|
|
635
654
|
reportSuite('keyboard', 'the whole keyboard path', keys);
|
|
636
|
-
extras.forEach((r, i) => reportSuite('browserChecks', own[i], r));
|
|
655
|
+
extras.forEach((r, i) => reportSuite('browserChecks', own[i].script, r));
|
|
637
656
|
if ([audit, presses, keys, ...extras].some((r) => suiteOutcome(r.status) === 'FAIL')) ok = false;
|
|
638
657
|
}
|
|
639
658
|
|
|
@@ -169,7 +169,7 @@ update (`2` pending, `0` finalized, `1` failure). `--abort`: `0` aborted, `1` re
|
|
|
169
169
|
|
|
170
170
|
## Resolving 0.9.0's migrations
|
|
171
171
|
|
|
172
|
-
Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0 a third for an assistant kept from before, to 0.13.0 a fourth for a navigation item without a `needs`, to 0.15.0 a fifth for a page under the project's own route group, and to 0.16.0 five more, for a configuration that does not declare `refusal`, an export gone from a managed module, a view tool without its page or its prepared query, a view tool's page whose specification does not say what the agent does there, and the sample's pages under their old names; to 0.16.2 one more, for a test of the rail standing over a `ResizeObserver` that answers `observe` with nothing, to 0.16.3 three more, for the assistant route's local `onPage`, a file importing the journey from `scripts/navigate.ts` and a route or a read that cuts today at the UTC date, to 0.17.0 one more, for a file calling `finalOutcome` with a boolean, and to 0.19.0 one more, for a test that expects a Column chart's period with no data to draw a track. Each is resolved the same way as those below:
|
|
172
|
+
Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0 a third for an assistant kept from before, to 0.13.0 a fourth for a navigation item without a `needs`, to 0.15.0 a fifth for a page under the project's own route group, and to 0.16.0 five more, for a configuration that does not declare `refusal`, an export gone from a managed module, a view tool without its page or its prepared query, a view tool's page whose specification does not say what the agent does there, and the sample's pages under their old names; to 0.16.2 one more, for a test of the rail standing over a `ResizeObserver` that answers `observe` with nothing, to 0.16.3 three more, for the assistant route's local `onPage`, a file importing the journey from `scripts/navigate.ts` and a route or a read that cuts today at the UTC date, to 0.17.0 one more, for a file calling `finalOutcome` with a boolean, and to 0.19.0 one more, for a test that expects a Column chart's period with no data to draw a track, and to 0.26.0 one more, for a test that expects a table's 256px floor or a table cell that spans columns. Each is resolved the same way as those below:
|
|
173
173
|
|
|
174
174
|
| Id | The old shape it found | The change |
|
|
175
175
|
|----|------------------------|------------|
|
|
@@ -189,6 +189,7 @@ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0
|
|
|
189
189
|
| `today-in-timezone` (0.16.3) | `optional:app/api/assistant/route.ts` or `optional:src/data/metrics.ts` cutting today with `clock().toISOString().slice(0, 10)` | `dayOf(clock())` from `src/lib/format-date.ts`, so today is the day in `app.timezone`; a file of your own comparing an instant's day with a day row asks `dayOf` too; checked by the gate |
|
|
190
190
|
| `verify-tiers` (0.17.0) | a file of the team's importing `finalOutcome` from `scripts/lib/coverage.ts` and calling it with a boolean, such as a copy of `optional:tests/verify-suite-outcome.test.ts` from before the tiers | `finalOutcome` takes `{ full, timed }` since verify has a timed tier: bring `optional:tests/verify-suite-outcome.test.ts` over from the release as it has it, and call `finalOutcome({ full, timed }, notRun)` in a file of your own, as `validation.md` describes; checked by the gate |
|
|
191
191
|
| `no-data-mark` (0.19.0) | a test of the team's importing the Column chart and expecting a `.bg-fill-track` in a period with no data, such as `optional:tests/column-chart-no-data.test.tsx` as the template wrote it | a `null` period draws a dashed hairline on the baseline since 0.19.0, not a track the height of the scale, which was the tallest mark on the chart: bring `optional:tests/column-chart-no-data.test.tsx` over from the release as it has it, and find `[data-no-data-mark]` where a test of your own found `.bg-fill-track` or `[data-track]`; checked by the gate |
|
|
192
|
+
| `table-grid` (0.26.0) | a file of the team's importing the Table or the DataTable that expects a `grow` column's `@min-[672px]:min-w-64`, such as `optional:tests/table-cut-cell.test.tsx` as the template wrote it, or passes a `colSpan` to a `TableCell` | a table is a grid since 0.26.0, its spare shared equally between the gaps, and a `grow` column's floor is `min-w-40` at every width: bring `optional:tests/table-cut-cell.test.tsx` over from the release as it has it, expect `min-w-40` alone where a test of your own expected the wider floor, and draw a row that spans the table, such as an empty state, outside it or as a `DataTable` `empty`, since a cell takes one track; checked by the gate |
|
|
192
193
|
|
|
193
194
|
A created project that never changed the template's pages brings the release's version of each affected file over, with
|
|
194
195
|
the files that arrived with them: the view contexts `optional:src/views/home-context.ts` and its siblings, the view
|
|
@@ -218,7 +219,7 @@ each one in the project, then record it as above, with the hashes of the files a
|
|
|
218
219
|
| `live-provider` | collections without `optional:src/data/live-actions.ts`, or a console layout without the provider | add the live files: `live.md`, "The live starters" and "Refreshing" |
|
|
219
220
|
| `authorized-endpoints` | the assistant route or an actions file that never calls `resolveAccess` | resolve the caller first, and bring `optional:src/lib/assistant/tools.ts` and `optional:src/lib/assistant/respond.ts` over, which take the guard: "The assistant route" |
|
|
220
221
|
|
|
221
|
-
Every migration but `verify-modes` is checked by the gate and a build; `verify-modes` by the gate alone. After 0.5.0 the gate alone checks `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers
|
|
222
|
+
Every migration but `verify-modes` is checked by the gate and a build; `verify-modes` by the gate alone. After 0.5.0 the gate alone checks `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers`, `no-data-mark` and `table-grid`.
|
|
222
223
|
|
|
223
224
|
How to make the change depends on whose file it is:
|
|
224
225
|
|
|
@@ -84,7 +84,9 @@ line naming the command that runs them. Steps in order:
|
|
|
84
84
|
border (or the frame around it, or any ancestor up to its host inside a shadow root) differs when it has focus. A ring
|
|
85
85
|
that is always there does not count. With them run the
|
|
86
86
|
product's own browser checks (`browserChecks` in `scripts/verify.config.ts`), each given `--base` and the served
|
|
87
|
-
app's address; none of them judges a time.
|
|
87
|
+
app's address; none of them judges a time. The run holds at most `--browsers` browsers at once (one per two CPUs and
|
|
88
|
+
per 4 GiB by default): each suite takes one, a check whose entry is `{ script, browsers }` takes that many, and a
|
|
89
|
+
check reads how many it holds from `VERIFY_BROWSERS`.
|
|
88
90
|
7. **Live data** (`--timed` only; `scripts/live.ts`), alone after the presses, which change members: two tabs, a quiet
|
|
89
91
|
or restarted stream, a hidden or offline tab and a burst, against a second server that drops its change hints. A case
|
|
90
92
|
that cannot run is `not run` and the suite exits 2. It drives the sample Members page; a product without it prints
|
|
@@ -138,7 +140,7 @@ and supply-chain check before a script runs; that is pnpm, not an install, and n
|
|
|
138
140
|
| `unmeasured: x-card (no open shadow root)` | A custom element shows a box but exposes no open root and no content in the page, so nothing inside it was measured | Attach its root open (`attachShadow({ mode: 'open' })`), or render its content as light DOM in a slot; the audit fails until it can look |
|
|
139
141
|
| `unmeasured: x-card (not defined)` | A custom element was still not registered after the settle wait | Register it (`customElements.define`) before the page settles, or remove the tag from the page |
|
|
140
142
|
| `"Export" does nothing when pressed` | A control with no action, or one that only works on hover | Wire it, or remove it; an info button uses `Tooltip toggle` so a tap opens it |
|
|
141
|
-
| `table … > its frame` | A table wider than its card, its last columns clipped | `hideBelow` one more column, or `truncate` a long column. Every column
|
|
143
|
+
| `table … > its frame` | A table wider than its card, its last columns clipped | `hideBelow` one more column, or `truncate` a long column. Every column is as wide as its content, the spare sits in the gaps between columns, an equal share in each, and a cut column shrinks only when the table lacks room; a fixed `width` keeps its width, and the `grow` floor is 160px |
|
|
142
144
|
| `exception:` / `console.error:` | A runtime error or a hydration mismatch | Hydration: no `Date.now()`, `Math.random()` or locale-dependent formatting in render; use the demo clock and `src/lib/format*` |
|
|
143
145
|
|
|
144
146
|
## Keyboard walk failures
|
|
@@ -7,10 +7,10 @@ Status: beta
|
|
|
7
7
|
## Anatomy
|
|
8
8
|
|
|
9
9
|
1. **Legend** (stacked, two or more parts drawn): above the plot, in stack order from the bottom. A 10px square swatch (3px radius, as the Heatmap's) at the part's own strength, ringed by a 1px `ink-3` hairline, and the part's label in `ink-2` at `text-xs`.
|
|
10
|
-
2. **Gridlines and y labels**: three or four "nice" ticks from zero, `chart-grid` lines (the one at zero `chart-axis`), `text-2xs` `ink-3` labels in a gutter sized to the longest. Each label is the value its tick stands at, written by an axis formatter. Stacked, the axis is the total's.
|
|
11
|
-
3. **Columns**: equal width, 2px apart while the plot has room for it (see More columns than room), rounded 4px at the top, growing from the
|
|
10
|
+
2. **Gridlines and y labels**: three or four "nice" ticks from zero, or, for a signed series, from a step under its lowest value, `chart-grid` lines (the one at zero `chart-axis`), `text-2xs` `ink-3` labels in a gutter sized to the longest. Each label is the value its tick stands at, written by an axis formatter. Stacked, the axis is the total's.
|
|
11
|
+
3. **Columns**: equal width, 2px apart while the plot has room for it (see More columns than room), rounded 4px at the top, growing from the zero line; a fall (a value under zero) hangs from it, rounded 4px at the bottom and growing down. Plain, the population is `chart-neutral` and the highlighted column `accent`.
|
|
12
12
|
4. **Parts** (stacked): the first at the bottom, a 1px hairline of the card where two touch. Only the topmost part drawn is rounded. A part that is not zero is at least 3px tall; a part at zero is absent, with no line and no gap.
|
|
13
|
-
5. **Value at rest**: the column's value, or its total, above it in `text-2xs` `ink-3`, while a column is at least 36px wide.
|
|
13
|
+
5. **Value at rest**: the column's value, or its total, above it (under a fall) in `text-2xs` `ink-3`, while a column is at least 36px wide.
|
|
14
14
|
6. **Dashed outline** (with a period still running): a 1px dashed `ink` line around the last column, open at the baseline, with 1px of the card between it and the fill, which is drawn at half strength. The outline has space of its own: it covers no part.
|
|
15
15
|
7. **No-data mark** (a period with no data): no column, and a dashed `line-strong` hairline across the slot, 2px above the baseline. Nothing stands up from the baseline, so the tallest mark on the chart is always a value; the slot keeps its place, and its value reads "No data" just above the hairline.
|
|
16
16
|
8. **Pointed column**: plain, it takes the accent and its value stands above it on `surface-inverse`. Stacked, it takes a 2px `ink` ring and the readout.
|
|
@@ -30,9 +30,12 @@ Status: beta
|
|
|
30
30
|
| Stacked, outcomes | `parts` with a tone each: how something ended, such as passed, skipped and failed |
|
|
31
31
|
| Stacked, identities | `parts` with a categorical `color` each: teams, or several usual outcomes (accepted, delivered and abandoned) that one grey would make one. No tone beside them |
|
|
32
32
|
| Running | The last column is a period still in progress, in either form |
|
|
33
|
+
| Rises and falls | A signed series in the plain form, such as a net change a day: values under zero are falls |
|
|
33
34
|
|
|
34
35
|
**The colour of a part.** Parts with no tone are one whole, so they share the accent, mixed into the surface: deepest at the bottom and paler with each part above, between the theme's `chart-stack-hi` and `chart-stack-lo` (dark 92% to 20%, light 78% to 36%). It is the mix a stacked Trend chart takes, from the same function, so the two read as one family. Once any part names a tone the parts are outcomes: the usual outcome is `neutral` and takes `chart-neutral-strong`, and one that needs somebody takes `warning` or `critical`. A part with no tone among parts that have one is neutral too, so the accent never stands beside a status colour, and the positive colour is never used for the usual case. When a part already is the neutral (`tone: 'neutral'`), a second usual part, Other among them, takes no tone and is drawn in the quieter `chart-neutral`, so the two usual parts are told apart and Other stays the quietest; like the paler parts of one hue, it is not held to 3:1, and the swatch's hairline, the legend and the table carry it. When the parts are identities (teams), or more than one outcome is usual, give each a categorical slot, `color` 1 to 6 in order, as a Trend series takes one: each draws in `series-<n>`, and a part with no slot (Other among them) is neutral. Keep status colours out of such a chart, as the accent is kept out of a toned one: coral (2) and saffron (5) read as a warning beside a real one. A chart with an outcome that needs somebody is a toned chart.
|
|
35
36
|
|
|
37
|
+
**Rises and falls.** A plain chart draws a signed series whole. When a value is under zero the scale goes down to a step under the lowest one, zero is one of its levels and is drawn as the axis (`chart-axis`), and the columns stand on it: a rise stands up from it and a fall hangs down from it, rounded at its lower end, its value at rest under it, its running outline open at the zero line. A fall is the same neutral as a rise, and the highlighted column the accent: which side of zero a column is on says the sign, and a second colour would say it twice. The plot keeps 28px under its lowest tick, as over its top one, for a fall's value. Format a change with its sign (`+12`, `−8`, with the true minus) so a figure read alone says which way it went. Stacked, the parts make up a whole, so a part under zero is no contribution and draws nothing; a signed series is plain. While no value is under zero, the scale is from zero as before.
|
|
38
|
+
|
|
36
39
|
**At most four parts.** Past four, nobody tells the steps of one hue apart. Given more, the fourth drawn is "Other": the sum of the fourth and every later part, in the columns, the legend, the readout and the table alike. Among outcomes it has no tone of its own and is neutral.
|
|
37
40
|
|
|
38
41
|
**The value at rest.** While `(plot width - 2 × (n - 1)) / n` is 36 or more for n columns, the plot width being the chart's width less the axis gutter, every column carries its value above it, or its total when stacked, as the chart formats it. Narrower, the figures would collide, so only the pointed column carries one. Pass a compact format: a figure wider than its column is cut with an ellipsis at rest, keeps the whole of itself in its column's title, and reads whole when the column is pointed at. A column that wide always has its whole gap, so the 2 in the rule is the gap as drawn.
|
|
@@ -4,7 +4,7 @@ import { useState, type KeyboardEvent } from 'react';
|
|
|
4
4
|
import { cn } from '@/lib/cn';
|
|
5
5
|
import { useMeridian } from '@/components/charts/meridian';
|
|
6
6
|
import { Readout } from '@/components/charts/readout';
|
|
7
|
-
import { niceTicks, SERIES_VAR, stackPart } from '@/components/charts/scale';
|
|
7
|
+
import { niceTicks, SERIES_VAR, stackPart, ticksBetween } from '@/components/charts/scale';
|
|
8
8
|
import { useSize } from '@/components/charts/use-size';
|
|
9
9
|
|
|
10
10
|
/** `null` is a period with no data: it keeps its slot, draws no column and reads `noData`, never zero. */
|
|
@@ -69,7 +69,7 @@ const opens = (n: number) => Array.from({ length: n > 1 ? GAP : 0 }, (_, k) => n
|
|
|
69
69
|
const gapOf = (n: number) => (n > 1 ? `calc(${opens(n).map((w) => `clamp(0px, (100% - ${w}px) * 1000, 1px)`).join(' + ')})` : '0px');
|
|
70
70
|
const TONES = { neutral: 'var(--chart-neutral-strong)', warning: 'var(--warning)', critical: 'var(--critical)' };
|
|
71
71
|
const count = (n: number) => n.toLocaleString('en-US');
|
|
72
|
-
/** A
|
|
72
|
+
/** A part that is missing, or below zero, is no contribution to its column: parts make up a whole. */
|
|
73
73
|
const amount = (v: number | null | undefined) => (typeof v === 'number' && v > 0 ? v : 0);
|
|
74
74
|
|
|
75
75
|
/** A column as drawn: its parts' values, `null` where a part has no data, and whether the whole column has none. */
|
|
@@ -149,9 +149,15 @@ export function ColumnChart({
|
|
|
149
149
|
const fill = height === 'fill';
|
|
150
150
|
const soFar = running ? n - 1 : -1;
|
|
151
151
|
|
|
152
|
-
// A period with no data has no height, so the scale is the periods that have one.
|
|
153
|
-
|
|
154
|
-
const
|
|
152
|
+
// A period with no data has no height, so the scale is the periods that have one. Plain, a value under zero is a
|
|
153
|
+
// fall: the scale goes under zero for it and the column hangs from the zero line (#78). Stacked, parts make a whole.
|
|
154
|
+
const read = bars.filter((b) => !b.none).map((b) => b.total);
|
|
155
|
+
const hi = Math.max(0, ...read), lo = Math.min(0, ...read);
|
|
156
|
+
const signed = !form.parts && lo < 0;
|
|
157
|
+
const ticks = signed ? ticksBetween(lo, hi, 3, whole) : niceTicks(hi > 0 ? hi : 1, 3, whole);
|
|
158
|
+
const low = ticks[0], top = ticks[ticks.length - 1];
|
|
159
|
+
/** A level's height in the scale, as a share of it from the bottom. */
|
|
160
|
+
const at100 = (v: number) => ((v - low) / (top - low)) * 100;
|
|
155
161
|
const af = axisFormat ?? format;
|
|
156
162
|
const left = Math.max(...ticks.map((t) => af(t).length)) * 6.4 + 10;
|
|
157
163
|
const longest = Math.max(...bars.map((b) => b.label.length));
|
|
@@ -213,19 +219,21 @@ export function ColumnChart({
|
|
|
213
219
|
It stands inside the plot's box with room for all it draws around itself: 28px over the top tick, the height of
|
|
214
220
|
a pointed value and its 4px off the column, and 8px under the zero tick, the half line its label hangs. So the
|
|
215
221
|
focus ring, 4px outside the box, crosses nothing the plot draws, and nothing moves when the width arrives. */}
|
|
216
|
-
<div className=
|
|
222
|
+
<div className={cn('absolute inset-x-0 top-7', signed ? 'bottom-7' : 'bottom-2')}>
|
|
217
223
|
{ticks.map((t) => (
|
|
218
|
-
<div key={t} aria-hidden className="absolute right-0 flex items-center" style={{ left: 0, bottom: `${(t
|
|
224
|
+
<div key={t} aria-hidden className="absolute right-0 flex items-center" style={{ left: 0, bottom: `${at100(t)}%`, transform: 'translateY(50%)' }}>
|
|
219
225
|
<span data-tick className="t-num w-(--w) pr-2.5 text-right text-2xs leading-4 text-ink-3" style={{ ['--w' as string]: `${left}px` }}>{af(t)}</span>
|
|
220
226
|
<span className={cn('h-px flex-1', t === 0 ? 'bg-chart-axis' : 'bg-chart-grid')} />
|
|
221
227
|
</div>
|
|
222
228
|
))}
|
|
223
|
-
<div className="absolute inset-y-0 right-0 flex
|
|
229
|
+
<div className="absolute inset-y-0 right-0 flex" style={{ left, columnGap: gapOf(n) }}>
|
|
224
230
|
{bars.map((b, i) => {
|
|
225
231
|
const pointed = active === i;
|
|
226
232
|
const hot = pointed || (active === null && b.key === highlight);
|
|
227
233
|
const shown = drawn ? drawn.map((p, k) => ({ p, v: b.values[k] ?? 0 })).filter(({ v }) => v > 0) : [];
|
|
228
234
|
const unfinished = i === soFar;
|
|
235
|
+
// A fall hangs from the zero line: it grows down, its round end and its value under it.
|
|
236
|
+
const fall = signed && !b.none && b.total < 0;
|
|
229
237
|
// Plain, the pointed value is the chart's readout and takes the tooltip's plane; stacked, the panel is.
|
|
230
238
|
const tip = pointed && !drawn;
|
|
231
239
|
// At rest a label is held to its column. A running period with no data whose words do not fit says "So far",
|
|
@@ -241,7 +249,7 @@ export function ColumnChart({
|
|
|
241
249
|
data-no-data={b.none ? '' : undefined}
|
|
242
250
|
// A figure wider than its column is cut at rest, and keeps the whole of itself where the pointer finds it.
|
|
243
251
|
title={rest && text.length * 7 > column ? text : undefined}
|
|
244
|
-
className="group/col relative
|
|
252
|
+
className="group/col relative h-full min-w-0 flex-1"
|
|
245
253
|
onPointerEnter={() => set(i)}
|
|
246
254
|
onPointerDown={() => set(i)}
|
|
247
255
|
>
|
|
@@ -249,19 +257,29 @@ export function ColumnChart({
|
|
|
249
257
|
period with no data has no height: it keeps its slot with a dashed hairline just above the baseline,
|
|
250
258
|
and its word stands over it where a value would. A track the height of the scale was the tallest mark
|
|
251
259
|
on the chart, and the eye went to the one period that had nothing to say. */}
|
|
252
|
-
<div
|
|
260
|
+
<div
|
|
261
|
+
data-box
|
|
262
|
+
data-fall={fall ? '' : undefined}
|
|
263
|
+
className="absolute inset-x-0"
|
|
264
|
+
style={{
|
|
265
|
+
...(fall ? { top: `${100 - at100(0)}%` } : { bottom: `${at100(0)}%` }),
|
|
266
|
+
height: b.none ? '0%' : `${(Math.abs(b.total) / (top - low)) * 100}%`,
|
|
267
|
+
minHeight: (drawn ? Math.max(0, shown.length * 4 - 1) : b.total !== 0 ? 2 : 0) + (unfinished ? 2 : 0),
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
253
270
|
<div
|
|
254
271
|
className={cn(
|
|
255
|
-
'grow-y relative h-full
|
|
272
|
+
'grow-y relative h-full',
|
|
273
|
+
fall ? 'rounded-b-[4px]' : 'rounded-t-[4px]',
|
|
256
274
|
drawn && 'transition-[box-shadow] duration-(--dur-hover)',
|
|
257
275
|
drawn && hot && 'z-1 ring-2 ring-ink',
|
|
258
276
|
// A period still running: a dashed outline, open at the baseline, around a fill at half strength. The
|
|
259
277
|
// outline has a pixel of its own and a pixel of the card inside it, so it covers no part and holds
|
|
260
278
|
// its contrast whatever it surrounds; it is what keeps a faded column a column on a dark card. It
|
|
261
279
|
// needs 4px of its own; in a narrower column it ends with the column and never runs past the plot.
|
|
262
|
-
unfinished && 'ml-[min(0px,calc(100%-4px))] border border-
|
|
280
|
+
unfinished && cn('ml-[min(0px,calc(100%-4px))] border border-dashed border-ink p-px', fall ? 'border-t-0 pt-0' : 'border-b-0 pb-0'),
|
|
263
281
|
)}
|
|
264
|
-
style={{ ['--i' as string]: i }}
|
|
282
|
+
style={{ ['--i' as string]: i, ...(fall ? { transformOrigin: 'center top' } : null) }}
|
|
265
283
|
>
|
|
266
284
|
{b.none ? (
|
|
267
285
|
<div data-no-data-mark aria-hidden className="absolute inset-x-0 bottom-0.5 border-t border-dashed border-line-strong" />
|
|
@@ -281,13 +299,13 @@ export function ColumnChart({
|
|
|
281
299
|
))}
|
|
282
300
|
</div>
|
|
283
301
|
) : (
|
|
284
|
-
<div className={cn('h-full w-full
|
|
302
|
+
<div className={cn('h-full w-full transition-colors duration-(--dur-hover)', fall ? 'rounded-b-[4px]' : 'rounded-t-[4px]', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/col:bg-chart-neutral-strong', unfinished && 'opacity-50')} />
|
|
285
303
|
)}
|
|
286
304
|
</div>
|
|
287
305
|
{rest || pointed ? (
|
|
288
306
|
// One element at rest and pointed at, so its entrance never replays. At rest the value is held to its
|
|
289
307
|
// column; the tooltip, and a total shown only on pointing, may be wider than theirs.
|
|
290
|
-
<span aria-hidden className={cn('pointer-events-none absolute bottom-full mb-1', rest && 'fade-in', tip || !rest ? cn('z-10', placed(i)) : 'inset-x-0')}>
|
|
308
|
+
<span aria-hidden className={cn('pointer-events-none absolute', fall ? 'top-full mt-1' : 'bottom-full mb-1', rest && 'fade-in', tip || !rest ? cn('z-10', placed(i)) : 'inset-x-0')}>
|
|
291
309
|
<span
|
|
292
310
|
className={cn(
|
|
293
311
|
't-num block text-2xs leading-4',
|
|
@@ -54,6 +54,11 @@ const byEndpoint = ENDPOINTS.map((e) => e.requests);
|
|
|
54
54
|
// The sample's clock stands at 09:00 UTC on its last day, so that day has run nine hours of its twenty-four.
|
|
55
55
|
const soFar = (d: DailyPoint) => (d === week[week.length - 1] ? Math.round((d.requests * DEMO_NOW.getUTCHours()) / 24) : d.requests);
|
|
56
56
|
const errors = (days: DailyPoint[]) => days.map((d) => ({ key: d.date, label: formatShortDate(d.date), value: d.errors }));
|
|
57
|
+
/** A day's errors less the day before's: a signed series, rises and falls about zero. */
|
|
58
|
+
const lastEight = month.slice(-8);
|
|
59
|
+
const errorChange = lastEight.slice(1).map((d, i) => ({ key: d.date, label: formatShortDate(d.date), value: d.errors - lastEight[i].errors }));
|
|
60
|
+
/** A change reads with its sign, and a fall with the true minus sign, on the axis as at rest. */
|
|
61
|
+
const signed = (read: (n: number) => string) => (n: number) => (n > 0 ? `+${read(n)}` : n < 0 ? `\u2212${read(-n)}` : read(0));
|
|
57
62
|
// The sample has no day of zero, so the zero specimens make one: a day with no errors, a day with no requests at all,
|
|
58
63
|
// and a day with none from one region.
|
|
59
64
|
const noErrors = errors(week).map((c, i) => (i === 2 ? { ...c, value: 0 } : c));
|
|
@@ -85,6 +90,11 @@ export default function ColumnChartPreview() {
|
|
|
85
90
|
<ColumnChart label="Errors per day, last 7 days" height={160} axisFormat={axis} highlight={week[week.length - 1].date} dates={dates(week)} columns={errors(week)} />
|
|
86
91
|
</Plane>
|
|
87
92
|
</Specimen>
|
|
93
|
+
<Specimen label="Rises and falls" note="A signed series, here each day's errors less the day before's: the scale goes under zero for it and zero is drawn as the axis. A rise stands on the zero line and a fall hangs from it, rounded at its lower end, its value under it. Parts make a whole, so a stacked column draws no part under zero; a signed series is plain.">
|
|
94
|
+
<Plane on="surface" className="max-w-lg">
|
|
95
|
+
<ColumnChart label="Change in errors, day on day" height={200} format={signed(formatCompact)} axisFormat={signed((n) => axis(n))} dates={dates(lastEight.slice(1))} columns={errorChange} />
|
|
96
|
+
</Plane>
|
|
97
|
+
</Specimen>
|
|
88
98
|
<Specimen label="On the Meridian" note="Given dates, the columns share the page's cursor: point at a column, or move through them with the arrow keys, and the trend above follows.">
|
|
89
99
|
<Meridian dates={dates(month)}>
|
|
90
100
|
<Plane on="surface" className="flex flex-col gap-6">
|
|
@@ -13,10 +13,12 @@ export function niceTicks(max: number, count = 4, whole = false): number[] {
|
|
|
13
13
|
* "Nice" levels from `floor` up to at least `max`: the same steps, counted from `floor` rather than from zero. For a
|
|
14
14
|
* quantity that lives in a narrow band — uptime, a success rate, a cache hit rate — where a zero-based axis would make
|
|
15
15
|
* the one thing the chart is about the smallest mark on it. Both ends land on a step, so the lowest level is the floor
|
|
16
|
-
* the plot is drawn to and `ticks[0]` may be read as it.
|
|
16
|
+
* the plot is drawn to and `ticks[0]` may be read as it. A floor under zero is a step under zero, so a signed series
|
|
17
|
+
* (a net change, a net flow) is drawn on levels either side of it, zero among them. Returns `[0, 1]` like `niceTicks`
|
|
18
|
+
* when there is nothing to show.
|
|
17
19
|
*/
|
|
18
20
|
export function ticksBetween(floor: number, max: number, count = 4, whole = false): number[] {
|
|
19
|
-
if (max <= 0) return [0, 1];
|
|
21
|
+
if (max <= 0 && floor >= 0) return [0, 1];
|
|
20
22
|
const raw = (max - floor) / count;
|
|
21
23
|
if (!(raw > 0)) return niceTicks(max, count, whole);
|
|
22
24
|
const mag = 10 ** Math.floor(Math.log10(raw));
|
|
@@ -27,8 +29,11 @@ export function ticksBetween(floor: number, max: number, count = 4, whole = fals
|
|
|
27
29
|
// and a floor of 70% on a 10% step would otherwise come out as 60% — a whole step of the plot spent on nothing.
|
|
28
30
|
const steps = (n: number) => Math.round(n / step * 1e6) / 1e6;
|
|
29
31
|
const top = Math.ceil(steps(max)) * step;
|
|
30
|
-
const low =
|
|
31
|
-
|
|
32
|
+
const low = Math.floor(steps(floor)) * step;
|
|
33
|
+
// Each level is rounded to a digit under its step, never to a fixed number of places: a ratio near 1.001061 steps
|
|
34
|
+
// in millionths, and six places would round two of its levels to one (#78).
|
|
35
|
+
const places = Math.max(0, 1 - Math.floor(Math.log10(step)));
|
|
36
|
+
return Array.from({ length: Math.round((top - low) / step) + 1 }, (_, i) => Number((low + i * step).toFixed(places)) || 0);
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/**
|