diff --git a/v5/AGENTS.md b/v5/AGENTS.md
new file mode 100644
index 0000000..09c3be0
--- /dev/null
+++ b/v5/AGENTS.md
@@ -0,0 +1,95 @@
+# AGENTS.md — MakerLab Tools v5
+
+Guidance for AI agents (and humans) working in the **v5** app. This is the
+active application. Everything below is scoped to the `v5/` directory.
+
+> [!IMPORTANT]
+> The **root `CLAUDE.md` describes v4** — an AirTable-backed app with
+> `AIRTABLE_TABLE_*` env vars. **That does not apply to v5.** v5's data layer is
+> **Notion** (`NOTION_DB_*`). When working in `v5/`, follow this file, not the
+> root v4 doc.
+
+## What this is
+
+A digital inventory + discovery app for makerspace equipment: browse/search a
+tool gallery, view tool detail pages, chat with an AI assistant (tool-aware,
+can look up units and file maintenance tickets), and an MCP endpoint exposing
+the catalog to external agents. White-labelled via env vars.
+
+## Stack
+
+- **Next.js 16** (App Router, React Server Components, `cacheComponents` enabled), **React 19**, **TypeScript**, **Tailwind CSS 4**.
+- **i18n:** `next-intl`, **12 locales**, cookie-based (`NEXT_LOCALE`) — no URL-prefix routing. Config in `src/i18n/config.ts`; messages in `messages/*.json`.
+- **AI:** Vercel **AI SDK v6** (`ai`, `@ai-sdk/react`) with `@ai-sdk/anthropic` → `claude-sonnet-4-6`. Markdown via `react-markdown` + `remark-gfm`.
+- **MCP:** `@modelcontextprotocol/sdk` (HTTP JSON-RPC server at `/api/mcp`).
+- **Validation:** `zod`. **Search:** `match-sorter` (fuzzy, ranked).
+
+## Data layer — Notion (not AirTable)
+
+Seven Notion databases, IDs supplied via env (`NOTION_DB_TOOLS`, `_CATEGORIES`,
+`_LOCATIONS`, `_UNITS`, `_RESOURCES`, `_MAINTENANCE_LOGS`, `_FLAGS`) plus
+`NOTION_API_KEY`. See `.env.example` for the full list and defaults.
+
+- `src/lib/notion.ts` — server-only Notion REST client: page→record parsers, pagination, 429 retry, `createMaintenanceLog`. Tolerant of snake_case **and** Title-case property names.
+- `src/lib/catalog.ts` — orchestration: fetch + join + derive `MakerLabTool`s, cached with `cacheTag("catalog")` / `cacheLife("minutes")`.
+- **Mock-catalog fallback (important):** `getCatalogTools()` / `getCatalogTool(id)` return the built-in `src/components/mock-catalog.ts` data whenever `hasNotionCatalogEnv()` is false (**any** Notion env var missing) **or** a fetch throws. So the app — and the whole E2E/test suite — runs with no Notion creds.
+
+## Key files
+
+| Path | Purpose |
+|---|---|
+| `src/lib/site-config.ts` | White-label branding (env-driven, all have defaults) |
+| `src/lib/notion.ts` | Notion API client (server-only) |
+| `src/lib/catalog.ts` | Catalog orchestration + cache + mock fallback |
+| `src/lib/rate-limit.ts` | In-memory (or Upstash) sliding-window limiter |
+| `src/lib/types.ts` / `src/components/catalog-types.ts` | Notion record types / resolved view types |
+| `src/app/api/chat/route.ts` | Claude chat: streaming, tools (`get_unit_details`, `report_issue`, `web_fetch`), PDF manual attach |
+| `src/app/api/mcp/route.ts` | MCP JSON-RPC server (5 tools), bearer-token auth |
+| `src/app/api/upload-notion/route.ts` | Image upload proxy → Notion file_uploads |
+| `src/app/api/admin/revalidate/route.ts` | Cache invalidation (`x-admin-secret`) |
+| `src/components/ChatFab.tsx` | Chat UI (`useChat`, citations stripped, photo upload) |
+| `src/app/page.tsx`, `tools/[id]/page.tsx` | Gallery + tool detail |
+
+## Conventions
+
+- Use the `@/` import alias (→ `src/`).
+- Server components by default; add `"use client"` only when needed.
+- Server-only modules import `"server-only"` (e.g. `rate-limit.ts`).
+- Theme/brand via **CSS variables** (`--primary`, `--background`, …) — `[data-theme="light|dark"]` on ``, never hardcoded colors.
+- All branding strings come from `siteConfig` (`@/lib/site-config`).
+- Every API route is **rate-limited by IP** before expensive work.
+- Maintenance tickets are always written in **English** even when the chat replies in another locale.
+
+## Testing
+
+Comprehensive, **fully-mocked** suite (no live Notion/Anthropic/Redis). Run
+everything with one command:
+
+```bash
+npm run test:all # lint + typecheck + vitest + playwright
+```
+
+Or individually: `npm test` (Vitest: unit + integration + component),
+`npm run test:e2e` (Playwright — run `npx playwright install chromium` once
+first), `npm run test:coverage`.
+
+- **Vitest** (jsdom) + React Testing Library + MSW; **Playwright** for E2E.
+- E2E boots its own server on **port 3100** with `NOTION_*` unset (mock catalog) and intercepts `/api/chat` — it never touches your `:3000` dev server or real services.
+- Tests are colocated (`*.test.ts(x)` next to source); shared harness in `test/`.
+- **Read these before writing tests:** `TESTING.md` (runbook), `test/README.md` (harness internals + the `streamText`-capture and env-stubbing patterns), and `docs/superpowers/specs/2026-05-29-v5-test-suite-design.md` (design + coverage matrix). The harness deps/scripts are already wired — don't hand-edit `package.json` for them.
+
+## Commands
+
+```bash
+npm run dev # dev server (:3000)
+npm run build # production build
+npm run lint # eslint
+npm run typecheck # tsc --noEmit
+npm run test:all # full test suite
+```
+
+## Gotchas
+
+- Because `cacheComponents` is enabled, API routes **cannot set `runtime`** — they use the default Node runtime.
+- The in-memory rate limiter is a per-process singleton; it resets on cold start (fine for abuse prevention). Upstash backs it only when **both** `UPSTASH_REDIS_REST_*` vars are set.
+- Python scripts under `scripts/` use Node with `--experimental-strip-types`; they are migration/maintenance tools, not part of the app build.
diff --git a/v5/e2e/dark-mode.spec.ts b/v5/e2e/dark-mode.spec.ts
new file mode 100644
index 0000000..5957b91
--- /dev/null
+++ b/v5/e2e/dark-mode.spec.ts
@@ -0,0 +1,53 @@
+import { test, expect } from "@playwright/test";
+
+// Guards the tool-detail dark-mode work (#23). The ThemeToggle cycles
+// system → light → dark; we click until the explicit dark theme is active,
+// then assert the dark design tokens actually resolve on a tool-detail page.
+// This protects the token wiring (not every pixel) from regressions.
+
+const DARK_BACKGROUND = "#0f0f0f"; // --background under [data-theme="dark"]
+
+async function cycleToDark(page: import("@playwright/test").Page) {
+ const toggle = page.getByRole("button", {
+ name: "Cycle color theme (system → light → dark)",
+ });
+ // At most 3 clicks reaches "dark" from any starting point in the cycle.
+ for (let i = 0; i < 3; i++) {
+ const theme = await page.evaluate(() =>
+ document.documentElement.getAttribute("data-theme")
+ );
+ if (theme === "dark") return;
+ await toggle.click();
+ }
+}
+
+test.describe("Dark mode", () => {
+ test("tool-detail page resolves dark design tokens when dark is active", async ({
+ page,
+ }) => {
+ await page.goto("/tools/form-4");
+ await expect(
+ page.getByRole("heading", { name: "Form 4", level: 1 })
+ ).toBeVisible();
+
+ await cycleToDark(page);
+
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+
+ const background = await page.evaluate(() =>
+ getComputedStyle(document.documentElement)
+ .getPropertyValue("--background")
+ .trim()
+ );
+ expect(background).toBe(DARK_BACKGROUND);
+ });
+
+ test("dark theme persists across a reload", async ({ page }) => {
+ await page.goto("/tools/form-4");
+ await cycleToDark(page);
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+
+ await page.reload();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ });
+});
diff --git a/v5/package-lock.json b/v5/package-lock.json
index 64367ff..bd7e799 100644
--- a/v5/package-lock.json
+++ b/v5/package-lock.json
@@ -32,6 +32,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.2",
+ "@vitest/coverage-v8": "^4.1.8",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^29.1.1",
@@ -442,6 +443,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -3789,17 +3800,49 @@
}
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
+ "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.8",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.8",
+ "vitest": "4.1.8"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
- "version": "4.1.7",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
- "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
+ "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.7",
- "@vitest/utils": "4.1.7",
+ "@vitest/spy": "4.1.8",
+ "@vitest/utils": "4.1.8",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -3808,13 +3851,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.7",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
- "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
+ "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.7",
+ "@vitest/spy": "4.1.8",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3835,9 +3878,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.7",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
- "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
+ "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3848,13 +3891,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.7",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
- "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
+ "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.7",
+ "@vitest/utils": "4.1.8",
"pathe": "^2.0.3"
},
"funding": {
@@ -3862,14 +3905,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.7",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
- "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
+ "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.7",
- "@vitest/utils": "4.1.7",
+ "@vitest/pretty-format": "4.1.8",
+ "@vitest/utils": "4.1.8",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3878,9 +3921,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.7",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
- "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
+ "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3888,13 +3931,13 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.7",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
- "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
+ "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.7",
+ "@vitest/pretty-format": "4.1.8",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -4233,6 +4276,25 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz",
+ "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -6399,6 +6461,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -7074,6 +7143,45 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -7137,7 +7245,6 @@
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
@@ -7639,6 +7746,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
+ "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -8636,7 +8784,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@inquirer/confirm": "^6.0.11",
"@mswjs/interceptors": "^0.41.3",
@@ -11422,19 +11569,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.7",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
- "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
+ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.7",
- "@vitest/mocker": "4.1.7",
- "@vitest/pretty-format": "4.1.7",
- "@vitest/runner": "4.1.7",
- "@vitest/snapshot": "4.1.7",
- "@vitest/spy": "4.1.7",
- "@vitest/utils": "4.1.7",
+ "@vitest/expect": "4.1.8",
+ "@vitest/mocker": "4.1.8",
+ "@vitest/pretty-format": "4.1.8",
+ "@vitest/runner": "4.1.8",
+ "@vitest/snapshot": "4.1.8",
+ "@vitest/spy": "4.1.8",
+ "@vitest/utils": "4.1.8",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -11462,12 +11609,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.7",
- "@vitest/browser-preview": "4.1.7",
- "@vitest/browser-webdriverio": "4.1.7",
- "@vitest/coverage-istanbul": "4.1.7",
- "@vitest/coverage-v8": "4.1.7",
- "@vitest/ui": "4.1.7",
+ "@vitest/browser-playwright": "4.1.8",
+ "@vitest/browser-preview": "4.1.8",
+ "@vitest/browser-webdriverio": "4.1.8",
+ "@vitest/coverage-istanbul": "4.1.8",
+ "@vitest/coverage-v8": "4.1.8",
+ "@vitest/ui": "4.1.8",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
diff --git a/v5/package.json b/v5/package.json
index a23d659..88c4022 100644
--- a/v5/package.json
+++ b/v5/package.json
@@ -43,6 +43,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.2",
+ "@vitest/coverage-v8": "^4.1.8",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^29.1.1",
diff --git a/v5/src/components/ChatFab.test.tsx b/v5/src/components/ChatFab.test.tsx
index 625b2e2..d95fe17 100644
--- a/v5/src/components/ChatFab.test.tsx
+++ b/v5/src/components/ChatFab.test.tsx
@@ -290,3 +290,215 @@ describe("ChatFab", () => {
expect(typeof opts.onData).toBe("function");
});
});
+
+// ── Citation stripping (#22) ───────────────────────────────────────
+//
+// The assistant grounds answers with inline … markup.
+// react-markdown has no raw-HTML plugin, so without stripping these would
+// render as literal text. Assert the tags are removed but the prose survives.
+describe("ChatFab — tag stripping", () => {
+ async function open(user: ReturnType) {
+ await user.click(
+ screen.getByRole("button", { name: "Open MakerLab assistant" })
+ );
+ }
+
+ it("removes a tag (with attributes) while keeping the cited prose", async () => {
+ const user = userEvent.setup();
+ useChatReturn = baseReturn({
+ messages: [
+ userMsg("u1", "how do I cut acrylic?"),
+ assistantMsg(
+ "a1",
+ 'Use the laser cutter after safety training first.'
+ ),
+ ],
+ });
+ render();
+ await open(user);
+
+ const dialog = screen.getByRole("dialog");
+ expect(dialog).toHaveTextContent(
+ "Use the laser cutter after safety training first."
+ );
+ expect(dialog.textContent).not.toContain("");
+ expect(dialog.textContent).not.toContain("index=");
+ });
+
+ it("removes multiple tags in one message", async () => {
+ const user = userEvent.setup();
+ useChatReturn = baseReturn({
+ messages: [
+ assistantMsg(
+ "a1",
+ 'First and second point.'
+ ),
+ ],
+ });
+ render();
+ await open(user);
+
+ const dialog = screen.getByRole("dialog");
+ expect(dialog).toHaveTextContent("First and second point.");
+ expect(dialog.textContent).not.toContain("cite");
+ });
+
+ it("leaves a message without citations untouched", async () => {
+ const user = userEvent.setup();
+ useChatReturn = baseReturn({
+ messages: [assistantMsg("a1", "Just plain advice, no sources.")],
+ });
+ render();
+ await open(user);
+
+ expect(
+ screen.getByText("Just plain advice, no sources.")
+ ).toBeInTheDocument();
+ });
+});
+
+// ── Pending tool-call status ───────────────────────────────────────
+//
+// While a tool call is mid-flight (state !== "output-available") and the
+// assistant message has no text yet, the UI shows a per-tool status line.
+describe("ChatFab — pending tool-call status", () => {
+ function toolMsg(id: string, toolType: string, state = "input-available") {
+ return {
+ id,
+ role: "assistant" as const,
+ parts: [{ type: toolType, state }],
+ };
+ }
+
+ async function openWith(messages: UseChatReturn["messages"]) {
+ const user = userEvent.setup();
+ useChatReturn = baseReturn({ messages });
+ render();
+ await user.click(
+ screen.getByRole("button", { name: "Open MakerLab assistant" })
+ );
+ }
+
+ it("shows the unit-lookup label for a pending get_unit_details call", async () => {
+ await openWith([
+ userMsg("u1", "is Prusa #1 ok?"),
+ toolMsg("a1", "tool-get_unit_details"),
+ ]);
+ expect(
+ screen.getByText("🔍 Looking up unit details…")
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Tool running")).toBeInTheDocument();
+ });
+
+ it("shows the ticket-filing label for a pending report_issue call", async () => {
+ await openWith([toolMsg("a1", "tool-report_issue")]);
+ expect(
+ screen.getByText("📝 Filing maintenance ticket…")
+ ).toBeInTheDocument();
+ });
+
+ it("shows a generic working label for any other pending tool", async () => {
+ await openWith([toolMsg("a1", "tool-web_fetch")]);
+ expect(screen.getByText("Working on it…")).toBeInTheDocument();
+ });
+});
+
+// ── Photo upload + attachment hint ─────────────────────────────────
+//
+// Selecting an image uploads it to /api/upload-notion, shows a removable
+// preview, and on submit appends a parseable [Attached photos: …] hint that
+// the chat route turns into report_issue photo_uploads.
+describe("ChatFab — photo uploads", () => {
+ let origCreate: typeof URL.createObjectURL;
+ let origRevoke: typeof URL.revokeObjectURL;
+
+ beforeEach(() => {
+ origCreate = URL.createObjectURL;
+ origRevoke = URL.revokeObjectURL;
+ URL.createObjectURL = vi.fn(() => "blob:preview");
+ URL.revokeObjectURL = vi.fn();
+ });
+
+ afterEach(() => {
+ URL.createObjectURL = origCreate;
+ URL.revokeObjectURL = origRevoke;
+ vi.unstubAllGlobals();
+ });
+
+ it("uploads an image and includes its file_upload hint in the sent message", async () => {
+ const user = userEvent.setup();
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({ file_upload_id: "fu_123", name: "broken.png" }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ )
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Open MakerLab assistant" })
+ );
+
+ const fileInput = document.querySelector(
+ 'input[type="file"]'
+ ) as HTMLInputElement;
+ const file = new File([new Uint8Array([1, 2, 3])], "broken.png", {
+ type: "image/png",
+ });
+ await user.upload(fileInput, file);
+
+ // Preview + remove control appear once the upload resolves.
+ expect(
+ await screen.findByRole("button", { name: "Remove broken.png" })
+ ).toBeInTheDocument();
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/upload-notion",
+ expect.objectContaining({ method: "POST" })
+ );
+
+ const input = screen.getByRole("textbox", { name: "Ask the lab console" });
+ await user.type(input, "the printer is broken");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+
+ expect(sendMessage).toHaveBeenCalledTimes(1);
+ const arg = sendMessage.mock.calls[0][0] as { text: string };
+ expect(arg.text).toContain("the printer is broken");
+ expect(arg.text).toContain(
+ "[Attached photos: file_upload_id=fu_123 name=broken.png]"
+ );
+ });
+
+ it("surfaces an upload error and does not add a preview", async () => {
+ const user = userEvent.setup();
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(JSON.stringify({ error: "File too large (max 18MB)" }), {
+ status: 400,
+ headers: { "content-type": "application/json" },
+ })
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Open MakerLab assistant" })
+ );
+ const fileInput = document.querySelector(
+ 'input[type="file"]'
+ ) as HTMLInputElement;
+ await user.upload(
+ fileInput,
+ new File([new Uint8Array([1])], "huge.png", { type: "image/png" })
+ );
+
+ expect(
+ await screen.findByText("File too large (max 18MB)")
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /^Remove / })
+ ).not.toBeInTheDocument();
+ });
+});