zz-meridian 0.31.1 → 0.31.2
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 +8 -0
- package/payload/package.json +1 -1
- package/payload/scripts/check.ts +1 -1
- package/payload/src/components/charts/column-chart/index.tsx +67 -28
- package/payload/src/components/charts/trend-chart/index.tsx +5 -2
- package/payload/src/components/charts/use-settled.ts +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.2",
|
|
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,14 @@
|
|
|
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.31.2] · 2026-10-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Home and Revenue no longer drop a frame when the period changes to 7D on a slow phone** (the known issue of 0.31.0). At 7D the year is 52 windows, drawn in two column charts and a trend, and the tap committed about a thousand elements in one frame: on the weekly runner's phone its longest gap was 83 ms against the 50 ms floor. Measured at the emulated phone's rate times 1.6, which reproduces the runner, the gap is now 50 ms in six of six runs (style and layout 31 to 36 ms, from 63 to 72).
|
|
10
|
+
- **A dense column chart draws a finished column as one element.** Past 30 columns, a plain chart's finished column is the `[data-column]` element alone, its bar the element's `::before` with the same share of the scale, rounded top and 2px floor, and the row of columns grows as one instead of a column at a time. A running column, a column with no data, a stacked or a signed chart is drawn as before. A finished plain column of any chart is its own fill, one element fewer.
|
|
11
|
+
- **A chart's data table and axis labels follow its marks a frame later when the period changes.** ColumnChart's and TrendChart's `sr-only` table, and ColumnChart's `aria-hidden` axis labels, render one frame after the set of columns or dates changes; the first render has them at once, and the label row keeps its height for that frame.
|
|
12
|
+
|
|
5
13
|
## [0.31.1] · 2026-10-27
|
|
6
14
|
|
|
7
15
|
### Fixed
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.2",
|
|
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
|
@@ -81,7 +81,7 @@ function walk(dir: string, ext: RegExp, out: string[] = []) {
|
|
|
81
81
|
const css = read('src/styles/tokens.css') + read('src/styles/base.css') + read('src/styles/motion.css');
|
|
82
82
|
const defined = new Set([...css.matchAll(/--([a-z0-9-]+)\s*:/g)].map((m) => m[1]));
|
|
83
83
|
/** Local custom properties a component sets for itself, and the host's bridged variables. */
|
|
84
|
-
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-.*)$/;
|
|
84
|
+
const LOCAL = /^(i|w|m-len|bar-(h|at)|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-.*)$/;
|
|
85
85
|
|
|
86
86
|
// ── Cards ────────────────────────────────────────────────────────────────────────────────────────────
|
|
87
87
|
const CARD_SECTIONS = ['## Surfaces', '## Agents', '## Accessibility'];
|
|
@@ -5,6 +5,7 @@ import { cn } from '@/lib/cn';
|
|
|
5
5
|
import { useMeridian } from '@/components/charts/meridian';
|
|
6
6
|
import { Readout } from '@/components/charts/readout';
|
|
7
7
|
import { niceTicks, SERIES_VAR, stackPart, ticksBetween } from '@/components/charts/scale';
|
|
8
|
+
import { useSettled } from '@/components/charts/use-settled';
|
|
8
9
|
import { useSize } from '@/components/charts/use-size';
|
|
9
10
|
import { columnFold } from '@/logic/derivations';
|
|
10
11
|
|
|
@@ -58,6 +59,12 @@ export type ColumnChartProps = Shared & (
|
|
|
58
59
|
* narrowest column that keeps its value at rest.
|
|
59
60
|
*/
|
|
60
61
|
const GAP = 2, THIN = 1, REST = 36;
|
|
62
|
+
/**
|
|
63
|
+
* Past this many columns a plain chart draws each finished column as one element, its bar the element's own `::before`,
|
|
64
|
+
* and grows them as one row: a year of 52 windows at 7D, three such charts on Home, committed about a thousand
|
|
65
|
+
* elements in the frame of the period tap and dropped it on a mid-range phone.
|
|
66
|
+
*/
|
|
67
|
+
const DENSE = 30;
|
|
61
68
|
/** About the width of one character of a value at rest (`text-2xs`), in px: enough to tell whether a label fits its column. */
|
|
62
69
|
const CH = 6.5;
|
|
63
70
|
/**
|
|
@@ -153,6 +160,8 @@ export function ColumnChart({
|
|
|
153
160
|
const set = (i: number | null) => { (timed ? meridian.setIndex : setOwn)(i); setLocal(i !== null); };
|
|
154
161
|
const fill = height === 'fill';
|
|
155
162
|
const soFar = running ? n - 1 : -1;
|
|
163
|
+
// The axis labels and the table follow the columns a frame later when the set of them changes (`useSettled`).
|
|
164
|
+
const settled = useSettled(bars.map((b) => b.key).join('\n'));
|
|
156
165
|
|
|
157
166
|
// A period with no data has no height, so the scale is the periods that have one. Plain, a value under zero is a
|
|
158
167
|
// fall: the scale goes under zero for it and the column hangs from the zero line (#78). Stacked, parts make a whole.
|
|
@@ -175,6 +184,7 @@ export function ColumnChart({
|
|
|
175
184
|
// A column wide enough for a figure keeps its value above it at rest; a narrower one shows it when pointed at.
|
|
176
185
|
const column = n ? (width - left - gap * (n - 1)) / n : 0;
|
|
177
186
|
const rest = column >= REST;
|
|
187
|
+
const dense = n > DENSE && !signed;
|
|
178
188
|
// Whatever is centred on a column and may be wider than it starts with the first column and ends with the last, so
|
|
179
189
|
// nothing runs past the plot; inside those, a narrower one is still centred.
|
|
180
190
|
/** A value as it reads; a period with no data reads the product's word, and "so far" with it while it is running. */
|
|
@@ -231,7 +241,7 @@ export function ColumnChart({
|
|
|
231
241
|
<span className={cn('h-px flex-1', t === 0 ? 'bg-chart-axis' : 'bg-chart-grid')} />
|
|
232
242
|
</div>
|
|
233
243
|
))}
|
|
234
|
-
<div className=
|
|
244
|
+
<div className={cn('absolute inset-y-0 right-0 flex', dense && 'grow-y')} style={{ left, columnGap: gapOf(n) }}>
|
|
235
245
|
{bars.map((b, i) => {
|
|
236
246
|
const pointed = active === i;
|
|
237
247
|
const hot = pointed || (active === null && b.key === highlight);
|
|
@@ -245,6 +255,49 @@ export function ColumnChart({
|
|
|
245
255
|
// never "No data…" cut mid-word; the tooltip, the readout, the announcement and the table say it whole (#71).
|
|
246
256
|
const full = totalOf(b, i);
|
|
247
257
|
const text = !tip && b.none && unfinished && full.length * CH > column ? 'So far' : full;
|
|
258
|
+
const plainFill = !b.none && !drawn && !unfinished;
|
|
259
|
+
const valueLabel = rest || pointed ? (
|
|
260
|
+
// One element at rest and pointed at, so its entrance never replays. At rest the value is held to its
|
|
261
|
+
// column; the tooltip, and a total shown only on pointing, may be wider than theirs.
|
|
262
|
+
<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')}>
|
|
263
|
+
<span
|
|
264
|
+
className={cn(
|
|
265
|
+
't-num block text-2xs leading-4',
|
|
266
|
+
tip
|
|
267
|
+
? 'rounded-sm bg-surface-inverse px-1.5 py-1 font-medium whitespace-nowrap text-ink-inverse shadow-overlay'
|
|
268
|
+
: rest
|
|
269
|
+
? cn('truncate text-center', pointed ? 'font-medium text-ink' : 'text-ink-3')
|
|
270
|
+
: 'font-medium whitespace-nowrap text-ink',
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
<span data-value>{text}</span>
|
|
274
|
+
{tip && unfinished && !b.none ? ' · so far' : null}
|
|
275
|
+
</span>
|
|
276
|
+
</span>
|
|
277
|
+
) : null;
|
|
278
|
+
const share = `${(Math.abs(b.total) / (top - low)) * 100}%`;
|
|
279
|
+
if (dense && plainFill) {
|
|
280
|
+
// One element: the bar is its ::before, its share of the scale and never under 2px; the value, when it shows,
|
|
281
|
+
// stands on a box of the bar's height.
|
|
282
|
+
return (
|
|
283
|
+
<div
|
|
284
|
+
key={b.key}
|
|
285
|
+
data-column={b.key}
|
|
286
|
+
data-active={pointed ? '' : undefined}
|
|
287
|
+
title={[rest && text.length * 7 > column ? text : null, b.key === highlight ? highlightLabel : null].filter(Boolean).join(' · ') || undefined}
|
|
288
|
+
className={cn(
|
|
289
|
+
'group/col relative h-full min-w-0 flex-1 before:absolute before:inset-x-0 before:bottom-(--bar-at) before:h-(--bar-h) before:rounded-t-[4px] before:transition-colors before:duration-(--dur-hover)',
|
|
290
|
+
b.total !== 0 && 'before:min-h-[2px]',
|
|
291
|
+
hot ? 'before:bg-accent' : 'before:bg-chart-neutral hover:before:bg-chart-neutral-strong',
|
|
292
|
+
)}
|
|
293
|
+
style={{ ['--bar-h' as string]: share, ['--bar-at' as string]: `${at100(0)}%` }}
|
|
294
|
+
onPointerEnter={() => set(i)}
|
|
295
|
+
onPointerDown={() => set(i)}
|
|
296
|
+
>
|
|
297
|
+
{valueLabel ? <div className={cn('absolute inset-x-0', b.total !== 0 && 'min-h-[2px]')} style={{ bottom: `${at100(0)}%`, height: share }}>{valueLabel}</div> : null}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
248
301
|
return (
|
|
249
302
|
<div
|
|
250
303
|
key={b.key}
|
|
@@ -269,14 +322,17 @@ export function ColumnChart({
|
|
|
269
322
|
className="absolute inset-x-0"
|
|
270
323
|
style={{
|
|
271
324
|
...(fall ? { top: `${100 - at100(0)}%` } : { bottom: `${at100(0)}%` }),
|
|
272
|
-
height: b.none ? '0%' :
|
|
325
|
+
height: b.none ? '0%' : share,
|
|
273
326
|
minHeight: (drawn ? Math.max(0, shown.length * 4 - 1) : b.total !== 0 ? 2 : 0) + (unfinished ? 2 : 0),
|
|
274
327
|
}}
|
|
275
328
|
>
|
|
276
329
|
<div
|
|
277
330
|
className={cn(
|
|
278
|
-
|
|
331
|
+
// A dense chart grows its row as one; a column of its own grows only in a chart that is not.
|
|
332
|
+
!dense && 'grow-y', 'relative h-full',
|
|
279
333
|
fall ? 'rounded-b-[4px]' : 'rounded-t-[4px]',
|
|
334
|
+
// A finished plain column is its own fill: one element fewer a column, in a chart of 52 of them.
|
|
335
|
+
plainFill && cn('w-full transition-colors duration-(--dur-hover)', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/col:bg-chart-neutral-strong'),
|
|
280
336
|
drawn && 'transition-[box-shadow] duration-(--dur-hover)',
|
|
281
337
|
drawn && hot && 'z-1 ring-2 ring-ink',
|
|
282
338
|
// A period still running: a dashed outline, open at the baseline, around a fill at half strength. The
|
|
@@ -304,29 +360,11 @@ export function ColumnChart({
|
|
|
304
360
|
/>
|
|
305
361
|
))}
|
|
306
362
|
</div>
|
|
307
|
-
) : (
|
|
308
|
-
<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',
|
|
363
|
+
) : plainFill ? null : (
|
|
364
|
+
<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', 'opacity-50')} />
|
|
309
365
|
)}
|
|
310
366
|
</div>
|
|
311
|
-
{
|
|
312
|
-
// One element at rest and pointed at, so its entrance never replays. At rest the value is held to its
|
|
313
|
-
// column; the tooltip, and a total shown only on pointing, may be wider than theirs.
|
|
314
|
-
<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')}>
|
|
315
|
-
<span
|
|
316
|
-
className={cn(
|
|
317
|
-
't-num block text-2xs leading-4',
|
|
318
|
-
tip
|
|
319
|
-
? 'rounded-sm bg-surface-inverse px-1.5 py-1 font-medium whitespace-nowrap text-ink-inverse shadow-overlay'
|
|
320
|
-
: rest
|
|
321
|
-
? cn('truncate text-center', pointed ? 'font-medium text-ink' : 'text-ink-3')
|
|
322
|
-
: 'font-medium whitespace-nowrap text-ink',
|
|
323
|
-
)}
|
|
324
|
-
>
|
|
325
|
-
<span data-value>{text}</span>
|
|
326
|
-
{tip && unfinished && !b.none ? ' · so far' : null}
|
|
327
|
-
</span>
|
|
328
|
-
</span>
|
|
329
|
-
) : null}
|
|
367
|
+
{valueLabel}
|
|
330
368
|
</div>
|
|
331
369
|
</div>
|
|
332
370
|
);
|
|
@@ -351,8 +389,9 @@ export function ColumnChart({
|
|
|
351
389
|
</Readout>
|
|
352
390
|
) : null}
|
|
353
391
|
</div>
|
|
354
|
-
|
|
355
|
-
|
|
392
|
+
{/* The row keeps its height for the frame its labels wait, so nothing under it moves. */}
|
|
393
|
+
<div aria-hidden className="mt-2 flex min-h-4 text-2xs text-ink-3" style={{ paddingLeft: left, columnGap: gapOf(n) }}>
|
|
394
|
+
{settled && bars.map((b, i) => (
|
|
356
395
|
<span key={b.key} className="relative h-4 min-w-0 flex-1">
|
|
357
396
|
{active === i ? (
|
|
358
397
|
<span className={cn('absolute z-10', placed(i))}><span className="rounded-xs bg-surface px-1 whitespace-nowrap text-ink">{b.label}</span></span>
|
|
@@ -365,7 +404,7 @@ export function ColumnChart({
|
|
|
365
404
|
))}
|
|
366
405
|
</div>
|
|
367
406
|
<p className="sr-only" aria-live="polite">{reading}</p>
|
|
368
|
-
<table className="sr-only">
|
|
407
|
+
{settled ? <table className="sr-only">
|
|
369
408
|
<caption>{label}{highlight && highlightLabel ? `. Accented: ${highlightLabel}` : ''}</caption>
|
|
370
409
|
{drawn ? <thead><tr><th scope="col">Period</th>{drawn.map((p) => <th key={p.key} scope="col">{p.label}</th>)}<th scope="col">Total</th></tr></thead> : null}
|
|
371
410
|
<tbody>
|
|
@@ -377,7 +416,7 @@ export function ColumnChart({
|
|
|
377
416
|
</tr>
|
|
378
417
|
))}
|
|
379
418
|
</tbody>
|
|
380
|
-
</table>
|
|
419
|
+
</table> : null}
|
|
381
420
|
</figure>
|
|
382
421
|
);
|
|
383
422
|
}
|
|
@@ -7,6 +7,7 @@ import { formatDate } from '@/lib/format-date';
|
|
|
7
7
|
import { useMeridian } from '@/components/charts/meridian';
|
|
8
8
|
import { Readout } from '@/components/charts/readout';
|
|
9
9
|
import { monotonePath, ticksBetween, SERIES_VAR, stackPart } from '@/components/charts/scale';
|
|
10
|
+
import { useSettled } from '@/components/charts/use-settled';
|
|
10
11
|
import { useSize } from '@/components/charts/use-size';
|
|
11
12
|
import { trendTotals } from '@/logic/derivations';
|
|
12
13
|
|
|
@@ -205,6 +206,8 @@ export function TrendChart({
|
|
|
205
206
|
const n = dates.length;
|
|
206
207
|
// The point still running, if there is one: everything solid stops at the point before it.
|
|
207
208
|
const soFar = running ? n - 1 : -1;
|
|
209
|
+
// The table follows the line a frame later when the dates change (`useSettled`).
|
|
210
|
+
const settled = useSettled(dates.join('\n'));
|
|
208
211
|
const x = (i: number) => pad.l + (n <= 1 ? W / 2 : (i / (n - 1)) * W);
|
|
209
212
|
const y = (v: number) => pad.t + H - ((v - low) / (top - low)) * H;
|
|
210
213
|
|
|
@@ -474,11 +477,11 @@ export function TrendChart({
|
|
|
474
477
|
) : null}
|
|
475
478
|
</Readout>
|
|
476
479
|
) : null}
|
|
477
|
-
<table className="sr-only">
|
|
480
|
+
{settled ? <table className="sr-only">
|
|
478
481
|
<caption>{label}{band ? `. ${band.label}` : ''}{marks ? `. Marked: ${marks.label}` : ''}{baseline ? `. ${baseline.label}` : ''}{running ? `. ${SO_FAR}` : ''}</caption>
|
|
479
482
|
<thead><tr><th>Date</th>{series.map((s) => <th key={s.key}>{s.label}</th>)}{totals ? <th>Total</th> : null}</tr></thead>
|
|
480
483
|
<tbody>{dates.map((d, i) => <tr key={d}><td>{d}{i === soFar ? ', so far' : null}</td>{series.map((s) => <td key={s.key}>{fmt(s.values[i])}</td>)}{totals ? <td>{fmt(totals[i])}</td> : null}</tr>)}</tbody>
|
|
481
|
-
</table>
|
|
484
|
+
</table> : null}
|
|
482
485
|
</div>
|
|
483
486
|
);
|
|
484
487
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether `key` was already the chart's at the last frame: false for the one frame after it changes, true on the first
|
|
7
|
+
* render and ever after. A chart draws its marks at once and its data table, which nobody sees, a frame later, so a
|
|
8
|
+
* change of period commits the marks without the table's rows: Home's year at 7D, three charts of 52 windows, dropped a
|
|
9
|
+
* frame on a mid-range phone when every row came in the same frame as the columns.
|
|
10
|
+
*/
|
|
11
|
+
export function useSettled(key: string): boolean {
|
|
12
|
+
const [settled, setSettled] = useState(key);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (settled === key) return;
|
|
15
|
+
const id = requestAnimationFrame(() => setSettled(key));
|
|
16
|
+
return () => cancelAnimationFrame(id);
|
|
17
|
+
}, [key, settled]);
|
|
18
|
+
return settled === key;
|
|
19
|
+
}
|