fix(site): show full product sidebar in mobile nav and fix dark LinkCard hover (#4780)

Co-authored-by: Timothy Moreton <tmoreton@amazon.com>
This commit is contained in:
Tim Moreton Jr
2026-10-01 11:55:45 -04:00
committed by GitHub
co-authored by Timothy Moreton
parent e0c95eeead
commit 47ad14bacb
4 changed files with 205 additions and 39 deletions
+127
View File
@@ -0,0 +1,127 @@
---
/**
* A product's full docs sidebar for the mobile menu. Mirrors the desktop
* sidebar shape: first-level bands render as open headings, deeper subgroups
* collapse behind a native <details> toggle.
*/
import type { ProductNavItem } from '../util/product-nav';
interface Props {
items: ProductNavItem[];
depth?: number;
}
const { items, depth = 0 } = Astro.props;
---
<ul class="mnav" role="list">
{items.map((item) =>
item.type === 'link' ? (
<li>
<a
href={item.href}
class="mnav__link"
{...(item.external ? { target: '_blank', rel: 'noopener' } : {})}
>
{item.label}
</a>
</li>
) : depth === 0 ? (
<li class="mnav__band">
<span class="mnav__band-label">{item.label}</span>
<Astro.self items={item.items} depth={depth + 1} />
</li>
) : (
<li>
<details class="mnav__group">
<summary class="mnav__link mnav__summary">{item.label}</summary>
<Astro.self items={item.items} depth={depth + 1} />
</details>
</li>
)
)}
</ul>
<style>
.mnav {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.mnav .mnav {
margin-top: 10px;
}
.mnav__group > .mnav {
padding-left: 12px;
}
.mnav__band-label {
display: block;
font-family: 'Space Grotesk', system-ui, sans-serif;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--nav-fg);
opacity: 0.75;
}
.mnav__band + .mnav__band,
li + .mnav__band {
margin-top: 6px;
}
.mnav__link {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-weight: 400;
font-size: 13px;
line-height: 1.3;
color: var(--strands-grey-400);
text-decoration: none;
}
.mnav__link:hover {
color: var(--nav-fg);
}
.mnav__summary {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
list-style: none;
}
.mnav__summary::-webkit-details-marker {
display: none;
}
.mnav__summary::after {
content: '';
width: 6px;
height: 6px;
margin-right: 4px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(45deg);
transition: transform 0.15s ease;
}
.mnav__group[open] > .mnav__summary::after {
transform: rotate(-135deg);
}
/* Language-scoped rows (Quickstart / API reference) follow the header language toggle, as in
the docs sidebar. Default (attr unset) = TypeScript. */
:global(html:not([data-lang-pref='python'])) li:has(> a[href*='/quickstart/python']),
:global(html:not([data-lang-pref='python'])) li:has(> a[href*='/api/python']),
:global(html[data-lang-pref='python']) li:has(> a[href*='/quickstart/typescript']),
:global(html[data-lang-pref='python']) li:has(> a[href*='/api/typescript']) {
display: none;
}
</style>
+18 -39
View File
@@ -11,22 +11,27 @@ import LanguageToggle from './LanguageToggle.astro';
import { getStarCount } from '../util/github';
import { products } from '../config/navbar';
import { pathWithBase } from '../util/links';
import { getProductNav } from '../util/product-nav';
import MobileNavTree from './MobileNavTree.astro';
const stars = await getStarCount();
// The "Projects" dropdown is organized around products (Strands harness, SDK,
// Shell, Evals) from navigation.yml, so the dropdown and the docs stay
// aligned. Each product renders as one entry linking to its hub; `pages` drive
// the mobile accordion. Labs (experimental projects) has its own docs hub, so
// aligned. Each product renders as one entry linking to its hub; its full
// docs sidebar (`nav`) drives the mobile accordion. Labs (experimental projects) has its own docs hub, so
// it's appended here linking to that page.
const docsCategories = [
...products.map((p) => ({
title: p.slug,
description: p.description,
href: p.href,
accent: p.accent,
pages: p.pages,
})),
...(await Promise.all(
products.map(async (p) => ({
title: p.slug,
description: p.description,
href: p.href,
accent: p.accent,
pages: p.pages,
nav: await getProductNav(p.href),
})),
)),
{
title: '/labs',
description: 'Experimental projects pushing the boundaries of what agents can do.',
@@ -144,7 +149,7 @@ const learnCategories = [
</button>
<div class="mobile-menu__section-body">
{section.categories.map((cat) =>
cat.pages ? (
cat.nav?.length ? (
<div class="mobile-menu__cat" data-mobile-cat>
<div class="mobile-menu__cat-head">
{cat.href ? (
@@ -181,22 +186,9 @@ const learnCategories = [
</button>
)}
</div>
<ul class="mobile-menu__pages" role="list">
{cat.pages.map((page) => (
<li>
<a
href={page.href}
class="mobile-menu__page"
{...(page.langAware ? { 'data-lang-href': '' } : {})}
{...(page.external && page.href.startsWith('http')
? { target: '_blank', rel: 'noopener' }
: {})}
>
{page.label}
</a>
</li>
))}
</ul>
<div class="mobile-menu__pages">
<MobileNavTree items={cat.nav} />
</div>
</div>
) : (
<a
@@ -924,19 +916,6 @@ const learnCategories = [
.mobile-menu__cat.is-open .mobile-menu__pages {
display: flex;
}
.mobile-menu__page {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-weight: 400;
font-size: 13px;
line-height: 1.3;
color: var(--strands-grey-400);
text-decoration: none;
}
.mobile-menu__page:hover {
color: var(--nav-fg);
}
}
/* ---------- Spacebar frog easter egg ---------- */
/* Each spacebar press hops the frog one step forward (the hop arc + forward
+1
View File
@@ -137,6 +137,7 @@
--sl-color-gray-4: #6f6d6d;
--sl-color-gray-5: var(--strands-charcoal-2);
--sl-color-gray-6: var(--strands-charcoal-1);
--sl-color-gray-7: var(--strands-charcoal-1);
--sl-color-black: var(--strands-black);
--sl-color-bg: var(--strands-black);
--sl-color-hairline-shade: var(--strands-charcoal-1);
+59
View File
@@ -0,0 +1,59 @@
import path from 'node:path'
import { getCollection } from 'astro:content'
import { loadSidebarFromConfig, type StarlightSidebarItem } from '../sidebar'
import { pathWithBase } from './links'
/** A page link or a labeled group of them, mirroring a product's docs sidebar. */
export type ProductNavItem =
| { type: 'link'; label: string; href: string; external?: boolean }
| { type: 'group'; label: string; items: ProductNavItem[] }
// Built once per build: the nav renders on every page but the tree is static.
let cached: Promise<StarlightSidebarItem[]> | undefined
let labelsCached: Promise<Map<string, string>> | undefined
function getSidebar(): Promise<StarlightSidebarItem[]> {
cached ??= Promise.resolve(
loadSidebarFromConfig(path.resolve('./src/config/navigation.yml'), path.resolve('./src/content'))
)
return cached
}
/** Docs id -> sidebar label, resolved the same way Starlight does (sidebar.label, then title). */
function getLabels(): Promise<Map<string, string>> {
labelsCached ??= getCollection('docs').then(
(docs) => new Map(docs.map((doc) => [doc.id, doc.data.sidebar?.label ?? doc.data.title]))
)
return labelsCached
}
function toNavItem(item: StarlightSidebarItem, labels: Map<string, string>): ProductNavItem | null {
if ('items' in item) {
const items = item.items.map((child) => toNavItem(child, labels)).filter((c) => c !== null)
return items.length > 0 ? { type: 'group', label: item.label, items } : null
}
if ('link' in item) {
return { type: 'link', label: item.label, href: item.link, external: /^https?:\/\//.test(item.link) }
}
const label = item.label ?? labels.get(item.slug)
if (!label) return null
return { type: 'link', label, href: pathWithBase(`/${item.slug}/`) }
}
function hasSlug(item: StarlightSidebarItem, slug: string): boolean {
if ('items' in item) return item.items.some((child) => hasSlug(child, slug))
return 'slug' in item && item.slug === slug
}
/**
* The full sidebar tree of the product whose hub page is `hubHref`, for the
* mobile menu. Matched by hub page rather than label because product and
* sidebar group labels differ (e.g. "Evals" vs "Evals SDK").
*/
export async function getProductNav(hubHref: string): Promise<ProductNavItem[]> {
const [sidebar, labels] = await Promise.all([getSidebar(), getLabels()])
const hubSlug = hubHref.replace(pathWithBase('/'), '').replace(/\/$/, '')
const group = sidebar.find((item) => 'items' in item && hasSlug(item, hubSlug))
if (!group || !('items' in group)) return []
return group.items.map((item) => toNavItem(item, labels)).filter((i) => i !== null)
}