zz-meridian 0.22.0 → 0.23.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 +1 -1
- package/payload/CHANGELOG.md +6 -0
- package/payload/package.json +1 -1
- package/payload/src/components/patterns/prose/README.md +5 -1
- package/payload/src/components/patterns/prose/index.tsx +49 -4
- package/payload/src/components/patterns/prose/preview.tsx +13 -0
- package/payload/tests/prose-cell-labels.test.tsx +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
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.23.0] · 2026-10-20
|
|
6
|
+
|
|
7
|
+
### New
|
|
8
|
+
|
|
9
|
+
- **`Prose` names each body cell's column, and stacks a narrow table into labelled cards** ([#75](https://github.com/zhixuan312/zz-meridian/issues/75)). Every body `td` carries its column's heading as `data-label`, written while the markdown is rendered, so it is in the server's HTML and a page can style a narrow table from `td[data-label]` on the first paint, with no second markdown parser. `stack` does it for you: a table under 480px of its own width reads as one card per row, the header row hidden, the first cell the title and each later cell under its column's heading. Wider, it is the same table; without `stack`, nothing changes.
|
|
10
|
+
|
|
5
11
|
## [0.22.0] · 2026-10-20
|
|
6
12
|
|
|
7
13
|
### New
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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.",
|
|
@@ -10,7 +10,7 @@ Status: beta
|
|
|
10
10
|
2. **Headings**: the writer's `#` is an h2 (`text-xl`), `##` an h3 with a hairline above it, `###` an h4: the page keeps the only h1.
|
|
11
11
|
3. **Lists**: discs and numbers in `ink-3`; task boxes drawn as checkboxes named "Done" or "Not done".
|
|
12
12
|
4. **Code**: inline on `surface-sunk` with a `line` border; a fenced block on `surface-sunk`, `radius-lg`, wrapping instead of scrolling.
|
|
13
|
-
5. **Tables**: inside a `line`-bordered frame, a `surface-sunk` header row, `text-sm` cells.
|
|
13
|
+
5. **Tables**: inside a `line`-bordered frame, a `surface-sunk` header row, `text-sm` cells. Every body cell carries its column's heading as `data-label`, in the server's HTML. With `stack`, a table under 480px of its own width reads as one card per row: the header row hidden, the first cell the title in `ink`, each later cell under its heading in `text-xs` `ink-3`.
|
|
14
14
|
6. **Quote**: a 2px `accent` rule on the left, `ink` text.
|
|
15
15
|
7. **Links**: `.link`, in `accent-ink`, turning `ink` on hover, never underlined; a link out of the product sends no referrer.
|
|
16
16
|
|
|
@@ -34,6 +34,7 @@ A markdown string. Line endings are normalised and the ends trimmed; nothing els
|
|
|
34
34
|
| An image from another origin | Its alt text in italics (or "Image not shown"), never fetched |
|
|
35
35
|
| A long token in code or a link: a URL, a path, a hash | Breaks anywhere rather than widen its paragraph or table; plain words wrap between words |
|
|
36
36
|
| A table with more columns than the card holds | Scrolls inside its own frame; the page never scrolls sideways |
|
|
37
|
+
| `stack`, and a table under 480px of its own width | One card per row, each value under its column's heading; no header row |
|
|
37
38
|
| Empty | Nothing: the caller shows the empty state ("No notes yet") |
|
|
38
39
|
|
|
39
40
|
## Surfaces
|
|
@@ -71,4 +72,7 @@ import { Prose } from '@/components/patterns/prose';
|
|
|
71
72
|
|
|
72
73
|
<Card className="p-(--card-pad)"><Prose>{incident.review}</Prose></Card>
|
|
73
74
|
<Prose size="sm">{comment.body}</Prose>
|
|
75
|
+
<Prose stack>{decision.record}</Prose> // tables become labelled cards in a narrow column
|
|
74
76
|
```
|
|
77
|
+
|
|
78
|
+
`stack` is for a document whose tables carry sentences (a decision record, a comparison) read in a narrow column. A table of short figures reads better scrolling in its frame, which is the default. Without `stack`, a page can still style its own narrow layout from `td[data-label]`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo } from 'react';
|
|
1
|
+
import { memo, type ReactNode } from 'react';
|
|
2
2
|
import ReactMarkdown, { type Components } from 'react-markdown';
|
|
3
3
|
import remarkGfm from 'remark-gfm';
|
|
4
4
|
import { cn } from '@/lib/cn';
|
|
@@ -7,6 +7,25 @@ import { safeMarkdownUrl } from '@/lib/safe-markdown';
|
|
|
7
7
|
/** A link that leaves the product sends no referrer: the reader's address is not the author's business. */
|
|
8
8
|
const external = (href?: string) => Boolean(href && /^(https?:)?\/\//i.test(href));
|
|
9
9
|
|
|
10
|
+
type Hast = { type: string; tagName?: string; value?: string; properties?: Record<string, unknown>; children?: Hast[] };
|
|
11
|
+
const text = (n: Hast): string => n.value ?? (n.children ?? []).map(text).join('');
|
|
12
|
+
const elements = (n: Hast, tag: string): Hast[] => (n.children ?? []).flatMap((c) => (c.tagName === tag ? [c] : c.tagName === 'table' ? [] : elements(c, tag)));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Each body cell carries its column's heading as `data-label`, written while the markdown is turned into HTML, so a
|
|
16
|
+
* table can be read as labelled cards with CSS alone on the server's first paint. A cell past the last heading, or
|
|
17
|
+
* under an empty one, carries none.
|
|
18
|
+
*/
|
|
19
|
+
const labelCells = () => (tree: Hast) => {
|
|
20
|
+
for (const table of elements({ type: 'root', children: [tree] }, 'table')) {
|
|
21
|
+
const headings = elements(table, 'th').map((th) => text(th).trim());
|
|
22
|
+
for (const tr of elements(table, 'tbody').flatMap((b) => elements(b, 'tr')))
|
|
23
|
+
elements(tr, 'td').forEach((td, i) => {
|
|
24
|
+
if (headings[i]) td.properties = { ...td.properties, dataLabel: headings[i] };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
10
29
|
/**
|
|
11
30
|
* Markdown a person wrote (a document, a note, an agent's reply), set for reading on Meridian's roles: a 72-character
|
|
12
31
|
* measure, quiet tables, code on the sunk surface. Each element is drawn here rather than by a typography plugin, so
|
|
@@ -39,7 +58,7 @@ const COMPONENTS: Components = {
|
|
|
39
58
|
thead: ({ children }) => <thead className="border-b border-line bg-surface-sunk">{children}</thead>,
|
|
40
59
|
th: ({ children }) => <th className="px-3 py-2 text-xs font-medium whitespace-nowrap text-ink-3">{children}</th>,
|
|
41
60
|
tr: ({ children }) => <tr className="border-b border-line last:border-0">{children}</tr>,
|
|
42
|
-
td: ({ children }) => <td className="px-3 py-2.5 align-top text-ink-2">{children}</td>,
|
|
61
|
+
td: ({ children, 'data-label': label }: { children?: ReactNode; 'data-label'?: string }) => <td data-label={label} className="px-3 py-2.5 align-top text-ink-2">{children}</td>,
|
|
43
62
|
blockquote: ({ children }) => <blockquote className="my-4 border-l-2 border-accent pl-4 text-ink">{children}</blockquote>,
|
|
44
63
|
hr: () => <hr className="my-8 border-line" />,
|
|
45
64
|
// An image the URL policy dropped reads as its alt text, never as a broken-image icon.
|
|
@@ -52,10 +71,36 @@ const COMPONENTS: Components = {
|
|
|
52
71
|
rest.type === 'checkbox' ? <input {...rest} aria-label={rest.checked ? 'Done' : 'Not done'} className="mr-2 size-3.5 align-[-2px] accent-(--accent)" /> : <input {...rest} />),
|
|
53
72
|
};
|
|
54
73
|
|
|
55
|
-
|
|
74
|
+
/**
|
|
75
|
+
* `stack`: below 480px of its own width a table reads as one card per row: the header row hidden, the first cell the
|
|
76
|
+
* card's title, each later cell on its own line under its column's heading. The frame is the container, so a table in a
|
|
77
|
+
* narrow column stacks wherever the column is narrow, not only on a phone.
|
|
78
|
+
*/
|
|
79
|
+
const STACKED: Components = {
|
|
80
|
+
...COMPONENTS,
|
|
81
|
+
table: ({ children }) => (
|
|
82
|
+
<div className="my-5 overflow-x-auto rounded-lg border border-line @container">
|
|
83
|
+
<table className={cn('w-full text-left text-sm leading-normal',
|
|
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
|
+
'@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',
|
|
86
|
+
'@max-[480px]:[&_td:not(:first-child)[data-label]]:before:block @max-[480px]:[&_td:not(:first-child)[data-label]]:before:text-xs @max-[480px]:[&_td:not(:first-child)[data-label]]:before:font-medium @max-[480px]:[&_td:not(:first-child)[data-label]]:before:text-ink-3 @max-[480px]:[&_td:not(:first-child)[data-label]]:before:content-[attr(data-label)]')}>
|
|
87
|
+
{children}
|
|
88
|
+
</table>
|
|
89
|
+
</div>
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const Prose = memo(function Prose({ children, size = 'base', stack = false, className }: {
|
|
94
|
+
children: string;
|
|
95
|
+
/** `sm` for a side panel, a comment or a quoted body. */
|
|
96
|
+
size?: 'base' | 'sm';
|
|
97
|
+
/** A table narrower than 480px reads as labelled cards, one per row. */
|
|
98
|
+
stack?: boolean;
|
|
99
|
+
className?: string;
|
|
100
|
+
}) {
|
|
56
101
|
return (
|
|
57
102
|
<div className={cn('min-w-0 text-ink-2 break-words', size === 'base' ? 'text-base leading-[1.7]' : 'text-sm leading-relaxed', className)}>
|
|
58
|
-
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={safeMarkdownUrl} components={COMPONENTS}>
|
|
103
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[labelCells]} urlTransform={safeMarkdownUrl} components={stack ? STACKED : COMPONENTS}>
|
|
59
104
|
{children.replace(/\r\n/g, '\n').trim()}
|
|
60
105
|
</ReactMarkdown>
|
|
61
106
|
</div>
|
|
@@ -40,6 +40,11 @@ const HOSTILE = `A reply pasted from somewhere else:
|
|
|
40
40
|
|
|
41
41
|
`;
|
|
42
42
|
|
|
43
|
+
const DECISIONS = `| Choice | Chosen | Rejected because |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| Queue | Postgres \`SKIP LOCKED\` | a broker is one more service to run for 40 jobs a minute |
|
|
46
|
+
| Retries | Three, with backoff | a fourth retry has never succeeded in the last 90 days |`;
|
|
47
|
+
|
|
43
48
|
export default function ProsePreview() {
|
|
44
49
|
return (
|
|
45
50
|
<>
|
|
@@ -58,6 +63,14 @@ export default function ProsePreview() {
|
|
|
58
63
|
<div className="w-80 p-4"><Prose size="sm">{REVIEW.split('## What we are changing')[1]}</Prose></div>
|
|
59
64
|
</Plane>
|
|
60
65
|
</Specimen>
|
|
66
|
+
<Specimen label="Stacked" note="With stack, a table under 480px of its own width reads as one card per row: the first cell its title, each later cell under its column's heading. Wider, it is the same table." stack>
|
|
67
|
+
<Plane on="surface">
|
|
68
|
+
<div className="flex flex-wrap gap-6 p-4">
|
|
69
|
+
<div className="w-80"><Prose size="sm" stack>{DECISIONS}</Prose></div>
|
|
70
|
+
<div className="w-[560px] max-w-full"><Prose size="sm" stack>{DECISIONS}</Prose></div>
|
|
71
|
+
</div>
|
|
72
|
+
</Plane>
|
|
73
|
+
</Specimen>
|
|
61
74
|
</>
|
|
62
75
|
);
|
|
63
76
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { Prose } from '@/components/patterns/prose';
|
|
4
|
+
|
|
5
|
+
const DOC = `| Choice | Chosen | Rejected because |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Store | **Postgres** | the queue needs order |
|
|
8
|
+
| Cache | none | |
|
|
9
|
+
|
|
10
|
+
| A | \`B\` |
|
|
11
|
+
|---|---|
|
|
12
|
+
| 1 | 2 | 3 |`;
|
|
13
|
+
|
|
14
|
+
describe('Prose names each body cell\'s column (#75)', () => {
|
|
15
|
+
test('writes the column heading on every body cell, per table, as plain text', () => {
|
|
16
|
+
const { container } = render(<Prose>{DOC}</Prose>);
|
|
17
|
+
const [first, second] = [...container.querySelectorAll('table')];
|
|
18
|
+
expect([...first.querySelectorAll('tbody td')].map((td) => td.getAttribute('data-label'))).toEqual(['Choice', 'Chosen', 'Rejected because', 'Choice', 'Chosen', 'Rejected because']);
|
|
19
|
+
expect([...second.querySelectorAll('tbody td')].map((td) => td.getAttribute('data-label'))).toEqual(['A', 'B']);
|
|
20
|
+
expect(container.querySelectorAll('th[data-label]')).toHaveLength(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('stack makes each table\'s frame the container its rows stack in', () => {
|
|
24
|
+
const plain = render(<Prose>{DOC}</Prose>);
|
|
25
|
+
expect(plain.container.querySelector('.\\@container')).toBeNull();
|
|
26
|
+
plain.unmount();
|
|
27
|
+
const { container } = render(<Prose stack>{DOC}</Prose>);
|
|
28
|
+
expect(container.querySelectorAll('.\\@container > table')).toHaveLength(2);
|
|
29
|
+
expect(container.querySelector('tbody td')).toHaveAttribute('data-label', 'Choice');
|
|
30
|
+
});
|
|
31
|
+
});
|