@eqtylab/docs 0.3.1 → 0.3.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.
Files changed (46) hide show
  1. package/dist/runtime/chrome/GlobalSearch.module.css +26 -0
  2. package/dist/runtime/chrome/GlobalSearch.tsx +422 -0
  3. package/dist/runtime/chrome/Header.astro +105 -0
  4. package/dist/runtime/chrome/LinkIcon.astro +35 -0
  5. package/dist/runtime/chrome/NavDrawer.astro +60 -0
  6. package/dist/runtime/chrome/NavTree.astro +112 -0
  7. package/dist/runtime/chrome/NotFoundBody.tsx +12 -0
  8. package/dist/runtime/chrome/PageFooter.astro +80 -0
  9. package/dist/runtime/chrome/Prose.astro +169 -0
  10. package/dist/runtime/chrome/Sidebar.astro +21 -0
  11. package/dist/runtime/chrome/TableOfContents.astro +86 -0
  12. package/dist/runtime/chrome/ThemeToggle.tsx +85 -0
  13. package/dist/runtime/chrome/TocElbow.astro +30 -0
  14. package/dist/runtime/chrome/TocList.astro +43 -0
  15. package/dist/runtime/components/AlertBridge.astro +16 -0
  16. package/dist/runtime/components/CodeFence.astro +38 -0
  17. package/dist/runtime/components/CodeFenceBridge.astro +20 -0
  18. package/dist/runtime/components/Link.astro +21 -0
  19. package/dist/runtime/components/TabItem.astro +16 -0
  20. package/dist/runtime/components/TableBridge.astro +18 -0
  21. package/dist/runtime/components/Tabs.astro +121 -0
  22. package/dist/runtime/components/TabsBridge.tsx +41 -0
  23. package/dist/runtime/components/index.ts +4 -0
  24. package/dist/runtime/layouts/DocsPage.astro +50 -0
  25. package/dist/runtime/layouts/DocsShell.astro +92 -0
  26. package/dist/runtime/lib/mdx-components.ts +49 -0
  27. package/dist/runtime/lib/nav-data.ts +74 -0
  28. package/dist/runtime/lib/summary.ts +57 -0
  29. package/dist/runtime/lib/theme.ts +84 -0
  30. package/dist/runtime/routes/docs-md.ts +33 -0
  31. package/dist/runtime/routes/docs.astro +92 -0
  32. package/dist/runtime/routes/llms-txt.ts +38 -0
  33. package/dist/runtime/routes/not-found.astro +16 -0
  34. package/dist/runtime/scripts/eq-copy.ts +27 -0
  35. package/dist/runtime/scripts/eq-highlight.ts +24 -0
  36. package/dist/runtime/scripts/eq-nav-drawer.ts +31 -0
  37. package/dist/runtime/scripts/eq-nav-group.ts +52 -0
  38. package/dist/runtime/scripts/eq-tabs.ts +114 -0
  39. package/dist/runtime/scripts/eq-toc.ts +166 -0
  40. package/dist/runtime/styles/chrome.css +30 -0
  41. package/dist/runtime/styles/prose.css +102 -0
  42. package/dist/runtime/styles/theme.css +2 -0
  43. package/dist/runtime/styles/utilities.css +38 -0
  44. package/package.json +1 -1
  45. package/dist/chunk-ATIOPYKE.js +0 -102
  46. package/dist/chunk-ATIOPYKE.js.map +0 -1
@@ -0,0 +1,112 @@
1
+ ---
2
+ /**
3
+ * Recursive sidebar list. Groups are <details>, so they open with no script and
4
+ * nothing remounts on a page load. `eq-nav-group.ts` adds the animation.
5
+ *
6
+ * Every value here comes from Equality: the dropdown item's geometry and hover, and
7
+ * the Separator's colour for the rails.
8
+ */
9
+ import type { NavNode } from '@eqtylab/docs/types';
10
+ import { Badge, Icon } from '@eqtylab/equality';
11
+
12
+ interface Props {
13
+ nodes: NavNode[];
14
+ depth?: number;
15
+ }
16
+
17
+ const { nodes, depth = 0 } = Astro.props;
18
+ const NavTree = Astro.self;
19
+
20
+ // 25px per level. A Tailwind default, not a token: Equality has no nesting value.
21
+ const list =
22
+ depth === 0 ? 'space-y-1' : 'border-border ml-3 mt-1 space-y-0.5 border-l pl-3';
23
+
24
+ // Equality's dropdown item, minus its `outline-hidden`: a link list needs keyboard
25
+ // focus visible, so `focus-ring` stands in.
26
+ const row =
27
+ 'dropdown-item-hover focus-ring flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm no-underline transition-colors';
28
+
29
+ // Equality has no selected-row colour, so the current page borrows the hover one.
30
+ // Search's selected row must keep matching it. The idle colour is left off a current
31
+ // row rather than overridden: two unvariated colour utilities on one element resolve
32
+ // by Tailwind's own order, not by the order they are written.
33
+ const current =
34
+ 'bg-lilac-300/50 text-lilac-700 dark:bg-lilac-600/50 dark:text-lilac-100 font-medium shadow-sm';
35
+ ---
36
+
37
+ <ul class={list} data-depth={depth}>
38
+ {
39
+ nodes.map((node) => (
40
+ <li class="list-none">
41
+ {node.kind === 'group' && node.children?.length ? (
42
+ <details class="block" data-eq-nav-group open={node.open}>
43
+ {/* pointer-focus undoes Equality's hover firing on plain `:focus`, which
44
+ left a clicked header filled as if it were the current page. */}
45
+ <summary
46
+ class={`${row} text-text-primary justify-between [&::-webkit-details-marker]:hidden pointer-focus:bg-transparent! pointer-focus:text-text-primary! pointer-focus:shadow-none!`}
47
+ >
48
+ <span class="flex min-w-0 flex-1 items-center gap-2 truncate">
49
+ {node.icon && <Icon icon={node.icon} size="sm" />}
50
+ {node.href ? (
51
+ <a
52
+ class="text-inherit no-underline"
53
+ href={node.href}
54
+ aria-current={node.current ? 'page' : undefined}
55
+ >
56
+ {node.label}
57
+ </a>
58
+ ) : (
59
+ <span>{node.label}</span>
60
+ )}
61
+ {node.badge && (
62
+ <Badge variant={node.badge.variant} size="sm" className="shrink-0">
63
+ {node.badge.text}
64
+ </Badge>
65
+ )}
66
+ </span>
67
+ {/* The direct-child selector is load-bearing: a plain `group-open`
68
+ would also rotate a closed child's chevron inside an open parent. */}
69
+ <span
70
+ class="text-text-secondary shrink-0 transition-transform duration-200 [details[open]>summary_&]:rotate-90"
71
+ aria-hidden="true"
72
+ >
73
+ <Icon icon="ChevronRight" size="xs" />
74
+ </span>
75
+ </summary>
76
+ <NavTree nodes={node.children} depth={depth + 1} />
77
+ </details>
78
+ ) : (
79
+ <a
80
+ class={`${row} ${node.current ? current : 'text-text-secondary'}`}
81
+ href={node.href}
82
+ aria-current={node.current ? 'page' : undefined}
83
+ data-current={node.current ? '' : undefined}
84
+ target={node.external ? '_blank' : undefined}
85
+ rel={node.external ? 'noopener noreferrer' : undefined}
86
+ {...(node.attrs ?? {})}
87
+ >
88
+ {node.icon && <Icon icon={node.icon} size="sm" />}
89
+ <span class="min-w-0 flex-1 truncate">{node.label}</span>
90
+ {node.badge && (
91
+ <Badge variant={node.badge.variant} size="sm" className="shrink-0">
92
+ {node.badge.text}
93
+ </Badge>
94
+ )}
95
+ {node.external && (
96
+ <span class="text-text-secondary shrink-0 opacity-60" aria-hidden="true">
97
+ <Icon icon="ArrowUpRight" size="xs" />
98
+ </span>
99
+ )}
100
+ </a>
101
+ )}
102
+ </li>
103
+ ))
104
+ }
105
+ </ul>
106
+
107
+ {
108
+ /** Imported here, so one import covers both the sidebar and the drawer. */
109
+ }
110
+ <script>
111
+ import '@eqtylab/docs/scripts/eq-nav-group.ts';
112
+ </script>
@@ -0,0 +1,12 @@
1
+ import { NotFound } from '@eqtylab/equality';
2
+
3
+ /** `NotFound`'s default onHomeClick ignores `base`, so pass the href explicitly. */
4
+ export default function NotFoundBody({ homeHref }: { homeHref: string }) {
5
+ return (
6
+ <NotFound
7
+ onHomeClick={() => {
8
+ window.location.href = homeHref;
9
+ }}
10
+ />
11
+ );
12
+ }
@@ -0,0 +1,80 @@
1
+ ---
2
+ /** The end of a page: where to go next, and how to fix what you just read. */
3
+ import type { NavNode } from '@eqtylab/docs/types';
4
+ import { Icon } from '@eqtylab/equality';
5
+
6
+ interface Props {
7
+ prev?: NavNode;
8
+ next?: NavNode;
9
+ editHref?: string;
10
+ text?: string;
11
+ }
12
+
13
+ const { prev, next, editHref, text } = Astro.props;
14
+ const hasPager = Boolean(prev?.href || next?.href);
15
+
16
+ const page = 'group inline-flex flex-col gap-1 no-underline';
17
+ const direction = 'text-text-tertiary inline-flex items-center gap-1.5 text-sm';
18
+ const pageLabel = 'text-text-secondary group-hover:text-text-primary text-base font-medium';
19
+ ---
20
+
21
+ {
22
+ /* 96px, a step above the 64px under the page header, so the footer sits further out
23
+ than anything inside the prose. Capped to the prose width so it does not run under
24
+ the table of contents. */
25
+ }
26
+ {
27
+ (hasPager || editHref || text) && (
28
+ <footer
29
+ class="mt-24 max-w-[var(--eq-docs-content-max)]"
30
+ data-eq-chrome
31
+ data-pagefind-ignore
32
+ >
33
+ {hasPager && (
34
+ <nav class="grid grid-cols-2 gap-4" aria-label="Previous and next page">
35
+ {/* Both cells always, so a page with only a next link keeps it on the right. */}
36
+ <div>
37
+ {prev?.href && (
38
+ <a class={page} href={prev.href} rel="prev">
39
+ <span class={direction}>
40
+ <Icon icon="ArrowLeft" size="xs" />
41
+ Previous
42
+ </span>
43
+ <span class={pageLabel}>{prev.label}</span>
44
+ </a>
45
+ )}
46
+ </div>
47
+ <div class="text-right">
48
+ {next?.href && (
49
+ <a class={page} href={next.href} rel="next">
50
+ <span class={`${direction} justify-end`}>
51
+ Next
52
+ <Icon icon="ArrowRight" size="xs" />
53
+ </span>
54
+ <span class={pageLabel}>{next.label}</span>
55
+ </a>
56
+ )}
57
+ </div>
58
+ </nav>
59
+ )}
60
+
61
+ {/* 32px: inside the footer's own group, so tighter than the 96px around it. */}
62
+ {(editHref || text) && (
63
+ <div class="text-text-tertiary mt-8 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-sm">
64
+ {editHref && (
65
+ <a
66
+ class="hover:text-text-primary inline-flex items-center gap-1.5 text-inherit no-underline"
67
+ href={editHref}
68
+ target="_blank"
69
+ rel="noopener noreferrer"
70
+ >
71
+ <Icon icon="Pencil" size="xs" />
72
+ Edit this page
73
+ </a>
74
+ )}
75
+ {text && <p class="m-0">{text}</p>}
76
+ </div>
77
+ )}
78
+ </footer>
79
+ )
80
+ }
@@ -0,0 +1,169 @@
1
+ ---
2
+ /** Article wrapper. `data-pagefind-body` bounds the search index to content. */
3
+ import { rewriteHtmlBase } from '@eqtylab/docs/internal/rehype-base-url';
4
+ import { Icon } from '@eqtylab/equality';
5
+
6
+ import AlertBridge from '../components/AlertBridge.astro';
7
+
8
+ interface Props {
9
+ title: string;
10
+ description?: string;
11
+ deprecated?: boolean | { message?: string; replacedBy?: string };
12
+ /** URL of the page's markdown twin. Absent when `routing.markdownTwins` is off. */
13
+ markdownHref?: string;
14
+ /** The page's authored MDX, inlined for the copy action. */
15
+ source?: string;
16
+ /** Ancestor trail ending in this page, published to the search index. */
17
+ crumbs?: string;
18
+ /** Ancestors only, for the rendered breadcrumb. Deliberately excludes this page. */
19
+ trail?: Array<{ label: string; href?: string }>;
20
+ /** Stands in for `description` in the search index when a page has none. Not rendered. */
21
+ fallbackSummary?: string;
22
+ }
23
+
24
+ const { title, description, deprecated, markdownHref, source, crumbs, trail, fallbackSummary } =
25
+ Astro.props;
26
+
27
+ const deprecation =
28
+ deprecated === true ? {} : deprecated && typeof deprecated === 'object' ? deprecated : null;
29
+
30
+ // Authored frontmatter HTML bypasses rehype entirely, so base-prefix it here.
31
+ const deprecationMessage = deprecation?.message
32
+ ? rewriteHtmlBase(deprecation.message, import.meta.env.BASE_URL)
33
+ : undefined;
34
+
35
+ // Hand-rolled: every Button variant is coloured or filled, and this row is neither.
36
+ const action =
37
+ 'text-text-secondary hover:text-text-primary flex cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium no-underline transition-colors';
38
+ ---
39
+
40
+ <article
41
+ class="eq-prose"
42
+ data-eq-prose
43
+ data-pagefind-body
44
+ data-pagefind-meta={crumbs ? `crumbs:${crumbs}` : undefined}
45
+ >
46
+ {
47
+ /* Ancestors only: ending the trail in a copy of the h1 repeats the page name in
48
+ two consecutive lines for a screen reader and competes with the only emphasis
49
+ on the page. */
50
+ trail && trail.length > 0 && (
51
+ <nav
52
+ class="text-text-tertiary mb-6 flex items-center text-sm"
53
+ data-eq-crumbs
54
+ data-eq-chrome
55
+ data-pagefind-ignore
56
+ aria-label="Breadcrumb"
57
+ >
58
+ {trail.map((node, index) => (
59
+ <Fragment>
60
+ {index > 0 && (
61
+ <span class="px-1.5" aria-hidden="true">
62
+ /
63
+ </span>
64
+ )}
65
+ {node.href ? (
66
+ <a
67
+ class="hover:text-text-secondary text-inherit no-underline hover:underline"
68
+ href={node.href}
69
+ >
70
+ {node.label}
71
+ </a>
72
+ ) : (
73
+ <span>{node.label}</span>
74
+ )}
75
+ </Fragment>
76
+ ))}
77
+ </nav>
78
+ )
79
+ }
80
+
81
+ {/* 64px, a step above the 48px between sections, so the header reads as further out. */}
82
+ <header
83
+ class="mb-16"
84
+ data-eq-chrome
85
+ data-pagefind-meta={fallbackSummary ? `description:${fallbackSummary}` : undefined}
86
+ >
87
+ {
88
+ /* `w-fit` is load-bearing. The gradient spans the box, so at full width a short
89
+ title sits entirely in the gradient's flat start. */
90
+ }
91
+ <h1
92
+ class="eq-display-gradient w-fit text-4xl font-bold forced-colors:text-text-primary"
93
+ data-pagefind-meta="title"
94
+ >
95
+ {title}
96
+ </h1>
97
+ {description && (
98
+ <p class="text-text-secondary mt-3 text-lg/7" data-pagefind-meta="description">
99
+ {description}
100
+ </p>
101
+ )}
102
+ {
103
+ /**
104
+ * Inside the header, so one margin governs the gap to the content whether or
105
+ * not this row renders. The closing rule is Equality's own SectionHeading pattern.
106
+ *
107
+ * `data-pagefind-ignore` is load-bearing. Without it every page matches "copy".
108
+ */
109
+ (source || markdownHref) && (
110
+ <div
111
+ class="border-border mt-4 flex items-center border-b pb-2"
112
+ data-eq-actions
113
+ data-eq-chrome
114
+ data-pagefind-ignore
115
+ >
116
+ {markdownHref && (
117
+ <a class={action} href={markdownHref} target="_blank">
118
+ <Icon icon="FileText" size="xs" />
119
+ View as Markdown
120
+ </a>
121
+ )}
122
+ {source && markdownHref && (
123
+ <span class="text-border-raised select-none" data-eq-action-sep aria-hidden="true">
124
+ |
125
+ </span>
126
+ )}
127
+ {source && (
128
+ /* Both labels render and swap on `data-copied` so the button does not resize. */
129
+ <button
130
+ type="button"
131
+ class={`group ${action}`}
132
+ data-eq-copy={source}
133
+ title="Copies the page source as Markdown"
134
+ >
135
+ <Icon icon="Copy" size="xs" />
136
+ <span class="inline-flex group-data-[copied]:hidden" data-eq-copy-idle>
137
+ Copy as Markdown
138
+ </span>
139
+ <span class="hidden group-data-[copied]:inline-flex" data-eq-copy-done>
140
+ Copied
141
+ </span>
142
+ </button>
143
+ )}
144
+ </div>
145
+ )
146
+ }
147
+ </header>
148
+
149
+ {
150
+ deprecation && (
151
+ <AlertBridge variant="warning" title="Deprecated">
152
+ {deprecationMessage ? (
153
+ <p set:html={deprecationMessage} />
154
+ ) : (
155
+ <p>This page documents a deprecated feature.</p>
156
+ )}
157
+ </AlertBridge>
158
+ )
159
+ }
160
+
161
+ <slot />
162
+ </article>
163
+
164
+ {
165
+ /** Imported here too so a page with no fenced code still drives the copy button. */
166
+ }
167
+ <script>
168
+ import '@eqtylab/docs/scripts/eq-copy.ts';
169
+ </script>
@@ -0,0 +1,21 @@
1
+ ---
2
+ import type { NavNode } from '@eqtylab/docs/types';
3
+
4
+ import NavTree from './NavTree.astro';
5
+
6
+ interface Props {
7
+ nodes: NavNode[];
8
+ }
9
+ const { nodes } = Astro.props;
10
+ ---
11
+
12
+ {/* border-raised pairs with the raised fill, as Gov Studio does. */}
13
+ <aside
14
+ class="bg-background-raised border-border-raised top-[var(--eq-docs-header-height)] hidden w-[var(--eq-docs-sidebar-width)] shrink-0 border-r lg:sticky lg:block lg:h-[calc(100vh-var(--eq-docs-header-height))] lg:overflow-y-auto"
15
+ data-eq-chrome
16
+ data-pagefind-ignore
17
+ >
18
+ <nav class="px-4 py-6" aria-label="Documentation">
19
+ <NavTree nodes={nodes} />
20
+ </nav>
21
+ </aside>
@@ -0,0 +1,86 @@
1
+ ---
2
+ /** On-this-page navigation. SSR'd so it works without JS; <eq-toc> only adds scroll-spy. */
3
+ import type { TocNode } from '@eqtylab/docs/types';
4
+
5
+ import TocList from './TocList.astro';
6
+
7
+ interface Props {
8
+ items: TocNode[];
9
+ label?: string;
10
+ }
11
+
12
+ const { items, label = 'On this page' } = Astro.props;
13
+
14
+ function flatten(nodes: TocNode[]): TocNode[] {
15
+ return nodes.flatMap((node) => [node, ...flatten(node.children)]);
16
+ }
17
+ const slugs = flatten(items).map((n) => n.slug);
18
+ ---
19
+
20
+ {
21
+ /* The elbow reads `--eq-toc-indent` as its width too. Change one and the rail stops
22
+ meeting the level below. */
23
+ }
24
+ {
25
+ items.length > 0 && (
26
+ <eq-toc
27
+ class="[--eq-toc-indent:0.75rem] top-[calc(var(--eq-docs-header-height)+2rem)] hidden w-[var(--eq-docs-toc-width)] shrink-0 xl:sticky xl:block xl:max-h-[calc(100vh-var(--eq-docs-header-height))] xl:overflow-y-auto"
28
+ data-slugs={slugs.join(',')}
29
+ data-eq-chrome
30
+ data-pagefind-ignore
31
+ >
32
+ <nav aria-labelledby="eq-toc-heading">
33
+ <p class="text-text-primary mb-3 text-sm font-semibold" id="eq-toc-heading">
34
+ {label}
35
+ </p>
36
+ <div class="relative" data-eq-toc-rails>
37
+ {
38
+ /* The lit segment. `eq-toc.ts` measures its path from the rendered rows and
39
+ raises the opacity once it has. With no JavaScript it stays empty and the
40
+ rail is simply unlit.
41
+
42
+ Absolute with no z-index paints above the rows, so the segment covers the
43
+ grey border. */
44
+ }
45
+ <svg
46
+ class="pointer-events-none absolute inset-0 h-full w-full overflow-visible [&_path]:opacity-0 [&_path]:[transition:opacity_300ms_ease]"
47
+ aria-hidden="true"
48
+ fill="none"
49
+ >
50
+ {
51
+ /* Keyed to the path's box, so the colour depends on how far down the rail
52
+ the segment has reached. Nothing recomputes as it moves.
53
+
54
+ Each theme needs its own pair: `lilac-*` has no dark values, and one
55
+ pair for both fails contrast. A flat lilac-600 measures 2.42:1 on dark,
56
+ under the 3:1 minimum. */
57
+ }
58
+ <defs>
59
+ <linearGradient id="eq-toc-rail" x1="0" y1="0" x2="0" y2="1">
60
+ <stop
61
+ class="[stop-color:var(--color-lilac-600)] dark:[stop-color:var(--color-lilac-300)]"
62
+ offset="0%"
63
+ />
64
+ <stop
65
+ class="[stop-color:var(--color-lilac-400)] dark:[stop-color:var(--color-lilac-500)]"
66
+ offset="100%"
67
+ />
68
+ </linearGradient>
69
+ </defs>
70
+ <path
71
+ data-eq-toc-rail
72
+ stroke="url(#eq-toc-rail)"
73
+ stroke-width="2"
74
+ stroke-linecap="round"
75
+ />
76
+ </svg>
77
+ <TocList items={items} />
78
+ </div>
79
+ </nav>
80
+ </eq-toc>
81
+ )
82
+ }
83
+
84
+ <script>
85
+ import '@eqtylab/docs/scripts/eq-toc.ts';
86
+ </script>
@@ -0,0 +1,85 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { Icon } from '@eqtylab/equality';
3
+
4
+ import {
5
+ getThemePreference,
6
+ setThemePreference,
7
+ subscribeToThemePreference,
8
+ type ThemePreference,
9
+ } from '../lib/theme.ts';
10
+
11
+ /**
12
+ * One button that cycles System, Light, Dark.
13
+ *
14
+ * Do not reduce this to the current state, or to an `aria-label`. All three render and
15
+ * CSS picks one, which is what keeps the button correct before React hydrates.
16
+ */
17
+ /** System first, so it is never more than one press away. */
18
+ const CYCLE: ThemePreference[] = ['system', 'light', 'dark'];
19
+ const LABEL: Record<ThemePreference, string> = { system: 'System', light: 'Light', dark: 'Dark' };
20
+ const ICON: Record<ThemePreference, string> = { system: 'Monitor', light: 'Sun', dark: 'Moon' };
21
+
22
+ /**
23
+ * Which state is showing. The root attribute picks one; with no JS it is absent, so
24
+ * System shows. These outrank the `invisible` default on their own, because an
25
+ * ancestor-qualified selector is the more specific of the two.
26
+ */
27
+ const VISIBLE: Record<ThemePreference, string> = {
28
+ system: '[html:not([data-eq-theme-pref])_&]:visible [html[data-eq-theme-pref=system]_&]:visible',
29
+ light: '[html[data-eq-theme-pref=light]_&]:visible',
30
+ dark: '[html[data-eq-theme-pref=dark]_&]:visible',
31
+ };
32
+
33
+ const after = (preference: ThemePreference) =>
34
+ CYCLE[(CYCLE.indexOf(preference) + 1) % CYCLE.length];
35
+
36
+ function usePreference() {
37
+ return useSyncExternalStore(
38
+ subscribeToThemePreference,
39
+ getThemePreference,
40
+ () => 'system' as const
41
+ );
42
+ }
43
+
44
+ interface Props {
45
+ /** The bar's control recipe, passed by Header so this cannot drift from the links. */
46
+ className?: string;
47
+ }
48
+
49
+ export default function ThemeToggle({ className }: Props) {
50
+ const preference = usePreference();
51
+
52
+ return (
53
+ /*
54
+ * All three states share one grid cell, so the button is always as wide as the
55
+ * widest and the bar never shifts. `visibility`, not `display`: a hidden state
56
+ * must still be sized. No room for a label below lg, and hiding it outright is
57
+ * safe because the sentence beside it is the accessible name.
58
+ */
59
+ <button
60
+ type="button"
61
+ className={[className, 'grid cursor-pointer max-lg:w-10 max-lg:justify-center max-lg:px-0']
62
+ .filter(Boolean)
63
+ .join(' ')}
64
+ onClick={() => setThemePreference(after(preference))}
65
+ >
66
+ {CYCLE.map((value) => (
67
+ <span
68
+ key={value}
69
+ className={`invisible col-start-1 row-start-1 flex items-center gap-2 ${VISIBLE[value]}`}
70
+ data-eq-pref={value}
71
+ >
72
+ <span aria-hidden="true" className="inline-flex">
73
+ <Icon icon={ICON[value]} size="xs" />
74
+ </span>
75
+ <span aria-hidden="true" className="text-sm max-lg:hidden">
76
+ {LABEL[value]}
77
+ </span>
78
+ <span className="sr-only">
79
+ Theme: {LABEL[value]}. Activate to switch to {LABEL[after(value)]}.
80
+ </span>
81
+ </span>
82
+ ))}
83
+ </button>
84
+ );
85
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ /**
3
+ * One corner of the table-of-contents rail. `in` descends a level, `out` returns.
4
+ *
5
+ * An SVG, not a CSS corner. `border-radius` arrives horizontal; the rail has to arrive
6
+ * vertical to meet the child's border. The 1px offset centres it on that border.
7
+ *
8
+ * `vector-effect` is load-bearing: the viewBox is stretched to the indent width, and
9
+ * without it the stroke stretches too.
10
+ */
11
+ interface Props {
12
+ direction: 'in' | 'out';
13
+ }
14
+ const { direction } = Astro.props;
15
+ const d = direction === 'in' ? 'M 0 0 C 0 0.5, 1 0.5, 1 1' : 'M 1 0 C 1 0.5, 0 0.5, 0 1';
16
+ ---
17
+
18
+ <svg
19
+ class="text-border-raised ml-px block h-2 w-[var(--eq-toc-indent)]"
20
+ data-eq-elbow={direction}
21
+ aria-hidden="true"
22
+ viewBox="0 0 1 1"
23
+ preserveAspectRatio="none"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ stroke-width="2"
27
+ overflow="visible"
28
+ >
29
+ <path d={d} vector-effect="non-scaling-stroke"></path>
30
+ </svg>
@@ -0,0 +1,43 @@
1
+ ---
2
+ /** Split out so recursion repeats only the list, not the <eq-toc>, <nav>, or heading id. */
3
+ import type { TocNode } from '@eqtylab/docs/types';
4
+
5
+ import TocElbow from './TocElbow.astro';
6
+
7
+ interface Props {
8
+ items: TocNode[];
9
+ }
10
+
11
+ const { items } = Astro.props;
12
+ const TocList = Astro.self;
13
+ ---
14
+
15
+ {
16
+ /* No gap: each link's left border forms the rail, and a gap breaks it into dashes.
17
+ The nested-list indent is a descendant rule, so one class covers every level.
18
+ The border stays grey on the active row: the lit rail above is what marks it. */
19
+ }
20
+ <ol class="list-none p-0 [&_ol]:ml-[var(--eq-toc-indent)]">
21
+ {
22
+ items.map((item, index) => (
23
+ <li class="list-none">
24
+ <a
25
+ class="text-text-secondary hover:border-text-primary hover:text-text-primary border-border-raised data-[active]:text-text-primary block border-l-2 py-1.5 pl-3 text-sm no-underline transition-all duration-300"
26
+ href={`#${item.slug}`}
27
+ data-slug={item.slug}
28
+ >
29
+ {item.text}
30
+ </a>
31
+ {item.children.length > 0 && (
32
+ <Fragment>
33
+ <TocElbow direction="in" />
34
+ <TocList items={item.children} />
35
+ {/* Only when something follows at this level: otherwise the rail would
36
+ turn back out towards nothing. */}
37
+ {index < items.length - 1 && <TocElbow direction="out" />}
38
+ </Fragment>
39
+ )}
40
+ </li>
41
+ ))
42
+ }
43
+ </ol>
@@ -0,0 +1,16 @@
1
+ ---
2
+ /**
3
+ * Equality's `Alert`, supplied to MDX as `Alert`. Keep `as="aside"`: the default
4
+ * `div` carries role="alert", which screen readers announce on page load.
5
+ */
6
+ import { Alert } from '@eqtylab/equality';
7
+
8
+ type Props = Record<string, unknown>;
9
+
10
+ const { class: className, ...rest } = Astro.props as Props & { class?: string };
11
+ ---
12
+
13
+ {/* Flow spacing only; every visual property comes from Alert. */}
14
+ <Alert as="aside" {...rest} className={['mb-4', className].filter(Boolean).join(' ')} data-eq-chrome>
15
+ <slot />
16
+ </Alert>