diff --git a/README.md b/README.md index 32e69f59..127b5718 100644 --- a/README.md +++ b/README.md @@ -1,192 +1,121 @@ # Atlas of the Known World -An interactive atlas of George R. R. Martin's world of Ice and Fire, covering the maps, timeline, and the rolls of the great houses. Statically generated from a corpus of markdown files with Zod-validated frontmatter, rendered through a parchment-styled UI. - -![The Known World main menu](docs/screenshots/home.png) +An interactive, static atlas and encyclopedia for the world of _A Song of Ice +and Fire_. It combines maps, a historical timeline, house and character +genealogies, and reference pages for battles, dragons, and weapons. ## Stack -- **Next.js 16** (App Router) with `output: 'export'`, so every route is pre-rendered to static HTML. -- **React 19**. -- **TypeScript 5**. -- **Bun** for install, scripts, and the Netlify build. -- **Zod 4** for content schemas. -- **gray-matter** + **remark** / **remark-html** for markdown. -- **react-svg-pan-zoom** for the regional map view. -- **Vitest 4** + **jsdom** + **@testing-library/react** for unit and component tests. -- **Netlify** for hosting (build = `bun run build`, publish = `out/`). - -## Getting started +- Next.js App Router with static export +- React and TypeScript +- SCSS modules +- Markdown content with YAML frontmatter, validated by Zod +- Vitest and Testing Library +- Bun for dependency management and scripts +- Netlify for deployment and image transformation -```bash -bun install -bun dev # http://localhost:3000 -bun test # vitest run -bun test:watch -bun run build # static export → out/ -bun run lint -bun run typecheck # tsc --noEmit -bun run system-check # lint + typecheck + test, in sequence -bun run prettier # format every file -bun run prettier:check # fail if any file is unformatted -bun run prettier:quick # format only staged files -``` +## Requirements -### Git hooks +- Node.js 24.16 or newer +- Bun 1.3.14 or newer -Husky installs hooks on `bun install` (via the `prepare` script): - -- **`pre-commit`** runs `bun system-check` (lint + typecheck + test). -- **`pre-push`** runs `bun run build` to confirm the static export still succeeds. +## Getting started -## Project layout +Install dependencies and start the development server: -``` -app/ Next.js App Router routes - page.tsx Home, main menu of atlas sections - maps/ Interactive world map (pan/zoom, keyboard shortcuts) - timeline/ Coming-soon stub - houses/ Index + per-house pages - [slug]/ Per-house page with family tree - characters/ Index + per-character pages - [slug]/ Per-character page (stub) - castles/[slug]/ Per-castle page - layout.tsx Root layout (Cinzel / EB Garamond / Inter fonts) - not-found.tsx - -components/ React components, each paired with a co-located CSS module - ParchmentLayout, MainMenu, MainMenuTile, ComingSoonPage, - WorldMap, MapStage, MapMarker, MapLayerToggle, - FamilyTree, DropCap, Sources, Sigil, SiteHeader, SiteMenu, ViewToggle, - FilteredHouseList, FilteredCharacterList, HouseInfobox - -lib/ Domain logic (loaders, schemas, helpers) - schemas.ts Zod schemas for Castle / House / Person / Event - content.ts Markdown loaders + renderer - family-tree.ts buildFamilyTree - map.ts Map coord helpers - relations.ts House / person relation helpers - *.test.ts Co-located vitest specs - -content/ Markdown source of truth - castles/ 9 entries (Winterfell, Casterly Rock, Highgarden, …) - houses/ 4 entries (Stark, Lannister, Targaryen, Tyrell) - characters/ characters with parents/spouses/children - -styles/ Single global stylesheet - globals.css Resets, CSS custom properties (--ink-*, --parchment-*, - --region-color-*, --font-*, --bp-*), `html`/`body`/`h1-h3` - rules, and the `.subtitle` typographic primitive. - -# All other styles are CSS modules co-located with the React component or -# page route that owns them — see "Styling" below. - -public/map/ westeros.svg basemap - -docs/superpowers/ Design specs + implementation plans -netlify.toml Build config + 404 redirect -next.config.ts output: 'export', trailingSlash: true +```sh +bun install +bun dev ``` -## Styling +The site is available at . -Styles are hand-written SCSS in two layers: +## Commands -- **`styles/globals.scss`** is the only global stylesheet. It holds resets, CSS custom properties (colour, font, and breakpoint tokens, plus the `--region-color-*` heraldic palette shared by both list views), `html`/`body`/`h1-h3` rules, and the `.subtitle` typographic primitive used by every `ParchmentLayout` page. -- **SCSS modules** carry everything else. Each component owns a sibling `.module.scss` (e.g. `components/SiteHeader.tsx` ↔ `components/SiteHeader.module.scss`); each route owns a sibling `page.module.scss` (e.g. `app/houses/[slug]/page.module.scss`). Two filtered list components share `components/listSearch.module.scss` for the search input + pagination apparatus they both render. - -Class names inside modules are `camelCase`, dropping BEM noise — the file scope already isolates them. Multiple classes compose through `lib/cx.ts`, a six-line helper that joins truthy class strings: - -```tsx -import styles from "@/components/Foo.module.scss"; -import { cx } from "@/lib/cx"; - -
; +```sh +bun run dev # start the development server +bun run test # run the test suite once +bun run test:watch # run tests in watch mode +bun run coverage # generate test coverage +bun run typecheck # check TypeScript +bun run lint # run ESLint +bun run prettier:check # check formatting +bun run build # create the static export in out/ +bun run system-check # run every repository quality gate ``` -Dynamic variant lookups use the indexed form: `styles[`card${capitalize(region)}`]` or a small per-component map. Cross-module styling — when a parent module needs to tweak a child component's element — is done by passing a className prop (``), not by reaching into another module's class names. - -Tests assert class strings directly because `vitest.config.ts` sets `test.css.modules.classNameStrategy: 'non-scoped'`, so `styles.foo` resolves to the literal `'foo'` in jsdom. Production builds use Vite's default scoping with hashes. +Repository scripts must be run through Bun. Dependencies are pinned to exact +versions in `package.json`. + +## Routes + +| Route | Description | +| --------------------- | ----------------------------------- | +| `/` | Main atlas menu | +| `/maps/` | Interactive world map | +| `/timeline/` | Chronological battles and events | +| `/houses/` | Searchable and grouped house index | +| `/houses/[slug]/` | House details and family tree | +| `/characters/` | Searchable character index | +| `/characters/[slug]/` | Character details and relationships | +| `/battles/` | Battle index | +| `/battles/[slug]/` | Battle details | +| `/weapons/` | Weapon index | +| `/weapons/[slug]/` | Weapon details | +| `/dragons/` | Dragon index | +| `/dragons/[slug]/` | Dragon details | +| `/castles/[slug]/` | Castle details | +| `/events/[slug]/` | Timeline event details | + +## Repository layout + +```text +app/ Next.js routes and route-level styles +components/ Reusable UI components and co-located tests +content/ Markdown collections for atlas entities +docs/ Feature specifications, plans, and screenshots +lib/ Content loading, validation, relationships, and utilities +netlify/ Local Netlify build plugins +public/ Images, sigils, maps, and other static assets +styles/ Global design tokens and typographic primitives +``` ## Content model -All content is markdown with frontmatter validated by Zod (`lib/schemas.ts`). Cross-references between entries are by slug. - -- **`House`**: `slug`, `name`, `seat` (castle slug), `liege`, `words`, `sigil.description`, `founded` (date), `status` (`extant` / `extinct` / `exiled` / `hidden`), `sworn-from`, `cadet-houses`, `sources`. -- **`Castle`**: `slug`, `name`, `type` (`castle` / `town` / `ruin` / `watchtower` / `holdfast`), `sub-region`, `liege-house`, `founded`, `sworn-houses`, `features`, `coords` (`{x, y}` on the basemap), `sources`. -- **`Character`**: `slug`, `name`, `born` / `died` (date or `null`), `primary-house`, `also-of-houses`, `parents`, `spouses`, `children`, `titles`, `placeholder` (+ reason), `sources`. Placeholder characters fill unnamed slots in family trees. -- **`Event`**: `slug`, `name`, `type` (`battle` / `siege` / `treaty` / `wedding` / `death` / `betrayal` / `other`), `date`, `location` (castle slug or coords), `participants` (sides + houses), `outcome`, `casualties`, `sources`. Schema is in place; no event entries yet. - -Dates use `{year, era, precision}` where `era` is one of `dawn-age`, `age-of-heroes`, `long-night`, `andal-invasion`, `targaryen-conquest`, `roberts-reign`, `game-of-thrones`, `AC`, `BC`, and `precision` is `exact` / `year` / `decade` / `era` / `legendary`. - -Sources point back to AWOIAF (CC-BY-SA-3.0) or to a book / show / other reference. - -## Routes today +Each entity is stored as `content//.md`. Its filename must +match the `slug` in its YAML frontmatter. Frontmatter is parsed and validated +against the corresponding schema in `lib/schemas.ts`. -| Route | Status | Notes | -| --------------------- | ------ | ------------------------------------------------------------------------------------- | -| `/` | live | Atlas main menu (Maps · Timeline · Houses) | -| `/houses/` | live | A to Z list of houses, alphabetized by short name | -| `/houses/[slug]/` | live | Per-house page: words, seat link, sigil, founded, status, body, family tree | -| `/characters/` | live | A to Z list of characters (sigil + name) with debounced filter | -| `/characters/[slug]/` | live | Per-character page: sigil, born/died, primary house link, titles, body, linked family | -| `/castles/[slug]/` | live | Per-castle page | -| `/maps/` | live | Interactive world map: drag/pinch/wheel pan & zoom, on-screen controls, keyboard keys | -| `/timeline/` | stub | Coming soon | +Cross-references use slugs. The content-integrity test validates unique slugs, +filename/frontmatter agreement, and references between modeled entities. Run it +directly with: -Per-house and per-castle pages are pre-rendered via `generateStaticParams` from the content directory. - -### Houses - -The A to Z roll of the great and minor houses, each tile showing the family sigil. Click through to a per-house page with its words, seat, founding date, status, body text, and full family tree. - -![Houses index](docs/screenshots/houses-index.png) - -![House Stark page](docs/screenshots/house-stark.png) - -![House Targaryen page](docs/screenshots/house-targaryen.png) - -### Characters - -A debounced filter over every named character, each row showing the character portrait and primary-house sigil. Per-character pages link back into the family graph and primary house. - -![Characters index](docs/screenshots/characters-index.png) - -![Jon Snow page](docs/screenshots/character-jon-snow.png) - -### Castles - -Per-castle pages cover the seat's type, sub-region, liege house, founding date, sworn houses, and notable features. - -![Winterfell page](docs/screenshots/castle-winterfell.png) +```sh +bun run test lib/content-integrity.test.ts +``` -## Family tree +Named people that are needed for a relationship but do not yet have a complete +article can use the character schema's `placeholder` fields. Draft entries are +excluded from generated route parameters. -`lib/family-tree.ts` builds a hierarchical tree for a house from the `parents` / `children` graph in `content/characters/`. Placeholder ancestors fill in unnamed slots (e.g. unknown mothers). Rendered by `components/FamilyTree.tsx` on each house page. +## Development workflow -## Testing +Read `CLAUDE.md` for repository conventions before making changes. In +particular: -Vitest runs in jsdom with the React plugin and native tsconfig path resolution. Unit tests live next to the modules they cover (`lib/*.test.ts`, `components/*.test.tsx`). +1. Create a branch for each feature or bug fix. +2. Keep tests co-located with their implementation. +3. Run `bun run system-check` before handing off a change. +4. Prefix commit subjects with `TKW:`. -```bash -bun test # one shot -bun test:watch # watch mode -``` +The pre-commit hook runs formatting, type checking, linting, and tests. The +pre-push hook runs the production build. ## Deployment -Netlify builds with `bun run build` and publishes `out/`. The Next config sets `output: 'export'` and `trailingSlash: true`, so every page ships as an `index.html` under a directory. `netlify.toml` includes a catch-all 404 redirect. - -## Design + planning docs - -`docs/superpowers/` holds the specs and implementation plans the work has followed: - -- `specs/2026-05-19-game-of-thrones-atlas-design.md`: overall atlas design -- `specs/2026-05-26-main-menu-design.md`: three-tile main menu -- `plans/2026-05-19-foundation-and-first-castle.md` -- `plans/2026-05-19-map-view.md` -- `plans/2026-05-26-main-menu.md` - -## Convention notes +`next.config.ts` configures `output: "export"`, so `bun run build` writes the +deployable site to `out/`. Netlify runs the same build, publishes `out/`, and +uses the custom image loader in `lib/netlify-image-loader.ts` for production +image transformations. -This repo uses a newer Next.js than most training data, so read `node_modules/next/dist/docs/` for current APIs before writing route handlers, params, or metadata. See `AGENTS.md`. +Content source attribution is rendered from each entry's `sources` frontmatter. diff --git a/bun.lock b/bun.lock index a584753c..ef8bf24a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "name": "game-of-thrones-atlas", "dependencies": { - "gray-matter": "4.0.3", "next": "16.2.10", "nuqs": "2.9.0", "react": "19.2.7", @@ -13,6 +12,7 @@ "react-svg-pan-zoom": "3.13.1", "remark": "15.0.1", "remark-html": "16.0.1", + "yaml": "2.9.0", "zod": "4.4.3", }, "devDependencies": { @@ -41,6 +41,9 @@ "trustedDependencies": [ "unrs-resolver", ], + "overrides": { + "postcss": "8.5.17", + }, "packages": { "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], @@ -426,7 +429,7 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], @@ -600,8 +603,6 @@ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], @@ -616,8 +617,6 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], @@ -670,8 +669,6 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -732,8 +729,6 @@ "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], @@ -786,7 +781,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], @@ -806,8 +801,6 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], - "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], @@ -992,7 +985,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + "postcss": ["postcss@8.5.17", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -1060,8 +1053,6 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -1092,8 +1083,6 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -1118,8 +1107,6 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -1234,6 +1221,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -1248,8 +1237,6 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "@eslint/eslintrc/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], - "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@types/react-svg-pan-zoom/@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], @@ -1294,8 +1281,6 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-quick/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -1308,8 +1293,6 @@ "vitest/tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="], - "@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "@unrs/resolver-binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], diff --git a/components/SiteMenu/SiteMenu.test.tsx b/components/SiteMenu/SiteMenu.test.tsx index 7ac9be4d..61460884 100644 --- a/components/SiteMenu/SiteMenu.test.tsx +++ b/components/SiteMenu/SiteMenu.test.tsx @@ -105,4 +105,37 @@ describe("SiteMenu", () => { fireEvent.click(screen.getByRole("button", { name: /close menu/i })); expect(trigger.getAttribute("aria-expanded")).toBe("false"); }); + + it("exposes the open drawer as a modal dialog", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /open menu/i })); + + const dialog = screen.getByRole("dialog", { name: /site menu/i }); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + }); + + it("contains forward keyboard focus within the open drawer", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /open menu/i })); + + const close = screen.getByRole("button", { name: /close menu/i }); + const links = screen.getAllByRole("link"); + const lastLink = links[links.length - 1]; + lastLink.focus(); + fireEvent.keyDown(document, { key: "Tab" }); + + expect(document.activeElement).toBe(close); + }); + + it("contains reverse keyboard focus within the open drawer", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /open menu/i })); + + const close = screen.getByRole("button", { name: /close menu/i }); + const links = screen.getAllByRole("link"); + close.focus(); + fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); + + expect(document.activeElement).toBe(links[links.length - 1]); + }); }); diff --git a/components/SiteMenu/SiteMenu.tsx b/components/SiteMenu/SiteMenu.tsx index 37a50e03..410da603 100644 --- a/components/SiteMenu/SiteMenu.tsx +++ b/components/SiteMenu/SiteMenu.tsx @@ -68,13 +68,32 @@ export function SiteMenu() { const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); const closeRef = useRef(null); + const panelRef = useRef(null); const wasOpen = useRef(false); const panelId = useId(); useEffect(() => { if (!isOpen) return; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setIsOpen(false); + if (e.key === "Escape") { + setIsOpen(false); + return; + } + if (e.key !== "Tab") return; + + const focusable = panelRef.current?.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (!focusable || focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); @@ -129,8 +148,12 @@ export function SiteMenu() { />