diff --git a/packages/demo/src/content/docs/components/nav.mdx b/packages/demo/src/content/docs/components/nav.mdx
index d24cadfb..ea8ca998 100644
--- a/packages/demo/src/content/docs/components/nav.mdx
+++ b/packages/demo/src/content/docs/components/nav.mdx
@@ -374,7 +374,7 @@ function goToLevel(level) {
### Docs Sidebar
-Rows built from a tree of folders and pages. Folders around the `current` row open themselves.
+Rows built from a tree of folders and pages. Folders around the `current` row open themselves. `@eqtylab/docs` builds its sidebar this way.
diff --git a/packages/docs/CHANGELOG.md b/packages/docs/CHANGELOG.md
index 312f1f18..2e761db1 100644
--- a/packages/docs/CHANGELOG.md
+++ b/packages/docs/CHANGELOG.md
@@ -4,6 +4,38 @@ Notable changes to Equality's Docsite Generator are recorded here, following [Ke
## Unreleased
+## 0.16.0 - 2026-10-29
+
+Needs `@eqtylab/equality` 5.1.0 or later. Sites on Equality 4 bump it with this release; none of
+our docs sites use the components 5.0 removed.
+
+### Changed
+
+- The sidebar is built from Equality's Nav parts. Rows are 36px tall (were 32px), every folder is a
+ group that opens and closes, and only top-level rows show their icon. Top-level folders lead in
+ primary text; folders inside them read as rows.
+- The current page's row is grey instead of lilac, and search's selected result matches it.
+- A group's header works once the sidebar's script has loaded. The sidebar starts it at the page's
+ first idle moment, or half a second after the page is ready at most; before that, a click does
+ nothing. Groups that hold the current page still render open. With JavaScript off, every group
+ shows open.
+- A sidebar link's `attrs` pass only `data-*`, `aria-*` and `id`; anything else is dropped. An
+ `aria-*` set to `true` renders as `"true"`.
+- A sidebar link to another site shows Equality's ↗ and tells screen readers it opens in a new
+ tab.
+- The site card's rows, the header's links and the landing page's links hover with the same tint
+ as the sidebar's rows, and the card's arrows darken with them. They used the page colour, which
+ was darker than its surface in dark mode.
+
+### Removed
+
+- `scripts/eq-nav-group.ts`, which animated the old `` groups.
+
+### Fixed
+
+- Links and inline code inside tables and alerts look like links and code again. Both had lost
+ their styling, so a link in a table read as plain text.
+
## 0.15.3 - 2026-10-28
### Added
diff --git a/packages/docs/README.md b/packages/docs/README.md
index 9b08fe93..54ce4a91 100644
--- a/packages/docs/README.md
+++ b/packages/docs/README.md
@@ -218,6 +218,14 @@ collapsed: false
The content root's `index.mdx` is the site landing page and stays out of the sidebar.
- A typo in `order` produces a build **warning naming the file and the token** rather than silently
reordering, and the schema is `.strict()` so an unknown key fails the build.
+- `icon` on a top-level folder (`_group.yaml`) or page (frontmatter) shows in front of its label.
+ Icons show on top-level rows only. Give every top-level folder and page one, or none: a mix
+ leaves the rows' text out of line.
+- `badge` marks a folder or page with a status, such as `{ text: Beta, variant: warning }`. It
+ shows after the label at any depth, and screen readers read it with the label. `deprecated: true`
+ adds one for you.
+- A link in a folder's `links` can carry `attrs`, such as `{ data-track: status }`. Only `data-*`,
+ `aria-*` and `id` reach the page; anything else is dropped.
## Linking between pages
diff --git a/packages/docs/package.json b/packages/docs/package.json
index bc0f743c..6c08decd 100644
--- a/packages/docs/package.json
+++ b/packages/docs/package.json
@@ -1,7 +1,7 @@
{
"name": "@eqtylab/docs",
"description": "Astro documentation framework built on the Equality design system",
- "version": "0.15.3",
+ "version": "0.16.0",
"license": "Apache-2.0",
"homepage": "https://equality.eqtylab.io/",
"repository": {
@@ -93,7 +93,7 @@
"build:types": "tsc -p tsconfig.build.json",
"dev": "node scripts/dev.mjs",
"check:source": "node scripts/check-tailwind-source.mjs",
- "test": "node --test tests/*.test.ts",
+ "test": "node --test tests/*.test.ts && vitest run",
"release": "pnpm run build && pnpm publish --access public --no-git-checks"
},
"dependencies": {
@@ -118,7 +118,7 @@
"peerDependencies": {
"@astrojs/mdx": "^8.0.0",
"@astrojs/react": "^7.0.0",
- "@eqtylab/equality": "^4.1.0",
+ "@eqtylab/equality": "^5.1.0",
"astro": "^7.2.10",
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
@@ -133,6 +133,7 @@
"react-dom": "^19.2.0",
"tsup": "^8.5.0",
"typescript": ">=5.4.x",
- "vite": "^8.3.1"
+ "vite": "^8.3.1",
+ "vitest": "^5.0.1"
}
}
diff --git a/packages/docs/src/runtime/chrome/DocsNavList.tsx b/packages/docs/src/runtime/chrome/DocsNavList.tsx
new file mode 100644
index 00000000..7f37535c
--- /dev/null
+++ b/packages/docs/src/runtime/chrome/DocsNavList.tsx
@@ -0,0 +1,67 @@
+import type { NavNode } from '@eqtylab/docs/types';
+import {
+ Badge,
+ Icon,
+ NavList,
+ NavListExpandable,
+ NavListExpandableContent,
+ NavListExpandableTrigger,
+ NavListGroup,
+ NavListItem,
+} from '@eqtylab/equality';
+
+export function DocsNavList({ nodes }: { nodes: NavNode[] }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function Rows({ nodes, depth }: { nodes: NavNode[]; depth: number }) {
+ return (
+ <>
+ {nodes.map((node, index) => {
+ const prefix = depth === 0 && node.icon ? : undefined;
+ const suffix = node.badge && (
+
+ {node.badge.text}
+
+ );
+
+ return node.kind === 'group' && node.children?.length ? (
+
+
+
+
+
+
+ ) : (
+
+ );
+ })}
+ >
+ );
+}
+
+/** Only data-, aria- and id pass. React renders the row: a `style` string from config throws and
+ * fails the site's build, and a `class` would replace the row's own. */
+function linkAttrs(attrs: NavNode['attrs']) {
+ return Object.fromEntries(
+ Object.entries(attrs ?? {})
+ .filter(([key]) => key === 'id' || key.startsWith('data-') || key.startsWith('aria-'))
+ .filter(([, value]) => value !== false)
+ .map(([key, value]) => [key, value === true && !key.startsWith('aria-') ? '' : String(value)])
+ );
+}
diff --git a/packages/docs/src/runtime/chrome/GlobalSearch.tsx b/packages/docs/src/runtime/chrome/GlobalSearch.tsx
index e6ed1d1b..0740f0b4 100644
--- a/packages/docs/src/runtime/chrome/GlobalSearch.tsx
+++ b/packages/docs/src/runtime/chrome/GlobalSearch.tsx
@@ -26,14 +26,13 @@ import styles from './GlobalSearch.module.css';
* shrinks. Utilities setting a property Equality leaves alone need nothing.
*/
-/** Equality styles the hover but not cmdk's selected row. */
-const ITEM =
- 'cursor-pointer data-[selected=true]:bg-lilac-300/50 dark:data-[selected=true]:bg-lilac-600/50';
+/** Equality styles the hover but not cmdk's selected row. It takes the sidebar's current-row fill,
+ * so the row a reader lands on looks the same in both places. */
+const ITEM = 'cursor-pointer data-[selected=true]:bg-list-item-current';
/**
- * Equality has no highlight token, so these name steps off the option scale directly. Not
- * `brand-primary`: it is the selected row's colour, so a mark painted with it vanishes exactly
- * when a row is selected. Each theme names its own step because `lilac-*` carries no dark values.
+ * Equality has no highlight token, so these name steps off the option scale directly. Each theme
+ * names its own step because `lilac-*` carries no dark values.
*/
const MARK = 'rounded bg-lilac-200 px-0.5 text-lilac-800 dark:bg-lilac-700 dark:text-lilac-200';
diff --git a/packages/docs/src/runtime/chrome/NavTree.astro b/packages/docs/src/runtime/chrome/NavTree.astro
index 1f9b453e..a7d091f9 100644
--- a/packages/docs/src/runtime/chrome/NavTree.astro
+++ b/packages/docs/src/runtime/chrome/NavTree.astro
@@ -1,108 +1,32 @@
---
/**
- * Recursive sidebar list. Groups are , so they open with no script and
- * nothing remounts on a page load. `eq-nav-group.ts` adds the animation.
- *
- * Every value here comes from Equality: the dropdown item's geometry and hover, and
- * the Separator's colour for the rails.
+ * The sidebar's rows, one React island on Equality's NavList parts. Groups render open or closed
+ * from the server, and closed groups keep their links in the HTML. Until the island hydrates,
+ * a group's header does nothing, so the timeout caps that wait on a busy page.
*/
import type { NavNode } from '@eqtylab/docs/types';
-import { Badge, Icon } from '@eqtylab/equality';
+
+import { DocsNavList } from './DocsNavList.tsx';
interface Props {
nodes: NavNode[];
- depth?: number;
}
-const { nodes, depth = 0 } = Astro.props;
-const NavTree = Astro.self;
-
-// 25px per level. A Tailwind default, not a token: Equality has no nesting value.
-const list =
- depth === 0 ? 'space-y-1' : 'border-border ml-3 mt-1 space-y-0.5 border-l pl-3';
-
-// Equality's dropdown item, minus its `outline-hidden`: a link list needs keyboard
-// focus visible, so `focus-ring` stands in.
-const row =
- 'dropdown-item-hover focus-ring flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 no-underline transition-colors';
-
-// `eq-docs-label` sets its own size and colour, so top-level groups must not also get
-// `text-sm` or `text-text-primary`.
-const groupText = depth === 0 ? 'eq-docs-label' : 'text-sm text-text-primary';
-const groupPointerFocus =
- depth === 0 ? 'pointer-focus:text-text-secondary!' : 'pointer-focus:text-text-primary!';
-
-// Equality has no selected-row colour, so the current page borrows the hover one.
-// Search's selected row must keep matching it. The idle colour is left off a current
-// row rather than overridden: two unvariated colour utilities on one element resolve
-// by Tailwind's own order, not by the order they are written.
-const current =
- 'bg-lilac-300/50 text-lilac-700 dark:bg-lilac-600/50 dark:text-lilac-100 font-medium shadow-sm';
+const { nodes } = Astro.props;
---
-
- {
- nodes.map((node) => (
-
- {node.kind === 'group' && node.children?.length ? (
-
- {/* pointer-focus undoes Equality's hover firing on plain `:focus`, which
- left a clicked header filled as if it were the current page. */}
-
-
- {node.icon && }
- {node.label}
- {node.badge && (
-
- {node.badge.text}
-
- )}
-
- {/* The direct-child selector is load-bearing: a plain `group-open`
- would also rotate a closed child's chevron inside an open parent. */}
-
-
-
-
-
-
- ) : (
-
- {node.icon && }
- {node.label}
- {node.badge && (
-
- {node.badge.text}
-
- )}
- {node.external && (
-
-
-
- )}
-
- )}
-
- ))
- }
-
-
-{
- /** Imported here, so one import covers both the sidebar and the drawer. */
-}
-
+
+
+{/* Without JavaScript a folder's header never opens it, so every folder shows open */}
+
diff --git a/packages/docs/src/runtime/chrome/Sidebar.astro b/packages/docs/src/runtime/chrome/Sidebar.astro
index 324cdf4e..3174b449 100644
--- a/packages/docs/src/runtime/chrome/Sidebar.astro
+++ b/packages/docs/src/runtime/chrome/Sidebar.astro
@@ -44,7 +44,7 @@ const { nodes, switcher } = Astro.props;
} catch {}
});
- const current = sidebar.querySelector('[data-current]');
+ const current = sidebar.querySelector('[aria-current="page"]');
if (!current) return;
let saved = null;
try {
diff --git a/packages/docs/src/runtime/chrome/SiteCardControls.tsx b/packages/docs/src/runtime/chrome/SiteCardControls.tsx
index f7ab45da..43feccc8 100644
--- a/packages/docs/src/runtime/chrome/SiteCardControls.tsx
+++ b/packages/docs/src/runtime/chrome/SiteCardControls.tsx
@@ -26,13 +26,14 @@ const NAME =
'eq-display-gradient-sm -my-[0.05em] block w-fit text-base leading-tight font-semibold text-balance forced-colors:text-inherit';
/* Equality's Icon colours and sizes itself in unlayered CSS, which beats any utility; hence `!`. */
-const QUIET_ICON = 'text-text-tertiary! [&_svg]:size-3.5!';
+const QUIET_ICON =
+ 'text-(color:--eq-list-item-muted-color)! group-hover:text-current! [&_svg]:size-3.5!';
const TAG_ICON = 'text-text-secondary! [&_svg]:size-3.5!';
-/* Side padding must match NavTree's row `px-2` (7px + 1px border), or the text drifts off the
- nav labels' left edge. */
-const BOX = 'rounded-lg border border-transparent px-[7px] py-[5px]';
-const ROW = `${BOX} focus-ring hover:bg-background data-[state=open]:border-brand-primary data-[state=open]:bg-background flex w-full items-center text-left transition-colors`;
+/* Side padding and border must match NavListItem's, or the text drifts off the nav labels' left
+ edge. */
+const BOX = 'rounded-lg border border-transparent px-(--eq-list-item-padding-x) py-[5px]';
+const ROW = `${BOX} group focus-ring hover:bg-list-item-hover data-[state=open]:border-brand-primary data-[state=open]:bg-background flex w-full items-center text-left transition-colors`;
/* Trigger width, not a 16rem floor: the sidebar's border leaves each row 255px on desktop, and a
16rem menu overhangs its row by a pixel. */
diff --git a/packages/docs/src/runtime/components/Landing.astro b/packages/docs/src/runtime/components/Landing.astro
index 049506cc..31f0a574 100644
--- a/packages/docs/src/runtime/components/Landing.astro
+++ b/packages/docs/src/runtime/components/Landing.astro
@@ -27,7 +27,7 @@ const target = (to: string) =>
: { href: siteHref(to, paths), external: false };
const linkRow =
- 'group/link text-text-secondary hover:text-text-primary hover:bg-background focus-ring -mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-sm font-medium no-underline transition-colors';
+ 'group/link text-text-secondary hover:text-text-primary hover:bg-list-item-hover focus-ring -mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-sm font-medium no-underline transition-colors';
---
{/* No outline on a landing page, so it owns its width: room for cards two or three across.
diff --git a/packages/docs/src/runtime/scripts/eq-nav-drawer.ts b/packages/docs/src/runtime/scripts/eq-nav-drawer.ts
index f10d3fc0..0fd6a39b 100644
--- a/packages/docs/src/runtime/scripts/eq-nav-drawer.ts
+++ b/packages/docs/src/runtime/scripts/eq-nav-drawer.ts
@@ -23,7 +23,7 @@ if (drawer && trigger) {
trigger.setAttribute('aria-expanded', String(open));
// The same centring as the sidebar's inline script in Sidebar.astro; keep the two in sync.
- const current = drawer.querySelector('[data-current]');
+ const current = drawer.querySelector('[aria-current="page"]');
if (open && current) {
const top = current.getBoundingClientRect().top - drawer.getBoundingClientRect().top;
if (top < 0 || top + current.offsetHeight > drawer.clientHeight) {
diff --git a/packages/docs/src/runtime/scripts/eq-nav-group.ts b/packages/docs/src/runtime/scripts/eq-nav-group.ts
deleted file mode 100644
index 17e77775..00000000
--- a/packages/docs/src/runtime/scripts/eq-nav-group.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Animates a navigation group open and closed, on top of ``.
- *
- * Do not swap this for Equality's `MotionCollapsibleContent`. It measures after mount
- * and animates up from zero, so a group that starts open expands on every page load.
- *
- * Duration and easing are that component's, so the motion still matches.
- */
-const DURATION = 300;
-const EASING = 'ease-in-out';
-
-function wire(group: HTMLDetailsElement) {
- const summary = group.querySelector('summary');
- const panel = summary?.nextElementSibling;
- if (!summary || !(panel instanceof HTMLElement)) return;
-
- let running: Animation | null = null;
-
- summary.addEventListener('click', (event) => {
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
-
- // The browser would toggle `open` immediately; the close has to outlive that.
- event.preventDefault();
- running?.cancel();
-
- const opening = !group.open;
- // Measured while open in both directions, so interrupting mid-animation does not
- // snap to a stale height.
- if (opening) group.open = true;
- const height = panel.scrollHeight;
- const from = opening ? 0 : panel.getBoundingClientRect().height;
-
- panel.style.overflow = 'hidden';
- running = panel.animate(
- {
- height: [`${from}px`, `${opening ? height : 0}px`],
- opacity: [opening ? 0 : 1, opening ? 1 : 0],
- },
- { duration: DURATION, easing: EASING }
- );
-
- running.onfinish = () => {
- running = null;
- panel.style.removeProperty('overflow');
- if (!opening) group.open = false;
- };
- });
-}
-
-for (const group of document.querySelectorAll('details[data-eq-nav-group]')) {
- wire(group);
-}
diff --git a/packages/docs/src/runtime/styles/chrome.css b/packages/docs/src/runtime/styles/chrome.css
index ba2b0492..3e7c0f0b 100644
--- a/packages/docs/src/runtime/styles/chrome.css
+++ b/packages/docs/src/runtime/styles/chrome.css
@@ -32,6 +32,12 @@
);
}
+/* Overrides Equality's switcher fade in the docs' own frame, so the site card matches the page
+ title. Demos in the page keep Equality's, or they would document the wrong default */
+[data-eq-chrome] {
+ --eq-switcher-name-fade: var(--eq-docs-display-gradient-sm);
+}
+
body {
@apply bg-background text-text-primary;
}
diff --git a/packages/docs/src/runtime/styles/prose.css b/packages/docs/src/runtime/styles/prose.css
index 9261a0da..ed64b362 100644
--- a/packages/docs/src/runtime/styles/prose.css
+++ b/packages/docs/src/runtime/styles/prose.css
@@ -124,4 +124,16 @@
@apply border-border mb-4;
}
}
+
+ /* Tables and alerts render through Equality, so the guard above skips them. Their cells are
+ still the page's own markdown: a link there must read as a link. */
+ & :where(table[data-eq-chrome], aside[data-eq-chrome]) :where([data-eq-md]) {
+ &:is(a) {
+ @apply text-text-primary hover:text-text-link decoration-text-link font-semibold underline decoration-1 underline-offset-4 transition-all;
+ }
+
+ :not(pre) > &:is(code) {
+ @apply bg-badge-background-neutral/50 text-badge-text-neutral rounded-sm px-1 py-0.5 font-mono text-[0.875em];
+ }
+ }
}
diff --git a/packages/docs/src/runtime/styles/utilities.css b/packages/docs/src/runtime/styles/utilities.css
index 4d9f7f3e..cf1c63e9 100644
--- a/packages/docs/src/runtime/styles/utilities.css
+++ b/packages/docs/src/runtime/styles/utilities.css
@@ -10,7 +10,7 @@
* toggle is a one-cell grid, and a display utility in here would collide with theirs.
*/
@utility eq-nav-control {
- @apply text-text-secondary hover:text-text-primary hover:bg-background;
+ @apply text-text-secondary hover:text-text-primary hover:bg-list-item-hover;
@apply h-10 items-center gap-1.5 rounded-md px-3;
@apply text-sm font-medium no-underline transition-colors;
@apply focus-ring;
@@ -48,8 +48,8 @@
}
}
-/** The display fade for text under 18.66px bold, such as the sidebar card's name. */
+/** The sidebar card's name: Equality's switcher fade, which the docs set to their small display fade. */
@utility eq-display-gradient-sm {
@apply eq-display-gradient;
- --eq-docs-display-gradient: var(--eq-docs-display-gradient-sm);
+ --eq-docs-display-gradient: var(--eq-switcher-name-fade);
}
diff --git a/packages/docs/tests/docs-nav-list.test.tsx b/packages/docs/tests/docs-nav-list.test.tsx
new file mode 100644
index 00000000..1e97ad4f
--- /dev/null
+++ b/packages/docs/tests/docs-nav-list.test.tsx
@@ -0,0 +1,142 @@
+import { renderToString } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+
+import { DocsNavList } from '../src/runtime/chrome/DocsNavList.tsx';
+import type { NavNode } from '../src/types.ts';
+
+const nodes: NavNode[] = [
+ { kind: 'page', name: 'index', label: 'Introduction', href: '/', icon: 'House' },
+ {
+ kind: 'group',
+ name: 'components',
+ label: 'Components',
+ open: true,
+ children: [
+ {
+ kind: 'page',
+ name: 'button',
+ label: 'Button',
+ href: '/components/button',
+ icon: 'MousePointerClick',
+ current: true,
+ badge: { text: 'New', variant: 'primary' },
+ },
+ {
+ kind: 'group',
+ name: 'forms',
+ label: 'Forms',
+ icon: 'Layers',
+ open: true,
+ children: [
+ {
+ kind: 'page',
+ name: 'input',
+ label: 'Input',
+ href: '/components/forms/input',
+ icon: 'TextCursorInput',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ kind: 'group',
+ name: 'guides',
+ label: 'Guides',
+ icon: 'BookOpen',
+ open: false,
+ children: [{ kind: 'page', name: 'theming', label: 'Theming', href: '/guides/theming' }],
+ },
+ {
+ kind: 'link',
+ name: 'github',
+ label: 'GitHub',
+ href: 'https://github.com/eqtylab/equality',
+ external: true,
+ attrs: { 'data-analytics': 'github' },
+ },
+];
+
+const html = renderToString();
+
+function rowHtml(label: string): string {
+ const end = html.indexOf(`>${label}`);
+ const start = html.lastIndexOf('