zz-meridian 0.26.0 → 0.27.1
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/package.json +1 -1
- package/payload/CHANGELOG.md +31 -0
- package/payload/app/embed/now/view.tsx +7 -1
- package/payload/app/system/(atlas)/page.tsx +5 -2
- package/payload/package.json +1 -1
- package/payload/scripts/audit.ts +34 -5
- package/payload/scripts/interactions.ts +8 -0
- package/payload/scripts/keyboard.ts +3 -0
- package/payload/scripts/live.ts +2 -1
- package/payload/scripts/verify.ts +2 -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/prose/index.tsx +2 -2
- package/payload/src/components/patterns/rail/index.tsx +3 -2
- package/payload/src/components/patterns/shell-tools/index.tsx +2 -1
- package/payload/src/components/ui/table/index.tsx +23 -10
- package/payload/src/system/markdown.tsx +2 -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 +33 -0
- package/payload/tests/shell-search-name.test.tsx +16 -0
- package/payload/tests/table-grid.test.tsx +31 -0
- package/payload/tests/trend-chart-signed.test.tsx +54 -0
- package/payload/tests/verify-loopback.test.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.1",
|
|
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,37 @@
|
|
|
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.1] · 2026-10-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`verify` and `live` start the app they measure on loopback** ([#81](https://github.com/zhixuan312/zz-meridian/issues/81)). `next start` with no host bound every interface, so for the half hour a full run takes, the app under test — served with the fake data source — answered on the local network. Both start it with `-H 127.0.0.1`; every client of a run already reached it there.
|
|
10
|
+
- **The keyboard walk and the presses open a disclosure before reaching into it** ([#82](https://github.com/zhixuan312/zz-meridian/issues/82)). A closed `<details>` lays its content out without drawing it, so a button inside was tagged as visible and never reached, and its press found nothing at its centre; `verify --full` failed a page with a folded table. Both suites open the disclosures they walk into, and the presses leave shut the one whose own `summary` holds the control.
|
|
11
|
+
- **The shell's Search button keeps its name below 40rem** ([#83](https://github.com/zhixuan312/zz-meridian/issues/83)). The only word naming it was `display: none`, leaving an icon-only button with no accessible name on every view at 390px. It is `sr-only` now: the word is not drawn, and it is still the button's name.
|
|
12
|
+
- **The rail's menu trigger separates the product name from the workspace** ([#84](https://github.com/zhixuan312/zz-meridian/issues/84)). Its visible text ran the two together, and WCAG 2.5.3 asks for the visible label inside the accessible name, where a comma alone kept them apart. A space now sits between them: invisible between two block spans, present for the name.
|
|
13
|
+
- **A table written in a server component lays out like one written on the client** ([#85](https://github.com/zhixuan312/zz-meridian/issues/85)). `Table` found its columns by component identity, and a server component reaches the client table as lazy references, so it found none and drew every column as one. It counts them from the table's shape now: the first row of the first section that has one, its component cells, through fragments.
|
|
14
|
+
- **The audit fails a strip that scrolls sideways at a phone width, and text drawn past a box that does not clip it** ([#86](https://github.com/zhixuan312/zz-meridian/issues/86)). A strip of chips wider than 390px hid its last items with nothing saying so, and a figure too long for its grid column passed while drawn over its neighbour and past its card. The first is reported unless it is the page's scroll region or draws the system's cut-edge fade (`scroll-fade-x`); the second is measured on the glyphs with a `Range`. The stricter reading found the Atlas counts row overflowing its cells at 1024, which now keeps four columns until `xl` and draws a rule only at the start of a row. The prose tables and code blocks that legitimately scroll now draw that fade.
|
|
15
|
+
|
|
16
|
+
## [0.27.0] · 2026-10-23
|
|
17
|
+
|
|
18
|
+
### Breaking
|
|
19
|
+
|
|
20
|
+
- **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.
|
|
21
|
+
- `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.
|
|
22
|
+
- `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.
|
|
23
|
+
- Levels are rounded to a digit under their step, not to six places, so a ratio a millionth apart keeps one level per tick.
|
|
24
|
+
- **`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.
|
|
25
|
+
|
|
26
|
+
### New
|
|
27
|
+
|
|
28
|
+
- **`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.
|
|
29
|
+
- The Trend chart's Atlas page draws "Below zero" and "A format of its own". The Column chart's page draws "Rises and falls".
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **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.
|
|
34
|
+
- **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.
|
|
35
|
+
|
|
5
36
|
## [0.26.0] · 2026-10-22
|
|
6
37
|
|
|
7
38
|
### Breaking
|
|
@@ -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
|
|
@@ -63,9 +63,12 @@ export default function AtlasHome() {
|
|
|
63
63
|
</section>
|
|
64
64
|
|
|
65
65
|
{/* Counts */}
|
|
66
|
-
|
|
66
|
+
{/* Eight columns only where the numbers and their labels fit them: at 1024 with the rail, eight cells were 49px wide
|
|
67
|
+
and five labels ran past the cell's edge and its neighbour's padding. Four until xl, and a rule only at the start
|
|
68
|
+
of a row, whichever count is in that row. */}
|
|
69
|
+
<section aria-label="The system in numbers" className="mt-20 grid grid-cols-2 border-y border-line sm:grid-cols-4 xl:grid-cols-8">
|
|
67
70
|
{counts.map(([n, l], i) => (
|
|
68
|
-
<div key={l} className={'px-1 py-6 sm:px-5 ' + (i
|
|
71
|
+
<div key={l} className={'border-line px-1 py-6 sm:px-5 ' + (i % 2 ? 'border-l ' : '') + (i % 4 === 0 ? 'sm:border-l-0 ' : 'sm:border-l ') + (i > 0 ? 'xl:border-l' : 'xl:border-l-0')}>
|
|
69
72
|
<p className="t-figure t-num">{n}</p>
|
|
70
73
|
<p className="t-eyebrow mt-2">{l}</p>
|
|
71
74
|
</div>
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.1",
|
|
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/audit.ts
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* light-DOM content, and one that is still undefined after the settle wait, are reported as `unmeasured:` instead of passed.
|
|
19
19
|
*
|
|
20
20
|
* Fails (exit 1) on: a route that served another page or dropped a parameter it was asked for, a page whose content
|
|
21
|
-
* region rendered nothing or that threw or logged a failure while it rendered, sideways scroll
|
|
22
|
-
*
|
|
21
|
+
* region rendered nothing or that threw or logged a failure while it rendered, sideways scroll (a strip that scrolls at a
|
|
22
|
+
* phone width included), text clipped without an ellipsis, text drawn past a box that does not clip it, text cut with an
|
|
23
|
+
* ellipsis that keeps the whole of it nowhere, two pieces of text in the same place or a line
|
|
23
24
|
* drawn through one, a control with no accessible name or one under 44px on touch, a heading two levels below the one
|
|
24
25
|
* before it, more than one page scroller, and rendered text under its WCAG minimum, an SVG's text included, read by its
|
|
25
26
|
* `fill` against whatever the SVG paints behind it. Prints the design metrics per page: distinct type sizes, weights and
|
|
@@ -83,13 +84,13 @@ if (atlasLine) console.log(atlasLine);
|
|
|
83
84
|
|
|
84
85
|
type Report = {
|
|
85
86
|
path: string; href: string; text: number;
|
|
86
|
-
sideways: string[]; clipped: string[]; truncated: string[]; overlap: string[]; focusless: string[]; unnamed: string[]; unmeasured: string[]; scrollers: string[]; contrast: string[]; targets: string[]; headings: string[];
|
|
87
|
+
sideways: string[]; clipped: string[]; spills: string[]; truncated: string[]; overlap: string[]; focusless: string[]; unnamed: string[]; unmeasured: string[]; scrollers: string[]; contrast: string[]; targets: string[]; headings: string[];
|
|
87
88
|
sizes: number[]; weights: number[]; radii: number[]; ratio: number;
|
|
88
89
|
};
|
|
89
90
|
|
|
90
91
|
/** Runs inside the page. Plain JavaScript: it is serialised into the browser. */
|
|
91
92
|
const MEASURE = `(() => {
|
|
92
|
-
const W = innerWidth, out = { path: location.pathname, href: location.href, text: 0, sideways: [], clipped: [], truncated: [], overlap: [], unnamed: [], unmeasured: [], scrollers: [], contrast: [], targets: [], headings: [], sizes: [], weights: [], radii: [], ratio: 0 };
|
|
93
|
+
const W = innerWidth, out = { path: location.pathname, href: location.href, text: 0, sideways: [], clipped: [], spills: [], truncated: [], overlap: [], unnamed: [], unmeasured: [], scrollers: [], contrast: [], targets: [], headings: [], sizes: [], weights: [], radii: [], ratio: 0 };
|
|
93
94
|
const label = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).slice(0, 3).join('.') : '')).slice(0, 90);
|
|
94
95
|
/* closest() across open roots and slots: deepParent (injected from scripts/lib/deep.ts) steps through both */
|
|
95
96
|
const deepClosest = (el, sel) => { for (let n = el; n; n = deepParent(n)) if (n.matches(sel)) return n; return null; };
|
|
@@ -98,6 +99,10 @@ const MEASURE = `(() => {
|
|
|
98
99
|
if (sr.scrollWidth > W + 1) out.sideways.push('document ' + sr.scrollWidth + 'px wide at ' + W);
|
|
99
100
|
const all = deepAll(document.body).filter((n) => n !== document.body);
|
|
100
101
|
all.filter((n) => n.matches('[data-scroll-region]')).forEach((s) => { if (s.scrollWidth > s.clientWidth + 1) out.sideways.push('scroll region ' + s.scrollWidth + ' > ' + s.clientWidth); });
|
|
102
|
+
// At a phone width nothing may hide off a sideways edge: a strip that scrolls holds items a reader cannot see, and the
|
|
103
|
+
// page cannot be scrolled to them. Two are not reported here: the page's own scroll region, which the check above owns,
|
|
104
|
+
// and a strip that fades at the cut edge (.scroll-fade-x), the system's own way of saying there is more this way.
|
|
105
|
+
if (W <= 400) all.filter((n) => !n.matches('[data-scroll-region], .scroll-fade-x') && n.clientWidth > 0 && /auto|scroll/.test(getComputedStyle(n).overflowX) && n.scrollWidth > n.clientWidth + 1).forEach((n) => out.sideways.push('strip ' + label(n) + ' ' + n.scrollWidth + ' > ' + n.clientWidth));
|
|
101
106
|
// A dashboard fills its canvas at every size: a data page narrower than the scroll region (less the gutters) is a
|
|
102
107
|
// centred strip on a wide screen.
|
|
103
108
|
all.filter((n) => n.matches('[data-page-width="data"]')).forEach((p) => { const region = deepClosest(p, '[data-scroll-region]'); if (region && p.getBoundingClientRect().width < region.clientWidth - 2) out.sideways.push('data page ' + Math.round(p.getBoundingClientRect().width) + 'px wide in a ' + region.clientWidth + 'px canvas (it should fill it)'); });
|
|
@@ -170,6 +175,29 @@ const MEASURE = `(() => {
|
|
|
170
175
|
texts.push(fs); out.sizes.push(Math.round(fs)); out.weights.push(+c.fontWeight);
|
|
171
176
|
if (el.scrollWidth > el.clientWidth + 1 && c.textOverflow !== 'ellipsis' && /hidden|clip/.test(c.overflow) && el.clientWidth > 0) out.clipped.push(label(el) + ' "' + el.textContent.trim().slice(0, 30) + '"');
|
|
172
177
|
if (el.scrollWidth > el.clientWidth + 1 && c.textOverflow === 'ellipsis' && /hidden|clip/.test(c.overflowX) && el.clientWidth > 0 && !titled(el)) out.truncated.push(label(el) + ' "' + el.textContent.trim().slice(0, 30) + '"');
|
|
178
|
+
// Text drawn past a box that does not clip it runs over whatever is beside that box: a figure too long for its grid
|
|
179
|
+
// column, over its neighbour and past its card's edge. Measured on the glyphs with a Range, since scrollWidth also
|
|
180
|
+
// counts an enlarged touch target. A clipping box ends the walk: what it hides is the clipped check's business, and
|
|
181
|
+
// a positioned box may overhang on purpose.
|
|
182
|
+
if (!(el instanceof SVGElement) && !/absolute|fixed/.test(c.position)) {
|
|
183
|
+
let left = Infinity, right = -Infinity;
|
|
184
|
+
for (const node of el.childNodes) {
|
|
185
|
+
if (node.nodeType !== 3 || !node.textContent.trim()) continue;
|
|
186
|
+
const range = document.createRange(); range.selectNodeContents(node);
|
|
187
|
+
const b = range.getBoundingClientRect();
|
|
188
|
+
left = Math.min(left, b.left); right = Math.max(right, b.right);
|
|
189
|
+
}
|
|
190
|
+
if (right > left) for (let n = el; n && n !== document.documentElement; n = deepParent(n)) {
|
|
191
|
+
const nc = getComputedStyle(n);
|
|
192
|
+
if (/hidden|clip|auto|scroll/.test(nc.overflowX) || /absolute|fixed/.test(nc.position)) break;
|
|
193
|
+
const b = n.getBoundingClientRect();
|
|
194
|
+
// display: contents draws no box of its own (a card's phone layout uses it to unwrap a tile): its rect is at
|
|
195
|
+
// the origin and zero wide, and comparing glyphs to it reported every figure on the page.
|
|
196
|
+
if (nc.display === 'contents' || (!b.width && !b.height)) continue;
|
|
197
|
+
const past = Math.max(right - (b.right - (parseFloat(nc.borderRightWidth) || 0)), b.left + (parseFloat(nc.borderLeftWidth) || 0) - left);
|
|
198
|
+
if (past > 1) { out.spills.push(label(el) + ' "' + el.textContent.trim().slice(0, 30) + '" spills its box by ' + Math.round(past) + 'px'); break; }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
173
201
|
inked.push(el);
|
|
174
202
|
// Gradient text paints its glyphs with a clipped background (color is transparent); both ends sit at accent-ink's
|
|
175
203
|
// lightness, which the contrast gate already holds, so it is not measured here.
|
|
@@ -274,7 +302,7 @@ const MEASURE = `(() => {
|
|
|
274
302
|
// Sampled every two pixels: four samples is eight pixels of line inside the text.
|
|
275
303
|
for (const [el, k] of inside) if (k >= 4) out.overlap.push('a line (' + label(g) + ') is drawn through ' + quote(el));
|
|
276
304
|
}
|
|
277
|
-
out.overlap = [...new Set(out.overlap)].slice(0, 8); out.truncated = [...new Set(out.truncated)].slice(0, 8);
|
|
305
|
+
out.overlap = [...new Set(out.overlap)].slice(0, 8); out.truncated = [...new Set(out.truncated)].slice(0, 8); out.spills = [...new Set(out.spills)].slice(0, 8);
|
|
278
306
|
// Headings descend one level at a time, so a screen reader's outline has no holes.
|
|
279
307
|
// A specimen's headings are the card's own, at the level the card uses where it is placed, not the documentation page's.
|
|
280
308
|
const levels = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].filter((h) => !h.closest('[aria-hidden="true"]') && !h.closest('[data-specimen]')).map((h) => +h.tagName[1]);
|
|
@@ -370,6 +398,7 @@ const run = async (route: string, width: number, theme: string, embed: boolean)
|
|
|
370
398
|
...(r.text < 24 ? [`the page holds ${r.text} characters of text — it rendered nothing, or only its loading state`] : []),
|
|
371
399
|
...r.sideways.map((x) => 'sideways: ' + x),
|
|
372
400
|
...r.clipped.map((x) => 'clipped: ' + x),
|
|
401
|
+
...r.spills.map((x) => 'spills: ' + x),
|
|
373
402
|
...r.truncated.map((x) => 'truncated: ' + x + ' is cut with an ellipsis and no title holds the whole of it'),
|
|
374
403
|
...r.overlap.map((x) => 'overlap: ' + x),
|
|
375
404
|
...r.unnamed.map((x) => 'unnamed: ' + x),
|
|
@@ -77,6 +77,14 @@ const LIST = `(() => {${CONTROLS}
|
|
|
77
77
|
const ARM = (name: string, nth: number, key: string, keyNth: number) => `(() => {${CONTROLS}
|
|
78
78
|
const all = pressable(deepAll(document.body));
|
|
79
79
|
const el = all.filter((b) => nameOf(b) === ${JSON.stringify(name)})[${nth}] ?? all.filter((b) => keyOf(b) === ${JSON.stringify(key)})[${keyNth}]; if (!el) return null;
|
|
80
|
+
// A control inside a closed disclosure is laid out but not drawn: open the ones it sits in, as a reader does before
|
|
81
|
+
// pressing it. The one whose own summary holds the control stays shut, since pressing that summary is the control (#82).
|
|
82
|
+
for (let n = deepParent(el); n; n = deepParent(n)) {
|
|
83
|
+
if (n.localName !== 'details' || n.open) continue;
|
|
84
|
+
const own = [...n.children].find((c) => c.localName === 'summary');
|
|
85
|
+
if (own && own.contains(el)) continue;
|
|
86
|
+
n.open = true;
|
|
87
|
+
}
|
|
80
88
|
el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
81
89
|
const r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
82
90
|
const hit = document.elementFromPoint(x, y);
|
|
@@ -51,6 +51,9 @@ const TAG = `(() => {
|
|
|
51
51
|
// :disabled, not [disabled]: the attribute is only the control's own, and a fieldset with disabled disables every
|
|
52
52
|
// control inside it without touching their attributes. A FormSection in its read-only or saving state is exactly
|
|
53
53
|
// that, so the attribute form expected a correctly-disabled input to be reachable, and failed on a page that was right.
|
|
54
|
+
// A reader opens a disclosure to reach what is in it. A closed <details> lays its content out without drawing it, so
|
|
55
|
+
// the content has a real box while Tab skips it: tagged, it was reported as never reached (#82).
|
|
56
|
+
for (const n of deepAll(document.body)) if (n.localName === 'details' && !n.open) n.open = true;
|
|
54
57
|
const every = deepAll(document.body).filter((n) => n !== document.body);
|
|
55
58
|
const all = every.filter((el) => el.matches('a[href], button:not(:disabled), input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'))
|
|
56
59
|
.filter((el) => vis(el) && el.tabIndex >= 0);
|
package/payload/scripts/live.ts
CHANGED
|
@@ -66,7 +66,8 @@ const status = (port: number) => new Promise<number | null>((resolve) => {
|
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
async function serve(port: number): Promise<ChildProcess> {
|
|
69
|
-
|
|
69
|
+
// Loopback only, as scripts/verify.ts starts it: every client here is on this machine (#81).
|
|
70
|
+
const server = spawn(bin('next'), ['start', '-H', '127.0.0.1', '-p', String(port)], { cwd: ROOT, env: process.env, stdio: 'ignore', detached: true });
|
|
70
71
|
started.push(server);
|
|
71
72
|
for (let i = 0; i < 120; i++) {
|
|
72
73
|
const code = await status(port);
|
|
@@ -391,10 +391,10 @@ process.on('exit', stopAll);
|
|
|
391
391
|
// An interrupted run does not emit 'exit' by itself, and the detached servers and running suites would outlive it.
|
|
392
392
|
for (const sig of ['SIGINT', 'SIGTERM'] as const) process.on(sig, () => process.exit(sig === 'SIGINT' ? 130 : 143));
|
|
393
393
|
|
|
394
|
-
/** Start the built app on a free port
|
|
394
|
+
/** Start the built app on a free port, loopback only, and wait until it answers: every client here reaches 127.0.0.1 (#81). */
|
|
395
395
|
async function start(env: NodeJS.ProcessEnv) {
|
|
396
396
|
const port = await freePort();
|
|
397
|
-
const server = spawn(bin('next'), ['start', '-p', String(port)], { cwd: ROOT, env, stdio: 'ignore', detached: true });
|
|
397
|
+
const server = spawn(bin('next'), ['start', '-H', '127.0.0.1', '-p', String(port)], { cwd: ROOT, env, stdio: 'ignore', detached: true });
|
|
398
398
|
children.push(server);
|
|
399
399
|
for (let i = 0; i < 120; i++) {
|
|
400
400
|
try { if ((await fetch(`http://127.0.0.1:${port}/`)).status < 500) return { port, server }; } catch { await new Promise((r) => setTimeout(r, 500)); }
|
|
@@ -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
|
/**
|
|
@@ -7,7 +7,7 @@ Status: beta
|
|
|
7
7
|
## Anatomy
|
|
8
8
|
|
|
9
9
|
1. **Plot**: horizontal gridlines in `chart-grid`, the baseline in `chart-axis`, 1px, crisp.
|
|
10
|
-
2. **Y axis**: 3 or 4 "nice" ticks from zero (whole numbers only with `whole`, for a chart of counts or a stack of them, so a top of 10 draws no level at 2.5 or 7.5), labels `text-2xs` `ink-3`, tabular, right-aligned in a gutter sized to the longest label. Axis labels drop cents and exact digits (`$12`, `1.2M`), and money from $10,000 reads at a glance (`$25K`, `$5.0B`) so a large scale does not widen the gutter; the tooltip and table carry the exact amounts.
|
|
10
|
+
2. **Y axis**: 3 or 4 "nice" ticks from zero, or from a step under zero when a value falls under it, up to a step at or over the highest value (whole numbers only with `whole`, for a chart of counts or a stack of them, so a top of 10 draws no level at 2.5 or 7.5), labels `text-2xs` `ink-3`, tabular, right-aligned in a gutter sized to the longest label. Axis labels drop cents and exact digits (`$12`, `1.2M`), and money from $10,000 reads at a glance (`$25K`, `$5.0B`) so a large scale does not widen the gutter; the tooltip and table carry the exact amounts.
|
|
11
11
|
3. **X axis**: dates, `text-2xs` `ink-3`, one label per 92px, counted back from today so the last day is always labelled.
|
|
12
12
|
4. **Series**: monotone curves that never overshoot their data.
|
|
13
13
|
5. **Band** (with `band`): the region from the floor up to the band's ceiling, in `fill-track`, under the gridlines, its top edge drawn as the axis is, and its label at an end of that edge — in `ink-2`, since it stands over the fill as often as over the card and `ink-3` on `fill-track` is 4.43:1 in light.
|
|
@@ -34,6 +34,10 @@ Status: beta
|
|
|
34
34
|
|
|
35
35
|
**A band is a region; a baseline is a line.** They are not interchangeable and a chart may carry both: a median is one level, a band is everything under one. Where a chart has both, the band's label is placed first, so what the page is judged against is named before what is usual.
|
|
36
36
|
|
|
37
|
+
**Below zero.** A signed series, such as a net change or a net flow, is drawn whole: without a floor the axis holds zero and every value, so it goes down to a step under the lowest one, zero is one of its levels and is drawn as the axis (`chart-axis`), and an `area` is filled to the zero line from either side, never down to the lowest level. The top is the data's own: a ratio that peaks at 0.4% is drawn to a step over 0.4%, not along the floor of a scale to 100%.
|
|
38
|
+
|
|
39
|
+
**A format of its own** (`format` as a function, with `axisFormat`): the named formats are what a server component passes, since a function cannot cross into a client one. From a client component, `format` may be a function for a figure no name says exactly: a price to four decimals, a ratio to six, an amount in a currency other than the product's. It reads the readout and the table, and the axis too unless `axisFormat` says otherwise, which is right only while the format is exact: one that rounds prints a tick it does not stand at. A missing value reads `—` whichever format is given. The levels are told apart however fine they are: each is rounded to a digit under its step, so a ratio a millionth apart keeps one level per tick.
|
|
40
|
+
|
|
37
41
|
**Floor** (`floor`): the lowest level the axis shows, for a quantity that lives in a narrow band. Uptime, a success rate and a cache hit rate are 100% on almost every day and never near zero, so an axis from zero spends most of the plot on a range no day reaches and leaves the one thing the card is about as a shallow dent — Services' uptime line sat pinned to the 100% gridline with 82% of the panel empty and its dips 32px of 180. The floor is snapped *down* to a step, so the level the plot is drawn to is one the axis names, and it is held at or under the lowest value drawn: a hint, never a crop, so the scale widens to the data and never hides a day below its own axis. For `line` and `dashed` series only — an `area` and every stack are read by their height, which a floor changes the meaning of. A caption should name the rule ("on an axis that starts under its lowest day rather than at zero") and not the number, which the next bad day would make wrong.
|
|
38
42
|
|
|
39
43
|
**Marks** (`marks: { dates, tone, label }`): the days the page names in its own words — the days a band left out, a change of state — marked so a reader finds them rather than hunting for one among 360 points. Each is a dot on the first series' own point, ringed in `surface` as the cursor's dots are, and a stem from the point down to the axis at 45% of the same tone. The stem is the load-bearing half: a day can leave a band on a measure the chart does not draw — its error rate, not its p95 — and then its point stands *inside* the band's region, where a 3px dot is lost in the line. A date the chart does not draw, and a day with no value, are skipped rather than marked at the wrong point. The tone is the caller's to say, as a finding's is: `warning` for a day worth a look, `neutral` for one that is only worth knowing. The caption names the count ("Marked: 3 days outside the band").
|
|
@@ -125,7 +129,7 @@ import { TrendChart } from '@/components/charts/trend-chart';
|
|
|
125
129
|
/>
|
|
126
130
|
```
|
|
127
131
|
|
|
128
|
-
Props: `dates`, `series` (`key`, `label`, `values`, `color`, `kind`), `format` (`count` | `compact` | `cost` | `cost-compact` | `duration` | `percent`), `height` (number or `fill`), `label`, `legend` (defaults to two or more series), `tick`, `stacked`, `whole` (the values are counts: whole levels only), `baseline`, `band`, `floor`, `marks`, `running` (the last point is still in progress).
|
|
132
|
+
Props: `dates`, `series` (`key`, `label`, `values`, `color`, `kind`), `format` (`count` | `compact` | `cost` | `cost-compact` | `duration` | `percent`, or a function from a client component), `axisFormat` (with a function `format`), `height` (number or `fill`), `label`, `legend` (defaults to two or more series), `tick`, `stacked`, `whole` (the values are counts: whole levels only), `baseline`, `band`, `floor`, `marks`, `running` (the last point is still in progress).
|
|
129
133
|
|
|
130
134
|
The colour of a stacked part is `stackPart(place, parts)` from `@/components/charts/scale`, the one mix every one-hue stack takes: the accent into the surface, by the part's place from the bottom.
|
|
131
135
|
|
|
@@ -92,6 +92,7 @@ export function TrendChart({
|
|
|
92
92
|
dates,
|
|
93
93
|
series,
|
|
94
94
|
format = 'count',
|
|
95
|
+
axisFormat,
|
|
95
96
|
height = 248,
|
|
96
97
|
label,
|
|
97
98
|
legend,
|
|
@@ -108,7 +109,16 @@ export function TrendChart({
|
|
|
108
109
|
}: {
|
|
109
110
|
dates: string[];
|
|
110
111
|
series: TrendSeries[];
|
|
111
|
-
|
|
112
|
+
/**
|
|
113
|
+
* How a value reads, in the readout and in the table, and on the axis unless `axisFormat` says otherwise. A name
|
|
114
|
+
* (`'cost'`, `'percent'`) is what a server component passes, since a function cannot cross into a client one; a
|
|
115
|
+
* function is for a figure no name says exactly, from a client component: a price to four decimals, a ratio to six,
|
|
116
|
+
* an amount in its own currency (#78).
|
|
117
|
+
*/
|
|
118
|
+
format?: NumberFormat | ((n: number) => string);
|
|
119
|
+
/** How a tick reads, when `format` is a function: an axis formatter, true at every tick. Without one the axis reads
|
|
120
|
+
* as `format` does, which is right only while `format` is exact: one that rounds prints a tick it does not stand at. */
|
|
121
|
+
axisFormat?: (n: number) => string;
|
|
112
122
|
/** Pixels, or `fill` to take the height its card gives it (never under 220px). */
|
|
113
123
|
height?: number | 'fill';
|
|
114
124
|
/** What the chart shows, for screen readers: "Requests per day". */
|
|
@@ -158,8 +168,9 @@ export function TrendChart({
|
|
|
158
168
|
const gid = useId().replace(/[^a-zA-Z0-9_-]/g, '');
|
|
159
169
|
const h = fill ? Math.max(220, size.height) : height;
|
|
160
170
|
|
|
161
|
-
const
|
|
162
|
-
const
|
|
171
|
+
const named = typeof format === 'string';
|
|
172
|
+
const fmt = named ? FORMATTERS[format] : (n: number | null) => (n === null ? '—' : format(n));
|
|
173
|
+
const axis = axisFormat ?? (named ? AXIS_FORMATTERS[format] : format);
|
|
163
174
|
// Stacked, each band sits on the sum of the ones below it; a missing value is no contribution that day.
|
|
164
175
|
const bases = useMemo(() => {
|
|
165
176
|
if (!stacked) return null;
|
|
@@ -174,10 +185,13 @@ export function TrendChart({
|
|
|
174
185
|
// Stacked parts are one whole, so they share one hue, each by its place in the stack. A series given its own colour keeps it.
|
|
175
186
|
const colorOf = (s: TrendSeries, k: number) => (stacked && !s.color ? stackPart(k, series.length) : SERIES_VAR(s.color ?? (s.kind === 'dashed' ? 'neutral' : 'accent')));
|
|
176
187
|
const values = totals ?? series.flatMap((s) => s.values.filter((v): v is number => v !== null));
|
|
177
|
-
const max = Math.max(1, baseline?.value ?? 0, band?.value ?? 0, ...values);
|
|
178
188
|
// A floor is a hint and never a crop: it is held at or under the lowest value drawn, or a day that fell further than
|
|
179
|
-
// the floor would be drawn below the plot's own axis. So the scale widens to the data and never hides it.
|
|
180
|
-
|
|
189
|
+
// the floor would be drawn below the plot's own axis. So the scale widens to the data and never hides it. Without a
|
|
190
|
+
// floor the axis holds zero, and a value under zero, a net change or a net flow, takes the axis down with it (#80).
|
|
191
|
+
const from = Math.min(floor ?? 0, ...values);
|
|
192
|
+
// The top is the data's, never a fixed 1: a ratio that peaks at 0.004 is drawn to its own top, not lost along the
|
|
193
|
+
// floor of a scale to 1. A scale with nothing above its floor falls back to the unit one.
|
|
194
|
+
const max = Math.max(floor === undefined ? 0 : -Infinity, baseline?.value ?? -Infinity, band?.value ?? -Infinity, ...values);
|
|
181
195
|
// The levels, and the floor the plot is drawn to: `ticks[0]` is 0 unless the chart was given a floor, and the two ends
|
|
182
196
|
// of the scale are what every point, area, band and level line is measured against.
|
|
183
197
|
const ticks = ticksBetween(from, max, h < 200 ? 3 : 4, whole);
|
|
@@ -221,11 +235,14 @@ export function TrendChart({
|
|
|
221
235
|
const open = running && s.kind !== 'dashed' && typeof s.values[n - 1] === 'number' && last.length > 1;
|
|
222
236
|
const end = (r: [number, number][]) => r.length - (open && r === last ? 2 : 1);
|
|
223
237
|
const line = segs.map((r) => monotonePath(r, 0, end(r))).join('');
|
|
224
|
-
|
|
238
|
+
// An area is filled down to zero, which is the floor of the plot unless the axis goes under it: a signed area
|
|
239
|
+
// is filled to its zero line from either side, never down to the lowest level.
|
|
240
|
+
const base = y(Math.min(Math.max(0, low), top));
|
|
241
|
+
const area = segs.map((r) => `${monotonePath(r, 0, end(r))}L${r[end(r)][0]},${base}L${r[0][0]},${base}Z`).join('');
|
|
225
242
|
return { s, line, area, tail: open ? monotonePath(last, last.length - 2) : '' };
|
|
226
243
|
}),
|
|
227
244
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
228
|
-
[series, width, h, top, running],
|
|
245
|
+
[series, width, h, low, top, running],
|
|
229
246
|
);
|
|
230
247
|
|
|
231
248
|
// What the plot draws, as the label placer needs it: every line, and the top of every band of a stack.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { Specimen, State, Plane } from '@/system/specimen';
|
|
4
4
|
import { DEMO_NOW } from '@/system/fixtures/sample';
|
|
5
5
|
import { demoSeries, REGIONS } from '@/system/fixtures/company';
|
|
6
|
+
import { AXIS_FORMATTERS } from '@/lib/format';
|
|
6
7
|
import { TrendChart } from '.';
|
|
7
8
|
|
|
8
9
|
const { current, previous } = demoSeries('30d');
|
|
@@ -18,6 +19,15 @@ const banded = current.map((d, i) => (i === 26 ? Math.round(d.p95 * 0.7) : SPIKE
|
|
|
18
19
|
const DOWN = new Set([3, 11, 12, 19, 27]);
|
|
19
20
|
const uptime = current.map((_, i) => (DOWN.has(i) ? 5 / 6 : 1));
|
|
20
21
|
|
|
22
|
+
/** A day's requests less the day before's: rises and falls about zero. */
|
|
23
|
+
const change = current.slice(1).map((d, i) => d.requests - current[i].requests);
|
|
24
|
+
/** A change reads with its sign, and a fall with the true minus sign: exact in the readout, short on the axis. */
|
|
25
|
+
const sign = (read: (n: number) => string) => (n: number) => (n > 0 ? `+${read(n)}` : n < 0 ? `\u2212${read(-n)}` : read(0));
|
|
26
|
+
const signed = sign((n) => n.toLocaleString('en-US'));
|
|
27
|
+
const signedAxis = sign((n) => AXIS_FORMATTERS.count(n));
|
|
28
|
+
/** A price that lives a few ten-thousandths about par, which no named format reads exactly. */
|
|
29
|
+
const price = current.map((d, i) => Math.round((1 + Math.sin(i / 3) * 0.0004 + (d.requests % 7) * 0.00002) * 10_000) / 10_000);
|
|
30
|
+
|
|
21
31
|
export default function TrendChartPreview() {
|
|
22
32
|
return (
|
|
23
33
|
<>
|
|
@@ -62,6 +72,16 @@ export default function TrendChartPreview() {
|
|
|
62
72
|
/>
|
|
63
73
|
</Plane>
|
|
64
74
|
</Specimen>
|
|
75
|
+
<Specimen label="Below zero" note="A signed series, here each day's requests less the day before's. Without a floor the axis holds zero and every value: it goes under zero for a fall, zero is drawn as the axis, and an area is filled to the zero line from either side. The readout is exact and signed; axisFormat keeps the ticks short.">
|
|
76
|
+
<Plane on="surface">
|
|
77
|
+
<TrendChart label="Change in requests, day on day" height={168} format={signed} axisFormat={signedAxis} dates={dates.slice(1)} series={[{ key: 'c', label: 'Change', values: change, kind: 'area' }]} />
|
|
78
|
+
</Plane>
|
|
79
|
+
</Specimen>
|
|
80
|
+
<Specimen label="A format of its own" note="format as a function, from a client component: for a figure no name says exactly, here a price to four decimals, read in the readout and the table, with axisFormat for the ticks. A server component names its format, since a function cannot cross into a client one.">
|
|
81
|
+
<Plane on="surface">
|
|
82
|
+
<TrendChart label="Price a day" height={168} format={(n) => n.toFixed(4)} axisFormat={(n) => n.toFixed(4)} floor={0.999} dates={dates} series={[{ key: 'p', label: 'Price', values: price, kind: 'line' }]} />
|
|
83
|
+
</Plane>
|
|
84
|
+
</Specimen>
|
|
65
85
|
<Specimen label="Inside a band" note="band: the region the page is judged against, from the floor up to its ceiling, in the track's own wash, its top edge drawn as the axis is and named at an end of it. marks: the days the page names, a dot on the day's own point and a stem to the axis. The third day here left the band on something this chart does not draw, so its point stands inside the region — which is why a mark is a stem and not only a dot.">
|
|
66
86
|
<Plane on="surface">
|
|
67
87
|
<TrendChart
|
|
@@ -54,7 +54,7 @@ const COMPONENTS: Components = {
|
|
|
54
54
|
code: ({ children, className }) =>
|
|
55
55
|
className ? <code className={className}>{children}</code> : <code className="rounded-xs border border-line bg-surface-sunk px-1 py-px font-mono text-[0.86em] text-ink [overflow-wrap:anywhere]">{children}</code>,
|
|
56
56
|
pre: ({ children }) => <pre className="my-4 rounded-lg border border-line bg-surface-sunk p-4 font-mono text-sm leading-relaxed whitespace-pre-wrap text-ink [overflow-wrap:anywhere] [&_code]:border-0 [&_code]:bg-transparent [&_code]:p-0">{children}</pre>,
|
|
57
|
-
table: ({ children }) => <div className="my-5 overflow-x-auto rounded-lg border border-line"><table className="w-full text-left text-sm leading-normal">{children}</table></div>,
|
|
57
|
+
table: ({ children }) => <div className="scroll-fade-x my-5 overflow-x-auto rounded-lg border border-line"><table className="w-full text-left text-sm leading-normal">{children}</table></div>,
|
|
58
58
|
thead: ({ children }) => <thead className="border-b border-line bg-surface-sunk">{children}</thead>,
|
|
59
59
|
th: ({ children }) => <th className="px-3 py-2 text-xs font-medium whitespace-nowrap text-ink-3">{children}</th>,
|
|
60
60
|
tr: ({ children }) => <tr className="border-b border-line last:border-0">{children}</tr>,
|
|
@@ -79,7 +79,7 @@ const COMPONENTS: Components = {
|
|
|
79
79
|
const STACKED: Components = {
|
|
80
80
|
...COMPONENTS,
|
|
81
81
|
table: ({ children }) => (
|
|
82
|
-
<div className="my-5 overflow-x-auto rounded-lg border border-line @container">
|
|
82
|
+
<div className="scroll-fade-x my-5 overflow-x-auto rounded-lg border border-line @container">
|
|
83
83
|
<table className={cn('w-full text-left text-sm leading-normal',
|
|
84
84
|
'@max-[480px]:block @max-[480px]:[&>thead]:hidden @max-[480px]:[&>tbody]:block @max-[480px]:[&_tr]:block @max-[480px]:[&_tr]:px-3 @max-[480px]:[&_tr]:py-2.5',
|
|
85
85
|
'@max-[480px]:[&_td]:block @max-[480px]:[&_td]:p-0 @max-[480px]:[&_td+td]:mt-2 @max-[480px]:[&_td:first-child]:font-medium @max-[480px]:[&_td:first-child]:text-ink',
|
|
@@ -144,8 +144,9 @@ export function Rail({
|
|
|
144
144
|
<button type="button" aria-label={`${app.name}, ${workspace}: ${switcher ? 'workspace menu' : 'account menu'}`} className="group -mx-1.5 flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-1.5 py-1.5 text-left hover:bg-fill-hover data-[state=open]:bg-fill-hover">
|
|
145
145
|
<AppMark size={28} />
|
|
146
146
|
<span className="min-w-0 flex-1">
|
|
147
|
-
|
|
148
|
-
<span className="
|
|
147
|
+
{/* Either may be cut to the rail's width; the title holds the whole of it, as every cut text keeps (#79). */}
|
|
148
|
+
<span title={app.name} className="block truncate text-md leading-tight font-semibold tracking-[-0.015em]">{app.name}</span>{' '}
|
|
149
|
+
<span title={workspace} className="t-eyebrow mt-0.5 block truncate">{workspace}</span>
|
|
149
150
|
</span>
|
|
150
151
|
{/* `ChevronsUpDown` is the glyph for choosing between things; with nothing to switch, the row is the menu. */}
|
|
151
152
|
{switcher ? <ChevronsUpDown className="size-3.5 text-ink-3 group-hover:text-ink-2" /> : <ChevronDown className="size-3.5 text-ink-3 group-hover:text-ink-2" />}
|
|
@@ -31,7 +31,8 @@ export function ShellTools({ alerts = [], now }: { alerts?: Alert[]; /** The dat
|
|
|
31
31
|
className="press hit flex h-9 items-center gap-2.5 rounded-full border border-line-strong bg-surface/60 pr-1.5 pl-3 text-sm text-ink-3 shadow-control backdrop-blur-md transition-[color,background-color,border-color,transform] hover:border-line-control/40 hover:text-ink-2 @max-[40rem]:w-9 @max-[40rem]:justify-center @max-[40rem]:px-0"
|
|
32
32
|
>
|
|
33
33
|
<Search className="size-4" strokeWidth={1.75} />
|
|
34
|
-
|
|
34
|
+
{/* Below 40rem the word is only its own name: `sr-only`, never `hidden`, so the icon-only button is still announced (#83). */}
|
|
35
|
+
<span className="w-36 text-left @max-[48rem]:w-20 @max-[40rem]:sr-only">Search</span>
|
|
35
36
|
<Kbd className="keyboard-only @max-[40rem]:hidden">⌘K</Kbd>
|
|
36
37
|
</button>
|
|
37
38
|
<Popover open={open} onOpenChange={setOpen}>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react';
|
|
4
|
-
import { Children, isValidElement, type CSSProperties, type HTMLAttributes, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
|
|
4
|
+
import { Children, Fragment, isValidElement, type CSSProperties, type HTMLAttributes, type ReactElement, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
|
|
5
5
|
import { cn } from '@/lib/cn';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -13,8 +13,9 @@ import { cn } from '@/lib/cn';
|
|
|
13
13
|
* between the columns, an equal share in every gap, so the gaps read even whatever each column's alignment. The table
|
|
14
14
|
* algorithm put each column's spare inside it, after a left-aligned value and before a right-aligned one, so a text
|
|
15
15
|
* column beside a number held both shares in one gap and a number beside a text column none. Each row is a subgrid of
|
|
16
|
-
* the table's columns, so a row's border, hover and selection run across the gaps. The columns are counted from the
|
|
17
|
-
*
|
|
16
|
+
* the table's columns, so a row's border, hover and selection run across the gaps. The columns are counted from the shape
|
|
17
|
+
* of the table as its own JSX writes it — the first row of its first section that has one — so a table written in a server
|
|
18
|
+
* component lays out exactly as one written on the client.
|
|
18
19
|
*
|
|
19
20
|
* `hideBelow` reads the table's own width, not the window's: the rail, a split row or an embed all narrow the table
|
|
20
21
|
* without narrowing the window. sm under 512px, md under 672px, lg under 896px, xl under 1152px.
|
|
@@ -27,14 +28,26 @@ const JUSTIFY = { left: undefined, right: 'justify-end', center: 'justify-center
|
|
|
27
28
|
|
|
28
29
|
type Props = { children?: ReactNode; hideBelow?: Breakpoint };
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
+
/** The elements a node writes, through fragments: the table's own JSX as a list. */
|
|
32
|
+
function elementsOf(node: ReactNode): ReactElement<Props>[] {
|
|
33
|
+
return Children.toArray(node).flatMap((c) => {
|
|
34
|
+
if (!isValidElement<Props>(c)) return [];
|
|
35
|
+
return c.type === Fragment ? elementsOf(c.props.children) : [c];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which columns the table draws, read from its shape and never from which component a cell is: the first row of its
|
|
41
|
+
* first section that has one, and that row's component cells (a raw `<th data-check>` is DataTable's selection box, not a
|
|
42
|
+
* column). A server component renders the same parts as lazy references rather than these functions, and a table whose
|
|
43
|
+
* columns were counted by identity laid every one of them out as a single column (#85).
|
|
44
|
+
*/
|
|
31
45
|
function columnsOf(node: ReactNode): (Breakpoint | undefined)[] | undefined {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (found) return found;
|
|
46
|
+
for (const section of elementsOf(node)) {
|
|
47
|
+
const row = elementsOf(section.props.children)[0];
|
|
48
|
+
if (!row) continue;
|
|
49
|
+
const cells = elementsOf(row.props.children).filter((c) => typeof c.type !== 'string');
|
|
50
|
+
if (cells.length > 0) return cells.map((c) => c.props.hideBelow);
|
|
38
51
|
}
|
|
39
52
|
return undefined;
|
|
40
53
|
}
|
|
@@ -34,9 +34,9 @@ export function Doc({ md, className }: { md: string; className?: string }) {
|
|
|
34
34
|
a: ({ href, children }) => <a href={href} className="link">{children}</a>,
|
|
35
35
|
code: ({ children, className: c }) =>
|
|
36
36
|
c ? <code className={c}>{children}</code> : <code className="t-code">{children}</code>,
|
|
37
|
-
pre: ({ children }) => <pre className="my-5 overflow-x-auto rounded-lg border border-line bg-surface-sunk p-5 font-mono text-sm leading-relaxed text-ink [&_code]:border-0 [&_code]:bg-transparent [&_code]:p-0">{children}</pre>,
|
|
37
|
+
pre: ({ children }) => <pre className="scroll-fade-x my-5 overflow-x-auto rounded-lg border border-line bg-surface-sunk p-5 font-mono text-sm leading-relaxed text-ink [&_code]:border-0 [&_code]:bg-transparent [&_code]:p-0">{children}</pre>,
|
|
38
38
|
table: ({ children }) => (
|
|
39
|
-
<div className="my-6 overflow-x-auto rounded-lg border border-line bg-surface/60">
|
|
39
|
+
<div className="scroll-fade-x my-6 overflow-x-auto rounded-lg border border-line bg-surface/60">
|
|
40
40
|
<table className="w-full min-w-[560px] text-left text-sm leading-normal">{children}</table>
|
|
41
41
|
</div>
|
|
42
42
|
),
|
|
@@ -40,6 +40,20 @@ describe('ticksBetween: the levels a chart is drawn on', () => {
|
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
describe('ticksBetween for a signed series and a fine one (#78)', () => {
|
|
44
|
+
it('counts a floor under zero down to a step under it, with zero among the levels', () => {
|
|
45
|
+
expect(ticksBetween(-35, 80, 4)).toEqual([-50, 0, 50, 100]);
|
|
46
|
+
expect(ticksBetween(-12, 0, 3)).toEqual([-15, -10, -5, 0]);
|
|
47
|
+
expect(ticksBetween(-0.3, 0.2, 4)).toContain(0);
|
|
48
|
+
expect(Object.is(ticksBetween(-0.3, 0.2, 4).find((t) => t === 0), 0)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it('tells levels a millionth apart from one another', () => {
|
|
51
|
+
const ticks = ticksBetween(1.00106, 1.00108, 4);
|
|
52
|
+
expect(ticks).toEqual([1.00106, 1.001065, 1.00107, 1.001075, 1.00108]);
|
|
53
|
+
expect(new Set(ticksBetween(0.99970, 1.0001, 4)).size).toBe(ticksBetween(0.99970, 1.0001, 4).length);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
43
57
|
describe('niceTicks for counts (#72)', () => {
|
|
44
58
|
it('steps by whole numbers only, so a top of 1 is ticked 0 and 1 and nothing between', () => {
|
|
45
59
|
expect(niceTicks(1, 3)).toEqual([0, 0.5, 1]);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
vi.mock('@/components/charts/use-size', async () => {
|
|
5
|
+
const { useRef } = await import('react');
|
|
6
|
+
return { useSize: () => [useRef(null), { width: 720, height: 0 }] as const };
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
import { ColumnChart } from '@/components/charts/column-chart';
|
|
10
|
+
|
|
11
|
+
// A daily net change: rises and falls about zero (#78).
|
|
12
|
+
const net = [12, -8, 20, -35, 5].map((value, i) => ({ key: `d${i}`, label: `Day ${i + 1}`, value }));
|
|
13
|
+
const box = (c: HTMLElement, key: string) => c.querySelector<HTMLElement>(`[data-column="${key}"] [data-box]`)!;
|
|
14
|
+
const ticks = (c: HTMLElement) => [...c.querySelectorAll('[data-tick]')].map((t) => t.textContent);
|
|
15
|
+
const level = (c: HTMLElement, text: string) => [...c.querySelectorAll<HTMLElement>('[data-tick]')].find((t) => t.textContent === text)!.parentElement!.style.bottom;
|
|
16
|
+
|
|
17
|
+
describe('a column chart of a signed series (#78)', () => {
|
|
18
|
+
it('takes its scale under zero, with zero a level, and stands every column on the zero line', () => {
|
|
19
|
+
const { container } = render(<ColumnChart label="Net change a day" columns={net} />);
|
|
20
|
+
// From -35 to 20 in three steps: a step of 20, so the levels run -40 to 20 and zero stands two thirds up.
|
|
21
|
+
expect(ticks(container)).toEqual(['-40', '-20', '0', '20']);
|
|
22
|
+
const pct = (v: string) => parseFloat(v);
|
|
23
|
+
expect(pct(level(container, '0'))).toBeCloseTo(66.667, 2);
|
|
24
|
+
// A rise stands on the zero line; a fall hangs from it, to its own depth.
|
|
25
|
+
expect(pct(box(container, 'd0').style.bottom)).toBeCloseTo(66.667, 2);
|
|
26
|
+
expect(box(container, 'd0').hasAttribute('data-fall')).toBe(false);
|
|
27
|
+
expect(pct(box(container, 'd0').style.height)).toBeCloseTo(20, 2);
|
|
28
|
+
expect(pct(box(container, 'd3').style.top)).toBeCloseTo(33.333, 2);
|
|
29
|
+
expect(box(container, 'd3').hasAttribute('data-fall')).toBe(true);
|
|
30
|
+
expect(pct(box(container, 'd3').style.height)).toBeCloseTo(58.333, 2);
|
|
31
|
+
expect(pct(box(container, 'd1').style.height)).toBeCloseTo(13.333, 2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('writes a fall\'s value under it and rounds its lower end', () => {
|
|
35
|
+
const { container } = render(<ColumnChart label="Net change a day" columns={net} />);
|
|
36
|
+
const fall = box(container, 'd3');
|
|
37
|
+
expect(fall.querySelector('[data-value]')).toHaveTextContent('-35');
|
|
38
|
+
expect(fall.querySelector('[data-value]')!.closest('.top-full')).not.toBeNull();
|
|
39
|
+
expect(fall.querySelector('.rounded-b-\\[4px\\]')).not.toBeNull();
|
|
40
|
+
expect(box(container, 'd0').querySelector('[data-value]')!.closest('.bottom-full')).not.toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reads a fall as its value in the table, signed', () => {
|
|
44
|
+
const { container } = render(<ColumnChart label="Net change a day" columns={net} />);
|
|
45
|
+
expect([...container.querySelectorAll('table tbody td')].map((t) => t.textContent)).toEqual(['12', '-8', '20', '-35', '5']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps a scale from zero while no value falls under it', () => {
|
|
49
|
+
const { container } = render(<ColumnChart label="Count a day" columns={net.map((c) => ({ ...c, value: Math.abs(c.value) }))} />);
|
|
50
|
+
expect(ticks(container)[0]).toBe('0');
|
|
51
|
+
expect(level(container, '0')).toBe('0%');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('leaves a stacked column\'s parts a whole: a part under zero is no contribution', () => {
|
|
55
|
+
const { container } = render(<ColumnChart label="Calls by region" parts={[{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }]} columns={[{ key: 'x', label: 'X', values: [10, -4] }]} />);
|
|
56
|
+
expect(ticks(container)[0]).toBe('0');
|
|
57
|
+
expect([...container.querySelectorAll('table tbody td')].map((t) => t.textContent)).toEqual(['10', '0', '10']);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
vi.mock('next/navigation', () => ({
|
|
5
|
+
usePathname: () => '/',
|
|
6
|
+
useRouter: () => ({ push() {}, replace() {}, refresh() {}, back() {}, forward() {}, prefetch() {} }),
|
|
7
|
+
}));
|
|
8
|
+
// A product name of 23 characters, three words: the rail's head cuts it at every width (#79).
|
|
9
|
+
vi.mock('@/app.config', async (real) => ({ ...(await real<typeof import('@/app.config')>()), app: { ...(await real<typeof import('@/app.config')>()).app, name: 'Northwind Treasury Console' } }));
|
|
10
|
+
|
|
11
|
+
import { Home } from 'lucide-react';
|
|
12
|
+
import { Rail } from '@/components/patterns/rail';
|
|
13
|
+
|
|
14
|
+
const nav = [{ label: 'Group', items: [{ href: '/', label: 'Home', icon: Home, needs: 'public' as const }] }];
|
|
15
|
+
|
|
16
|
+
describe('the rail\'s head (#79)', () => {
|
|
17
|
+
it('keeps the whole product name and workspace in a title, so a name cut to the rail is still read whole', () => {
|
|
18
|
+
const { container } = render(<Rail nav={nav} user={null} workspace="Treasury operations, Europe and Asia" />);
|
|
19
|
+
const name = [...container.querySelectorAll('span.truncate')].find((s) => s.textContent === 'Northwind Treasury Console');
|
|
20
|
+
const workspace = [...container.querySelectorAll('span.truncate')].find((s) => s.textContent === 'Treasury operations, Europe and Asia');
|
|
21
|
+
expect(name).toHaveAttribute('title', 'Northwind Treasury Console');
|
|
22
|
+
expect(workspace).toHaveAttribute('title', 'Treasury operations, Europe and Asia');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// WCAG 2.5.3: the visible text of the menu trigger has to be in its name. The name is "<app>, <workspace>: account
|
|
26
|
+
// menu"; with no space between the two spans the visible text ran them together and axe read a word that name does
|
|
27
|
+
// not hold (#84). The blocks make the space invisible, so it costs the layout nothing.
|
|
28
|
+
it('separates the product name and the workspace, so the trigger\'s visible text is in its name', () => {
|
|
29
|
+
const { container } = render(<Rail nav={nav} user={null} workspace="Treasury operations, Europe and Asia" />);
|
|
30
|
+
const trigger = container.querySelector('button[aria-label$="account menu"]')!;
|
|
31
|
+
expect(trigger.textContent).toContain('Northwind Treasury Console Treasury operations, Europe and Asia');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ShellTools } from '@/components/patterns/shell-tools';
|
|
4
|
+
|
|
5
|
+
// Below 40rem the button draws only its icon; the word stays in the DOM as its name. `display: none` on the only text
|
|
6
|
+
// left an icon-only button with no accessible name, which axe reported on every view at 390px (#83).
|
|
7
|
+
describe('the shell\'s Search button at a phone width (#83)', () => {
|
|
8
|
+
it('keeps "Search" as its name, visually hidden rather than removed', () => {
|
|
9
|
+
render(<ShellTools alerts={[]} now={new Date('2026-10-05T09:00:00Z')} />);
|
|
10
|
+
// The name is "Search ⌘K": the shortcut is drawn beside the word, which is what keeps the icon-only state named.
|
|
11
|
+
const button = screen.getAllByRole('button', { name: /Search/ })[0];
|
|
12
|
+
const word = [...button.querySelectorAll('span')].find((s) => s.textContent === 'Search')!;
|
|
13
|
+
expect(word.className).toContain('@max-[40rem]:sr-only');
|
|
14
|
+
expect(word.className).not.toContain('@max-[40rem]:hidden');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { render } from '@testing-library/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
2
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
4
|
|
|
4
5
|
vi.mock('next/navigation', () => ({ useRouter: () => ({ push() {}, refresh() {}, replace() {} }), usePathname: () => '/' }));
|
|
@@ -34,6 +35,36 @@ describe('the table counts its columns from its head', () => {
|
|
|
34
35
|
expect(cols(container.querySelector('table')!)).toEqual(['repeat(2, auto)', 'repeat(2, auto)', 'repeat(2, auto)', 'repeat(2, auto)', 'repeat(1, auto)']);
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
// A server component's JSX reaches the client Table as references to the client module, not as the functions it
|
|
39
|
+
// exported; only the props survive. A wrapper of another type reproduces that here: the Table cannot match a cell by
|
|
40
|
+
// identity, so it reads the shape, and a table that counted columns by identity laid every one out as a single
|
|
41
|
+
// column (#85).
|
|
42
|
+
it('counts a table a server component wrote, where no child is the component the client module exports', () => {
|
|
43
|
+
const Head = ({ children }: { children?: ReactNode }) => <TableHead>{children}</TableHead>;
|
|
44
|
+
const Body = ({ children }: { children?: ReactNode }) => <TableBody>{children}</TableBody>;
|
|
45
|
+
const Row = ({ children }: { children?: ReactNode }) => <TableRow>{children}</TableRow>;
|
|
46
|
+
const Th = ({ children, hideBelow, grow, align }: { children?: ReactNode; hideBelow?: 'xl' | 'md'; grow?: boolean; align?: 'left' | 'right' }) => (
|
|
47
|
+
<TableHeader hideBelow={hideBelow} grow={grow} align={align}>{children}</TableHeader>
|
|
48
|
+
);
|
|
49
|
+
const Td = ({ children }: { children?: ReactNode }) => <TableCell>{children}</TableCell>;
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<Table>
|
|
52
|
+
<>
|
|
53
|
+
<Head>
|
|
54
|
+
<Row>
|
|
55
|
+
<Th grow>Request</Th>
|
|
56
|
+
<Th hideBelow="xl">Region</Th>
|
|
57
|
+
<Th hideBelow="md">Customer</Th>
|
|
58
|
+
<Th align="right">Latency</Th>
|
|
59
|
+
</Row>
|
|
60
|
+
</Head>
|
|
61
|
+
</>
|
|
62
|
+
<Body><Row><Td>a</Td><Td>b</Td><Td>c</Td><Td>d</Td></Row></Body>
|
|
63
|
+
</Table>,
|
|
64
|
+
);
|
|
65
|
+
expect(cols(container.querySelector('table')!)).toEqual(['repeat(4, auto)', 'repeat(3, auto)', 'repeat(3, auto)', 'repeat(2, auto)', 'repeat(2, auto)']);
|
|
66
|
+
});
|
|
67
|
+
|
|
37
68
|
it("does not count DataTable's selection column, which sits over the lead's padding", () => {
|
|
38
69
|
type Row = { id: string; name: string };
|
|
39
70
|
const columns: Column<Row>[] = [
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// The plot is drawn at a stated width: the size hook is replaced, as the Column chart's tests do.
|
|
5
|
+
vi.mock('@/components/charts/use-size', async () => {
|
|
6
|
+
const { useRef } = await import('react');
|
|
7
|
+
return { useSize: () => [useRef(null), { width: 600, height: 0 }] as const };
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
import { TrendChart } from '@/components/charts/trend-chart';
|
|
11
|
+
|
|
12
|
+
const dates = ['2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04'];
|
|
13
|
+
const ticks = (c: HTMLElement) => [...c.querySelectorAll('svg text')].map((t) => t.textContent ?? '').filter((t) => /\d/.test(t) && !/Oct|Sep/.test(t));
|
|
14
|
+
/** Every y a path passes through. */
|
|
15
|
+
const ys = (c: HTMLElement) => [...c.querySelectorAll('svg path')].flatMap((p) => [...(p.getAttribute('d') ?? '').matchAll(/-?\d+(?:\.\d+)?,(-?\d+(?:\.\d+)?)/g)].map((m) => Number(m[1])));
|
|
16
|
+
const cells = (c: HTMLElement) => [...c.querySelectorAll('table tbody td:not(:first-child)')].map((t) => t.textContent);
|
|
17
|
+
|
|
18
|
+
describe('a trend chart of a signed series (#80)', () => {
|
|
19
|
+
it('takes its axis under zero, with zero a level, and draws every value inside the plot', () => {
|
|
20
|
+
const { container } = render(<TrendChart label="Net change a day" height={248} dates={dates} series={[{ key: 'n', label: 'Net change', values: [3, -2, 4, -3], kind: 'line' }]} />);
|
|
21
|
+
expect(ticks(container)).toContain('0');
|
|
22
|
+
expect(ticks(container).some((t) => t.startsWith('-') || t.startsWith('−'))).toBe(true);
|
|
23
|
+
for (const y of ys(container)) { expect(y).toBeGreaterThanOrEqual(0); expect(y).toBeLessThanOrEqual(248); }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('holds a series below zero on every day', () => {
|
|
27
|
+
const { container } = render(<TrendChart label="Net flow a day" height={248} dates={dates} series={[{ key: 'n', label: 'Net flow', values: [-3, -8, -5, -12], kind: 'area' }]} />);
|
|
28
|
+
expect(ticks(container)).toContain('0');
|
|
29
|
+
for (const y of ys(container)) { expect(y).toBeGreaterThanOrEqual(0); expect(y).toBeLessThanOrEqual(248); }
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('a trend chart given a format of its own (#78)', () => {
|
|
34
|
+
it('reads each value with it, and the axis with axisFormat', () => {
|
|
35
|
+
const price = (n: number) => n.toFixed(4);
|
|
36
|
+
const { container } = render(<TrendChart label="Price a day" height={248} floor={0.999} format={price} axisFormat={(n) => n.toFixed(4)} dates={dates.slice(0, 3)} series={[{ key: 'p', label: 'Price', values: [0.99986, 0.9997, 1.0001], kind: 'line' }]} />);
|
|
37
|
+
expect(cells(container)).toEqual(['0.9999', '0.9997', '1.0001']);
|
|
38
|
+
// Every level is its own figure: no two ticks read alike.
|
|
39
|
+
expect(new Set(ticks(container)).size).toBe(ticks(container).length);
|
|
40
|
+
expect(ticks(container).length).toBeGreaterThanOrEqual(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reads the axis as the format does when no axisFormat is given, and a missing value as a dash', () => {
|
|
44
|
+
const ratio = (n: number) => n.toFixed(6);
|
|
45
|
+
const { container } = render(<TrendChart label="Ratio a day" height={248} floor={1.001} format={ratio} dates={dates.slice(0, 3)} series={[{ key: 'r', label: 'Ratio', values: [1.001061, null, 1.001064], kind: 'line' }]} />);
|
|
46
|
+
expect(cells(container)).toEqual(['1.001061', '—', '1.001064']);
|
|
47
|
+
expect(ticks(container).every((t) => /^\d\.\d{6}$/.test(t))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('draws a ratio that peaks under 1 to its own top, not along the floor of a scale to 1', () => {
|
|
51
|
+
const { container } = render(<TrendChart label="Error rate a day" height={248} format="percent" dates={dates.slice(0, 3)} series={[{ key: 'e', label: 'Errors', values: [0.002, 0.004, 0.003], kind: 'line' }]} />);
|
|
52
|
+
expect(ticks(container)).not.toContain('100%');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const ROOT = path.resolve(import.meta.dirname, '..');
|
|
7
|
+
const source = (file: string) => fs.readFileSync(path.join(ROOT, file), 'utf8');
|
|
8
|
+
|
|
9
|
+
// The built app a run starts is for that run: the readiness probe, the browser, the assistant and the live run all
|
|
10
|
+
// reach it at 127.0.0.1. `next start` with no host binds every interface, so for the half hour a full run takes the
|
|
11
|
+
// app under test answered on the network (#81).
|
|
12
|
+
describe('the built app a run starts for itself (#81)', () => {
|
|
13
|
+
for (const file of ['scripts/verify.ts', 'scripts/live.ts']) {
|
|
14
|
+
it(`${file} binds loopback`, () => {
|
|
15
|
+
expect(source(file)).toContain("['start', '-H', '127.0.0.1', '-p', String(port)]");
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
});
|