diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7ab821f..9776904 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,11 @@ on: push: branches: [main] workflow_dispatch: + # Docs are fetched from the product repos' HEADs at build time, so a + # scheduled run is the only thing that picks up doc changes that land + # there without a corresponding push to this repo. + schedule: + - cron: '17 9 * * *' permissions: contents: read diff --git a/.gitignore b/.gitignore index a6408bc..1282f70 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ dist/ # generated types .astro/ +# Lighthouse CI report output +.lighthouseci/ # dependencies node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 38b625a..f799c55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Static documentation site for Major Context products, built with Astro. Currently hosts documentation for [Moat](https://github.com/majorcontext/moat), with architecture designed to support multiple products. +Static documentation site for Major Context products, built with Astro. Hosts documentation for three products: [Moat](https://github.com/majorcontext/moat), [Keep](https://github.com/majorcontext/keep), and [Gatekeeper](https://github.com/majorcontext/gatekeeper). **Key characteristic**: Documentation content is fetched from source repositories at build time, not stored in this repo. @@ -38,6 +38,8 @@ bun run test:lighthouse # Run Lighthouse performance tests ```bash bun run fetch:docs # Fetch all product documentation from GitHub bun run fetch:moat # Fetch only Moat documentation +bun run fetch:keep # Fetch only Keep documentation +bun run fetch:gatekeeper # Fetch only Gatekeeper documentation bun run check:assets # Verify required assets exist (logo.svg, favicons) ``` @@ -55,7 +57,7 @@ bun run validate:links # Check for broken internal/external links 3. **Link rewriting**: Markdown links (`../concepts/file.md`) are rewritten to Astro routes (`/moat/concepts/slug`) 4. **Static generation**: Astro builds static HTML with proper routing -**Critical**: Never manually edit files in `src/content/moat/` — they're overwritten on every build. +**Critical**: Never manually edit files in `src/content/{moat,keep,gatekeeper}/` — they're overwritten on every build. ### Multi-Product System @@ -64,7 +66,7 @@ The site is architected to host documentation for multiple products: - **Product registry**: `src/lib/products.ts` defines all products with their GitHub repos and doc paths - **Content collections**: Each product gets its own collection in `src/content/config.ts` - **Dynamic routing**: Pages use `[category]/[slug]` pattern to support any product structure -- **Navigation config**: `src/config/navigation.ts` defines sidebar structure (currently Moat-only) +- **Navigation config**: `src/config/navigation.ts` builds sidebar structure per product (`buildNavigation(docs, productId)`), shared across Moat, Keep, and Gatekeeper To add a new product: 1. Add entry to `src/lib/products.ts` @@ -114,7 +116,7 @@ BaseLayout.astro `scripts/fetch-docs.ts` handles fetching docs from GitHub: -**Authentication**: Uses `gh` CLI (GitHub CLI). In CI, set `MOAT_DOCS_TOKEN` secret for private repos. +**Authentication**: Uses `gh` CLI (GitHub CLI). CI runs authenticated with the built-in `GH_TOKEN` (`github.token`) — sufficient since all three product repos are public; a broader-scoped token would be needed if a source repo went private. **Fetching logic**: 1. Calls GitHub API via `gh api` to list directory contents @@ -143,7 +145,7 @@ See `docs/style-guide.md` for complete guidelines. Key principles: - Background: `stone-100` (warm paper) - Sidebar: `stone-200` - Text: `stone-800` / `stone-600` (muted) -- Accent: `sky-700` (links, active states) +- Accent: per-product (`sky-700` Moat, `amber-700` Keep, `emerald-700` Gatekeeper) for links and active states; `sky-700` is the site-wide default outside product pages - Code blocks: `stone-900` background **Spacing philosophy**: Prefer consistent Tailwind spacing (4, 6, 8, 12). Avoid arbitrary values except for specific design needs (e.g., tracking). @@ -166,7 +168,7 @@ See `docs/style-guide.md` for complete guidelines. Key principles: **Adding a navigation item**: Edit `src/config/navigation.ts` and add to appropriate section. -**Modifying page layout**: Edit `src/layouts/DocsLayout.astro` for global changes, or `src/pages/moat/[category]/[slug].astro` for content-specific changes. +**Modifying page layout**: Edit `src/layouts/DocsLayout.astro` for global changes, or `src/pages/{moat,keep,gatekeeper}/[category]/[slug].astro` for content-specific changes. **Styling H2 headings**: All H2 styling is in DocsLayout's prose classes. Look for `prose-h2:` prefixes. @@ -175,15 +177,15 @@ See `docs/style-guide.md` for complete guidelines. Key principles: - Scroll offset: `scroll-mt-24` in DocsLayout prose classes - Right padding to prevent overlap: `lg:pr-72` on main element -**Testing with real content**: Run `bun run fetch:moat` to pull latest Moat docs from GitHub, then `bun run dev`. +**Testing with real content**: Run `bun run fetch:docs` (or a per-product variant like `bun run fetch:moat`) to pull latest docs from GitHub, then `bun run dev`. ## CI/CD -GitHub Actions workflow in `.github/workflows/`: +GitHub Actions workflows in `.github/workflows/`: - Runs validation on push and PRs -- Builds and deploys to GitHub Pages -- Requires `MOAT_DOCS_TOKEN` secret if source repos are private +- Builds and deploys to GitHub Pages on push to `main`, manual dispatch, and a daily schedule (`deploy.yml`) — the schedule exists because docs are fetched from the product repos' HEADs at build time, and nothing else triggers a rebuild when only a docs repo changes +- Uses the built-in `GH_TOKEN`; a custom token secret is only needed if a source repo becomes private ## OG Images -Dynamic OG (social share) images generated at `/og/[...path].png` using `@vercel/og`. Images render page title and Moat branding. +Dynamic OG (social share) images generated at `/og/[...path].png` using `@vercel/og`. Images render page title, description, and a per-product brand color (sky for Moat, amber for Keep, emerald for Gatekeeper; see `BRAND_COLORS` in `src/pages/og/[...path].png.ts`). diff --git a/astro.config.mjs b/astro.config.mjs index a4eb956..1e36a46 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -25,6 +25,20 @@ export default defineConfig({ ], shikiConfig: { theme: 'github-dark-dimmed', + transformers: [ + { + name: 'bump-comment-contrast', + // github-dark-dimmed renders comments as #768390 on a #22272e + // background -- a 3.87:1 contrast ratio, short of the 4.5:1 WCAG + // AA minimum for body text. Lighten the same hue just enough + // (4.61:1) to pass while keeping the muted "comment" look. + span(node) { + if (node.properties?.style === 'color:#768390') { + node.properties.style = 'color:#84909b'; + } + }, + }, + ], }, }, output: 'static', diff --git a/lighthouserc.json b/lighthouserc.json index 99ac3e4..eaa4f0c 100644 --- a/lighthouserc.json +++ b/lighthouserc.json @@ -15,7 +15,8 @@ "categories:performance": ["error", {"minScore": 0.9}], "categories:accessibility": ["error", {"minScore": 0.9}], "categories:best-practices": ["error", {"minScore": 0.9}], - "categories:seo": ["error", {"minScore": 0.9}] + "categories:seo": ["error", {"minScore": 0.9}], + "network-dependency-tree-insight": ["warn", {"minScore": 0.9}] } }, "upload": { diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 7de38a5..91c2c66 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -94,8 +94,11 @@ const organizationSchema = { - - + diff --git a/src/layouts/DocsLayout.astro b/src/layouts/DocsLayout.astro index 0152f80..bcea84e 100644 --- a/src/layouts/DocsLayout.astro +++ b/src/layouts/DocsLayout.astro @@ -145,7 +145,6 @@ const navigationSections = buildNavigation(docs, product.id); id="sidebar" class="w-64 bg-stone-200 dark:bg-stone-800 fixed top-0 pt-[57px] h-screen overflow-y-auto z-40 transform -translate-x-full md:translate-x-0 transition-transform duration-300 ease-in-out" - aria-hidden="true" >
diff --git a/src/lib/gatekeeper-llms.ts b/src/lib/gatekeeper-llms.ts index 83f0fc2..348ffcb 100644 --- a/src/lib/gatekeeper-llms.ts +++ b/src/lib/gatekeeper-llms.ts @@ -3,6 +3,7 @@ export { SITE_URL, MAIN_CATEGORIES, OPTIONAL_CATEGORIES } from './llms-shared'; export const INTRO = `Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. Route HTTPS \ traffic through Gatekeeper and it transparently injects authentication headers based on \ hostname matching — clients never see raw credentials. Pluggable credential sources cover \ -environment variables, static values, AWS Secrets Manager, GCP Secret Manager, GitHub App \ -tokens, and RFC 8693 token exchange. Includes an MCP relay, network policy enforcement, \ -and OpenTelemetry observability. Open source, actively developed.`; +environment variables, static values, host commands, AWS Secrets Manager, GCP Secret Manager, \ +GCP service account tokens, GitHub App tokens, and RFC 8693 token exchange. Includes an MCP \ +relay, a Postgres data plane, LLM policy evaluation, network policy enforcement, and \ +OpenTelemetry observability. Open source, actively developed.`; diff --git a/src/pages/gatekeeper/[category]/[slug].md.ts b/src/pages/gatekeeper/[category]/[slug].md.ts new file mode 100644 index 0000000..7ebeedf --- /dev/null +++ b/src/pages/gatekeeper/[category]/[slug].md.ts @@ -0,0 +1,29 @@ +import type { APIRoute } from 'astro'; +import { getCollection } from 'astro:content'; +import { slugify } from '../../../utils/strings'; +import { SITE_URL } from '../../../lib/gatekeeper-llms'; + +export async function getStaticPaths() { + const docs = await getCollection('gatekeeper'); + + return docs.map((doc) => { + const parts = doc.id.split('/'); + const category = parts[0]; + const fileName = parts[1]; + const slug = slugify(fileName); + + return { + params: { category, slug }, + props: { doc }, + }; + }); +} + +const FOOTER = `\n\n---\n[← Gatekeeper documentation index](${SITE_URL}/gatekeeper/llms.txt)\n`; + +export const GET: APIRoute = ({ props }) => { + const { doc } = props as { doc: Awaited>>[number] }; + return new Response(doc.body + FOOTER, { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + }); +}; diff --git a/src/pages/gatekeeper/index.astro b/src/pages/gatekeeper/index.astro index 0a9c213..2c23df1 100644 --- a/src/pages/gatekeeper/index.astro +++ b/src/pages/gatekeeper/index.astro @@ -17,19 +17,22 @@ import DocsLayout from '../../layouts/DocsLayout.astro'; Ships as a CLI, Go library, and MCP relay.
Powers Moat's network-layer credential injection. Works standalone or embedded.

-
# Install
+    
# Install
 $ go install github.com/majorcontext/gatekeeper/cmd/gatekeeper@latest
 
-# Start the proxy
+# ...or pull the container image
+$ docker pull ghcr.io/majorcontext/gatekeeper:latest
+
+# Start the proxy
 $ gatekeeper --config gatekeeper.yaml
 
-# Make a request — credential injected automatically
+# Make a request — credential injected automatically
 $ curl --proxy http://127.0.0.1:9080 --cacert ca.crt https://api.github.com/user

Credentials

-
01
+
01

TLS Interception

Per-host certificates generated on demand from your CA. The proxy terminates TLS, reads the request, injects the header, and forwards over a fresh upstream TLS connection. @@ -37,7 +40,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
02
+
02

Hostname-Based Injection

Map upstream hosts to credentials in YAML. Headers are added only for matching requests — everything else passes through untouched. @@ -45,15 +48,15 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
03
+
03

Pluggable Sources

- Env vars and static values for development. AWS Secrets Manager, GCP Secret Manager, GitHub App tokens, and RFC 8693 token exchange for production. + Env vars and static values for development. Host commands, AWS Secrets Manager, GCP Secret Manager, GCP service account tokens, GitHub App tokens, and RFC 8693 token exchange for production.

-
04
+
04

Network Policy

Permissive by default, strict allowlist when you need it. Block egress to anything outside your declared host list. @@ -64,7 +67,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

Integration

-
05
+
05

Standard HTTP Proxy

Any client that honors HTTPS_PROXY can route through Gatekeeper. No SDK or library required. @@ -72,7 +75,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
06
+
06

MCP Relay

Forward Model Context Protocol requests to upstream MCP servers with credential injection and SSE streaming preserved. @@ -80,7 +83,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
07
+
07

OpenTelemetry

Traces, metrics, and logs configured entirely via standard OTEL_* environment variables. Canonical log line per request. @@ -88,7 +91,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
08
+
08

Go Library

Embed the proxy directly in your Go tooling. Full programmatic control over config, credential sources, and lifecycle. @@ -96,6 +99,33 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

+

Data Plane & Policy

+
+
+
09
+

Postgres Data Plane

+

+ A second listener speaks the Postgres wire protocol, authenticating clients with their run token in place of a database password. Gatekeeper resolves the real per-branch Neon password and completes SCRAM-SHA-256 upstream. +

+
+ +
+
10
+

LLM Policy

+

+ Anthropic API responses are evaluated against Keep policy rules before reaching the client. Denials are blocked fail-closed, including streamed SSE responses. +

+
+ +
+
11
+

Host Gateway

+

+ Maps a synthetic in-container hostname to the host machine's real IP. Containers reach host services through the same credential injection and network policy as any other request. +

+
+
+

Why This Exists

diff --git a/src/pages/keep/[category]/[slug].md.ts b/src/pages/keep/[category]/[slug].md.ts new file mode 100644 index 0000000..5582b28 --- /dev/null +++ b/src/pages/keep/[category]/[slug].md.ts @@ -0,0 +1,29 @@ +import type { APIRoute } from 'astro'; +import { getCollection } from 'astro:content'; +import { slugify } from '../../../utils/strings'; +import { SITE_URL } from '../../../lib/keep-llms'; + +export async function getStaticPaths() { + const docs = await getCollection('keep'); + + return docs.map((doc) => { + const parts = doc.id.split('/'); + const category = parts[0]; + const fileName = parts[1]; + const slug = slugify(fileName); + + return { + params: { category, slug }, + props: { doc }, + }; + }); +} + +const FOOTER = `\n\n---\n[← Keep documentation index](${SITE_URL}/keep/llms.txt)\n`; + +export const GET: APIRoute = ({ props }) => { + const { doc } = props as { doc: Awaited>>[number] }; + return new Response(doc.body + FOOTER, { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + }); +}; diff --git a/src/pages/keep/index.astro b/src/pages/keep/index.astro index 2bd1ca5..06958e1 100644 --- a/src/pages/keep/index.astro +++ b/src/pages/keep/index.astro @@ -17,17 +17,17 @@ import DocsLayout from '../../layouts/DocsLayout.astro'; Ships as a Go library, MCP relay, and LLM gateway.
Works standalone or composed with Moat.

-
# Install
+    
# Install
 $ brew tap majorcontext/tap
 $ brew install keep
 
-# Evaluate a call against your rules
+# Evaluate a call against your rules
 $ keep eval --rules rules.yaml call.json

Policy

-
01
+
01

Declarative Rules

YAML rule files define what to deny, redact, or log. CEL expressions for matching with full access to call structure. @@ -35,7 +35,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
02
+
02

Redaction

Strip sensitive fields from tool call arguments before they leave the agent. Pattern-based or path-based targeting. @@ -43,7 +43,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
03
+
03

Scoped Evaluation

Rules target specific tools, namespaces, or call patterns. Broad defaults with surgical overrides. @@ -51,7 +51,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
04
+
04

Audit Logging

Every evaluation is logged with full context: the call, matched rules, and outcome. Structured JSON for downstream analysis. @@ -62,7 +62,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

Integration

-
05
+
05

MCP Relay

Drop-in gateway for Claude Code and other MCP clients. Intercepts tool calls, applies policy, forwards approved calls. @@ -70,7 +70,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
06
+
06

LLM Gateway

HTTP proxy between agent and LLM provider. Decomposes tool-use responses and evaluates each call against policy. @@ -78,7 +78,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
07
+
07

Go Library

Embed Keep directly in your Go tooling. Full API for rule loading, evaluation, and result inspection. @@ -86,7 +86,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
08
+
08

CEL Expressions

Rules use Common Expression Language for matching. Type-safe, sandboxed, and familiar to anyone who's used Firebase or Kubernetes. diff --git a/src/pages/moat/index.astro b/src/pages/moat/index.astro index 0fbb770..4582cb4 100644 --- a/src/pages/moat/index.astro +++ b/src/pages/moat/index.astro @@ -16,11 +16,11 @@ import DocsLayout from '../../layouts/DocsLayout.astro'; Works with Claude, Codex, and Gemini.
Credential injection for GitHub, GitLab, AWS, OpenAI, npm, SSH, 1Password, and more.

-
# Install
+    
# Install
 $ brew tap majorcontext/tap
 $ brew install moat
 
-# Grant credentials and run Claude safely
+# Grant credentials and run Claude safely
 $ moat grant anthropic
 $ moat grant github
 $ moat claude .
@@ -28,7 +28,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

Safety

-
01
+
01

Sandboxed Execution

Every agent runs in an isolated container—Docker, Apple containers, or gVisor. No host access. @@ -36,7 +36,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
02
+
02

Network-Layer Credentials

OAuth tokens and API keys are injected at the proxy layer. Agents never see raw secrets. @@ -44,7 +44,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
03
+
03

Network Policies

Permissive or strict firewall mode. Whitelist allowed hosts, block everything else. @@ -52,7 +52,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
04
+
04

Tamper-Proof Audit

Hash-chained audit logs with cryptographic verification. Export proof bundles for compliance. @@ -63,7 +63,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

Developer Experience

-
05
+
05

Declarative Config

One agent.yaml defines runtime, credentials, services, and network policy. @@ -71,7 +71,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
06
+
06

Service Dependencies

PostgreSQL, MySQL, and Redis sidecars auto-provisioned with injected credentials. @@ -79,7 +79,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
07
+
07

Snapshots & Recovery

Automatic workspace snapshots on commits, builds, and idle. Point-in-time restore without stopping. @@ -87,7 +87,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';

-
08
+
08

Parallel Worktrees

Run multiple agents on separate git branches simultaneously. No workspace conflicts. diff --git a/src/pages/og/[...path].png.ts b/src/pages/og/[...path].png.ts index 8aca3dd..71f1bb4 100644 --- a/src/pages/og/[...path].png.ts +++ b/src/pages/og/[...path].png.ts @@ -5,8 +5,20 @@ import { ImageResponse } from '@vercel/og'; interface Props { title: string; description: string; + brandColor: string; } +// Tailwind `-700` hex values for each product's accent color (see +// src/lib/products.ts `color`), used for the OG image brand square. +// Falls back to Moat's sky-700 so the site homepage and any unrecognized +// path render identically to before this map existed. +const BRAND_COLORS: Record = { + moat: '#0369a1', // sky-700 + keep: '#b45309', // amber-700 + gatekeeper: '#047857', // emerald-700 +}; +const DEFAULT_BRAND_COLOR = BRAND_COLORS.moat; + export async function getStaticPaths() { const moatDocs = await getCollection('moat'); const keepDocs = await getCollection('keep'); @@ -19,6 +31,7 @@ export async function getStaticPaths() { props: { title: 'Major Context', description: 'Safe infrastructure for AI agents', + brandColor: DEFAULT_BRAND_COLOR, }, }, // Moat homepage @@ -27,6 +40,7 @@ export async function getStaticPaths() { props: { title: 'Moat', description: 'Let agents break things safely', + brandColor: BRAND_COLORS.moat, }, }, // Keep homepage @@ -35,6 +49,7 @@ export async function getStaticPaths() { props: { title: 'Keep', description: 'Policy engine for AI agent tool calls', + brandColor: BRAND_COLORS.keep, }, }, // Gatekeeper homepage @@ -43,6 +58,7 @@ export async function getStaticPaths() { props: { title: 'Gatekeeper', description: 'Credential-injecting TLS-intercepting proxy', + brandColor: BRAND_COLORS.gatekeeper, }, }, // All Moat documentation pages @@ -58,6 +74,7 @@ export async function getStaticPaths() { props: { title: doc.data.title, description: doc.data.description || 'Moat Documentation', + brandColor: BRAND_COLORS.moat, }, }; }), @@ -74,6 +91,7 @@ export async function getStaticPaths() { props: { title: doc.data.title, description: doc.data.description || 'Keep Documentation', + brandColor: BRAND_COLORS.keep, }, }; }), @@ -90,6 +108,7 @@ export async function getStaticPaths() { props: { title: doc.data.title, description: doc.data.description || 'Gatekeeper Documentation', + brandColor: BRAND_COLORS.gatekeeper, }, }; }), @@ -99,7 +118,7 @@ export async function getStaticPaths() { } export const GET: APIRoute = async ({ props, params }) => { - const { title, description } = props as Props; + const { title, description, brandColor } = props as Props; const pathStr = (params as { path: string }).path || 'home'; const footerUrl = `majorcontext.com/${pathStr.split('/')[0]}`; @@ -176,7 +195,7 @@ export const GET: APIRoute = async ({ props, params }) => { style: { width: 40, height: 40, - backgroundColor: '#0369a1', + backgroundColor: brandColor || DEFAULT_BRAND_COLOR, borderRadius: 4, }, }, diff --git a/src/scripts/mobile-menu.ts b/src/scripts/mobile-menu.ts index 4c2ce1a..81ce7a1 100644 --- a/src/scripts/mobile-menu.ts +++ b/src/scripts/mobile-menu.ts @@ -1,19 +1,47 @@ const toggle = document.getElementById('mobile-menu-toggle'); const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); +const sidebarLinks = sidebar ? Array.from(sidebar.querySelectorAll('a')) : []; + +// The sidebar is only actually off-screen (hidden) below the md breakpoint +// while closed -- at md+ `md:translate-x-0` keeps it permanently visible. +const desktopQuery = window.matchMedia('(min-width: 768px)'); + +function isOpen(): boolean { + return !sidebar?.classList.contains('-translate-x-full'); +} + +/** + * Keeps aria-hidden and focusability of the sidebar's nav links in sync with + * whether it's actually visible. Without this, the sidebar was aria-hidden + * unconditionally while its links stayed in the tab order -- a WCAG + * aria-hidden-focus violation -- and remained wrong at md+ where the + * sidebar is always visible. + */ +function syncSidebarAccessibility(): void { + const hidden = !desktopQuery.matches && !isOpen(); + sidebar?.setAttribute('aria-hidden', String(hidden)); + sidebarLinks.forEach((link) => { + if (hidden) { + link.setAttribute('tabindex', '-1'); + } else { + link.removeAttribute('tabindex'); + } + }); +} function openSidebar(): void { sidebar?.classList.remove('-translate-x-full'); overlay?.classList.remove('hidden'); toggle?.setAttribute('aria-expanded', 'true'); - sidebar?.setAttribute('aria-hidden', 'false'); + syncSidebarAccessibility(); } function closeSidebar(): void { sidebar?.classList.add('-translate-x-full'); overlay?.classList.add('hidden'); toggle?.setAttribute('aria-expanded', 'false'); - sidebar?.setAttribute('aria-hidden', 'true'); + syncSidebarAccessibility(); } toggle?.addEventListener('click', () => { @@ -34,11 +62,13 @@ document.addEventListener('keydown', (e) => { }); // Close sidebar on navigation (mobile only) -const links = sidebar?.querySelectorAll('a'); -links?.forEach(link => { +sidebarLinks.forEach(link => { link.addEventListener('click', () => { if (window.innerWidth < 768) { closeSidebar(); } }); }); + +syncSidebarAccessibility(); +desktopQuery.addEventListener('change', syncSidebarAccessibility);