zz-meridian 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,16 @@
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.22.0] · 2026-10-20
6
+
7
+ ### New
8
+
9
+ - **A tile whose figure is a share reads its change in points** ([#74](https://github.com/zhixuan312/zz-meridian/issues/74)). `MetricTile` takes `deltaFormat` and hands it to `Delta`, whose `format` now takes a name as well as a function: `percent` (the default, a share of the earlier value) or `points`, so a share that went from 33.0% to 46.4% reads "▲13.4 pts" (`delta={0.134}`) with the same arrow, colour and screen-reader word, where it could only read "▲40.6%". `DeltaFormat` and `DELTA_FORMATS` are exported from `@/components/ui/delta`.
10
+
11
+ ### Fixed
12
+
13
+ - **A Delta with no change reads its format** (#74): "0.0 pts" under `points`, or what a function makes of zero, where it printed a literal "0%" whatever the format. With no format given it still reads "0%".
14
+
5
15
  ## [0.21.0] · 2026-10-20
6
16
 
7
17
  ### New
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
@@ -20,7 +20,7 @@ Card, Delta, Status dot, Tooltip, Sparkline. Tiles sit in a `Row split="tiles"`,
20
20
 
21
21
  ## Data
22
22
 
23
- - `value` is the period's figure; `points` one value a point of the same period, oldest first (days on a page of days), or `null` at a point nothing was measured for, which the Sparkline draws as a gap rather than a fall to zero; `delta` the change as a fraction (0.057 is +5.7%), or `null` when there is no earlier period. `compare` names what the change is measured against (default "vs previous period"; "vs the half hour before" when the window is not the period). `note` is a short line in that place when no comparison fits, such as who is past due, carrying `data-note`; in a narrow tile it takes a second line before it is cut ("296 active · 14 trials · 10 past due" in a third of a page with the assistant's panel open), and a note that is text keeps the whole of itself in its title when the tile cuts it. On a compact tile it stays on the tile's one line.
23
+ - `value` is the period's figure; `points` one value a point of the same period, oldest first (days on a page of days), or `null` at a point nothing was measured for, which the Sparkline draws as a gap rather than a fall to zero; `delta` the change as a fraction (0.057 is +5.7%), or `null` when there is no earlier period; `deltaFormat="points"` reads it in percentage points for a figure that is itself a share (a share from 33.0% to 46.4% is `delta={0.134}`, "13.4 pts"). `compare` names what the change is measured against (default "vs previous period"; "vs the half hour before" when the window is not the period). `note` is a short line in that place when no comparison fits, such as who is past due, carrying `data-note`; in a narrow tile it takes a second line before it is cut ("296 active · 14 trials · 10 past due" in a third of a page with the assistant's panel open), and a note that is text keeps the whole of itself in its title when the tile cuts it. On a compact tile it stays on the tile's one line.
24
24
  - `intent` says which way is good: `up` (requests), `down` (errors, latency), `neutral` (compute cost).
25
25
  - `value` may be a word for a categorical state ("Likely new", "On track"); it is set at `text-2xl` instead of the figure size and wraps to a second line rather than losing its end ("03 Oct 2026" in a narrow tile), and `format` is not used.
26
26
  - `format` turns the number into its text: a name (`count`, `compact`, `cost`, `cost-compact`, `duration`, `percent`), which a server page can pass, or a function from a client component; the default split recognises "$298.43" (any currency symbol from `app.currency`), "2.9M", "0.90%" and "294ms".
@@ -139,4 +139,4 @@ import { MetricTile } from '@/components/patterns/metric-tile';
139
139
  <MetricTile size="compact" label="Northwind Labs" value={41_200} format="cost-compact" delta={0.12} compare="vs last week" note="Scale" points={last14Days} />
140
140
  ```
141
141
 
142
- Props: `label`, `value`, `format`, `split`, `delta`, `compare`, `intent`, `note`, `points`, `baseline`, `finding`, `hint`, `icon`, `emphasis`, `size`, `className`. A compact tile holds a container, so it takes its cell's width and never sizes itself from what it holds: give it a grid cell or a width.
142
+ Props: `label`, `value`, `format`, `split`, `delta`, `deltaFormat`, `compare`, `intent`, `note`, `points`, `baseline`, `finding`, `hint`, `icon`, `emphasis`, `size`, `className`. A compact tile holds a container, so it takes its cell's width and never sizes itself from what it holds: give it a grid cell or a width.
@@ -5,7 +5,7 @@ import { cn } from '@/lib/cn';
5
5
  import { formatDate } from '@/lib/format-date';
6
6
  import { fitFigure, formatBy, splitFigure, type NumberFormat } from '@/lib/format';
7
7
  import { Card } from '@/components/ui/card';
8
- import { Delta } from '@/components/ui/delta';
8
+ import { Delta, type DeltaFormat } from '@/components/ui/delta';
9
9
  import { Tooltip } from '@/components/ui/tooltip';
10
10
  import { Sparkline } from '@/components/charts/sparkline';
11
11
  import { useMeridianIndex, usePointMeridian } from '@/components/charts/meridian';
@@ -47,6 +47,7 @@ export function MetricTile({
47
47
  hint,
48
48
  value,
49
49
  delta,
50
+ deltaFormat,
50
51
  note,
51
52
  compare = 'vs previous period',
52
53
  intent = 'up',
@@ -67,6 +68,8 @@ export function MetricTile({
67
68
  value: number | string;
68
69
  /** Change against the previous period, as a fraction. */
69
70
  delta?: number | null;
71
+ /** How the change reads: `percent` (of the earlier value, the default) or `points`, for a figure that is itself a share. */
72
+ deltaFormat?: DeltaFormat | ((n: number) => string);
70
73
  /**
71
74
  * A short line where the change would sit, for a figure no period comparison fits: who is past due, what is next.
72
75
  * On a compact tile it follows the figure on the tile's one line: "$41.2K · next plan".
@@ -126,7 +129,7 @@ export function MetricTile({
126
129
  // The figure alone is drawn. Down a wall the words it is against would stand once a tile, so the card that
127
130
  // holds the wall says them; here they follow the figure for a screen reader, which meets a tile on its own.
128
131
  <p className={cn('flex h-5 items-center justify-self-start', COMPACT_CHANGE)}>
129
- <Delta value={delta} intent={intent} />
132
+ <Delta value={delta} intent={intent} format={deltaFormat} />
130
133
  <span className="sr-only">{' '}{delta === null ? 'No earlier period to compare' : compare}</span>
131
134
  </p>
132
135
  ) : null}
@@ -185,7 +188,7 @@ export function MetricTile({
185
188
  <span className="truncate text-ink-3" title="No earlier period to compare">No earlier period to compare</span>
186
189
  ) : delta !== undefined ? (
187
190
  <>
188
- <Delta value={delta} intent={intent} />
191
+ <Delta value={delta} intent={intent} format={deltaFormat} />
189
192
  <span className="leading-5 whitespace-nowrap text-ink-3">{compare}</span>
190
193
  </>
191
194
  ) : note ? (
@@ -18,11 +18,11 @@ Status: beta
18
18
  | Down | Latency, error rate, cost per request | `critical-ink` | `positive-ink` |
19
19
  | Neutral | Spend, volume where neither direction is good | `ink-2` | `ink-2` |
20
20
 
21
- A change under 0.05% is flat: no arrow, "0%", `ink-2`. A missing comparison (`null`) is an em dash in `ink-3`: "we measured no change" and "there is nothing to compare with" are different facts.
21
+ A change under 0.05% is flat: no arrow, `ink-2`, and the format reading no change ("0.0 pts" under `points`; "0%" with no format given). A missing comparison (`null`) is an em dash in `ink-3`: "we measured no change" and "there is nothing to compare with" are different facts.
22
22
 
23
23
  ## Sizes
24
24
 
25
- One size, `text-xs`, so it sits on the caption line under a figure. The format defaults to a percentage with one decimal; pass `format` for absolute changes ("43ms").
25
+ One size, `text-xs`, so it sits on the caption line under a figure. The format defaults to `percent`, the change as a share of the earlier value with one decimal; `points` reads a change in a value that is itself a share in percentage points ("13.4 pts", where `percent` would print a share that went from 33.0% to 46.4% as up 40.6%); pass a function for absolute changes ("43ms").
26
26
 
27
27
  ## States
28
28
 
@@ -64,4 +64,4 @@ import { Delta } from '@/components/ui/delta';
64
64
  <Delta value={23.73} format={(n) => `$${n}`} intent="neutral" />
65
65
  ```
66
66
 
67
- Props: `value` (a fraction, or null), `format`, `intent` (`up` | `down` | `neutral`), `className`.
67
+ Props: `value` (a fraction, or null), `format` (`percent` | `points` | a function), `intent` (`up` | `down` | `neutral`), `className`.
@@ -1,24 +1,37 @@
1
1
  import { cn } from '@/lib/cn';
2
2
 
3
+ /**
4
+ * How a change reads: `percent` is the change as a share of the earlier value (0.12 reads 12.0%); `points` is a change in
5
+ * a value that is itself a share, in percentage points (0.134 reads 13.4 pts), so a share that went from 33.0% to 46.4%
6
+ * never reads as having risen 40.6%. A name, so a server page can pass it to a client tile (#74).
7
+ */
8
+ export type DeltaFormat = 'percent' | 'points';
9
+ export const DELTA_FORMATS: Record<DeltaFormat, (n: number) => string> = {
10
+ percent: (n) => `${Math.abs(n * 100).toFixed(1)}%`,
11
+ points: (n) => `${Math.abs(n * 100).toFixed(1)} pts`,
12
+ };
13
+
3
14
  /**
4
15
  * A change against the previous period. The arrow carries the direction; the colour carries the judgement, which is
5
16
  * not the same thing: for latency or errors, down is good. `intent` says which way is good.
6
17
  */
7
18
  export function Delta({
8
19
  value,
9
- format = (n) => `${Math.abs(n * 100).toFixed(1)}%`,
20
+ format,
10
21
  intent = 'up',
11
22
  className,
12
23
  }: {
13
- /** The change as a fraction (0.12 is +12%), or null when there is nothing to compare with. */
24
+ /** The change as a fraction (0.12 is +12%, or +12 points under `points`), or null when there is nothing to compare with. */
14
25
  value: number | null;
15
- format?: (n: number) => string;
26
+ /** How the change reads, a name or a function of its magnitude; `percent` by default. No change reads it too ("0.0 pts"). */
27
+ format?: DeltaFormat | ((n: number) => string);
16
28
  /** Which direction is good. `neutral` paints both directions in ink. */
17
29
  intent?: 'up' | 'down' | 'neutral';
18
30
  className?: string;
19
31
  }) {
20
32
  if (value === null || !Number.isFinite(value)) return <span className={cn('text-xs text-ink-3', className)}>—</span>;
21
33
  const flat = Math.abs(value) < 0.0005;
34
+ const read = typeof format === 'function' ? format : DELTA_FORMATS[format ?? 'percent'];
22
35
  const up = value > 0;
23
36
  const good = intent === 'neutral' || flat ? null : (up && intent === 'up') || (!up && intent === 'down');
24
37
  return (
@@ -33,7 +46,8 @@ export function Delta({
33
46
  <path d="M5 1.5 8.5 7h-7z" fill="currentColor" />
34
47
  </svg>
35
48
  <span className="sr-only">{flat ? 'unchanged' : up ? 'up' : 'down'} </span>
36
- {flat ? '0%' : format(value)}
49
+ {/* No change is read as the format reads a change, so a points tile says "0.0 pts", never a literal 0%. */}
50
+ {flat ? (format ? read(0) : '0%') : read(value)}
37
51
  </span>
38
52
  );
39
53
  }
@@ -19,6 +19,10 @@ export default function DeltaPreview() {
19
19
  <State label="Flat"><Delta value={0} /></State>
20
20
  <State label="Nothing to compare"><Delta value={null} /></State>
21
21
  </Specimen>
22
+ <Specimen label="In points" note='format="points": a change in a figure that is itself a share, a refusal share from 33.0% to 46.4%, reads 13.4 points, never 40.6% of itself. No change reads in the same format.'>
23
+ <State label="Rise"><Delta value={0.134} format="points" intent="down" /></State>
24
+ <State label="Flat"><Delta value={0} format="points" intent="down" /></State>
25
+ </Specimen>
22
26
  <Specimen label="In context">
23
27
  <span className="flex items-center gap-2 text-xs"><Delta value={0.057} /><span className="text-ink-3">vs previous period</span></span>
24
28
  </Specimen>
@@ -0,0 +1,25 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { MetricTile } from '@/components/patterns/metric-tile';
4
+ import { Delta } from '@/components/ui/delta';
5
+
6
+ describe('a tile whose figure is a share (#74)', () => {
7
+ it('reads its change in points under deltaFormat="points", with the same arrow, colour and word', () => {
8
+ const { container } = render(<MetricTile label="Share refused" value={0.464} format="percent" delta={0.134} deltaFormat="points" intent="down" />);
9
+ const change = container.querySelector('.t-num.inline-flex')!;
10
+ expect(change).toHaveTextContent(/^up 13\.4 pts$/);
11
+ expect(change).toHaveClass('text-critical-ink');
12
+ });
13
+ it('still reads a change as a percentage of the earlier value without it', () => {
14
+ const { container } = render(<MetricTile label="Requests" value={1200} delta={0.406} />);
15
+ expect(container.querySelector('.t-num.inline-flex')).toHaveTextContent(/^up 40\.6%$/);
16
+ });
17
+ });
18
+
19
+ describe('a Delta with no change', () => {
20
+ it('reads it in its format, never a literal 0%', () => {
21
+ expect(render(<Delta value={0} format="points" />).container).toHaveTextContent(/^unchanged 0\.0 pts$/);
22
+ expect(render(<Delta value={0} format={(n) => `${n.toFixed(2)} ms`} />).container).toHaveTextContent(/^unchanged 0\.00 ms$/);
23
+ expect(render(<Delta value={0} />).container).toHaveTextContent(/^unchanged 0%$/);
24
+ });
25
+ });