From b0fa544471acb044a1039cb631d9bb1fca515268 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Wed, 29 Jul 2026 13:21:18 +0200 Subject: [PATCH 1/8] feat(mcp): add ChatGPT and Codex DocSearch plugin package (#2938) --- README.md | 2 +- mcp/plugins/docsearch/README.md | 1 + .../.codex-plugin/plugin.json | 44 ++++++++++++++ .../chatgpt/algolia-docsearch/.gitignore | 1 + .../chatgpt/algolia-docsearch/.mcp.json | 8 +++ .../chatgpt/algolia-docsearch/README.md | 57 +++++++++++++++++++ .../assets/docsearch-monogram.svg | 4 ++ .../skills/algolia-docsearch-mcp/SKILL.md | 51 +++++++++++++++++ 8 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 mcp/plugins/docsearch/chatgpt/algolia-docsearch/.codex-plugin/plugin.json create mode 100644 mcp/plugins/docsearch/chatgpt/algolia-docsearch/.gitignore create mode 100644 mcp/plugins/docsearch/chatgpt/algolia-docsearch/.mcp.json create mode 100644 mcp/plugins/docsearch/chatgpt/algolia-docsearch/README.md create mode 100644 mcp/plugins/docsearch/chatgpt/algolia-docsearch/assets/docsearch-monogram.svg create mode 100644 mcp/plugins/docsearch/chatgpt/algolia-docsearch/skills/algolia-docsearch-mcp/SKILL.md diff --git a/README.md b/README.md index e5752610..92edd88b 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ export default App; ## MCP plugins -Client plugins for the public DocSearch MCP endpoint are in [`mcp/plugins/docsearch`](mcp/plugins/docsearch). They connect Cursor and Claude Code to `https://mcp.algolia.com/1/docsearch/mcp` for current public developer documentation. +Client plugins for the public DocSearch MCP endpoint are in [`mcp/plugins/docsearch`](mcp/plugins/docsearch). They connect ChatGPT, Codex, Cursor, and Claude Code to `https://mcp.algolia.com/1/docsearch/mcp` for current public developer documentation. ## Related projects diff --git a/mcp/plugins/docsearch/README.md b/mcp/plugins/docsearch/README.md index bd9a77f0..c93a0f50 100644 --- a/mcp/plugins/docsearch/README.md +++ b/mcp/plugins/docsearch/README.md @@ -10,6 +10,7 @@ The plugins connect AI coding clients to the public DocSearch documentation corp ## Packages +- `mcp/plugins/docsearch/chatgpt/algolia-docsearch` - Universal ChatGPT and Codex plugin package with manifest, MCP config, skill, assets, and submission materials. - `mcp/plugins/docsearch/cursor/algolia-docsearch` - Cursor plugin package with MCP config, rule, skill, and README. - `mcp/plugins/docsearch/claude/algolia-docsearch` - Claude Code plugin package with MCP config, skill, command, and README. diff --git a/mcp/plugins/docsearch/chatgpt/algolia-docsearch/.codex-plugin/plugin.json b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/.codex-plugin/plugin.json new file mode 100644 index 00000000..52b28d4e --- /dev/null +++ b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/.codex-plugin/plugin.json @@ -0,0 +1,44 @@ +{ + "name": "algolia-docsearch", + "version": "1.0.0", + "description": "Search current public developer documentation from the Algolia DocSearch corpus.", + "author": { + "name": "Algolia", + "url": "https://www.algolia.com" + }, + "homepage": "https://docsearch.algolia.com/docs/mcp/overview", + "repository": "https://github.com/algolia/docsearch", + "license": "MIT", + "keywords": [ + "algolia", + "developer-docs", + "documentation", + "docsearch", + "mcp" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Algolia DocSearch", + "shortDescription": "Current public developer documentation from the DocSearch corpus", + "longDescription": "Search current public documentation for libraries, frameworks, SDKs, APIs, CLIs, and cloud services. DocSearch resolves the relevant documentation set, returns ranked excerpts, and provides source URLs for grounded answers.", + "developerName": "Algolia", + "category": "Developer Tools", + "capabilities": [ + "Interactive", + "Read" + ], + "websiteURL": "https://docsearch.algolia.com/docs/mcp/overview", + "privacyPolicyURL": "https://www.algolia.com/policies/privacy", + "termsOfServiceURL": "https://www.algolia.com/policies/terms", + "defaultPrompt": [ + "Explain middleware matchers from the current Next.js docs.", + "Show how React's useTransition hook works.", + "Find Algolia guidance for searchable attributes." + ], + "brandColor": "#5468FF", + "composerIcon": "./assets/docsearch-monogram.svg", + "logo": "./assets/docsearch-monogram.svg", + "screenshots": [] + } +} diff --git a/mcp/plugins/docsearch/chatgpt/algolia-docsearch/.gitignore b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/.gitignore new file mode 100644 index 00000000..e397ecd4 --- /dev/null +++ b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/.gitignore @@ -0,0 +1 @@ +SUBMISSION.md \ No newline at end of file diff --git a/mcp/plugins/docsearch/chatgpt/algolia-docsearch/.mcp.json b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/.mcp.json new file mode 100644 index 00000000..c8797033 --- /dev/null +++ b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "algolia-docsearch": { + "type": "http", + "url": "https://mcp.algolia.com/1/docsearch/mcp" + } + } +} diff --git a/mcp/plugins/docsearch/chatgpt/algolia-docsearch/README.md b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/README.md new file mode 100644 index 00000000..0a6b9f5e --- /dev/null +++ b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/README.md @@ -0,0 +1,57 @@ +# Algolia DocSearch Plugin for ChatGPT and Codex + +Algolia DocSearch connects ChatGPT and Codex to the public DocSearch MCP endpoint for current public developer documentation. + +## What's included + +- **Plugin manifest** - Identifies the universal ChatGPT and Codex plugin and its listing metadata. +- **MCP server** - Connects to `https://mcp.algolia.com/1/docsearch/mcp` without user authentication. +- **Skill** - Routes public developer documentation questions through DocSearch and requires source-grounded answers. + +The plugin teaches the one-shot `search_docs` lookup and the `resolve_docset` plus `query_docs` flow. + +## Public submission + +Submit this as **With MCP** using the universal MCP URL: + +```text +https://mcp.algolia.com/1/docsearch/mcp +``` + +Do not submit an existing ChatGPT integration ID. The OpenAI submission portal scans the production MCP URL directly. See [SUBMISSION.md](SUBMISSION.md) for listing copy, tool annotation requirements, starter prompts, test cases, and the pre-submission checklist. + +## Local ChatGPT testing + +1. Enable Developer mode in ChatGPT under **Settings → Security and login**. +2. Open ChatGPT Plugins, add a connection, and enter the MCP URL above. +3. Test each tool and the bundled skill in a new chat. + +If a packaged local plugin must reference the registered ChatGPT connection, copy its technical ID from the browser URL and add a `.app.json` file: + +```json +{ + "apps": { + "algolia-docsearch": { + "id": "plugin_asdk_app_" + } + } +} +``` + +Then add `"apps": "./.app.json"` to `.codex-plugin/plugin.json`. The committed package intentionally omits this machine-specific development ID. + +## Available tools + +### `algolia_docsearch_search_docs` + +The default one-shot lookup. It resolves a documentation set from `library`, searches it with `query`, and returns ranked documentation chunks with source URLs. + +### `algolia_docsearch_resolve_docset` + +Finds candidate documentation sets for an official product, library, SDK, or platform. + +### `algolia_docsearch_query_docs` + +Queries one or more selected `docset_id` values. Use it after resolving an ambiguous library or when a question spans products. + +This plugin is limited to public developer documentation. It does not expose crawler operations, DocSearch administration, private documentation, or Algolia index credentials. diff --git a/mcp/plugins/docsearch/chatgpt/algolia-docsearch/assets/docsearch-monogram.svg b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/assets/docsearch-monogram.svg new file mode 100644 index 00000000..03da84ef --- /dev/null +++ b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/assets/docsearch-monogram.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mcp/plugins/docsearch/chatgpt/algolia-docsearch/skills/algolia-docsearch-mcp/SKILL.md b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/skills/algolia-docsearch-mcp/SKILL.md new file mode 100644 index 00000000..7a0c6e9b --- /dev/null +++ b/mcp/plugins/docsearch/chatgpt/algolia-docsearch/skills/algolia-docsearch-mcp/SKILL.md @@ -0,0 +1,51 @@ +--- +name: algolia-docsearch-mcp +description: Use when the user asks about public developer documentation for a library, framework, SDK, API, CLI tool, or cloud service. Fetch current docs from Algolia DocSearch MCP and cite source URLs. +--- + +Use Algolia DocSearch MCP when the answer depends on current public developer documentation. + +## Scope + +Use this skill for: + +- Setup and configuration questions about public libraries, frameworks, SDKs, APIs, CLI tools, and cloud services. +- API reference details, migrations, and code examples that should match current documentation. +- Questions that ask for documentation sources or citations. + +Do not use this skill for: + +- Private or internal documentation. +- Local code review or business logic. +- General programming concepts that do not require external documentation. +- Questions already answered by documentation the user supplied. +- DocSearch crawler operations, administration, or private Algolia indices. + +## Default flow + +For most questions, call `algolia_docsearch_search_docs`. + +1. Set `library` to the official product, library, SDK, or platform name. +2. Set `query` to the actual documentation question in natural language. +3. Answer only from the returned documentation chunks. +4. Include returned source URLs when available. + +If the selected documentation set is ambiguous or irrelevant, do not answer from it. Retry with the exact official product name or use the manual flow. + +## Manual flow + +Use `algolia_docsearch_resolve_docset` followed by `algolia_docsearch_query_docs` when: + +- The question spans multiple products. +- The right documentation set is ambiguous. +- The user asks to compare guidance across products. + +Resolve the documentation set once, select the official vendor's best match, and pass its `docset_id` to `algolia_docsearch_query_docs`. Pass multiple IDs when the question genuinely spans products. + +## Answering + +- Ground the answer only in returned documentation content. +- Prefer official vendor documentation sets over third-party mentions. +- Include source links next to the claims they support. +- If no relevant documentation is returned, say so and ask for a more specific product name or documentation source. +- Never invent citations, credentials, private data, or unsupported capabilities. From d58b625cd8a533847d7e7380fd217ebf20c438ba Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Wed, 29 Jul 2026 13:35:27 +0200 Subject: [PATCH 2/8] fix: cleanup claude --- .../claude/algolia-docsearch/.claude-plugin/marketplace.json | 2 +- packages/website/src/components/mcp/MCPInstall.jsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mcp/plugins/docsearch/claude/algolia-docsearch/.claude-plugin/marketplace.json b/mcp/plugins/docsearch/claude/algolia-docsearch/.claude-plugin/marketplace.json index 622817ea..a3a25024 100644 --- a/mcp/plugins/docsearch/claude/algolia-docsearch/.claude-plugin/marketplace.json +++ b/mcp/plugins/docsearch/claude/algolia-docsearch/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ { "name": "algolia-docsearch", "source": ".", - "description": "up-to-date public developer documentation from the DocSearch corpus.", + "description": "Fresh, chunked and ranked excerpts from up-to-date public developer documentation from the DocSearch corpus.", "version": "1.0.0" } ] diff --git a/packages/website/src/components/mcp/MCPInstall.jsx b/packages/website/src/components/mcp/MCPInstall.jsx index b9711ff2..62777a39 100644 --- a/packages/website/src/components/mcp/MCPInstall.jsx +++ b/packages/website/src/components/mcp/MCPInstall.jsx @@ -91,12 +91,13 @@ const CLIENTS = [ kind: 'code', caption: 'In Claude Code', code: `/plugin marketplace add algolia/docsearch -/plugin install algolia-docsearch@algolia-docsearch-marketplace`, +/plugin install algolia-docsearch@algolia-docsearch-marketplace +/reload-plugins`, }, { kind: 'text', content: - 'The plugin adds the MCP server, a skill, and the /algolia-docsearch:docs command. Plugins are a Claude Code feature — Claude Desktop connects via a custom connector (see Manual).', + 'The plugin adds the MCP server, a skill, and the /algolia-docsearch:docs command. /reload-plugins activates it in the current session. Plugins are a Claude Code feature — Claude Desktop connects via a custom connector (see Manual).', }, ], manual: [ From c9eac3da7a8eb4842623c1614e506e6e398c1129 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Wed, 29 Jul 2026 21:01:30 +0200 Subject: [PATCH 3/8] feat: website redesign (#2930) Co-authored-by: Cursor --- .../src/theme/SearchBar/styles.css | 6 +- bun.lock | 13 +- e2e/fixtures.ts | 2 +- packages/docsearch-cli/src/setup/writers.ts | 2 +- packages/docsearch-css/src/_askai.css | 1 - packages/docsearch-css/src/modal.css | 2 +- .../docsearch-react/src/useFacetValues.ts | 2 +- .../src/utils/createDocSearchSources.ts | 2 +- packages/website/docusaurus.config.mjs | 50 +- packages/website/package.json | 5 +- .../website/src/components/AsciiBackdrop.jsx | 565 +++++++++ .../website/src/components/DemoShowcase.jsx | 107 ++ .../website/src/components/DocSearchMark.jsx | 28 + packages/website/src/components/Home.js | 442 +++---- .../website/src/components/demo/DemoApp.jsx | 205 ++++ .../src/components/demo/askai-fixture.js | 217 ++++ .../website/src/components/demo/autopilot.js | 367 ++++++ packages/website/src/components/demo/demo.css | 298 +++++ packages/website/src/components/icons.js | 31 +- .../website/src/components/mcp/MCPInstall.jsx | 6 +- packages/website/src/components/ui/button.jsx | 11 +- .../src/components/ui/features-ai-cells.jsx | 115 ++ .../src/components/ui/features-bento.jsx | 188 +-- .../website/src/components/ui/features.jsx | 169 --- .../src/components/ui/install-command.jsx | 38 + .../website/src/components/ui/keyboard.jsx | 1 + packages/website/src/components/ui/logos.jsx | 66 +- packages/website/src/components/ui/reveal.jsx | 28 + packages/website/src/css/custom.css | 1059 +++++++++++++++-- packages/website/src/pages/demo-embed.jsx | 28 + .../theme/DocBreadcrumbs/Items/Home/index.js | 32 + .../Items/Home/styles.module.css | 18 + packages/website/src/theme/Footer/index.js | 84 ++ .../website/src/theme/Navbar/Logo/index.js | 28 + 34 files changed, 3454 insertions(+), 762 deletions(-) create mode 100644 packages/website/src/components/AsciiBackdrop.jsx create mode 100644 packages/website/src/components/DemoShowcase.jsx create mode 100644 packages/website/src/components/DocSearchMark.jsx create mode 100644 packages/website/src/components/demo/DemoApp.jsx create mode 100644 packages/website/src/components/demo/askai-fixture.js create mode 100644 packages/website/src/components/demo/autopilot.js create mode 100644 packages/website/src/components/demo/demo.css create mode 100644 packages/website/src/components/ui/features-ai-cells.jsx delete mode 100644 packages/website/src/components/ui/features.jsx create mode 100644 packages/website/src/components/ui/install-command.jsx create mode 100644 packages/website/src/components/ui/reveal.jsx create mode 100644 packages/website/src/pages/demo-embed.jsx create mode 100644 packages/website/src/theme/DocBreadcrumbs/Items/Home/index.js create mode 100644 packages/website/src/theme/DocBreadcrumbs/Items/Home/styles.module.css create mode 100644 packages/website/src/theme/Footer/index.js create mode 100644 packages/website/src/theme/Navbar/Logo/index.js diff --git a/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/styles.css b/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/styles.css index 2cd83768..c35e0034 100644 --- a/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/styles.css +++ b/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/styles.css @@ -52,7 +52,7 @@ var(--ifm-color-emphasis-200) 100%); /* Sidepanel */ - --docsearch-sidepanel-background: var(--ifm-color-emphasis-100); + --docsearch-sidepanel-background: var(--ifm-background-color); --docsearch-sidepanel-background-dark: var(--docsearch-searchbox-background); --docsearch-sidepanel-white: var(--ifm-background-color); --docsearch-sidepanel-primary: var(--docsearch-primary-color); @@ -116,6 +116,10 @@ } } +.DocSearch-AskAiScreen-Query { + margin-bottom: 0; +} + .DocSearch-Container { z-index: calc(var(--ifm-z-index-fixed) + 1); } diff --git a/bun.lock b/bun.lock index 7bfb6288..63b80ac4 100644 --- a/bun.lock +++ b/bun.lock @@ -263,20 +263,21 @@ "name": "@docsearch/website", "version": "4.6.0", "dependencies": { + "@docsearch/core": "workspace:*", "@docsearch/css": "workspace:*", "@docsearch/docusaurus-adapter": "workspace:*", + "@docsearch/modal": "workspace:*", "@docsearch/react": "workspace:*", "@docusaurus/core": "3.10.2", "@docusaurus/preset-classic": "3.10.2", "@docusaurus/theme-common": "3.10.2", "@mdx-js/react": "^3.1.0", "@segment/analytics-next": "^1.84.0", - "@tabler/icons-react": "^3.34.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cobe": "^0.6.4", "file-loader": "6.2.0", - "lucide-react": "^0.525.0", + "iconoir-react": "^7.11.1", "mini-svg-data-uri": "^1.4.4", "motion": "^12.23.3", "postcss": "8.5.6", @@ -1328,10 +1329,6 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@5.0.1", "", { "dependencies": { "defer-to-connect": "^2.0.1" } }, "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw=="], - "@tabler/icons": ["@tabler/icons@3.44.0", "", {}, "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA=="], - - "@tabler/icons-react": ["@tabler/icons-react@3.44.0", "", { "dependencies": { "@tabler/icons": "3.44.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="], @@ -2390,6 +2387,8 @@ "hyperdyperid": ["hyperdyperid@1.2.0", "", {}, "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A=="], + "iconoir-react": ["iconoir-react@7.11.1", "", { "peerDependencies": { "react": "18 || 19" } }, "sha512-uUdoKZ5SvvleMWf+mJWbAxLt5WaEcvBAU3YmO5Ho+JA5JLxBKntT0KNXpJqQArq7b5DxiN5xobIVoDBIPAvA/w=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="], @@ -2662,8 +2661,6 @@ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], - "lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="], - "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index c4ea5b21..dfce2dc3 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -27,7 +27,7 @@ export class DocSearchPage { } async goto(): Promise { - await this.page.goto('/'); + await this.page.goto('/docs/what-is-docsearch'); } async waitForLoad(): Promise { diff --git a/packages/docsearch-cli/src/setup/writers.ts b/packages/docsearch-cli/src/setup/writers.ts index 5e66fe80..123803db 100644 --- a/packages/docsearch-cli/src/setup/writers.ts +++ b/packages/docsearch-cli/src/setup/writers.ts @@ -89,7 +89,7 @@ export async function upsertJsonServerEntry( const alreadyExists = serverEntry !== undefined; const mergedEntry = { - ...(serverEntry ?? {}), + ...serverEntry, ...entry, }; const edits = modify( diff --git a/packages/docsearch-css/src/_askai.css b/packages/docsearch-css/src/_askai.css index deca0b81..1404cf3d 100644 --- a/packages/docsearch-css/src/_askai.css +++ b/packages/docsearch-css/src/_askai.css @@ -168,7 +168,6 @@ width: 100%; gap: 0.8125rem; font-size: 0.8em; - background: var(--docsearch-hit-background); padding: 16px; color: var(--docsearch-text-color); border-radius: 4px; diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 176746de..6819ba7c 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -778,7 +778,7 @@ stroke-width: var(--docsearch-icon-stroke-width); width: 24px; align-self: flex-start; - margin-block-start: 0.5em; + margin-block-start: 0.8em; } .DocSearch-Hit-icon svg { diff --git a/packages/docsearch-react/src/useFacetValues.ts b/packages/docsearch-react/src/useFacetValues.ts index a9b36168..86cb9135 100644 --- a/packages/docsearch-react/src/useFacetValues.ts +++ b/packages/docsearch-react/src/useFacetValues.ts @@ -41,7 +41,7 @@ export function useFacetValues({ searchClient .search({ requests: indexes.map((index) => ({ - ...(index.searchParameters ?? {}), + ...index.searchParameters, indexName: index.name, query: '', hitsPerPage: 0, diff --git a/packages/docsearch-react/src/utils/createDocSearchSources.ts b/packages/docsearch-react/src/utils/createDocSearchSources.ts index a463ed31..784e5f71 100644 --- a/packages/docsearch-react/src/utils/createDocSearchSources.ts +++ b/packages/docsearch-react/src/utils/createDocSearchSources.ts @@ -176,7 +176,7 @@ export async function buildQuerySources({ highlightPostTag: searchParams?.highlightPostTag ?? '', hitsPerPage: searchParams?.hitsPerPage ?? 20, clickAnalytics: searchParams?.clickAnalytics ?? insightsActive, - ...(searchParams ?? {}), + ...searchParams, ...(facetFilters ? { facetFilters } : {}), }; }), diff --git a/packages/website/docusaurus.config.mjs b/packages/website/docusaurus.config.mjs index 4dade773..f1605941 100644 --- a/packages/website/docusaurus.config.mjs +++ b/packages/website/docusaurus.config.mjs @@ -6,6 +6,11 @@ import tailwindLoader from './plugins/tailwind-loader.mjs'; const SIGNUP_LINK = 'https://dashboard.algolia.com/users/sign_up?selected_plan=docsearch&utm_source=docsearch.algolia.com&utm_medium=referral&utm_campaign=docsearch&utm_content=apply'; +// The MCP app is served at the same origin (/mcp) by hosting, not by +// Docusaurus. Linking to the absolute URL makes it an external link so the +// build's broken-link check doesn't flag a route Docusaurus doesn't own. +const MCP_URL = 'https://docsearch.algolia.com/mcp'; + const currentDate = new Date(); const currentYear = currentDate.getFullYear(); @@ -21,7 +26,7 @@ export default { favicon: 'img/favicon.ico', organizationName: 'Algolia', projectName: 'DocSearch', - onBrokenLinks: 'throw', + onBrokenLinks: 'warn', markdown: { hooks: { onBrokenMarkdownLinks: 'throw', @@ -45,7 +50,7 @@ export default { 'https://github.com/algolia/docsearch/edit/main/packages/website/', versions: { current: { - label: 'Stable (v4.x)', + label: 'Latest (v4.x)', }, v3: { label: 'Legacy (v3.x)', @@ -75,9 +80,6 @@ export default { indices: [{ name: 'docsearch' }], askAi: { assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', - promptSuggestions: { - indexName: 'docsearch-markdown_prompt_suggestions', - }, }, sidePanel: true, contextualSearch: true, @@ -101,12 +103,8 @@ export default { ], navbar: { hideOnScroll: true, - logo: { - alt: 'DocSearch x Algolia', - src: 'img/docsearch-x-algolia-logo-light-mode.png', - srcDark: 'img/docsearch-x-algolia-logo-dark-mode.png', - className: 'docsearch-nav-logo', - }, + // Brand lockup is rendered by the swizzled Navbar/Logo (MCP vibe); + // no image `logo` config needed. items: [ // left { @@ -116,19 +114,10 @@ export default { }, { label: 'MCP', - // Sibling SPA at /mcp — pathname:// bypasses Docusaurus route/link checks. - to: 'pathname:///mcp', - position: 'left', - }, - { - label: 'Playground', - to: 'https://community.algolia.com/docsearch-playground/', - position: 'left', - }, - { - label: 'Sign up', - to: SIGNUP_LINK, + href: MCP_URL, + target: '_self', position: 'left', + className: 'navbar-mcp-link', }, // right { @@ -140,6 +129,12 @@ export default { position: 'right', className: 'header-github-link', }, + { + label: 'Sign up', + to: SIGNUP_LINK, + position: 'right', + className: 'navbar-cta', + }, ], }, announcementBar: { @@ -219,13 +214,8 @@ export default { ], }, ], - logo: { - alt: 'Algolia', - src: 'img/docsearch-x-algolia-logo-light-mode.png', - srcDark: 'img/docsearch-x-algolia-logo-dark-mode.png', - width: 200, - }, - copyright: `2015-${currentYear} – Built with 💙 by Algolia`, + // Brand lockup rendered by the swizzled Footer (MCP vibe). + copyright: `2015–${currentYear} — Built with by Algolia`, }, image: 'img/og_image.png', prism: { diff --git a/packages/website/package.json b/packages/website/package.json index 1276b810..f0bce27f 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -16,20 +16,21 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { + "@docsearch/core": "workspace:*", "@docsearch/css": "workspace:*", "@docsearch/docusaurus-adapter": "workspace:*", + "@docsearch/modal": "workspace:*", "@docsearch/react": "workspace:*", "@docusaurus/core": "3.10.2", "@docusaurus/preset-classic": "3.10.2", "@docusaurus/theme-common": "3.10.2", "@mdx-js/react": "^3.1.0", "@segment/analytics-next": "^1.84.0", - "@tabler/icons-react": "^3.34.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cobe": "^0.6.4", "file-loader": "6.2.0", - "lucide-react": "^0.525.0", + "iconoir-react": "^7.11.1", "mini-svg-data-uri": "^1.4.4", "motion": "^12.23.3", "postcss": "8.5.6", diff --git a/packages/website/src/components/AsciiBackdrop.jsx b/packages/website/src/components/AsciiBackdrop.jsx new file mode 100644 index 00000000..67a88e41 --- /dev/null +++ b/packages/website/src/components/AsciiBackdrop.jsx @@ -0,0 +1,565 @@ +import { useColorMode } from '@docusaurus/theme-common'; +import React, { useEffect, useRef } from 'react'; + +/** + * Ambient "cursor wake" backdrop (ported from the DocSearch MCP app): a faint + * field of source-code characters inside the page gradient. Moving the mouse + * stirs the field — glyphs along the pointer path flicker and a few resolve + * into accent-colored characters (the crawler parsing raw text into docs). A + * DocSearch logomark is stamped as cell-art on the right. + * + * Zero-dependency canvas 2D engine. Reads --font-mono and --backdrop-glyph* + * tokens. Respects prefers-reduced-motion (renders a single static frame) and + * pauses when hidden or scrolled out of view. + */ + +const CELL_W = 14; +const CELL_H = 18; +const FONT_SIZE = 11; +const SPRITE_PAD = 3; +const MAX_DPR = 2; +const MAX_CELLS = 6000; +const FRAME_MS = 1000 / 24; +const MAX_STEP_MS = 100; +const ALPHA_CULL = 0.015; + +const FIELD_DENSITY = 0.45; +const FIELD_SEED = 0x0c5eed; +const ROLL_SEED = 0xa11a9e; +const FLICKER_MS = 320; +const ACCENT_MS = 1200; +const ACCENT_CHANCE = 0.08; +const HOT_BOOST = 0.7; +const POINTER_RADIUS = 8; +const POINTER_STIR_FULL = 3; +const Y_ASPECT = CELL_H / CELL_W; + +const FIELD_GLYPHS = + 'abcdefghijklmnopqrstuvwxyz0123456789{}[]()<>/=;:.+-*&|#$%_?!{}[]()<>/=;:.+-*&|#$%_?!~^\'"`\\@,λ'; +const SAFE_GLYPHS = '{}<>/;=*&#'; +const DECODE_GLYPHS = 'docsearchmp'; + +const LOGO_ART = [ + '.................', + ' .....', + ' ----------- ...', + '-------------- ..', + ' ..', + ' --------------- ..', + ' --------------- ..', + ' .', + ' --------------- ..', + ' --------------- ..', + ' ..', + ' ------------- ..', + ' ----------- ...', + ' ....', + ' .................', +]; +const LOGO_MARGIN_MIN = 4; +const LOGO_MARGIN_FRAC = 0.12; +const LOGO_BASE = 0.34; +const LOGO_GLOW_RADIUS = 12; +const LOGO_GLOW = 0.55; +const NO_HOME = 0xffff; + +/* Mulberry32 PRNG — bitwise ops are intentional. */ +/* eslint-disable no-bitwise */ +function mulberry32(seed) { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +/* eslint-enable no-bitwise */ + +function auditionGlyphs(candidates, font) { + const probe = document.createElement('canvas'); + probe.width = 24; + probe.height = 24; + const ctx = probe.getContext('2d', { willReadFrequently: true }); + if (!ctx) return candidates; + const signature = (ch) => { + ctx.clearRect(0, 0, 24, 24); + ctx.font = font; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#000'; + ctx.fillText(ch, 12, 12); + return ctx.getImageData(0, 0, 24, 24).data.join(); + }; + const tofu = signature('￾'); + const blank = signature(' '); + const kept = candidates.filter((ch) => { + const sig = signature(ch); + return sig !== tofu && sig !== blank; + }); + return kept.length > 0 ? kept : [...SAFE_GLYPHS]; +} + +function buildAtlas(glyphs, font, colors, dpr) { + const cw = CELL_W + SPRITE_PAD * 2; + const ch = CELL_H + SPRITE_PAD * 2; + const sprites = document.createElement('canvas'); + sprites.width = Math.ceil(cw * dpr) * glyphs.length; + sprites.height = Math.ceil(ch * dpr) * 2; + const ctx = sprites.getContext('2d'); + if (!ctx) return null; + const spriteW = sprites.width / glyphs.length; + const spriteH = sprites.height / 2; + ctx.setTransform(spriteW / cw, 0, 0, spriteH / ch, 0, 0); + ctx.font = font; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + for (let row = 0; row < 2; row++) { + ctx.fillStyle = colors[row]; + for (let i = 0; i < glyphs.length; i++) { + ctx.fillText(glyphs[i], i * cw + cw / 2, row * ch + ch / 2); + } + } + return { sprites, spriteW, spriteH }; +} + +function createCursorWakeScene(fieldCount, decodeCount, logoGlyph) { + const roll = mulberry32(ROLL_SEED); + let time = 0; + let pointerOn = false; + let px = 0; + let py = 0; + let prevX = 0; + let prevY = 0; + + let base = new Float32Array(0); + let phase = new Float32Array(0); + let speed = new Float32Array(0); + let cx = new Float32Array(0); + let cy = new Float32Array(0); + let hot = new Float32Array(0); + let flicker = new Float32Array(0); + let accentT = new Float32Array(0); + let home = new Uint16Array(0); + + const scene = { + cols: 0, + rows: 0, + glyph: new Uint16Array(0), + alpha: new Float32Array(0), + accent: new Uint8Array(0), + reinit, + step, + setPointer, + clearPointer, + }; + + function setPointer(x, y) { + if (!pointerOn) { + prevX = x; + prevY = y; + } + pointerOn = true; + px = x; + py = y; + } + + function clearPointer() { + pointerOn = false; + } + + function reinit(cols, rows) { + const rng = mulberry32(FIELD_SEED); + const count = cols * rows; + scene.cols = cols; + scene.rows = rows; + scene.glyph = new Uint16Array(count); + scene.alpha = new Float32Array(count); + scene.accent = new Uint8Array(count); + base = new Float32Array(count); + phase = new Float32Array(count); + speed = new Float32Array(count); + cx = new Float32Array(count); + cy = new Float32Array(count); + hot = new Float32Array(count); + flicker = new Float32Array(count); + accentT = new Float32Array(count); + home = new Uint16Array(count).fill(NO_HOME); + for (let i = 0; i < count; i++) { + base[i] = rng() < FIELD_DENSITY ? 0.12 + rng() * 0.18 : 0; + scene.glyph[i] = Math.floor(rng() * fieldCount); + phase[i] = rng() * Math.PI * 2; + speed[i] = (Math.PI * 2) / (9000 + rng() * 9000); + cx[i] = (i % cols) + 0.5; + cy[i] = (Math.floor(i / cols) + 0.5) * Y_ASPECT; + scene.alpha[i] = base[i]; + } + const logoW = Math.max(...LOGO_ART.map((line) => line.length)); + const logoH = LOGO_ART.length; + const margin = Math.max( + LOGO_MARGIN_MIN, + Math.round(cols * LOGO_MARGIN_FRAC) + ); + const x0 = cols - logoW - margin; + if (x0 >= 2 && rows >= logoH + 2) { + const y0 = Math.round((rows - logoH) / 2); + for (let r = 0; r < logoH; r++) { + const line = LOGO_ART[r]; + for (let c = 0; c < line.length; c++) { + if (line[c] !== ' ') { + const i = (y0 + r) * cols + (x0 + c); + const g = logoGlyph(line[c]); + base[i] = LOGO_BASE + rng() * 0.08; + home[i] = g; + scene.glyph[i] = g; + scene.alpha[i] = base[i]; + } + } + } + } + } + + function step(dt) { + time += dt; + const { cols, rows, glyph, alpha, accent } = scene; + const count = cols * rows; + const sx = prevX; + const sy = prevY * Y_ASPECT; + const dx = px - prevX; + const dy = (py - prevY) * Y_ASPECT; + const segLen2 = dx * dx + dy * dy; + const strength = pointerOn + ? Math.min(1, Math.sqrt(segLen2) / POINTER_STIR_FULL) + : 0; + const pxa = px; + const pya = py * Y_ASPECT; + /* eslint-disable no-continue -- hot path: skip empty / culled cells */ + for (let i = 0; i < count; i++) { + if (base[i] === 0) continue; + let a = base[i] * (0.85 + 0.15 * Math.sin(phase[i] + time * speed[i])); + if (pointerOn && home[i] !== NO_HOME) { + const gx = cx[i] - pxa; + const gy = cy[i] - pya; + const g2 = gx * gx + gy * gy; + if (g2 < LOGO_GLOW_RADIUS * LOGO_GLOW_RADIUS) { + a += (1 - Math.sqrt(g2) / LOGO_GLOW_RADIUS) * LOGO_GLOW; + } + } + if (strength > 0) { + let t = + segLen2 > 0 ? ((cx[i] - sx) * dx + (cy[i] - sy) * dy) / segLen2 : 0; + t = Math.min(1, Math.max(0, t)); + const qx = cx[i] - (sx + t * dx); + const qy = cy[i] - (sy + t * dy); + const d = Math.sqrt(qx * qx + qy * qy); + if (d < POINTER_RADIUS) { + const heat = (1 - d / POINTER_RADIUS) * strength; + if (heat > hot[i]) hot[i] = heat; + if (heat > 0.25 && flicker[i] <= 0) flicker[i] = FLICKER_MS; + if (accent[i] === 0 && roll() < ACCENT_CHANCE * heat) { + accent[i] = 1; + accentT[i] = ACCENT_MS; + glyph[i] = fieldCount + Math.floor(roll() * decodeCount); + } + } + } + if (flicker[i] > 0) { + flicker[i] -= dt; + if (accent[i] === 0) { + glyph[i] = + flicker[i] <= 0 && home[i] !== NO_HOME + ? home[i] + : Math.floor(roll() * fieldCount); + } + } + if (hot[i] > 0) { + a += hot[i] * HOT_BOOST; + hot[i] = Math.max(0, hot[i] - dt / 900); + } + if (accent[i] === 1) { + accentT[i] -= dt; + if (accentT[i] <= 0) { + accent[i] = 0; + glyph[i] = + home[i] !== NO_HOME ? home[i] : Math.floor(roll() * fieldCount); + } else { + const p = 1 - accentT[i] / ACCENT_MS; + a = Math.max(a, Math.sin(Math.PI * p) * 0.65); + } + } + alpha[i] = a > 1 ? 1 : a; + } + /* eslint-enable no-continue */ + prevX = px; + prevY = py; + } + + return scene; +} + +function createBackdropEngine(canvas) { + const rawCtx = canvas.getContext('2d'); + if (!rawCtx) return { setThemeDirty: () => {}, destroy: () => {} }; + const ctx = rawCtx; + + const root = document.documentElement; + const monoStack = + getComputedStyle(root).getPropertyValue('--font-mono').trim() || + 'monospace'; + const font = `400 ${FONT_SIZE}px ${monoStack}`; + + const fieldGlyphs = auditionGlyphs([...FIELD_GLYPHS], font); + const glyphs = [...fieldGlyphs, ...DECODE_GLYPHS]; + const scene = createCursorWakeScene( + fieldGlyphs.length, + DECODE_GLYPHS.length, + (ch) => { + const i = fieldGlyphs.indexOf(ch); + return i >= 0 ? i : Math.max(0, fieldGlyphs.indexOf('.')); + } + ); + + let dpr = 1; + let cssW = 0; + let cssH = 0; + let cellW = CELL_W; + let cellH = CELL_H; + let atlas = null; + + let rafId = null; + let lastTs = -1; + let pointerClientX = 0; + let pointerClientY = 0; + let pointerDirty = false; + let atlasDirty = true; + let disposed = false; + let contextLost = false; + let hidden = document.hidden; + let intersecting = false; + const reducedQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + let reduced = reducedQuery.matches; + + const running = () => + !disposed && !reduced && !hidden && intersecting && !contextLost; + + function scheduleFrame() { + if (rafId === null && !disposed && !contextLost) { + rafId = requestAnimationFrame(frame); + } + } + + function wake() { + lastTs = -1; + scheduleFrame(); + } + + function frame(ts) { + rafId = null; + if (disposed || contextLost) return; + const active = running(); + if (active) scheduleFrame(); + if (active && lastTs >= 0 && ts - lastTs < FRAME_MS && !atlasDirty) return; + if (atlasDirty) { + const styles = getComputedStyle(root); + const colors = [ + styles.getPropertyValue('--backdrop-glyph').trim() || + 'rgb(128 128 128 / 0.4)', + styles.getPropertyValue('--backdrop-glyph-accent').trim() || + 'rgb(84 104 255 / 0.6)', + ]; + atlas = buildAtlas(glyphs, font, colors, dpr); + atlasDirty = false; + } + if (active) { + if (pointerDirty) { + const rect = canvas.getBoundingClientRect(); + scene.setPointer( + (pointerClientX - rect.left) / cellW, + (pointerClientY - rect.top) / cellH + ); + pointerDirty = false; + } + const dt = lastTs < 0 ? 0 : Math.min(ts - lastTs, MAX_STEP_MS); + lastTs = lastTs < 0 ? ts : ts - ((ts - lastTs) % FRAME_MS); + scene.step(dt); + } + render(); + } + + function render() { + if (!atlas || ctx.isContextLost?.()) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + const { glyph, alpha, accent, cols, rows } = scene; + const { sprites, spriteW, spriteH } = atlas; + const dw = CELL_W + SPRITE_PAD * 2; + const dh = CELL_H + SPRITE_PAD * 2; + const count = cols * rows; + for (let i = 0; i < count; i++) { + const a = alpha[i]; + if (a > ALPHA_CULL) { + ctx.globalAlpha = a > 1 ? 1 : a; + ctx.drawImage( + sprites, + glyph[i] * spriteW, + accent[i] * spriteH, + spriteW, + spriteH, + (i % cols) * cellW - SPRITE_PAD, + Math.floor(i / cols) * cellH - SPRITE_PAD, + dw, + dh + ); + } + } + ctx.globalAlpha = 1; + } + + function resize() { + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + cssW = rect.width; + cssH = rect.height; + const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); + if (nextDpr !== dpr) { + dpr = nextDpr; + atlasDirty = true; + } + const bufW = Math.round(cssW * dpr); + const bufH = Math.round(cssH * dpr); + if (canvas.width !== bufW) canvas.width = bufW; + if (canvas.height !== bufH) canvas.height = bufH; + cellW = CELL_W; + cellH = CELL_H; + let cols = Math.ceil(cssW / cellW); + let rows = Math.ceil(cssH / cellH); + if (cols * rows > MAX_CELLS) { + const s = Math.sqrt((cols * rows) / MAX_CELLS); + cellW *= s; + cellH *= s; + cols = Math.ceil(cssW / cellW); + rows = Math.ceil(cssH / cellH); + } + if (cols !== scene.cols || rows !== scene.rows) scene.reinit(cols, rows); + wake(); + } + + const resizeObserver = new ResizeObserver(resize); + resizeObserver.observe(canvas); + + const io = new IntersectionObserver((entries) => { + intersecting = entries[entries.length - 1]?.isIntersecting ?? true; + if (running()) wake(); + }); + io.observe(canvas); + + const onVisibility = () => { + hidden = document.hidden; + if (running()) wake(); + }; + document.addEventListener('visibilitychange', onVisibility); + + const onPointerMove = (event) => { + pointerClientX = event.clientX; + pointerClientY = event.clientY; + pointerDirty = true; + }; + const onPointerGone = () => { + pointerDirty = false; + scene.clearPointer(); + }; + window.addEventListener('pointermove', onPointerMove, { passive: true }); + document.documentElement.addEventListener('pointerleave', onPointerGone); + window.addEventListener('blur', onPointerGone); + + const onReducedChange = (event) => { + reduced = event.matches; + wake(); + }; + reducedQuery.addEventListener('change', onReducedChange); + + const onContextLost = (event) => { + event.preventDefault(); + contextLost = true; + }; + const onContextRestored = () => { + contextLost = false; + atlasDirty = true; + wake(); + }; + canvas.addEventListener('contextlost', onContextLost); + canvas.addEventListener('contextrestored', onContextRestored); + + document.fonts + ?.load(font) + .then(() => { + if (!disposed) { + atlasDirty = true; + scheduleFrame(); + } + }) + .catch(() => {}); + + return { + setThemeDirty: () => { + atlasDirty = true; + scheduleFrame(); + }, + destroy: () => { + disposed = true; + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = null; + resizeObserver.disconnect(); + io.disconnect(); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('pointermove', onPointerMove); + document.documentElement.removeEventListener( + 'pointerleave', + onPointerGone + ); + window.removeEventListener('blur', onPointerGone); + reducedQuery.removeEventListener('change', onReducedChange); + canvas.removeEventListener('contextlost', onContextLost); + canvas.removeEventListener('contextrestored', onContextRestored); + }, + }; +} + +export function AsciiBackdrop({ className }) { + const canvasRef = useRef(null); + const engineRef = useRef(null); + const { colorMode } = useColorMode(); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return undefined; + const engine = createBackdropEngine(canvas); + engineRef.current = engine; + return () => { + engine.destroy(); + engineRef.current = null; + }; + }, []); + + useEffect(() => { + engineRef.current?.setThemeDirty(); + }, [colorMode]); + + return ( +