zz-meridian 0.12.0 → 0.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.12.0",
3
+ "version": "0.12.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",
@@ -2,6 +2,20 @@
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.12.2] · 2026-10-09
6
+
7
+ ### Fixed
8
+
9
+ - **A `navigationChecks` control inside a web component is pressable once its page has hydrated.** On a Next page the navigation check counted a control as ready only when the control itself carried React's marker; React never renders into a shadow root, so a control inside one (a design system's input or button) was never ready and the check failed `never-ready`. Readiness is now its rendering host's.
10
+
11
+ ## [0.12.1] · 2026-10-09
12
+
13
+ ### Fixed
14
+
15
+ - **A web component's button named by its slotted text is no longer reported `unnamed:`.** The audit read a control's name from its own text, which for the native button inside a web component's shadow root leaves out the text slotted into its host, so a labelled design-system button was reported as unnamed. Names now count slotted text (`deepText` in `scripts/lib/deep.ts`), and the presses and the keyboard walk name such controls by it too.
16
+ - **The browser checks wait up to 30 s for Chrome to start.** A cold Chrome on a busy machine or runner could take longer than the 12 s they allowed and fail a check with `Chrome did not start`; a Chrome that exits instead is now reported at once, with its exit code.
17
+ - **A test may take 30 s.** On a loaded machine or a slow runner a correct jsdom case could pass vitest's default 5 s in the gate's parallel files; `vitest.config.ts` now allows 30 s. A project made before this keeps its own `vitest.config.ts`: add `testTimeout: 30_000` to its `test` block if its gate times out on correct cases.
18
+
5
19
  ## [0.12.0] · 2026-10-09
6
20
 
7
21
  ### Added
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. DTCG tokens, five layers of React components and a Design Atlas, in a light and a dark theme.",
@@ -122,7 +122,7 @@ const MEASURE = `(() => {
122
122
  // A card drawn as a specimen in the Atlas keeps its own scroll regions; only the page's count against the one scroller.
123
123
  if (/(auto|scroll)/.test(c.overflowY) && el.scrollHeight > el.clientHeight + 1 && el.clientHeight > 120 && !deepClosest(el, '[data-specimen]')) out.scrollers.push(label(el));
124
124
  if (el.matches('button,a[href],[role="button"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],input:not([type=hidden]),select,textarea')) {
125
- const name = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title') || el.textContent.trim() || el.querySelector('img[alt]')?.getAttribute('alt') || (el.id && el.getRootNode().querySelector('label[for="' + el.id + '"]')?.textContent.trim()) || el.closest('label')?.textContent.trim() || el.getAttribute('placeholder') || '';
125
+ const name = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title') || deepText(el).trim() || el.querySelector('img[alt]')?.getAttribute('alt') || (el.id && el.getRootNode().querySelector('label[for="' + el.id + '"]')?.textContent.trim()) || el.closest('label')?.textContent.trim() || el.getAttribute('placeholder') || '';
126
126
  if (!name) out.unnamed.push(label(el));
127
127
  // On touch (a phone is emulated as one) every control answers at least 44px: its own box, its field's frame, or
128
128
  // the .hit square around it. A link inside running text is exempt, as WCAG exempts it.
@@ -165,7 +165,7 @@ const run = async (route: string, width: number, theme: string, embed: boolean)
165
165
  if (embed) await page.eval(`(() => { const d = document.documentElement; d.setAttribute('data-theme','${theme}'); window.__hostGround = '${theme}' === 'dark' ? '#1C1C20' : '#FFFFFF'; return true; })()`);
166
166
  // One implementation of the walk: the helper is injected, never copied. A page it cannot be injected into fails the audit.
167
167
  try {
168
- if (!(await page.eval<boolean>(`${DEEP_SOURCE}\n;typeof deepAll === 'function' && typeof deepActive === 'function' && typeof deepParent === 'function'`))) throw new Error('the helper is not defined after injection');
168
+ if (!(await page.eval<boolean>(`${DEEP_SOURCE}\n;typeof deepAll === 'function' && typeof deepActive === 'function' && typeof deepParent === 'function' && typeof deepText === 'function'`))) throw new Error('the helper is not defined after injection');
169
169
  } catch (e) {
170
170
  console.error(`audit: cannot inject the deep-DOM helper into ${route}: ${(e as Error).message}`);
171
171
  page.close();
@@ -38,7 +38,7 @@ const LIST = `(() => {
38
38
  const all = every.filter((b) => b.matches('button, [role="button"], [role="tab"], [role="switch"], [role="checkbox"], [role="radio"], [role="menuitem"]'))
39
39
  .filter((b) => !b.disabled && b.getAttribute('aria-disabled') !== 'true' && vis(b) && !chosen(b));
40
40
  return {
41
- controls: all.map((b, i) => { b.setAttribute('data-press', i); const h = hosts(b); return (b.getAttribute('aria-label') || b.textContent || b.title || '').trim().replace(/\\s+/g, ' ').slice(0, 60) + (h.length ? ' (in ' + h.join(' > ') + ')' : ''); }),
41
+ controls: all.map((b, i) => { b.setAttribute('data-press', i); const h = hosts(b); return (b.getAttribute('aria-label') || deepText(b) || b.title || '').trim().replace(/\\s+/g, ' ').slice(0, 60) + (h.length ? ' (in ' + h.join(' > ') + ')' : ''); }),
42
42
  links: every.filter((a) => a.matches('a[href]') && vis(a)).map((a) => a.getAttribute('href')),
43
43
  };
44
44
  })()`;
@@ -82,7 +82,7 @@ const page = await launch();
82
82
  /** One implementation of the walk: the helper is injected, never copied. A page it cannot be injected into fails the suite. */
83
83
  async function inject(route: string) {
84
84
  try {
85
- if (!(await page.eval<boolean>(`${DEEP_SOURCE}\n;typeof deepAll === 'function' && typeof deepParent === 'function' && typeof deepQuery === 'function'`))) throw new Error('the helper is not defined after injection');
85
+ if (!(await page.eval<boolean>(`${DEEP_SOURCE}\n;typeof deepAll === 'function' && typeof deepParent === 'function' && typeof deepQuery === 'function' && typeof deepText === 'function'`))) throw new Error('the helper is not defined after injection');
86
86
  } catch (e) {
87
87
  console.error(`interactions: cannot inject the deep-DOM helper into ${route}: ${(e as Error).message}`);
88
88
  page.close();
@@ -48,7 +48,7 @@ const TAG = `(() => {
48
48
  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"])'))
49
49
  .filter((el) => vis(el) && el.tabIndex >= 0);
50
50
  const hosts = (el) => { const out = []; for (let r = el.getRootNode(); r instanceof ShadowRoot; r = r.host.getRootNode()) out.unshift(r.host.localName); return out; };
51
- window.__kbName = (el) => { const h = hosts(el); return (el.getAttribute('aria-label') || el.textContent || el.getAttribute('placeholder') || el.tagName).trim().replace(/\\s+/g, ' ').slice(0, 40) + (h.length ? ' (in ' + h.join(' > ') + ')' : ''); };
51
+ window.__kbName = (el) => { const h = hosts(el); return (el.getAttribute('aria-label') || deepText(el).trim() || el.getAttribute('placeholder') || el.tagName).trim().replace(/\\s+/g, ' ').slice(0, 40) + (h.length ? ' (in ' + h.join(' > ') + ')' : ''); };
52
52
  // A stop's identity: its tag, or, for a control the tagging did not list (a roving group hands the tab stop to a member that was
53
53
  // not tabbable when the page was tagged), a mark of its own, so a stop is the same stop forward and back.
54
54
  window.__kbN = 0; window.__kbId = (el) => el.getAttribute('data-kb') ?? (el.__kbU ??= 'u' + (++window.__kbN));
@@ -117,7 +117,7 @@ for (const route of ROUTES) {
117
117
  await page.open(base + route, { width: 1440, height: 900, theme: 'dark', wait: 2500 });
118
118
  // One implementation of the walk: the helper is injected, never copied. A page it cannot be injected into fails the suite.
119
119
  try {
120
- if (!(await page.eval<boolean>(`${DEEP_SOURCE}\n;typeof deepAll === 'function' && typeof deepActive === 'function' && typeof deepParent === 'function'`))) throw new Error('the helper is not defined after injection');
120
+ if (!(await page.eval<boolean>(`${DEEP_SOURCE}\n;typeof deepAll === 'function' && typeof deepActive === 'function' && typeof deepParent === 'function' && typeof deepText === 'function'`))) throw new Error('the helper is not defined after injection');
121
121
  } catch (e) {
122
122
  console.error(`keyboard: cannot inject the deep-DOM helper into ${route}: ${(e as Error).message}`);
123
123
  page.close();
@@ -49,13 +49,16 @@ export async function launch(): Promise<Page> {
49
49
 
50
50
  async function attach(proc: ChildProcess, dir: string, handle: { proc: ChildProcess; dir: string }): Promise<Page> {
51
51
  let list: any[] | undefined;
52
- for (let i = 0; i < 80 && !list; i++) {
52
+ // A cold Chrome on a busy machine or runner can take well over ten seconds to open its debugging port; wait up to 30 s,
53
+ // and stop at once if it exited instead.
54
+ const deadline = Date.now() + 30_000;
55
+ while (!list && Date.now() < deadline && proc.exitCode === null && proc.signalCode === null) {
53
56
  try {
54
57
  const port = fs.readFileSync(path.join(dir, 'DevToolsActivePort'), 'utf8').split('\n')[0];
55
58
  list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
56
59
  } catch { await sleep(150); }
57
60
  }
58
- if (!list) throw new Error('Chrome did not start');
61
+ if (!list) throw new Error(proc.exitCode !== null || proc.signalCode !== null ? `Chrome exited before it started (${proc.exitCode ?? proc.signalCode})` : 'Chrome did not start within 30 s');
59
62
  const ws = new WebSocket(list.find((t) => t.type === 'page').webSocketDebuggerUrl);
60
63
  await new Promise((r) => ws.addEventListener('open', r));
61
64
  let id = 0;
@@ -2,10 +2,10 @@
2
2
  * The deep-DOM helper: one implementation of "every element of the page", for the browser suites.
3
3
  *
4
4
  * `document.querySelectorAll('*')` stops at a shadow root, so a suite built on it never sees a custom element's own
5
- * content. These four functions walk through open shadow roots and slots instead. Every suite injects `DEEP_SOURCE`
5
+ * content. These five functions walk through open shadow roots and slots instead. Every suite injects `DEEP_SOURCE`
6
6
  * (the same code as a string) into the page, so there is one implementation, not a copy per suite:
7
7
  *
8
- * await page.eval(DEEP_SOURCE); // once per page load, defines the four functions as globals
8
+ * await page.eval(DEEP_SOURCE); // once per page load, defines the five functions as globals
9
9
  * await page.eval(`deepAll().length`); // then call them in any later expression
10
10
  *
11
11
  * Supported boundary:
@@ -70,5 +70,23 @@ export function deepParent(el: Element): Element | null {
70
70
  return top instanceof ShadowRoot ? top.host : null;
71
71
  }
72
72
 
73
- /** The four functions as page code, for `page.eval`: function declarations, so they become globals of the page. */
74
- export const DEEP_SOURCE: string = [deepAll, deepActive, deepQuery, deepParent].map((f) => f.toString()).join('\n');
73
+ /**
74
+ * An element's text as rendered: a slot contributes the text of what is assigned to it, or its fallback content when
75
+ * nothing is. A web component's button is usually named by the text slotted into its host, which `textContent` of the
76
+ * button inside the root does not include.
77
+ */
78
+ export function deepText(el: Element): string {
79
+ let out = '';
80
+ for (const n of el.childNodes) {
81
+ if (n.nodeType === 3) out += n.textContent ?? '';
82
+ else if (n instanceof HTMLSlotElement) {
83
+ const assigned = n.assignedNodes({ flatten: true });
84
+ if (!assigned.length) out += deepText(n);
85
+ else for (const a of assigned) out += a.nodeType === 3 ? (a.textContent ?? '') : a instanceof Element ? deepText(a) : '';
86
+ } else if (n instanceof Element) out += deepText(n);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** The five functions as page code, for `page.eval`: function declarations, so they become globals of the page. */
92
+ export const DEEP_SOURCE: string = [deepAll, deepActive, deepQuery, deepParent, deepText].map((f) => f.toString()).join('\n');
@@ -189,7 +189,10 @@ const INSTRUMENT = `${DEEP_SOURCE}\n${DEEP_MATCHES}\n(() => {
189
189
  * press before hydration does nothing. Null when there is none.
190
190
  */
191
191
  const pickable = (selector: string) => `(() => {
192
- const hydrated = typeof self.__next_f === 'undefined' ? () => true : (el) => Object.keys(el).some((k) => k.startsWith('__reactProps$'));
192
+ // React marks what it rendered once it hydrates; it never renders into a shadow root, so a control inside one is ready
193
+ // when the host React rendered is.
194
+ const rendered = (el) => { let n = el; while (n.getRootNode() instanceof ShadowRoot) n = n.getRootNode().host; return n; };
195
+ const hydrated = typeof self.__next_f === 'undefined' ? () => true : (el) => Object.keys(rendered(el)).some((k) => k.startsWith('__reactProps$'));
193
196
  return deepMatches(${JSON.stringify(selector)}).find((el) => {
194
197
  const r = el.getBoundingClientRect();
195
198
  return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden' && hydrated(el);
@@ -89,7 +89,7 @@ and supply-chain check before a script runs; that is pnpm, not an install, and n
89
89
  |---|---|---|
90
90
  | `sideways: scroll region 1042 > 768` | Something wider than the window | A table with too many columns: `hideBelow` on low-value ones; a long id: `truncate` with a `title`; a fixed width: use `min-w-0` and fractions |
91
91
  | `clipped: … "text"` | Text cut without an ellipsis | Give the element `truncate`, or let it wrap; never shrink the type |
92
- | `unnamed: button…` | An icon-only control | `aria-label` on it (Icon button requires one) |
92
+ | `unnamed: button…` | An icon-only control (a web component's button counts the text slotted into its host as its name) | `aria-label` on it (Icon button requires one) |
93
93
  | `scrollers: …` | A card scrolls on its own | Page the list (`DataTable` pages at 20) or cap it ("top 5"); only the page scrolls |
94
94
  | `contrast: 3.1:1 … "text"` | Text below 4.5:1 | Use `ink`, `ink-2` or `ink-3` on surfaces; status text uses `*-ink`, never the fill colour |
95
95
  | `no focus ring: …` | A control with `outline-none` and no ring, or one whose outline, shadow or border is the same with and without focus | Remove `outline-none`, or add `focus-visible:outline-2 focus-visible:outline-accent`. A ring painted permanently is not a focus ring; a specimen that depicts the focus state is drawn inert (`State` with `still`) |
@@ -34,10 +34,11 @@ describe('the person\'s switch', () => {
34
34
  await settings(true);
35
35
  const toggle = screen.getByRole('switch', { name: /Show the assistant/ });
36
36
  expect(toggle).toBeChecked();
37
- // The assistant's code loads on demand (next/dynamic), so the launcher and the panel arrive a moment later.
38
- await waitFor(() => expect(launcher()).not.toBeNull());
37
+ // The assistant's code loads on demand (next/dynamic), so the launcher and the panel arrive a moment later; the
38
+ // first import of the panel is the slowest step of the suite under a busy gate, so these two waits allow 20 s.
39
+ await waitFor(() => expect(launcher()).not.toBeNull(), { timeout: 20_000 });
39
40
  fireEvent.click(launcher()!);
40
- await waitFor(() => expect(document.querySelector('[data-assistant]')).not.toBeNull());
41
+ await waitFor(() => expect(document.querySelector('[data-assistant]')).not.toBeNull(), { timeout: 20_000 });
41
42
  fireEvent.click(toggle);
42
43
  await waitFor(() => expect(launcher()).toBeNull());
43
44
  expect(document.querySelector('[data-assistant]')).toBeNull();
@@ -23,4 +23,13 @@ describe('scripts/lib/chrome.ts launch', () => {
23
23
  expect(alive()).toBe(false);
24
24
  expect(fs.existsSync(fs.readFileSync(path.join(work, 'dir'), 'utf8').trim())).toBe(false);
25
25
  }, 60_000);
26
+
27
+ it('says at once that a browser which exited never started, without waiting out the start-up deadline', () => {
28
+ const fake = path.join(work, 'chrome-exits');
29
+ fs.writeFileSync(fake, '#!/bin/sh\nexit 3\n', { mode: 0o755 });
30
+ const started = Date.now();
31
+ const run = spawnSync(process.execPath, ['--input-type=module', '-e', `import { launch } from './scripts/lib/chrome.ts'; try { await launch(); } catch (e) { console.log(e.message); }`], { cwd: ROOT, env: { ...process.env, CHROME: fake }, encoding: 'utf8' });
32
+ expect(run.stdout).toContain('Chrome exited before it started (3)');
33
+ expect(Date.now() - started).toBeLessThan(15_000);
34
+ }, 60_000);
26
35
  });
@@ -5,8 +5,6 @@ vi.mock('next/navigation', () => ({ usePathname: () => '/members', useRouter: ()
5
5
  const toasts = vi.hoisted(() => [] as { tone: string; title: string; description?: string }[]);
6
6
  vi.mock('@/components/ui/toast', () => ({ toast: (t: { tone: string; title: string; description?: string }) => { toasts.push(t); } }));
7
7
  vi.stubGlobal('IntersectionObserver', class { observe() {} unobserve() {} disconnect() {} });
8
- // Each case renders a whole view and drives its sheet: on a loaded 4-CPU runner one can pass the 5 s default.
9
- vi.setConfig({ testTimeout: 15_000 });
10
8
 
11
9
  import { KeysView } from '@/views/keys';
12
10
  import { MembersView, type Result } from '@/views/members';
@@ -5,5 +5,7 @@ import path from 'node:path';
5
5
  export default defineConfig({
6
6
  plugins: [react()],
7
7
  resolve: { alias: { '@': path.resolve(import.meta.dirname, 'src') } },
8
- test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'], include: ['tests/**/*.test.{ts,tsx}'] },
8
+ // The gate runs the files in parallel; on a loaded machine or a slow runner a jsdom case that takes a second alone can
9
+ // pass vitest's default 5 s and fail a correct tree. The timeout says how long a case may take, not what it proves.
10
+ test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'], include: ['tests/**/*.test.{ts,tsx}'], testTimeout: 30_000 },
9
11
  });