zz-meridian 0.34.0 → 0.35.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 +15 -0
- package/payload/app/(dashboard)/requests/README.md +1 -1
- package/payload/app/(dashboard)/requests/loading.tsx +4 -1
- package/payload/app/(dashboard)/requests/page.tsx +4 -2
- package/payload/journeys/workspaces.ts +107 -8
- package/payload/package.json +1 -1
- package/payload/scripts/check.ts +10 -0
- package/payload/scripts/lib/nav-under.ts +35 -0
- package/payload/skills/zz-meridian/references/customize.md +17 -0
- package/payload/src/app.config.ts +10 -2
- package/payload/src/components/base/shell/index.tsx +2 -2
- package/payload/src/components/patterns/command-palette/README.md +2 -2
- package/payload/src/components/patterns/command-palette/index.tsx +2 -1
- package/payload/src/components/patterns/rail/README.md +30 -5
- package/payload/src/components/patterns/rail/index.tsx +104 -42
- package/payload/src/components/patterns/rail/preview.tsx +42 -10
- package/payload/src/components/patterns/rail/trail.ts +27 -0
- package/payload/tests/check-nav-under.test.ts +59 -0
- package/payload/tests/page-frame-title.test.tsx +3 -3
- package/payload/tests/palette-hint.test.tsx +57 -0
- package/payload/tests/rail-branch-feel.test.tsx +106 -0
- package/payload/tests/rail-branch.test.tsx +258 -0
- package/payload/tests/rail-trail.test.ts +39 -0
- package/payload/tests/workspaces.test.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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,21 @@
|
|
|
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.35.0] · 2026-10-29
|
|
6
|
+
|
|
7
|
+
A third level in the rail: a navigation item can have sub pages, drawn in a branch under it, and the sample shows one (Traffic, Usage, Requests).
|
|
8
|
+
|
|
9
|
+
### Breaking
|
|
10
|
+
|
|
11
|
+
- **`PageFrame` draws its kicker in a `div`, not a `p`.** A kicker can now hold block content such as a `Breadcrumb`, which inside a `p` was invalid HTML and a hydration mismatch. The `t-kicker` class and the look are unchanged. A test of your own that asserts the kicker's tag name expects `DIV`, and a selector written as `p.t-kicker` is written `.t-kicker`.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **A navigation item can have sub pages, drawn as a third level in the rail.** Set `under` on a `NavItem` to the `href` of another item of the same group, and the rail draws the item in that item's branch, on a guide line and without an icon. The parent stays a link to its own page, with a button beside it named `<label> pages` that opens and closes the branch. A branch is open on arrival when the current page is its parent or one of its sub pages, and a closed branch draws none of its links. A closed branch that holds the current page puts the pill on its parent, so the rail always shows where the person is. A sub page the person may not open is not drawn, and one whose parent they may not open is drawn as a plain item. No migration is needed: `under` is optional, and a navigation without it draws as before.
|
|
16
|
+
- **The gate refuses a sub page the rail cannot draw.** `node scripts/check.ts` refuses an `under` that names no item of the same group, the item itself, or an item that is itself under another (three levels, never four). It also refuses a sub page in another workspace than its parent, and more than 7 sub pages under one parent. Each refusal names the item's `href`.
|
|
17
|
+
- **The palette and the masthead say where a sub page sits.** The command palette's hint for a sub page is its parent's label, so typing a parent's label finds its sub pages. `navTrail(href, nav)` in `src/components/patterns/rail/trail.ts` derives a page's crumbs from the navigation: for a sub page, its parent as a link, then the page. The sample's Requests page draws it as its kicker, Usage then Requests.
|
|
18
|
+
- **The sample shows all three levels.** Business is now two labelled groups: Accounts (Revenue, Customers) and Traffic (Usage, with Requests under it). The Rail preview draws every branch state.
|
|
19
|
+
|
|
5
20
|
## [0.34.0] · 2026-10-29
|
|
6
21
|
|
|
7
22
|
### Changed
|
|
@@ -8,7 +8,7 @@ Status: beta
|
|
|
8
8
|
|
|
9
9
|
| Row | Pattern | Console (1024px and wider) | Mobile |
|
|
10
10
|
|---|---|---|---|
|
|
11
|
-
| Masthead | PageFrame: kicker
|
|
11
|
+
| Masthead | PageFrame: kicker its trail in the navigation (Usage, then Requests), from `navTrail`, title "Requests", one sentence, Freshness, Export CSV | One band | Title, sentence, then Freshness and Export on their own row |
|
|
12
12
|
| 1 | Row `tiles`: three Metric tiles for what the filters let through: requests, errors and limits, latency p95, each with its 5-minute shape over the last hour and the last half hour against the one before | Three across | One column |
|
|
13
13
|
| 2 | Data table with a Filter bar in its toolbar: search, Status, Method, Region, the result count | Columns: Request (grow), Status, Latency, Customer and Region (once the table is 896px wide), Size (1152px), Received; a table under 640px wide is the card list. A row opens its request; its Customer opens the customer's page | Card list: route and status, then latency, customer and when |
|
|
14
14
|
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { nav } from '@/app.config';
|
|
2
|
+
import { navTrail } from '@/components/patterns/rail/trail';
|
|
3
|
+
import { Breadcrumb } from '@/components/ui/breadcrumb';
|
|
1
4
|
import { LoadingPage } from '@/components/patterns/page-loading';
|
|
2
5
|
import { gatedLoading } from '@/data/refusal';
|
|
3
6
|
import { RecordsSkeleton } from '@/components/templates/records';
|
|
@@ -5,7 +8,7 @@ import { RecordsSkeleton } from '@/components/templates/records';
|
|
|
5
8
|
/** Requests on its way: three tiles over a few table rows; the page's own fallback is the same, so a navigation does not jump. */
|
|
6
9
|
export default function RequestsLoading() {
|
|
7
10
|
return (
|
|
8
|
-
<LoadingPage {...gatedLoading({ name: 'requests', title: 'Requests' })}>
|
|
11
|
+
<LoadingPage {...gatedLoading({ name: 'requests', title: 'Requests', kicker: <Breadcrumb items={navTrail('/requests', nav)} /> })}>
|
|
9
12
|
<RecordsSkeleton tiles={3} rows={3} filters={false} />
|
|
10
13
|
</LoadingPage>
|
|
11
14
|
);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Suspense, cache } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { nav } from '@/app.config';
|
|
3
3
|
import { PageFrame } from '@/components/base/shell';
|
|
4
|
+
import { navTrail } from '@/components/patterns/rail/trail';
|
|
5
|
+
import { Breadcrumb } from '@/components/ui/breadcrumb';
|
|
4
6
|
import { ExportButton } from '@/components/patterns/export-button';
|
|
5
7
|
import { Freshness } from '@/components/patterns/freshness';
|
|
6
8
|
import { gate, gatedMetadata } from '@/data/access';
|
|
@@ -26,7 +28,7 @@ export default async function RequestsPage({ searchParams }: { searchParams: Sea
|
|
|
26
28
|
if (denied) return RefusalScreen(denied);
|
|
27
29
|
return (
|
|
28
30
|
<PageFrame
|
|
29
|
-
kicker={
|
|
31
|
+
kicker={<Breadcrumb items={navTrail('/requests', nav)} />}
|
|
30
32
|
title="Requests"
|
|
31
33
|
description="The most recent calls that reached the gateway, newest first. Open one to see where its time went."
|
|
32
34
|
meta={<Suspense fallback={<span className="inline-block h-4 w-28" />}><RequestsFreshness searchParams={searchParams} /></Suspense>}
|
|
@@ -128,8 +128,11 @@ async function press(page: Page, spec: Spec) {
|
|
|
128
128
|
await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: at.x, y: at.y, button: 'left', clickCount: 1 });
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const key = (page: Page, k: string, code: string, vk: number, text?: string
|
|
132
|
-
|
|
131
|
+
const key = (page: Page, k: string, code: string, vk: number, text?: string, modifiers?: number) => {
|
|
132
|
+
const mods = modifiers ? { modifiers } : {};
|
|
133
|
+
return page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk, ...mods, ...(text ? { text, unmodifiedText: text } : {}) })
|
|
134
|
+
.then(() => page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk, ...mods }));
|
|
135
|
+
};
|
|
133
136
|
|
|
134
137
|
/** Type into the focused field, one key at a time. */
|
|
135
138
|
async function type(page: Page, s: string) {
|
|
@@ -169,7 +172,54 @@ const ownerOf = (path: string): string | null => {
|
|
|
169
172
|
|
|
170
173
|
/** A person's routes: the ones the verification configuration says they may open, written there by hand. */
|
|
171
174
|
const routesOf = (name: string) => [...persona(name).expectedRoutes];
|
|
172
|
-
|
|
175
|
+
|
|
176
|
+
/** The held items of one navigation group in a workspace, for a person who may open `routes`. */
|
|
177
|
+
const heldIn = (g: (typeof nav)[number], ws: string, routes: string[]) => g.items.filter((i) => i.workspace === ws && routes.includes(i.href));
|
|
178
|
+
/**
|
|
179
|
+
* The parent a held item hangs under, as the rail reads it: the held item of the same group that `under` names, when that
|
|
180
|
+
* is not itself a sub page; otherwise null and the item stands at the top level.
|
|
181
|
+
*/
|
|
182
|
+
const parentIn = (held: (typeof items)[number][], it: (typeof items)[number]): string | null => {
|
|
183
|
+
const under = 'under' in it && typeof it.under === 'string' ? it.under : undefined;
|
|
184
|
+
const parent = under === undefined ? undefined : held.find((p) => p.href === under && p !== it);
|
|
185
|
+
if (!parent) return null;
|
|
186
|
+
const grand = 'under' in parent && typeof parent.under === 'string' ? parent.under : undefined;
|
|
187
|
+
return grand !== undefined && held.some((x) => x.href === grand && x !== parent) ? null : parent.href;
|
|
188
|
+
};
|
|
189
|
+
/** The parent a destination hangs under for a person who may open `routes`; null when it stands at the top level. */
|
|
190
|
+
const parentOfHref = (href: string, routes: string[]) => {
|
|
191
|
+
const g = nav.find((x) => x.items.some((i) => i.href === href));
|
|
192
|
+
const it = g?.items.find((i) => i.href === href);
|
|
193
|
+
if (!g || !it?.workspace) return null;
|
|
194
|
+
return parentIn(heldIn(g, it.workspace, routes), it);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The parents whose branch each page has open, as the rail keeps its own: every full load empties it, arriving at a page
|
|
199
|
+
* opens the branch that holds its current entry, pressing a branch's button toggles it, and a client navigation leaves it.
|
|
200
|
+
*/
|
|
201
|
+
const branches = new WeakMap<Page, Set<string>>();
|
|
202
|
+
const openIn = (page: Page) => { let s = branches.get(page); if (!s) branches.set(page, (s = new Set())); return s; };
|
|
203
|
+
const loaded = (page: Page) => openIn(page).clear();
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What the rail draws for a workspace, in the order of the page: each group's top-level destinations, and after a parent
|
|
207
|
+
* its sub pages when its branch is open. A sub page whose parent is not held stands at the top level.
|
|
208
|
+
*/
|
|
209
|
+
const railHrefs = (ws: string, routes: string[], open: Set<string>) =>
|
|
210
|
+
nav.flatMap((g) => {
|
|
211
|
+
const held = heldIn(g, ws, routes);
|
|
212
|
+
return held.filter((i) => !parentIn(held, i)).flatMap((i) => [i.href, ...(open.has(i.href) ? held.filter((s) => parentIn(held, s) === i.href).map((s) => s.href) : [])]);
|
|
213
|
+
});
|
|
214
|
+
/** The branch that holds the address's current entry: the parent whose own page it is, or whose sub page it is. */
|
|
215
|
+
const branchOf = (ws: string, routes: string[], path: string) => {
|
|
216
|
+
const all = nav.flatMap((g) => heldIn(g, ws, routes));
|
|
217
|
+
const cur = currentIn(path, all.map((i) => i.href));
|
|
218
|
+
if (cur === null) return null;
|
|
219
|
+
const parent = parentOfHref(cur, routes);
|
|
220
|
+
if (parent) return parent;
|
|
221
|
+
return nav.some((g) => { const held = heldIn(g, ws, routes); return held.some((s) => parentIn(held, s) === cur); }) ? cur : null;
|
|
222
|
+
};
|
|
173
223
|
const currentIn = (path: string, hrefs: string[]) => hrefs.filter((h) => (h === '/' ? path === '/' : path === h || path.startsWith(`${h}/`))).sort((a, b) => b.length - a.length)[0] ?? null;
|
|
174
224
|
|
|
175
225
|
/**
|
|
@@ -179,19 +229,25 @@ const currentIn = (path: string, hrefs: string[]) => hrefs.filter((h) => (h ===
|
|
|
179
229
|
*/
|
|
180
230
|
async function settle(page: Page, who: string, path: string | RegExp, ws?: string) {
|
|
181
231
|
const routes = routesOf(who);
|
|
232
|
+
const open = openIn(page);
|
|
182
233
|
let found = 'nothing read';
|
|
183
234
|
let expected = '';
|
|
184
235
|
try {
|
|
185
236
|
await until('', async () => {
|
|
186
237
|
const r = await railOf(page);
|
|
187
238
|
const owner = ws ?? ownerOf(r.path) ?? 'main';
|
|
188
|
-
const
|
|
189
|
-
|
|
239
|
+
const at = path instanceof RegExp ? path.test(r.path) : r.path === path;
|
|
240
|
+
// Arriving at a page opens the branch that holds its entry.
|
|
241
|
+
const arrival = at ? branchOf(owner, routes, r.path) : null;
|
|
242
|
+
if (arrival) open.add(arrival);
|
|
243
|
+
const hrefs = railHrefs(owner, routes, open);
|
|
244
|
+
// The current entry is the longest held one; when its branch is closed it is not drawn, and the rail marks nothing as the page.
|
|
245
|
+
const entry = currentIn(r.path, nav.flatMap((g) => heldIn(g, owner, routes).map((i) => i.href)));
|
|
246
|
+
const cur = entry !== null && hrefs.includes(entry) ? entry : null;
|
|
190
247
|
expected = `${path} with the rail on "${W[owner].label}" showing ${hrefs.join(' ')} (current ${cur})`;
|
|
191
248
|
const seen = r.links?.map((l) => l.href) ?? null;
|
|
192
249
|
const now = r.links?.find((l) => l.current)?.href ?? null;
|
|
193
250
|
found = `${r.path}${r.search} with the rail on "${railWorkspace(r)}" showing ${seen?.join(' ') ?? 'no links'} (current ${now})`;
|
|
194
|
-
const at = path instanceof RegExp ? path.test(r.path) : r.path === path;
|
|
195
251
|
return at && railWorkspace(r) === W[owner].label && seen?.join(' ') === hrefs.join(' ') && now === cur;
|
|
196
252
|
});
|
|
197
253
|
} catch {
|
|
@@ -200,6 +256,21 @@ async function settle(page: Page, who: string, path: string | RegExp, ws?: strin
|
|
|
200
256
|
return (await railOf(page)).path;
|
|
201
257
|
}
|
|
202
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Press a destination's link in the rail. A sub page's link is not drawn while its parent's branch is closed, so a person
|
|
261
|
+
* first presses the parent's `<label> pages` button, and the branch is then open.
|
|
262
|
+
*/
|
|
263
|
+
async function pressRail(page: Page, who: string, href: string) {
|
|
264
|
+
const link: Spec = { role: 'link', name: labelOf(href), within: 'nav[aria-label="Main"]' };
|
|
265
|
+
const parent = parentOfHref(href, routesOf(who));
|
|
266
|
+
if (parent && !(await ask<boolean>(page, `return find(${JSON.stringify(link)}).length > 0;`))) {
|
|
267
|
+
await press(page, { role: 'button', name: `${labelOf(parent)} pages`, within: 'nav[aria-label="Main"]' });
|
|
268
|
+
const open = openIn(page);
|
|
269
|
+
if (!open.delete(parent)) open.add(parent);
|
|
270
|
+
}
|
|
271
|
+
await press(page, link);
|
|
272
|
+
}
|
|
273
|
+
|
|
203
274
|
/** Choose a workspace in the rail's menu, as a person does: open the menu, press the row. */
|
|
204
275
|
async function choose(page: Page, id: string) {
|
|
205
276
|
await press(page, { role: 'button', re: `${MENU_END}$` });
|
|
@@ -216,6 +287,7 @@ const opened: Page[] = [];
|
|
|
216
287
|
const browser = async () => { const p = await launch(); opened.push(p); return p; };
|
|
217
288
|
const go = async (page: Page, who: string, path: string) => {
|
|
218
289
|
quiet(page, 'the page before');
|
|
290
|
+
loaded(page);
|
|
219
291
|
await page.open(base + path, { width: 1440, height: 900, theme: 'dark', wait: 300, cookie: persona(who).cookie });
|
|
220
292
|
};
|
|
221
293
|
|
|
@@ -336,8 +408,34 @@ if (import.meta.main) {
|
|
|
336
408
|
// Home's Business card opens the Business home; the rail then names Business and offers Requests.
|
|
337
409
|
await press(a, { role: 'link', hrefRe: '^/business$', within: '[role="region"][aria-label="Business"]' });
|
|
338
410
|
await settle(a, 'member', '/business', 'business');
|
|
339
|
-
await
|
|
411
|
+
await pressRail(a, 'member', REQUESTS);
|
|
340
412
|
await settle(a, 'member', REQUESTS, 'business');
|
|
413
|
+
// The masthead's trail names the parent as a link, then the page as the current one; the palette hints the parent.
|
|
414
|
+
const parentHref = parentOfHref(REQUESTS, routesOf('member'));
|
|
415
|
+
const parentLabel = parentHref === null ? null : labelOf(parentHref);
|
|
416
|
+
const groupLabel = nav.find((g) => g.items.some((i) => i.href === REQUESTS))?.label ?? '';
|
|
417
|
+
const label = labelOf(REQUESTS);
|
|
418
|
+
const crumbs = await ask<{ links: { href: string; label: string }[]; current: string | null }>(a, `
|
|
419
|
+
const bc = document.querySelector('nav[aria-label="Breadcrumb"]');
|
|
420
|
+
if (!bc) return { links: [], current: null };
|
|
421
|
+
const cur = bc.querySelector('[aria-current="page"]');
|
|
422
|
+
return { links: [...bc.querySelectorAll('a[href]')].map((l) => ({ href: l.getAttribute('href'), label: text(l) })), current: cur ? text(cur) : null };`);
|
|
423
|
+
const wantLinks = parentHref === null ? [] : [{ href: parentHref, label: parentLabel }];
|
|
424
|
+
if (JSON.stringify(crumbs.links) !== JSON.stringify(wantLinks) || crumbs.current !== label) {
|
|
425
|
+
throw new Error(`expected the Breadcrumb to hold ${JSON.stringify(wantLinks)} and then "${label}" as the current page; found links ${JSON.stringify(crumbs.links)} and current ${JSON.stringify(crumbs.current)}`);
|
|
426
|
+
}
|
|
427
|
+
// The palette listens on window for Ctrl or Command with K; a button would be ambiguous, the not-found view has a second one.
|
|
428
|
+
await key(a, 'k', 'KeyK', 75, undefined, 2);
|
|
429
|
+
await until('the palette search field', () => centre(a, { role: 'combobox', name: 'Search pages and commands' }));
|
|
430
|
+
await type(a, label);
|
|
431
|
+
const hint = await until(`the palette option "${label}"`, () => ask<{ hint: string | null } | null>(a, `
|
|
432
|
+
const row = [...document.querySelectorAll('[role="option"]')].find((o) => [...o.querySelectorAll('span[title]')].some((t) => t.getAttribute('title') === ${JSON.stringify(label)}));
|
|
433
|
+
if (!row) return null;
|
|
434
|
+
const h = row.querySelector('span.text-xs');
|
|
435
|
+
return { hint: h ? text(h) : null };`));
|
|
436
|
+
const wantHint = parentLabel ?? groupLabel;
|
|
437
|
+
if (hint.hint !== wantHint) throw new Error(`expected the palette option "${label}" to carry the hint "${wantHint}"; found ${JSON.stringify(hint.hint)}`);
|
|
438
|
+
await key(a, 'Escape', 'Escape', 27);
|
|
341
439
|
await press(a, { role: 'link', hrefRe: '^/requests/[^/]+$', within: 'main table tbody' });
|
|
342
440
|
const at = await settle(a, 'member', /^\/requests\/[^/]+$/, 'business');
|
|
343
441
|
return `(${at})`;
|
|
@@ -400,13 +498,14 @@ if (import.meta.main) {
|
|
|
400
498
|
await settle(a, 'member', HOME, 'main');
|
|
401
499
|
await choose(a, 'business');
|
|
402
500
|
await settle(a, 'member', W.business.homeHref, 'business');
|
|
403
|
-
await
|
|
501
|
+
await pressRail(a, 'member', REQUESTS);
|
|
404
502
|
await settle(a, 'member', REQUESTS, 'business');
|
|
405
503
|
await press(a, { role: 'link', hrefRe: '^/requests/[^/]+$', within: 'main table tbody' });
|
|
406
504
|
const request = await settle(a, 'member', /^\/requests\/[^/]+$/, 'business');
|
|
407
505
|
// A refresh: the page is marked first, so that only the new document can satisfy the wait.
|
|
408
506
|
await a.eval('window.__before = true');
|
|
409
507
|
await a.send('Page.reload');
|
|
508
|
+
loaded(a);
|
|
410
509
|
await until('the page to reload', () => a.eval<boolean>('window.__before === undefined && document.readyState !== "loading"'));
|
|
411
510
|
await settle(a, 'member', request, 'business');
|
|
412
511
|
for (const path of trail) {
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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.",
|
package/payload/scripts/check.ts
CHANGED
|
@@ -23,12 +23,15 @@
|
|
|
23
23
|
* `drift:` line, a warning the gate carries: nothing else says so between releases.
|
|
24
24
|
* - Every nav item's `needs` has the shape the rail can read: 'public', a grant `object:read|create|update|remove`, or an
|
|
25
25
|
* `{ allOf: [...] }` of them.
|
|
26
|
+
* - Every nav item's `under` names an item of its own group that is not itself under another, in the same workspace, and
|
|
27
|
+
* no item has more than seven sub pages: the rail draws three levels and nothing it cannot draw.
|
|
26
28
|
*/
|
|
27
29
|
import { createHash } from 'node:crypto';
|
|
28
30
|
import fs from 'node:fs';
|
|
29
31
|
import path from 'node:path';
|
|
30
32
|
import { importTarget, projectAliases } from './lib/aliases.ts';
|
|
31
33
|
import { checkBrief, scanReferences } from './lib/context-check.ts';
|
|
34
|
+
import { navUnderProblems } from './lib/nav-under.ts';
|
|
32
35
|
import { blankedText, noDirectQuery, presentationDoesNotRead, resolveSpec as resolveProjectSpec, valueImports } from './lib/layer-rules.ts';
|
|
33
36
|
import { APP_DIR, agentPageSpec, agentSection, specRoute, viewToolPages } from './lib/routes.ts';
|
|
34
37
|
import { keepProblems, unresolved, parseJournal, parseKeep, parseResolutions, verifiedRetirements, type Hash, type Retirement } from './lib/update-session.ts';
|
|
@@ -360,6 +363,13 @@ for (const item of nav.flatMap((g) => g.items)) {
|
|
|
360
363
|
if (why) problems.push(`src/app.config.ts: nav item ${item.href} ${why}`);
|
|
361
364
|
}
|
|
362
365
|
|
|
366
|
+
// ── A sub page names a parent the rail can draw ───────────────────────────────────────────────────────
|
|
367
|
+
// A nav item may name its parent with `under`, and the rail draws it as one list nested under that parent. An `under`
|
|
368
|
+
// that names nothing of its group, the item itself, an item that is already a sub page (a fourth level), or an item of
|
|
369
|
+
// another workspace has no place in that list, so the page would vanish from the rail or hang in the wrong one. A parent
|
|
370
|
+
// with more than seven sub pages is a group of its own. The rule is pure and lives in scripts/lib/nav-under.ts.
|
|
371
|
+
problems.push(...navUnderProblems(nav));
|
|
372
|
+
|
|
363
373
|
// ── The update session, the keep register and the logo ────────────────────────────────────────────────
|
|
364
374
|
// A session under .meridian/update/ must be resolved before the gate passes. With one ready, its candidate manifest
|
|
365
375
|
// stands in for the original: the files it will own are what keep, retirements and the dormant-code sweep read.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate's rule for a sub page: a nav item that names its parent with `under`. The rail draws one list nested under
|
|
3
|
+
* the parent, so a navigation it cannot draw is refused here. Pure, and imports nothing from the project, so a test can
|
|
4
|
+
* feed it navigations of its own.
|
|
5
|
+
*/
|
|
6
|
+
type Item = { href: string; workspace?: string; under?: string };
|
|
7
|
+
type Group = { label?: string; items: Item[] };
|
|
8
|
+
|
|
9
|
+
const MAX_SUB_PAGES = 7;
|
|
10
|
+
|
|
11
|
+
/** The `under` an item names, or undefined: a project whose `NavItem` has no such field passes. */
|
|
12
|
+
const underOf = (it: Item): string | undefined => ('under' in it && typeof it.under === 'string' ? it.under : undefined);
|
|
13
|
+
|
|
14
|
+
/** One line per refused item in item order, then per group the parents with too many sub pages. */
|
|
15
|
+
export function navUnderProblems(groups: Group[]): string[] {
|
|
16
|
+
const out: string[] = [];
|
|
17
|
+
const at = (href: string) => `src/app.config.ts: nav item ${href}`;
|
|
18
|
+
for (const g of groups) {
|
|
19
|
+
const byHref = new Map(g.items.map((it) => [it.href, it]));
|
|
20
|
+
for (const it of g.items) {
|
|
21
|
+
const under = underOf(it);
|
|
22
|
+
if (under === undefined) continue;
|
|
23
|
+
const parent = byHref.get(under);
|
|
24
|
+
if (under === it.href) out.push(`${at(it.href)} is under itself`);
|
|
25
|
+
else if (!parent) out.push(`${at(it.href)} is under ${under}, which is no item of its group`);
|
|
26
|
+
else if (underOf(parent) !== undefined) out.push(`${at(it.href)} is under ${under}, which is itself under ${underOf(parent)}; the rail has three levels, never four`);
|
|
27
|
+
else if (it.workspace !== parent.workspace) out.push(`${at(it.href)} is in workspace ${it.workspace ?? 'none'} under ${under}, which is in workspace ${parent.workspace ?? 'none'}`);
|
|
28
|
+
}
|
|
29
|
+
for (const it of g.items) {
|
|
30
|
+
const n = g.items.filter((s) => underOf(s) === it.href).length;
|
|
31
|
+
if (n > MAX_SUB_PAGES) out.push(`${at(it.href)} has ${n} sub pages; at most ${MAX_SUB_PAGES}, past that the item is a group of its own`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
@@ -67,6 +67,23 @@ timezone, currency and the signed-in user the rail shows (all but the descriptio
|
|
|
67
67
|
palette: groups of `{ href, label, icon, needs }`, plus a `workspace` while the workspaces stay (icons from `lucide-react`, 1.75 stroke is applied by the rail). A badge
|
|
68
68
|
is a short string, used only for something that needs attention ("1").
|
|
69
69
|
|
|
70
|
+
**Sub pages.** An item may be a sub page of another item of the same group: give it `under`, the `href` of its parent.
|
|
71
|
+
The rail draws the parent as its link plus a button named `<label> pages`, and the sub pages in a list under it; the
|
|
72
|
+
branch is open on arrival when the current page is the parent or one of its sub pages. Nothing else reads a hierarchy:
|
|
73
|
+
every module that reads `nav` still sees a flat list of items, each with its own `needs` and `workspace`, and a
|
|
74
|
+
navigation with no `under` draws as before. `node scripts/check.ts` refuses, naming the item's `href`:
|
|
75
|
+
|
|
76
|
+
- an `under` that names no item of the same group;
|
|
77
|
+
- an `under` that names the item itself;
|
|
78
|
+
- an `under` that names an item which itself has an `under` (three levels, never four);
|
|
79
|
+
- a sub page whose `workspace` differs from its parent's, where a declared workspace and an undeclared one count as differing;
|
|
80
|
+
- more than 7 sub pages under one parent (past that, the item is a group of its own).
|
|
81
|
+
|
|
82
|
+
Use `under` for destinations: pages with their own address, access and place in the palette. Use in-page tabs (`Tabs`)
|
|
83
|
+
instead for the views of one record, under one header. For a sub page's masthead to show its path, pass
|
|
84
|
+
`<Breadcrumb items={navTrail(href, nav)} />` (from `@/components/patterns/rail/trail`) as the kicker; the crumbs are
|
|
85
|
+
derived from `nav`, never written out.
|
|
86
|
+
|
|
70
87
|
`needs` is required, and in a created project it is a feature's own need **by reference** — `needs: FEATURES.home.needs`,
|
|
71
88
|
from `optional:src/data/features.ts` — never a grant written a second time: the rail and the palette draw only the
|
|
72
89
|
destinations the signed-in person satisfies, and a test fails an item that restates a need. That filtering is
|
|
@@ -52,8 +52,10 @@ export type WorkspaceId = keyof typeof workspaces;
|
|
|
52
52
|
* `prefetch` asks for the item's page to be prefetched in full, its URL data with it, where Next's default prefetches the
|
|
53
53
|
* route's App Shell only. It is for a destination whose content is its URL data (a document page under a dynamic
|
|
54
54
|
* segment), and every visible such link makes a request of its own, so it is for one or two items.
|
|
55
|
+
*
|
|
56
|
+
* `under` is the href of an item in the same group that this item is a sub page of.
|
|
55
57
|
*/
|
|
56
|
-
export type NavItem = { href: string; label: string; icon: LucideIcon; badge?: string; workspace?: WorkspaceId; needs: Need; feature?: string; prefetch?: true };
|
|
58
|
+
export type NavItem = { href: string; label: string; icon: LucideIcon; badge?: string; workspace?: WorkspaceId; needs: Need; feature?: string; prefetch?: true; under?: string };
|
|
57
59
|
export type NavGroup = { label?: string; items: NavItem[] };
|
|
58
60
|
|
|
59
61
|
export const nav: NavGroup[] = [
|
|
@@ -67,11 +69,17 @@ export const nav: NavGroup[] = [
|
|
|
67
69
|
],
|
|
68
70
|
},
|
|
69
71
|
{
|
|
72
|
+
label: 'Accounts',
|
|
70
73
|
items: [
|
|
71
74
|
{ href: '/business', label: 'Revenue', icon: CircleDollarSign, workspace: 'business', needs: FEATURES.revenue.needs },
|
|
72
75
|
{ href: '/customers', label: 'Customers', icon: Users, workspace: 'business', needs: FEATURES.customers.needs },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
label: 'Traffic',
|
|
80
|
+
items: [
|
|
73
81
|
{ href: '/usage', label: 'Usage', icon: BarChart3, workspace: 'business', needs: FEATURES.usage.needs },
|
|
74
|
-
{ href: '/requests', label: 'Requests', icon: Activity, workspace: 'business', needs: FEATURES.requests.needs },
|
|
82
|
+
{ href: '/requests', label: 'Requests', icon: Activity, workspace: 'business', needs: FEATURES.requests.needs, under: '/usage' },
|
|
75
83
|
],
|
|
76
84
|
},
|
|
77
85
|
{
|
|
@@ -490,7 +490,7 @@ export function PageFrame({
|
|
|
490
490
|
{name === undefined ? (
|
|
491
491
|
<div className="flex flex-wrap items-end gap-x-8 gap-y-5">
|
|
492
492
|
<div className="min-w-0 flex-1 basis-[28rem]">
|
|
493
|
-
{kicker ? <
|
|
493
|
+
{kicker ? <div className="t-kicker mb-4">{kicker}</div> : null}
|
|
494
494
|
<PageTitle ref={head} title={title} />
|
|
495
495
|
{description ? <p className="t-lead mt-3 max-w-[60ch]">{description}</p> : null}
|
|
496
496
|
</div>
|
|
@@ -503,7 +503,7 @@ export function PageFrame({
|
|
|
503
503
|
state has none, so the block kept two lines while the page loaded and three once it had, and
|
|
504
504
|
everything under it dropped. Now its width follows the viewport, the rail and the assistant alone. */}
|
|
505
505
|
<div>
|
|
506
|
-
{kicker ? <
|
|
506
|
+
{kicker ? <div className="t-kicker mb-4">{kicker}</div> : null}
|
|
507
507
|
<PageTitle ref={head} name={name} title={title} speaksName={speaksName} />
|
|
508
508
|
</div>
|
|
509
509
|
{/* The description grows, so the meta and the actions end the row on its last line; with no description
|
|
@@ -7,7 +7,7 @@ Status: beta
|
|
|
7
7
|
## Anatomy
|
|
8
8
|
|
|
9
9
|
1. **Query**: a 56px input with a search icon, `text-md`, and an Esc hint.
|
|
10
|
-
2. **Results**: commands grouped under mono eyebrows ("Go to", "Appearance"), each a 40px row with an icon, a label, an optional hint (its group) and, on the highlighted row, an Enter glyph.
|
|
10
|
+
2. **Results**: commands grouped under mono eyebrows ("Go to", "Appearance"), each a 40px row with an icon, a label, an optional hint (its group, or its parent's label for a sub page) and, on the highlighted row, an Enter glyph.
|
|
11
11
|
3. **Key hints**: a `surface-sunk` band at the foot: ↑ ↓ move, ↵ open, ⌘K toggle.
|
|
12
12
|
|
|
13
13
|
## Composition
|
|
@@ -16,7 +16,7 @@ A Dialog (`surface-raised`, `radius-xl` 24px, `shadow-overlay`, 600px wide, 14%
|
|
|
16
16
|
|
|
17
17
|
## Data
|
|
18
18
|
|
|
19
|
-
Commands are built from the `nav` prop, the same groups the Rail gets (one "Go to" per destination, its group as the hint) and the appearance actions. A match ranks by where the query starts in the label, then by the original order; the
|
|
19
|
+
Commands are built from the `nav` prop, the same groups the Rail gets (one "Go to" per destination, its group as the hint, or for a sub page its parent's label) and the appearance actions. A match ranks by where the query starts in the label, then by the original order; the hint also matches, so typing a parent's label finds its sub pages, and where a navigation names its groups ("billing" finds every entry under Billing; the sample's groups carry no name). Like the Rail's, `nav` carries each entry's icon as a component, so it cannot be built in a server component — see the Rail's README.
|
|
20
20
|
|
|
21
21
|
## States
|
|
22
22
|
|
|
@@ -9,6 +9,7 @@ import type { NavGroup } from '@/app.config';
|
|
|
9
9
|
import { cn } from '@/lib/cn';
|
|
10
10
|
import { usePreferences } from '@/components/base/providers';
|
|
11
11
|
import { Kbd } from '@/components/ui/kbd';
|
|
12
|
+
import { parentOf } from '@/components/patterns/rail/trail';
|
|
12
13
|
|
|
13
14
|
export type Command = { id: string; group: string; label: string; hint?: string; icon: ReactNode; run: () => void };
|
|
14
15
|
|
|
@@ -55,7 +56,7 @@ export function CommandPalette({ nav }: { /** Every destination the rail offers,
|
|
|
55
56
|
...nav.flatMap((g) =>
|
|
56
57
|
g.items.map((it) => {
|
|
57
58
|
const Icon = it.icon;
|
|
58
|
-
return { id: it.href, group: 'Go to', label: it.label, hint: g.label, icon: <Icon />, run: () => leave(() => router.push(it.href)) };
|
|
59
|
+
return { id: it.href, group: 'Go to', label: it.label, hint: parentOf(g, it)?.label ?? g.label, icon: <Icon />, run: () => leave(() => router.push(it.href)) };
|
|
59
60
|
}),
|
|
60
61
|
),
|
|
61
62
|
{ id: 'theme-light', group: 'Appearance', label: 'Use the light theme', icon: <Sun />, run: () => set({ theme: 'light' }) },
|
|
@@ -9,8 +9,9 @@ Status: beta
|
|
|
9
9
|
1. **Brand**: the App mark at 28px, the product name (`text-md`, 600) and the workspace as an eyebrow; the whole row opens the account menu: Workspace settings, Sign out, and — where the product may be read as someone else — the View as group. A product with several workspaces or scopes (one team, the whole platform) passes them as `scopes` (`{ id, label, active, onSelect, href }`, `href` where choosing it goes); the menu then lists them as radio items under "Show", the row's glyph is the switcher's `ChevronsUpDown`, and `workspace` sets the eyebrow to the one in view. With one scope or none there is nothing to switch between, so there is no `Show` group and no checked row of its own: the row is the account menu, its glyph is a plain `ChevronDown`, and its label says so.
|
|
10
10
|
2. **Groups**: an optional mono eyebrow (`t-eyebrow`, `ink-3`) over a list of items; groups sit `space-6` 24px apart.
|
|
11
11
|
3. **Item**: a 16px icon at a 1.75 stroke, the label, and an optional count badge.
|
|
12
|
-
4. **
|
|
13
|
-
5. **
|
|
12
|
+
4. **Branch**: an item that other items name with `under` is a parent. Its row holds two controls: the item's link, to the parent's own page, and a button at the row's right end named `<label> pages`, whose one `ChevronRight` turns a quarter to point down while the branch is open. Its sub pages are a nested list under the row, on a 1px `line` guide line hung under the parent's icon; a sub page has no icon and its label lines up with the parent's label. A navigation has three levels at most: group, item, sub page.
|
|
13
|
+
5. **Marker**: one pill behind the current item, `accent-tint` with an inset `accent-line` ring and a 2px `accent` edge on the left that carries a soft glow.
|
|
14
|
+
6. **Account**: the person's avatar, name and role, and the Appearance menu, in a hairline card at the foot.
|
|
14
15
|
|
|
15
16
|
## Composition
|
|
16
17
|
|
|
@@ -37,6 +38,11 @@ App mark, Avatar and Appearance menu, on `frame` (a translucent wash with `backd
|
|
|
37
38
|
| Hover | `fill-hover` behind, label `ink` | `ink-2` |
|
|
38
39
|
| Current | the marker behind, label `ink` 500 | `accent-ink` |
|
|
39
40
|
| Focus (keyboard) | 2px `accent` outline | as rest |
|
|
41
|
+
| Branch open, a sub page current | the sub page takes the marker, and the guide line beside it lights in `accent`; the parent's label is `ink` and its icon `accent-ink`, with no pill | |
|
|
42
|
+
| Branch closed, a sub page current | the parent's link takes the marker, with `aria-current="true"` | `accent-ink` |
|
|
43
|
+
| Parent's own page current | the parent's link takes the marker, whether the branch is open or closed | `accent-ink` |
|
|
44
|
+
| Sub pages the person may not open | the parent is a plain item, with no button | |
|
|
45
|
+
| Parent the person may not open | the sub page is a plain item in the group, with its own icon | |
|
|
40
46
|
|
|
41
47
|
The preview's specimens are the real Rail, so rest and current are drawn in them, and hover and focus are seen by using them: point at an item, or tab to it.
|
|
42
48
|
|
|
@@ -48,13 +54,19 @@ Colours change over `dur-hover` 160ms. The marker moves (`transform`, `height`)
|
|
|
48
54
|
- While a destination's page is on the way (a page that renders on request and is not prefetched), its icon turns into a 14px Spinner in the same 16px slot (`useLinkStatus`), named "Opening Members", so the click is answered at once and nothing shifts. A prefetched page is never pending, so it never shows.
|
|
49
55
|
- An item is current when the path equals its route or sits beneath it (`/requests/req_…` keeps Requests current); Home (`/`) only matches itself. The current page is the longest such route among every destination the product declares (`declared`, which `ConsoleRail` passes from the whole navigation), and it is lit only when this person's rail holds it: an address under a destination they may not open (`/admin/audit` for someone who holds `/admin` only) lights nothing, never the shorter one above it.
|
|
50
56
|
- Navigation from the account menu (Workspace settings and Sign out) asks first while a form on the page has unsaved changes, through the shell's `useLeave`; a link is held by the shell itself. Outside a shell it goes at once. Opening the menu prefetches each other scope's `href`: a row is a press, so nothing fetches its page before, and a phone's closed drawer prefetches nothing. A workspace chosen from the switcher runs the `onSelect` the product passed, so the product's own handler asks (the sample's does, in `src/views/workspace-chrome.tsx`); choosing a person under View as is not a navigation and is not held.
|
|
57
|
+
- A parent's button opens and closes its branch: Enter or Space, or a click. A closed branch mounts none of its links, so nothing in it is prefetched, tabbed to or read by a suite. The marker is measured again whenever a branch opens or closes.
|
|
58
|
+
- The button's chevron is one icon that turns a quarter by the hover duration (right when closed, down when open) rather than swapping, and reduced motion stills it through the system-wide reset. Focus stays on the button after a toggle, because a toggle never remounts it.
|
|
59
|
+
- Beside a current sub page the marker draws no lit edge of its own: the guide line's lit stretch is the only accent line there. Over a second-level item, including the parent of a closed branch, the marker keeps its lit edge.
|
|
60
|
+
- A branch is open on arrival when the current page is its parent's page or one of its sub pages, and closed otherwise. Every navigation that lands on a parent or one of its sub pages opens that branch again, even one the person closed. Otherwise a person's open or close stands for as long as the rail stays mounted, which spans every navigation inside the console: a branch they opened stays open elsewhere, and one they closed from inside stays closed until a navigation lands inside it. The state is not stored, so a reload starts from the arrival rule. The phone drawer mounts its rail each time it opens, so it applies the arrival rule afresh.
|
|
61
|
+
- Only one element in the rail carries `aria-current` at a time: `page` on the current link, or `true` on the parent of a closed branch that holds the current page.
|
|
62
|
+
- `nav` is what this person may open. A sub page whose parent is not in `nav` is drawn as a plain item in its group, with its icon; a parent with none of its sub pages in `nav` is a plain item. `declared` still lists every href, so an address under a destination the person may not open lights nothing.
|
|
51
63
|
- `current` overrides the pathname (previews and tests); products leave it unset.
|
|
52
64
|
- View as: the personas are radio items, so the arrows move through them and Enter switches. The one in view carries the check; a persona who is not Active is listed but disabled. A `choose` that rejects shows its message in a critical toast and changes nothing — the check stays on the person who is still signed in.
|
|
53
65
|
|
|
54
66
|
## Surfaces
|
|
55
67
|
|
|
56
68
|
- **Console**: fixed at the left edge, its own scroll when the list outgrows the window.
|
|
57
|
-
- **Mobile**: under 1024px the same node opens as a drawer from the top bar's menu button: 260px (at most 86% of the screen), over a blurred scrim; it closes on Escape, on the scrim and on navigation, and returns focus to the trigger.
|
|
69
|
+
- **Mobile**: under 1024px the same node opens as a drawer from the top bar's menu button: 260px (at most 86% of the screen), over a blurred scrim; it closes on Escape, on the scrim and on navigation, and returns focus to the trigger. On a touch screen (`pointer: coarse`) every parent, sub page and button row is 44px tall and the button's target is 44 by 44; on a fine pointer a sub page row is 32px and the button 28px.
|
|
58
70
|
- **Embed**: absent. A view in a host has one job and no navigation; it links out with Open in ZZ Meridian.
|
|
59
71
|
|
|
60
72
|
## Agents
|
|
@@ -64,6 +76,7 @@ Not applicable: navigation is how a person moves. An agent opens a view by its a
|
|
|
64
76
|
## Accessibility
|
|
65
77
|
|
|
66
78
|
- `nav` with the name "Main"; the current item carries `aria-current="page"`.
|
|
79
|
+
- A parent's button has the accessible name `<label> pages` ("Usage pages"), `aria-expanded` set to `true` or `false`, and `aria-controls` naming the branch's list. It is a separate control from the parent's link, so the parent's page stays reachable from the rail. The parent of a closed branch that holds the current page carries `aria-current="true"`.
|
|
67
80
|
- Contrast (dark, indigo): inactive label `ink-2` on the rail 8.7:1, group label `ink-3` 7.0:1, current label on the marker 9.2:1; light 6.6, 4.9 and 5.7:1.
|
|
68
81
|
- The count badge repeats a number the page states in words; it is never the only signal.
|
|
69
82
|
|
|
@@ -75,7 +88,8 @@ Not applicable: navigation is how a person moves. An agent opens a view by its a
|
|
|
75
88
|
|
|
76
89
|
## Do and do not
|
|
77
90
|
|
|
78
|
-
- Do keep the rail to about ten items; a product with more needs a second level on its pages (Tabs), not a longer rail.
|
|
91
|
+
- Do keep the rail to about ten items; a product with more needs sub pages under an item (`under`), or a second level on its pages (Tabs), not a longer rail.
|
|
92
|
+
- Do use `under` for sub pages that are destinations of their own, each with its own page and access. Use Tabs in the page for the views of one record, under one header.
|
|
79
93
|
- Do not put actions in the rail; it holds places only.
|
|
80
94
|
- Do not use the count badge for anything but something that needs attention now.
|
|
81
95
|
|
|
@@ -94,4 +108,15 @@ import { Rail } from '@/components/patterns/rail';
|
|
|
94
108
|
<AppShell rail={<Rail />} tools={<ShellTools />} assistant={assistant}>{children}</AppShell>
|
|
95
109
|
```
|
|
96
110
|
|
|
97
|
-
Edit `nav` in `src/app.config.ts` to change the destinations: `{ label?, items: [{ href, label, icon, badge?, prefetch? }] }`; set `prefetch: true` on an item whose page is its URL data (a document under a dynamic segment), for one or two items at most.
|
|
111
|
+
Edit `nav` in `src/app.config.ts` to change the destinations: `{ label?, items: [{ href, label, icon, badge?, prefetch?, under? }] }`; set `prefetch: true` on an item whose page is its URL data (a document under a dynamic segment), for one or two items at most.
|
|
112
|
+
|
|
113
|
+
`under` makes an item a sub page: it is the `href` of an item of the same group, and the rail draws the item in that item's branch.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
{ label: 'Traffic', items: [
|
|
117
|
+
{ href: '/usage', label: 'Usage', icon: BarChart3, needs: FEATURES.usage.needs },
|
|
118
|
+
{ href: '/requests', label: 'Requests', icon: Activity, needs: FEATURES.requests.needs, under: '/usage' },
|
|
119
|
+
] }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`node scripts/check.ts` refuses a navigation it cannot draw, naming the item's `href`: an `under` that names no item of the same group, an `under` that names the item itself, an `under` that names an item that is itself under another (three levels, never four), a sub page whose `workspace` differs from its parent's (a declared workspace and an undeclared one differ), and more than 7 sub pages under one parent.
|