diff --git a/.env.example b/.env.example index 9829c8a..396bc80 100755 --- a/.env.example +++ b/.env.example @@ -45,6 +45,10 @@ BERRYBRAIN_DONATION_URL= NEXT_PUBLIC_BERRYBRAIN_API_URL=/berrybrain NEXT_PUBLIC_BERRYBRAIN_BASE_PATH=/berrybrain NEXT_PUBLIC_BERRYBRAIN_ASSET_PREFIX=/berrybrain +# Optional. Leave empty on self-hosted instances to disable Google Analytics. +NEXT_PUBLIC_GOOGLE_ANALYTICS_ID= +# Set to browser only for the hosted webapp build. Self-hosted Docker should leave this empty. +NEXT_PUBLIC_BERRYBRAIN_STORAGE_MODE= BERRYBRAIN_AUTH_RATE_LIMIT_WINDOW_SECONDS=900 BERRYBRAIN_AUTH_RATE_LIMIT_MAX_ATTEMPTS=8 BERRYBRAIN_AUTH_LOCKOUT_MINUTES=15 diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml index 5d934cd..4e27c2c 100644 --- a/.github/workflows/ci-web.yml +++ b/.github/workflows/ci-web.yml @@ -58,3 +58,33 @@ jobs: timeout 120 bash -c 'until curl -fsS http://127.0.0.1:8000/health >/dev/null; do sleep 2; done' timeout 120 bash -c 'until curl -fsS http://127.0.0.1:3000 >/dev/null; do sleep 2; done' E2E_BASE_URL=http://127.0.0.1:3000 npm run test:e2e + + browser-storage: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/web + env: + NEXT_PUBLIC_BERRYBRAIN_STORAGE_MODE: browser + NEXT_PUBLIC_BERRYBRAIN_API_URL: "" + NEXT_PUBLIC_BERRYBRAIN_BASE_PATH: "" + NEXT_PUBLIC_BERRYBRAIN_ASSET_PREFIX: "" + NEXT_TELEMETRY_DISABLED: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: apps/web/package-lock.json + - name: Install dependencies + run: npm ci + - name: Production build + run: npm run build + - name: Install Playwright + run: npx playwright install --with-deps chromium + - name: Browser storage E2E + run: | + npm run start -- --hostname 127.0.0.1 --port 3000 & + timeout 120 bash -c 'until curl -fsS http://127.0.0.1:3000/brain >/dev/null; do sleep 2; done' + E2E_BASE_URL=http://127.0.0.1:3000 E2E_BROWSER_STORAGE_MODE=browser npm run test:e2e -- e2e/browser-storage.spec.ts diff --git a/README.md b/README.md index b59b3ad..4ae0cc6 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ There is no central BerryBrain account, SaaS tenant, billing gate, demo mode, or ![Source Available](https://img.shields.io/badge/source--available-yes-3C8F5A) ![License](https://img.shields.io/badge/license-non--commercial-lightgrey) +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/berrybrain) + --- ## Table of Contents @@ -97,7 +99,7 @@ The current worktree implements the complete local product foundation. Release g | Cognitive attachments | PDF/document extraction, image OCR, audio/video transcription, attachment chunks and graph evidence | | Data safety | Manifest/checksum backup, validated restore, versioned schema migrations, readable export | | Owner security | Local single-owner setup, configurable `admin` alias, no default password, Argon2id, signed sessions, CSRF, rate limiting, lockout, audit events | -| Delivery evidence | 156 API tests, 34 Worker tests, 13 production-browser checks, protected CI gates, container scans, SBOM workflow | +| Delivery evidence | API, Worker, production-browser, security, container, CodeQL, and SBOM CI gates | Release evidence is tracked in [`AUDIT.md`](AUDIT.md) and the public [`v1.0.0` release](https://github.com/imsouza/berrybrain/releases/tag/v1.0.0). Protected `main`, required checks/review, clean-stack validation, 12 consecutive green container runs, multi-architecture images, OIDC signatures, and SPDX SBOM attestations are complete. @@ -477,8 +479,7 @@ berrybrain/ ### Prerequisites -- 64-bit Linux host or Linux VM -- Recent Docker Engine and Docker Compose v2 +- 64-bit Linux with Docker Engine and Docker Compose v2, or Windows with Docker Desktop, WSL 2, and Linux containers - Ollama with an installed model, or an OpenAI-compatible cloud provider ### Run Locally @@ -622,6 +623,9 @@ Edit `.env` and set at minimum: | `BERRYBRAIN_INTERNAL_API_URL` | Server-side API origin used by the web proxy. Defaults to `http://api:8000`; use `http://127.0.0.1:8000` when running Web outside Docker. | | `BERRYBRAIN_ENV_FILE` | Optional Compose environment file path. Defaults to `.env`; useful for isolated smoke tests or multiple self-hosted instances. | | `BERRYBRAIN_DONATION_URL` | Optional donation link shown/documented by the operator; no payment processing is built in. | +| `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` | Optional analytics property for public pages. Empty by default on self-hosted instances; tracking still requires visitor consent. Never send note or account data as analytics events. | +| `NEXT_PUBLIC_BERRYBRAIN_STORAGE_MODE` | Experimental browser-workspace development mode. The official hosted deployment does not expose `/brain`. Leave empty for Docker/self-hosted mode. | +| `NEXT_PUBLIC_BERRYBRAIN_LANDING_ONLY` | Set to `true` on the official Netlify deployment. Allows only the landing page, Docs, and FAQ; private workspace/auth routes redirect and browser-AI routes return 404. | | `BERRYBRAIN_PUBLIC_APP_URL` | Public base URL of the web app (used in emails/links). | | `BERRYBRAIN_CORS_ORIGINS` | Comma-separated allowed web origins. | | `SMTP_*` | Optional legacy email delivery settings. Not required for default self-hosted setup. | @@ -640,6 +644,46 @@ docker compose up -d Web serves on `http://localhost:3000`, API on `http://localhost:8000`, and the Worker starts in the background to process vault scans, embeddings, graph expansion, and insights. +### Browser-only workspace mode (experimental, not deployed) + +The repository retains an experimental browser-only workspace implementation for development and +automated tests. It is not exposed by the official Netlify deployment. To test it locally, build +without `NEXT_PUBLIC_BERRYBRAIN_LANDING_ONLY=true` and set: + +```ini +NEXT_PUBLIC_BERRYBRAIN_STORAGE_MODE=browser +NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-36YL9QLC5K +``` + +In this mode, notes and browser-workspace records are persisted in IndexedDB. Settings exposes +**Export all data** and **Import all data**. Backups include all versioned browser stores plus +non-sensitive BerryBrain preferences, use a SHA-256 integrity checksum, and replace the current +browser workspace only after validation and explicit confirmation. + +Browser storage belongs to one browser profile and origin. Clearing site data, browser eviction, +or changing domains can remove it. Keep exported backups outside the browser. On first use, the +tour may be skipped, but a verified OpenAI-compatible cloud provider is mandatory. The provider key is stored only in +that origin's IndexedDB, excluded from backups, and sent through a same-origin stateless proxy to +the selected provider. This experimental mode is not exposed by the official hosted site. + +While the workspace is open, a browser cognitive worker resumes queued note analysis, extracts +concepts and evidence-based insights, creates explained graph edges, and powers graph Ask with the +configured NVIDIA model. Closing the tab pauses this worker; reopening BerryBrain resumes pending +jobs. OCR, media transcription, durable background processing while the browser is closed, and +server-side vector databases remain self-hosted capabilities. + +For Netlify, deploy the `webapp` branch. The repository's `netlify.toml` sets `apps/web` as the +base directory, `npm run build` as the build command, `.next` as the publish directory, and +`NEXT_PUBLIC_BERRYBRAIN_LANDING_ONLY=true`. This serves the landing page, Docs, and FAQ. Workspace, +authentication, setup, and browser-AI routes redirect to the download section or return 404. +Previously registered BerryBrain service workers and caches are removed in this mode. Keep +`NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` in the Netlify environment when analytics is enabled. Do not set +`BERRYBRAIN_INTERNAL_API_URL` or `NEXT_PUBLIC_BERRYBRAIN_API_URL` for the landing deployment. +The Netlify Next.js runtime is pinned in the web package so App Router pages and middleware are +packaged as Netlify Functions instead of publishing raw `.next` artifacts that return 404. +The dormant `/api/browser-ai/*` functions retain their proxy protections for local development but +are blocked by middleware on the landing deployment. + ### 3. Create the local owner account Open `http://localhost:3000`, choose **Setup**, then complete the one-time owner setup. The default username alias is `admin`, but **there is no default password**: the owner must create a strong password of at least 12 characters. Change the alias with `BERRYBRAIN_OWNER_USERNAME` before startup. On the first workspace load, BerryBrain shows the guided tour and then requires Local or Cloud AI configuration. @@ -675,7 +719,7 @@ If you serve the app under a path prefix, set the public web env values before b The landing page and app can be served at: ```text -https://optlabs.com.br/berrybrain +https://your.domain/berrybrain ``` Use these web environment values: @@ -684,9 +728,9 @@ Use these web environment values: NEXT_PUBLIC_BERRYBRAIN_API_URL=/berrybrain NEXT_PUBLIC_BERRYBRAIN_BASE_PATH=/berrybrain NEXT_PUBLIC_BERRYBRAIN_ASSET_PREFIX=/berrybrain -BERRYBRAIN_PUBLIC_APP_URL=https://optlabs.com.br/berrybrain -BERRYBRAIN_CORS_ORIGINS=https://optlabs.com.br -BERRYBRAIN_ALLOWED_HOSTS=localhost,127.0.0.1,testserver,api,optlabs.com.br +BERRYBRAIN_PUBLIC_APP_URL=https://your.domain/berrybrain +BERRYBRAIN_CORS_ORIGINS=https://your.domain +BERRYBRAIN_ALLOWED_HOSTS=localhost,127.0.0.1,testserver,api,your.domain ``` Recommended reverse-proxy behavior: @@ -805,13 +849,10 @@ The repository includes `CODEOWNERS`, a structured epic form, CI workflows, and The script creates the release epics and protects `main` with required CI checks, one approving code-owner review, stale-review dismissal, conversation resolution, and force-push/deletion protection. It never accepts or stores a token in the repository; authentication remains managed by the GitHub CLI or its environment. - No flashcard surface; study suggestions should be insight/review oriented, not legacy flashcard UI. -Latest local verification evidence (13 July 2026): - -- 156 API tests pass with a 60% total coverage gate and critical-module ratchets; -- 34 Worker tests pass, including disposable-database integration coverage; -- 13 production-browser Playwright checks pass against an isolated authenticated stack, including landing-to-login owner entry with no default password; -- API, Worker, and Web images pass the local zero-fixable-HIGH/CRITICAL Trivy gate; -- SPDX SBOM generation is wired into CI and signed release publication is defined in `.github/workflows/release.yml`. +Current verification evidence is the repository's commit and pull-request checks. They cover API +and Worker tests, production-browser Playwright flows, TypeScript/build validation, Compose, +security scanning, CodeQL, container checks, and release/SBOM workflows. Exact test counts are not +embedded here because they become stale as coverage grows. ### Error Handling diff --git a/WEBAPP_ARCHITECTURE.md b/WEBAPP_ARCHITECTURE.md new file mode 100644 index 0000000..a56eac5 --- /dev/null +++ b/WEBAPP_ARCHITECTURE.md @@ -0,0 +1,172 @@ +# BerryBrain Web App Architecture + +Status: architecture baseline for the `webapp` branch. No hosted authentication or browser +knowledge persistence is considered production-ready until the security and recovery gates below +pass. + +## Product modes + +BerryBrain must keep two explicit deployment modes: + +1. **Self-hosted** — the existing FastAPI, worker, SQLite/Postgres, vault, and local owner account. +2. **Hosted web app** — Netlify serves the Next.js frontend; personal knowledge remains on the + user's device unless the user deliberately exports or later enables an encrypted sync service. + +The hosted mode must not silently fall back to a server database for notes, attachments, graph +content, embeddings, prompts, or provider keys. + +## Non-negotiable storage decision + +Browser storage is viable for a local-only knowledge workspace, with hard limits: + +- Use **OPFS** for Markdown, attachments, and encrypted archive blobs. +- Use **IndexedDB** for metadata, graph records, job state, retrieval indexes, and migrations. +- Use `navigator.storage.persist()` and show whether durable storage was granted. +- Use `localStorage` only for non-sensitive UI preferences. Never store passwords, session tokens, + provider keys, note content, recovery codes, or encryption keys there. +- Encrypt sensitive browser records with Web Crypto AES-GCM. Derive wrapping keys with a reviewed, + memory-hard KDF; maintain format and migration versions. +- Provide encrypted export/import and scheduled backup reminders. +- Detect quota pressure, failed writes, private browsing, unsupported OPFS, eviction risk, and + partial migrations before accepting writes. + +Browser persistence is device- and origin-bound. Clearing site data, browser eviction, changing +origin, or losing the device can permanently remove local knowledge. Email recovery cannot recover +browser-only notes. Cross-device recovery requires user-controlled encrypted backup or an optional +zero-knowledge sync service in a later phase. + +## Account data reality + +Login, email confirmation, 2FA, password recovery, sessions, abuse prevention, and account deletion +cannot be implemented without persistent authentication state. A static Netlify site and Umbler +SMTP alone are insufficient. Umbler sends email; it is not an authentication database. + +Hosted mode may store only this minimal control-plane data: + +- normalized email and verification state; +- password hash, never the password; +- encrypted TOTP secret and hashed recovery codes; +- hashed email verification and recovery challenges with expiry and attempt counters; +- hashed sessions, device label, creation, expiry, revocation, and last-use metadata; +- security audit events and rate-limit state with short retention; +- privacy consent version and account deletion state. + +No notes or cognitive data belong in this account store. The UI and privacy policy must disclose the +minimal account metadata before signup. + +## Recommended runtime + +- Netlify: static/SSR frontend and narrowly scoped server functions. +- Authentication API: reuse the reviewed BerryBrain FastAPI security core, deployed on a runtime + with stable secrets and a transactional Postgres database. Do not place SMTP or database secrets + in the browser bundle. +- Email: Umbler SMTP over TLS from the authentication API only. +- Knowledge runtime: Web Workers for parsing, graph operations, embeddings where supported, and + IndexedDB/OPFS access through one versioned repository layer. +- AI: bring-your-own-provider key encrypted locally. Prefer direct provider calls only where CORS + and provider policy allow it; otherwise use an explicit stateless relay that disables body logs + and never persists prompts or keys. + +## Authentication controls + +- Argon2id password hashing with calibrated memory/time parameters and automatic rehashing. +- Password length and compromised-password checks; no arbitrary composition rules. +- Generic signup, login, resend, and recovery responses to prevent account enumeration. +- One-time, hashed, expiring email confirmation and recovery tokens. +- TOTP authenticator 2FA with QR setup, confirmation challenge, recovery codes, replay prevention, + and step-up authentication for destructive actions. +- Email OTP is recovery/verification fallback, not the preferred second factor. +- HttpOnly, Secure, host-only, SameSite cookies; session rotation after login and 2FA. +- CSRF tokens bound to the session for every state-changing request. +- Progressive per-account and per-network rate limits, exponential delays, lockout notifications, + challenge escalation, and short-retention abuse telemetry. +- Session/device list, revoke-one, revoke-all, password-change revocation, and idle/absolute expiry. +- Reauthentication for email, password, 2FA, export, and account deletion changes. +- Constant-time secret comparisons and transaction-safe challenge consumption. + +## Web security controls + +- Strict CSP with nonces/hashes; no `unsafe-eval`; smallest possible `connect-src` allowlist. +- HSTS, `frame-ancestors 'none'`, `X-Content-Type-Options`, strict referrer policy, and restrictive + permissions policy. +- Validate redirects and origins; reject malformed hosts and cross-origin credential requests. +- Escape rendered Markdown, sanitize HTML, reject dangerous attachment previews, and isolate PDFs, + images, audio, and video in sandboxed viewers. +- Validate request size, MIME and file signatures; quarantine unsupported active content. +- No secrets in `NEXT_PUBLIC_*`, source maps, logs, analytics, error reports, URL parameters, or + client-side build artifacts. +- Dependency pinning, lockfile review, secret scanning, SAST, dependency review, SBOM, signed release + artifacts, and automated security updates. +- Third-party scripts stay outside the knowledge workspace. The authenticated app uses a native + Ko-fi link; the public site may load the Ko-fi widget. Google Analytics runs only on public routes, + only after consent, with advertising signals disabled. +- Treat interception proxies as clients, not special attack classes. Enforce authorization, + validation, replay resistance, rate limits, and audit controls server-side. + +## Privacy and analytics + +- Official Google Analytics property: `G-36YL9QLC5K`. +- Self-hosted analytics default: disabled. Operators may set + `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` for their own property. +- No note titles, paths, search terms, graph questions, provider names, emails, user IDs, or content + in analytics events. +- Consent must be explicit, revocable, versioned, and independent from product access. +- Publish retention, subprocessors, legal basis, data-subject request, deletion, and breach handling + details before hosted signup opens. + +## Delivery phases + +### Phase 0 — Boundaries and threat model + +- [x] Create `webapp` from published `origin/main`. +- [x] Define self-hosted and hosted modes. +- [x] Reject `localStorage` as the knowledge database. +- [x] Define minimal account metadata and no-knowledge-data boundary. +- [ ] Produce STRIDE/LINDDUN threat models and data-flow diagrams. +- [ ] Approve retention periods, subprocessors, and incident owner. + +### Phase 1 — Browser knowledge repository + +- [x] Versioned IndexedDB repository and migration boundary. +- [ ] OPFS attachment repository. +- [x] Transactional note writes. +- [ ] Transactional graph writes with crash recovery. +- [ ] Encryption envelope, key lifecycle, migration tests, and lock screen. +- [x] Quota and persistent-storage diagnostics. +- [ ] Eviction, private-mode, and cross-browser support diagnostics. +- [x] Complete backup/export/import with SHA-256 corruption checks. +- [ ] Optional encrypted backup envelope and key recovery design. +- [ ] Web Worker pipeline and deterministic migration fixtures. + +### Phase 2 — Hosted account control plane + +- [ ] Transactional account schema and migration process. +- [ ] Signup, generic email confirmation, login, logout, and session lifecycle. +- [ ] Umbler SMTP adapter with TLS, retry, idempotency, and redacted logs. +- [ ] TOTP 2FA, recovery codes, step-up auth, and secure reset. +- [ ] Progressive rate limits, enumeration resistance, audit events, and alerts. +- [ ] Account privacy/settings screen, export, revoke sessions, and deletion workflow. + +### Phase 3 — Hosted cognitive runtime + +- [ ] Browser parser, graph store, retrieval index, and jobs state machine. +- [ ] Local/provider model router with explicit data-egress confirmation. +- [ ] No-log stateless relay only where direct provider access is impossible. +- [ ] Explainable insights and graph inference using local evidence. +- [ ] Performance budgets and background cancellation/recovery. + +### Phase 4 — Security and release gates + +- [ ] Unit, integration, browser E2E, migration, and storage-failure suites. +- [ ] Authentication abuse tests, CSRF, XSS, IDOR, replay, fixation, and race tests. +- [ ] CSP/headers tests and proof that third-party scripts cannot access the workspace. +- [ ] Secret scan, SAST, dependency review, SBOM, container scan, and signed artifacts. +- [ ] Independent security review and remediation before public hosted accounts. +- [ ] Restore and deletion drills with published evidence. + +## Definition of ready + +Hosted BerryBrain is ready only when account recovery works without exposing knowledge data, local +knowledge survives tested reload/crash/migration scenarios, encrypted backups restore cleanly, +third-party scripts cannot execute in the workspace, all authentication abuse tests pass, and the +privacy UI truthfully describes every persisted field and external data flow. diff --git a/apps/web/e2e/browser-ai-proxy.spec.ts b/apps/web/e2e/browser-ai-proxy.spec.ts new file mode 100644 index 0000000..097172f --- /dev/null +++ b/apps/web/e2e/browser-ai-proxy.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Browser AI proxy security", () => { + test("rejects invalid credentials, private endpoints, and cross-site requests", async ({ request }) => { + const invalid = await request.post("/api/browser-ai/models", { + data: { apiKey: "short" }, + }); + expect(invalid.status()).toBe(400); + + const crossSite = await request.post("/api/browser-ai/models", { + headers: { Origin: "https://evil.example" }, + data: { apiKey: "nvapi-test-key-that-is-long-enough" }, + }); + expect(crossSite.status()).toBe(403); + + const privateProvider = await request.post("/api/browser-ai/models", { + data: { + providerUrl: "https://127.0.0.1/v1", + apiKey: "test-key-that-is-long-enough-for-validation", + }, + }); + expect(privateProvider.status()).toBe(400); + + const invalidChat = await request.post("/api/browser-ai/chat", { + data: { apiKey: "short" }, + }); + expect(invalidChat.status()).toBe(400); + }); +}); diff --git a/apps/web/e2e/browser-storage.spec.ts b/apps/web/e2e/browser-storage.spec.ts new file mode 100644 index 0000000..fabad33 --- /dev/null +++ b/apps/web/e2e/browser-storage.spec.ts @@ -0,0 +1,228 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Browser-only persistence", () => { + test.skip( + process.env.E2E_BROWSER_STORAGE_MODE !== "browser", + "Requires a build with NEXT_PUBLIC_BERRYBRAIN_STORAGE_MODE=browser.", + ); + test.setTimeout(90_000); + test.beforeEach(async ({ page }) => { + await page.route("**/api/browser-ai/models", (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ connected: true, provider: "NVIDIA NIM", providerUrl: "https://integrate.api.nvidia.com/v1", models: ["test/model"] }), + })); + await page.route("**/api/browser-ai/chat", (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + content: JSON.stringify({ summary: "", concepts: [], insights: [], connections: [] }), + provider: "nvidia-nim", + model: "test/model", + }), + })); + await page.goto("/brain"); + await page.evaluate(async () => { + localStorage.setItem("bb_tour_seen", "1"); + localStorage.setItem("bb_nome", "Browser owner"); + await new Promise((resolve, reject) => { + const request = indexedDB.open("berrybrain-webapp", 1); + request.onupgradeneeded = () => { + const database = request.result; + for (const name of ["notes", "attachments", "graphNodes", "graphEdges", "insights", "jobs", "settings", "metadata"]) { + if (!database.objectStoreNames.contains(name)) { + database.createObjectStore(name, { keyPath: name === "notes" ? "path" : "id" }); + } + } + }; + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction("settings", "readwrite"); + transaction.objectStore("settings").put({ + id: "cloud-provider", + provider: "nvidia-nim", + apiUrl: "https://integrate.api.nvidia.com/v1", + apiKey: "nvapi-test-key-that-is-long-enough", + model: "test/model", + verifiedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + }; + }); + }); + }); + + test("persists notes and restores a complete verified backup", async ({ page }) => { + const unexpectedApiRequests: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname.includes("/api/v1/")) unexpectedApiRequests.push(request.url()); + }); + await page.goto("/brain"); + await expect(page.getByRole("complementary", { name: "Navigation" })).toBeVisible(); + + const quickNote = page.getByRole("textbox", { name: "Quick note draft" }); + await quickNote.fill("# Browser persistence test\n\nThis note survives reload and backup restore."); + await page.getByRole("button", { name: "Create note" }).click(); + await expect(page.getByRole("textbox", { name: "Editor" })).toHaveValue( + /This note survives reload and backup restore\./, + ); + await expect(page.getByText("Saved", { exact: true }).first()).toBeVisible(); + await page.locator('input[type="file"]').first().setInputFiles({ + name: "evidence.txt", + mimeType: "text/plain", + buffer: Buffer.from("Browser attachment evidence"), + }); + await expect(page.getByText("evidence.txt", { exact: true })).toBeVisible(); + await page.reload(); + + await page.getByText("Browser persistence test", { exact: true }).first().click(); + await expect(page.getByRole("textbox", { name: "Editor" })).toHaveValue( + /This note survives reload and backup restore\./, + ); + + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(page.getByText("Data portability", { exact: true })).toBeVisible(); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export all data" }).click(); + const download = await downloadPromise; + const backupPath = await download.path(); + expect(backupPath).toBeTruthy(); + + const backupText = await (await import("node:fs/promises")).readFile(backupPath!, "utf8"); + const backup = JSON.parse(backupText); + expect(backup.product).toBe("BerryBrain"); + expect(backup.formatVersion).toBe(1); + expect(backup.checksum?.algorithm).toBe("SHA-256"); + expect(backup.stores.notes).toHaveLength(1); + expect(backup.stores.attachments).toHaveLength(1); + expect(backup.stores.attachments[0].blob.__berrybrainType).toBe("Blob"); + expect(backup.stores.settings).toEqual([]); + expect(backupText).not.toContain("nvapi-test-key-that-is-long-enough"); + + const tamperedBackup = structuredClone(backup); + tamperedBackup.stores.notes[0].title = "Tampered title"; + page.once("dialog", (dialog) => dialog.accept()); + await page.locator('input[type="file"][accept*="berrybrain"]').setInputFiles({ + name: "tampered.berrybrain.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(tamperedBackup)), + }); + await expect(page.getByText(/Backup checksum does not match/)).toBeVisible(); + + await page.evaluate(async () => { + await new Promise((resolve, reject) => { + const request = indexedDB.open("berrybrain-webapp", 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(["notes", "attachments"], "readwrite"); + transaction.objectStore("notes").clear(); + transaction.objectStore("attachments").clear(); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + }; + }); + }); + await page.reload(); + await expect(page.getByText("Browser persistence test", { exact: true })).toHaveCount(0); + + await page.getByRole("button", { name: "Settings", exact: true }).click(); + page.once("dialog", (dialog) => dialog.accept()); + await page.locator('input[type="file"][accept*="berrybrain"]').setInputFiles(backupPath!); + await expect(page.getByText("Backup restored. Reloading...", { exact: true })).toBeVisible(); + const restoredCounts = await page.evaluate(async () => + new Promise<{ notes: number; attachments: number }>((resolve, reject) => { + const request = indexedDB.open("berrybrain-webapp", 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(["notes", "attachments"], "readonly"); + const notesRequest = transaction.objectStore("notes").count(); + const attachmentsRequest = transaction.objectStore("attachments").count(); + transaction.oncomplete = () => { + database.close(); + resolve({ notes: notesRequest.result, attachments: attachmentsRequest.result }); + }; + transaction.onerror = () => reject(transaction.error); + }; + }), + ); + expect(restoredCounts).toEqual({ notes: 1, attachments: 1 }); + await expect(page.getByText("Browser persistence test", { exact: true }).first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("heading", { name: "Connect a cloud AI provider to continue." })).toBeVisible(); + await page.getByLabel("Cloud API Key").fill("nvapi-restored-key-that-is-long-enough"); + await page.getByRole("button", { name: "Load models" }).click(); + await page.getByLabel("Model").selectOption("test/model"); + await page.getByRole("button", { name: "Connect and open workspace" }).click(); + await page.getByText("Browser persistence test", { exact: true }).first().click(); + await expect(page.getByText("evidence.txt", { exact: true })).toBeVisible(); + expect(unexpectedApiRequests).toEqual([]); + }); + + test("recovers an interrupted browser worker job after reload", async ({ page }) => { + let chatRequests = 0; + await page.route("**/api/browser-ai/chat", async (route) => { + chatRequests += 1; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + content: JSON.stringify({ summary: "Recovered analysis.", concepts: [], insights: [], connections: [] }), + provider: "nvidia-nim", + model: "test/model", + }), + }); + }); + await page.evaluate(async () => new Promise((resolve, reject) => { + const request = indexedDB.open("berrybrain-webapp", 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(["notes", "jobs"], "readwrite"); + const now = new Date().toISOString(); + transaction.objectStore("notes").put({ + title: "Interrupted note", + path: "inbox/interrupted-note.md", + folder: "inbox", + content: "# Interrupted note\n\nThis analysis must resume after reload.", + content_hash: "interrupted-hash", + createdAt: now, + updatedAt: now, + }); + transaction.objectStore("jobs").put({ + id: "expand:inbox/interrupted-note.md", + type: "EXPAND_KNOWLEDGE_GRAPH", + notePath: "inbox/interrupted-note.md", + status: "running", + progress: 35, + createdAt: now, + updatedAt: now, + }); + transaction.oncomplete = () => { database.close(); resolve(); }; + transaction.onerror = () => reject(transaction.error); + }; + })); + + await page.reload(); + await expect.poll(async () => page.evaluate(async () => new Promise((resolve, reject) => { + const request = indexedDB.open("berrybrain-webapp", 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const get = database.transaction("jobs", "readonly").objectStore("jobs").get("expand:inbox/interrupted-note.md"); + get.onsuccess = () => { database.close(); resolve(get.result?.status || "missing"); }; + get.onerror = () => reject(get.error); + }; + }))).toBe("completed"); + expect(chatRequests).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/e2e/home.spec.ts b/apps/web/e2e/home.spec.ts index e827d81..095fd39 100644 --- a/apps/web/e2e/home.spec.ts +++ b/apps/web/e2e/home.spec.ts @@ -11,6 +11,13 @@ test.describe("Home page", () => { await expect(page.locator("h1")).toContainText("knowledge you can navigate"); await expect(page.locator("header").getByRole("link", { name: "Open BerryBrain", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "GitHub", exact: true }).first()).toBeVisible(); + const donate = page.getByRole("link", { name: "Donate to BerryBrain on Ko-fi" }); + await expect(donate).toHaveAttribute( + "href", + "https://ko-fi.com/berrybrain", + ); + await expect(donate).toContainText("♥ Donate"); + await expect(page.locator('script[src*="storage.ko-fi.com"]')).toHaveCount(0); await expect(page.getByRole("heading", { name: /More than notes with an AI button/ })).toBeVisible(); }); diff --git a/apps/web/e2e/landing-only.spec.ts b/apps/web/e2e/landing-only.spec.ts new file mode 100644 index 0000000..58ac36e --- /dev/null +++ b/apps/web/e2e/landing-only.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Hosted landing-only deployment", () => { + test.skip( + process.env.E2E_LANDING_ONLY !== "true", + "Requires a build with NEXT_PUBLIC_BERRYBRAIN_LANDING_ONLY=true.", + ); + + test("offers self-hosted downloads without exposing the workspace", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("heading", { name: /knowledge you can navigate/i })).toBeVisible(); + await expect(page.getByText("Web app in development", { exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Open BerryBrain", exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Download", exact: true }).first()).toHaveAttribute("href", "/#download"); + await expect(page.getByRole("heading", { name: "Run BerryBrain on infrastructure you control." })).toBeVisible(); + await expect(page.getByRole("link", { name: "Download .tar.gz" })).toHaveAttribute("href", /main\.tar\.gz$/); + await expect(page.getByRole("link", { name: "Download .zip" })).toHaveAttribute("href", /main\.zip$/); + await expect(page.locator('link[rel="manifest"]')).toHaveCount(0); + + const actionTops = await Promise.all( + ["Installation guide", "Download .tar.gz", "Download .zip", "Containers"].map(async (name) => { + const box = await page.getByRole("link", { name, exact: true }).boundingBox(); + return box?.y ?? -1; + }), + ); + expect(Math.max(...actionTops) - Math.min(...actionTops)).toBeLessThanOrEqual(1); + }); + + test("redirects workspace routes and blocks browser AI", async ({ page, request }) => { + await page.goto("/brain"); + await expect(page).toHaveURL(/\/#download$/); + await expect(page.getByRole("heading", { name: "Run BerryBrain on infrastructure you control." })).toBeVisible(); + + const aiResponse = await request.post("/api/browser-ai/chat", { data: {} }); + expect(aiResponse.status()).toBe(404); + }); + + test("keeps Docs and FAQ public", async ({ page }) => { + await page.goto("/docs"); + await expect(page).toHaveURL(/\/docs$/); + await expect(page.getByRole("heading", { name: "BerryBrain Docs" })).toBeVisible(); + + await page.getByRole("link", { name: "FAQ", exact: true }).first().click(); + await expect(page).toHaveURL(/\/faq$/); + await expect(page.getByRole("heading", { name: "Frequently asked questions" })).toBeVisible(); + + await page.goto("/login"); + await expect(page).toHaveURL(/\/$/); + }); +}); diff --git a/apps/web/e2e/webapp-onboarding.spec.ts b/apps/web/e2e/webapp-onboarding.spec.ts new file mode 100644 index 0000000..1c05d89 --- /dev/null +++ b/apps/web/e2e/webapp-onboarding.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Hosted web app onboarding", () => { + test.skip( + process.env.E2E_BROWSER_STORAGE_MODE !== "browser", + "Requires a build with NEXT_PUBLIC_BERRYBRAIN_STORAGE_MODE=browser.", + ); + + test("allows skipping the tour but requires a verified cloud provider", async ({ page }) => { + await page.route("**/api/browser-ai/models", (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ connected: true, provider: "NVIDIA NIM", providerUrl: "https://integrate.api.nvidia.com/v1", models: ["qwen/test-model"] }), + })); + await page.route("**/api/browser-ai/chat", (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + content: JSON.stringify({ + summary: "A grounded summary of the existing note.", + concepts: [{ name: "Existing knowledge", description: "A concept extracted from the existing note.", confidence: 0.9 }], + insights: [], + connections: [], + }), + provider: "nvidia-nim", + model: "qwen/test-model", + }), + })); + await page.goto("/brain"); + await expect(page.getByRole("heading", { name: "Capture first, organize later." })).toBeVisible(); + + await page.evaluate(async () => new Promise((resolve, reject) => { + const request = indexedDB.open("berrybrain-webapp", 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction("notes", "readwrite"); + transaction.objectStore("notes").put({ + title: "Existing note", + path: "inbox/existing-note.md", + folder: "inbox", + content: "# Existing note\n\nEvidence that must be assimilated after cloud setup.", + content_hash: "test-hash", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + transaction.oncomplete = () => { database.close(); resolve(); }; + transaction.onerror = () => reject(transaction.error); + }; + })); + + await page.getByRole("button", { name: "Skip" }).click(); + await expect(page.getByRole("heading", { name: "Connect a cloud AI provider to continue." })).toBeVisible(); + await expect(page.getByRole("button", { name: "Connect and open workspace" })).toBeDisabled(); + + await page.getByLabel("Cloud API Key").fill("nvapi-test-key-that-is-long-enough"); + await page.getByRole("button", { name: "Load models" }).click(); + await page.getByLabel("Model").selectOption("qwen/test-model"); + await page.getByRole("button", { name: "Connect and open workspace" }).click(); + + await expect(page.getByRole("heading", { name: "Connect a cloud AI provider to continue." })).toHaveCount(0); + await expect(page.getByRole("complementary", { name: "Navigation" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Donate to BerryBrain on Ko-fi" })).toContainText("♥ Donate"); + const stored = await page.evaluate(async () => new Promise>((resolve, reject) => { + const request = indexedDB.open("berrybrain-webapp", 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const get = database.transaction("settings", "readonly").objectStore("settings").get("cloud-provider"); + get.onsuccess = () => { + database.close(); + resolve(get.result); + }; + get.onerror = () => reject(get.error); + }; + })); + expect(stored.provider).toBe("NVIDIA NIM"); + expect(stored.model).toBe("qwen/test-model"); + await expect.poll(async () => page.evaluate(async () => new Promise((resolve, reject) => { + const request = indexedDB.open("berrybrain-webapp", 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const get = database.transaction("jobs", "readonly").objectStore("jobs").get("expand:inbox/existing-note.md"); + get.onsuccess = () => { database.close(); resolve(get.result?.status || "missing"); }; + get.onerror = () => reject(get.error); + }; + }))).toBe("completed"); + + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(page.getByText("Cloud API (required)", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Local", exact: true })).toHaveCount(0); + await expect(page.getByText("Local Ollama", { exact: true })).toHaveCount(0); + }); +}); diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index cc5a0f7..641e513 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", + "@netlify/plugin-nextjs": "^5.15.12", "@playwright/test": "^1.61.1", "@types/node": "^22.0.0", "@types/react": "^19.0.0", @@ -808,6 +809,16 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@netlify/plugin-nextjs": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@netlify/plugin-nextjs/-/plugin-nextjs-5.15.12.tgz", + "integrity": "sha512-eydPd4gtc/OJOx9IbIJ5CRrC5K3rLxfEQS/S/fRNEAAT0qFvPoQVy23VdK3jRXKFJWXdCRl6ZudWLEZECQPSRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@next/env": { "version": "15.5.20", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.20.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index ea87fb2..6612b76 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -2,6 +2,7 @@ "name": "@berrybrain/web", "version": "0.1.0", "private": true, + "packageManager": "npm@10.9.8", "scripts": { "dev": "next dev", "build": "next build", @@ -20,6 +21,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", + "@netlify/plugin-nextjs": "^5.15.12", "@playwright/test": "^1.61.1", "@types/node": "^22.0.0", "@types/react": "^19.0.0", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml deleted file mode 100644 index 87f2261..0000000 --- a/apps/web/pnpm-lock.yaml +++ /dev/null @@ -1,2161 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - next: - specifier: ^15.0.0 - version: 15.5.20(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react: - specifier: ^19.0.0 - version: 19.2.7 - react-dom: - specifier: ^19.0.0 - version: 19.2.7(react@19.2.7) - react-markdown: - specifier: ^9.1.0 - version: 9.1.0(@types/react@19.2.17)(react@19.2.7) - remark-gfm: - specifier: ^4.0.1 - version: 4.0.1 - devDependencies: - '@playwright/test': - specifier: ^1.61.1 - version: 1.61.1 - '@types/node': - specifier: ^22.0.0 - version: 22.20.1 - '@types/react': - specifier: ^19.0.0 - version: 19.2.17 - '@types/react-dom': - specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.17) - autoprefixer: - specifier: ^10.4.20 - version: 10.5.2(postcss@8.5.17) - postcss: - specifier: ^8.4.49 - version: 8.5.17 - tailwindcss: - specifier: ^3.4.17 - version: 3.4.19 - typescript: - specifier: ^5.7.0 - version: 5.9.3 - -packages: - - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@next/env@15.5.20': - resolution: {integrity: sha512-dXh51Wvddf8daEyBXryZZEe1FdVxEWx9lgaTseLZUtC1XP/W8Wri+Z+VPOElHlByk23CyqHdc2oVByX7wsTWsw==} - - '@next/swc-darwin-arm64@15.5.20': - resolution: {integrity: sha512-in0yXG7/pRBVjWeEl7f7ZZETpletSMFKXVS4GJgHENTPVrJFNJKPrYewa9rpZcvdjwFece5fZP0CK34G4PxowA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-x64@15.5.20': - resolution: {integrity: sha512-0hsFshdPnTzGJdDTHeHJ+XPUShOpnyp9pUFDwDhqctsA0Cd8NcIVGRPtptYhgYY9DjkKgCDRkXxmgRc+CgT5Wg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-linux-arm64-gnu@15.5.20': - resolution: {integrity: sha512-DMvkoBtAABOzE6pMZRW/xNm7sKqql3wzzzZJ1R/d/rp4BCxv6LykouD3tHjGY8WdQqGpZs11t+R9AtjPxvvljw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@next/swc-linux-arm64-musl@15.5.20': - resolution: {integrity: sha512-RQmDfeYBtXV2FSId7dfA1hE6M/T6+g7wdbYnFQ47tw/gUBwV+CccLVejNmCGa9yLDitk83foeg8hl/3DjfYQ5g==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@next/swc-linux-x64-gnu@15.5.20': - resolution: {integrity: sha512-DkWLEdKajJwdGt27M3i1VEO2kelTvZrK6Pcb7JvW2BY+nofWm7FBsBNDj7g7Pr1NuQ5PLJvqEqYa20GTsBDnKQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@next/swc-linux-x64-musl@15.5.20': - resolution: {integrity: sha512-rAO5b7pKHvX+ExdmJskusDXTNbiNZfptifIPZItbUx+AOXxxTydVBsPt7Oz84DRd5mY8e0DcE8kvLj3AIfjE6w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@next/swc-win32-arm64-msvc@15.5.20': - resolution: {integrity: sha512-Hp3zFsN8N8Kj9+vY6L4vnZ9EtA9eXyATu0q4EfGbZTiocgPUNSfz8NWhym6xvaOmHpJ8EuoypuU1WejCPsTFtg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@next/swc-win32-x64-msvc@15.5.20': - resolution: {integrity: sha512-T/L7CXpR1M0wij/xbF3rT1+7KvSkfOLr7C+ToHHWZTG2eKmb52C5WvsyGCBNtkVvDEUESWkRUbbqSH4rSbOCYQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@playwright/test@1.61.1': - resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} - engines: {node: '>=18'} - hasBin: true - - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - - '@types/debug@4.1.13': - resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} - - '@types/estree-jsx@1.0.5': - resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/hast@3.0.5': - resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} - - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@22.20.1': - resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - - '@types/unist@2.0.11': - resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} - - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - - '@ungap/structured-clone@1.3.3': - resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - - autoprefixer@10.5.2: - resolution: {integrity: sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - - bail@2.0.2: - resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - - baseline-browser-mapping@2.10.43: - resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.6: - resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - - caniuse-lite@1.0.30001805: - resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} - - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - - character-entities@2.0.2: - resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - - character-reference-invalid@2.0.1: - resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decode-named-character-reference@1.3.0: - resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - - didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - - electron-to-chromium@1.5.389: - resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - - estree-util-is-identifier-name@3.0.0: - resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - - hast-util-to-jsx-runtime@2.3.6: - resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} - - hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - - html-url-attributes@3.0.1: - resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - - inline-style-parser@0.2.7: - resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - - is-alphabetical@2.0.1: - resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - - is-alphanumerical@2.0.1: - resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} - engines: {node: '>= 0.4'} - - is-decimal@2.0.1: - resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-hexadecimal@2.0.1: - resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - longest-streak@3.1.0: - resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - - markdown-table@3.0.4: - resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - - mdast-util-find-and-replace@3.0.2: - resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - - mdast-util-from-markdown@2.0.3: - resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} - - mdast-util-gfm-autolink-literal@2.0.1: - resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} - - mdast-util-gfm-footnote@2.1.0: - resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} - - mdast-util-gfm-strikethrough@2.0.0: - resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} - - mdast-util-gfm-table@2.0.0: - resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} - - mdast-util-gfm-task-list-item@2.0.0: - resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} - - mdast-util-gfm@3.1.0: - resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} - - mdast-util-mdx-expression@2.0.1: - resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} - - mdast-util-mdx-jsx@3.2.0: - resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} - - mdast-util-mdxjs-esm@2.0.1: - resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} - - mdast-util-phrasing@4.1.0: - resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - - mdast-util-to-hast@13.2.1: - resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - - mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - - mdast-util-to-string@4.0.0: - resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromark-core-commonmark@2.0.3: - resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} - - micromark-extension-gfm-autolink-literal@2.1.0: - resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} - - micromark-extension-gfm-footnote@2.1.0: - resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} - - micromark-extension-gfm-strikethrough@2.1.0: - resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} - - micromark-extension-gfm-table@2.1.1: - resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} - - micromark-extension-gfm-tagfilter@2.0.0: - resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} - - micromark-extension-gfm-task-list-item@2.1.0: - resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} - - micromark-extension-gfm@3.0.0: - resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} - - micromark-factory-destination@2.0.1: - resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - - micromark-factory-label@2.0.1: - resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - - micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - - micromark-factory-title@2.0.1: - resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - - micromark-factory-whitespace@2.0.1: - resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-chunked@2.0.1: - resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - - micromark-util-classify-character@2.0.1: - resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - - micromark-util-combine-extensions@2.0.1: - resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - - micromark-util-decode-numeric-character-reference@2.0.2: - resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - - micromark-util-decode-string@2.0.1: - resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-html-tag-name@2.0.1: - resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - - micromark-util-normalize-identifier@2.0.1: - resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - - micromark-util-resolve-all@2.0.1: - resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-subtokenize@2.1.0: - resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - - micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - next@15.5.20: - resolution: {integrity: sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} - engines: {node: '>=18'} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - - parse-entities@4.0.2: - resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} - hasBin: true - - postcss-import@15.1.0: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - - postcss-js@4.1.0: - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - - postcss-selector-parser@6.1.4: - resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} - engines: {node: '>=4'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.17: - resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} - engines: {node: ^10 || ^12 || >=14} - - property-information@7.2.0: - resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} - peerDependencies: - react: ^19.2.7 - - react-markdown@9.1.0: - resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} - peerDependencies: - '@types/react': '>=18' - react: '>=18' - - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} - engines: {node: '>=0.10.0'} - - read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - remark-gfm@4.0.1: - resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} - - remark-parse@11.0.0: - resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - - remark-rehype@11.1.2: - resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} - - remark-stringify@11.0.0: - resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - - style-to-js@1.1.21: - resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} - - style-to-object@1.0.14: - resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - - styled-jsx@5.1.6: - resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - tailwindcss@3.4.19: - resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} - engines: {node: '>=14.0.0'} - hasBin: true - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - - trough@2.2.0: - resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - unified@11.0.5: - resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - - unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - - unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - - unist-util-visit@5.1.0: - resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vfile-message@4.0.3: - resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - - vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - -snapshots: - - '@alloc/quick-lru@5.2.0': {} - - '@emnapi/runtime@1.11.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@img/colour@1.1.0': - optional: true - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.11.2 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@next/env@15.5.20': {} - - '@next/swc-darwin-arm64@15.5.20': - optional: true - - '@next/swc-darwin-x64@15.5.20': - optional: true - - '@next/swc-linux-arm64-gnu@15.5.20': - optional: true - - '@next/swc-linux-arm64-musl@15.5.20': - optional: true - - '@next/swc-linux-x64-gnu@15.5.20': - optional: true - - '@next/swc-linux-x64-musl@15.5.20': - optional: true - - '@next/swc-win32-arm64-msvc@15.5.20': - optional: true - - '@next/swc-win32-x64-msvc@15.5.20': - optional: true - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@playwright/test@1.61.1': - dependencies: - playwright: 1.61.1 - - '@swc/helpers@0.5.15': - dependencies: - tslib: 2.8.1 - - '@types/debug@4.1.13': - dependencies: - '@types/ms': 2.1.0 - - '@types/estree-jsx@1.0.5': - dependencies: - '@types/estree': 1.0.9 - - '@types/estree@1.0.9': {} - - '@types/hast@3.0.5': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/ms@2.1.0': {} - - '@types/node@22.20.1': - dependencies: - undici-types: 6.21.0 - - '@types/react-dom@19.2.3(@types/react@19.2.17)': - dependencies: - '@types/react': 19.2.17 - - '@types/react@19.2.17': - dependencies: - csstype: 3.2.3 - - '@types/unist@2.0.11': {} - - '@types/unist@3.0.3': {} - - '@ungap/structured-clone@1.3.3': {} - - any-promise@1.3.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - - arg@5.0.2: {} - - autoprefixer@10.5.2(postcss@8.5.17): - dependencies: - browserslist: 4.28.6 - caniuse-lite: 1.0.30001805 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.17 - postcss-value-parser: 4.2.0 - - bail@2.0.2: {} - - baseline-browser-mapping@2.10.43: {} - - binary-extensions@2.3.0: {} - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.28.6: - dependencies: - baseline-browser-mapping: 2.10.43 - caniuse-lite: 1.0.30001805 - electron-to-chromium: 1.5.389 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.6) - - camelcase-css@2.0.1: {} - - caniuse-lite@1.0.30001805: {} - - ccount@2.0.1: {} - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - character-entities@2.0.2: {} - - character-reference-invalid@2.0.1: {} - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - client-only@0.0.1: {} - - comma-separated-tokens@2.0.3: {} - - commander@4.1.1: {} - - cssesc@3.0.0: {} - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decode-named-character-reference@1.3.0: - dependencies: - character-entities: 2.0.2 - - dequal@2.0.3: {} - - detect-libc@2.1.2: - optional: true - - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - didyoumean@1.2.2: {} - - dlv@1.1.3: {} - - electron-to-chromium@1.5.389: {} - - es-errors@1.3.0: {} - - escalade@3.2.0: {} - - escape-string-regexp@5.0.0: {} - - estree-util-is-identifier-name@3.0.0: {} - - extend@3.0.2: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.5): - optionalDependencies: - picomatch: 4.0.5 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - fraction.js@5.3.4: {} - - fsevents@2.3.2: - optional: true - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - hasown@2.0.4: - dependencies: - function-bind: 1.1.2 - - hast-util-to-jsx-runtime@2.3.6: - dependencies: - '@types/estree': 1.0.9 - '@types/hast': 3.0.5 - '@types/unist': 3.0.3 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.2.0 - space-separated-tokens: 2.0.2 - style-to-js: 1.1.21 - unist-util-position: 5.0.0 - vfile-message: 4.0.3 - transitivePeerDependencies: - - supports-color - - hast-util-whitespace@3.0.0: - dependencies: - '@types/hast': 3.0.5 - - html-url-attributes@3.0.1: {} - - inline-style-parser@0.2.7: {} - - is-alphabetical@2.0.1: {} - - is-alphanumerical@2.0.1: - dependencies: - is-alphabetical: 2.0.1 - is-decimal: 2.0.1 - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-core-module@2.16.2: - dependencies: - hasown: 2.0.4 - - is-decimal@2.0.1: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-hexadecimal@2.0.1: {} - - is-number@7.0.0: {} - - is-plain-obj@4.1.0: {} - - jiti@1.21.7: {} - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - longest-streak@3.1.0: {} - - markdown-table@3.0.4: {} - - mdast-util-find-and-replace@3.0.2: - dependencies: - '@types/mdast': 4.0.4 - escape-string-regexp: 5.0.0 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - mdast-util-from-markdown@2.0.3: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.2 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-autolink-literal@2.0.1: - dependencies: - '@types/mdast': 4.0.4 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.2 - micromark-util-character: 2.1.1 - - mdast-util-gfm-footnote@2.1.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - micromark-util-normalize-identifier: 2.0.1 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-strikethrough@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-table@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-task-list-item@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm@3.1.0: - dependencies: - mdast-util-from-markdown: 2.0.3 - mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-expression@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-jsx@3.2.0: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - parse-entities: 4.0.2 - stringify-entities: 4.0.4 - unist-util-stringify-position: 4.0.0 - vfile-message: 4.0.3 - transitivePeerDependencies: - - supports-color - - mdast-util-mdxjs-esm@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-phrasing@4.1.0: - dependencies: - '@types/mdast': 4.0.4 - unist-util-is: 6.0.1 - - mdast-util-to-hast@13.2.1: - dependencies: - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.3 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - - mdast-util-to-markdown@2.1.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.1 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.1.0 - zwitch: 2.0.4 - - mdast-util-to-string@4.0.0: - dependencies: - '@types/mdast': 4.0.4 - - merge2@1.4.1: {} - - micromark-core-commonmark@2.0.3: - dependencies: - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-factory-destination: 2.0.1 - micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.1 - micromark-factory-whitespace: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-autolink-literal@2.1.0: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-footnote@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-strikethrough@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-table@2.1.1: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-tagfilter@2.0.0: - dependencies: - micromark-util-types: 2.0.2 - - micromark-extension-gfm-task-list-item@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm@3.0.0: - dependencies: - micromark-extension-gfm-autolink-literal: 2.1.0 - micromark-extension-gfm-footnote: 2.1.0 - micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.1 - micromark-extension-gfm-tagfilter: 2.0.0 - micromark-extension-gfm-task-list-item: 2.1.0 - micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-destination@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-label@2.0.1: - dependencies: - devlop: 1.1.0 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 - - micromark-factory-title@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-whitespace@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-chunked@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-classify-character@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-combine-extensions@2.0.1: - dependencies: - micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-decode-numeric-character-reference@2.0.2: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-decode-string@2.0.1: - dependencies: - decode-named-character-reference: 1.3.0 - micromark-util-character: 2.1.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-symbol: 2.0.1 - - micromark-util-encode@2.0.1: {} - - micromark-util-html-tag-name@2.0.1: {} - - micromark-util-normalize-identifier@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-resolve-all@2.0.1: - dependencies: - micromark-util-types: 2.0.2 - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-subtokenize@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.2: {} - - micromark@4.0.2: - dependencies: - '@types/debug': 4.1.13 - debug: 4.4.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - transitivePeerDependencies: - - supports-color - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - ms@2.1.3: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.15: {} - - next@15.5.20(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - '@next/env': 15.5.20 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001805 - postcss: 8.4.31 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - styled-jsx: 5.1.6(react@19.2.7) - optionalDependencies: - '@next/swc-darwin-arm64': 15.5.20 - '@next/swc-darwin-x64': 15.5.20 - '@next/swc-linux-arm64-gnu': 15.5.20 - '@next/swc-linux-arm64-musl': 15.5.20 - '@next/swc-linux-x64-gnu': 15.5.20 - '@next/swc-linux-x64-musl': 15.5.20 - '@next/swc-win32-arm64-msvc': 15.5.20 - '@next/swc-win32-x64-msvc': 15.5.20 - '@playwright/test': 1.61.1 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - node-releases@2.0.51: {} - - normalize-path@3.0.0: {} - - object-assign@4.1.1: {} - - object-hash@3.0.0: {} - - parse-entities@4.0.2: - dependencies: - '@types/unist': 2.0.11 - character-entities-legacy: 3.0.0 - character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.3.0 - is-alphanumerical: 2.0.1 - is-decimal: 2.0.1 - is-hexadecimal: 2.0.1 - - path-parse@1.0.7: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - picomatch@4.0.5: {} - - pify@2.3.0: {} - - pirates@4.0.7: {} - - playwright-core@1.61.1: {} - - playwright@1.61.1: - dependencies: - playwright-core: 1.61.1 - optionalDependencies: - fsevents: 2.3.2 - - postcss-import@15.1.0(postcss@8.5.17): - dependencies: - postcss: 8.5.17 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.12 - - postcss-js@4.1.0(postcss@8.5.17): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.17 - - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.17): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 1.21.7 - postcss: 8.5.17 - - postcss-nested@6.2.0(postcss@8.5.17): - dependencies: - postcss: 8.5.17 - postcss-selector-parser: 6.1.4 - - postcss-selector-parser@6.1.4: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-value-parser@4.2.0: {} - - postcss@8.4.31: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.17: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - property-information@7.2.0: {} - - queue-microtask@1.2.3: {} - - react-dom@19.2.7(react@19.2.7): - dependencies: - react: 19.2.7 - scheduler: 0.27.0 - - react-markdown@9.1.0(@types/react@19.2.17)(react@19.2.7): - dependencies: - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - '@types/react': 19.2.17 - devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6 - html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.1 - react: 19.2.7 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - unified: 11.0.5 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - - react@19.2.7: {} - - read-cache@1.0.0: - dependencies: - pify: 2.3.0 - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - - remark-gfm@4.0.1: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 - micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 - remark-stringify: 11.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-parse@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 - micromark-util-types: 2.0.2 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-rehype@11.1.2: - dependencies: - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.1 - unified: 11.0.5 - vfile: 6.0.3 - - remark-stringify@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-to-markdown: 2.1.2 - unified: 11.0.5 - - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - scheduler@0.27.0: {} - - semver@7.8.5: - optional: true - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.5 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - optional: true - - source-map-js@1.2.1: {} - - space-separated-tokens@2.0.2: {} - - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - - style-to-js@1.1.21: - dependencies: - style-to-object: 1.0.14 - - style-to-object@1.0.14: - dependencies: - inline-style-parser: 0.2.7 - - styled-jsx@5.1.6(react@19.2.7): - dependencies: - client-only: 0.0.1 - react: 19.2.7 - - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.17 - ts-interface-checker: 0.1.13 - - supports-preserve-symlinks-flag@1.0.0: {} - - tailwindcss@3.4.19: - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.17 - postcss-import: 15.1.0(postcss@8.5.17) - postcss-js: 4.1.0(postcss@8.5.17) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.17) - postcss-nested: 6.2.0(postcss@8.5.17) - postcss-selector-parser: 6.1.4 - resolve: 1.22.12 - sucrase: 3.35.1 - transitivePeerDependencies: - - tsx - - yaml - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - trim-lines@3.0.1: {} - - trough@2.2.0: {} - - ts-interface-checker@0.1.13: {} - - tslib@2.8.1: {} - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - unified@11.0.5: - dependencies: - '@types/unist': 3.0.3 - bail: 2.0.2 - devlop: 1.1.0 - extend: 3.0.2 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 6.0.3 - - unist-util-is@6.0.1: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-visit@5.1.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - update-browserslist-db@1.2.3(browserslist@4.28.6): - dependencies: - browserslist: 4.28.6 - escalade: 3.2.0 - picocolors: 1.1.1 - - util-deprecate@1.0.2: {} - - vfile-message@4.0.3: - dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 - - vfile@6.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.3 - - zwitch@2.0.4: {} diff --git a/apps/web/src/app/(pages)/activity/page.tsx b/apps/web/src/app/(pages)/activity/page.tsx index 9efc9a9..30918c8 100644 --- a/apps/web/src/app/(pages)/activity/page.tsx +++ b/apps/web/src/app/(pages)/activity/page.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { getApiUrl } from "@/contexts/workspace-context"; +import { listBrowserCognitiveJobs } from "@/lib/browser-storage"; import { t, tf, locale } from "@/i18n"; type ActivityKind = "log" | "completed" | "failed" | "running" | "pending"; @@ -98,6 +99,27 @@ export default function ActivityPage() { const loadActivity = useCallback(async () => { setLoading(true); + if (api === "__browser__") { + const jobs = await listBrowserCognitiveJobs(); + const counts = { completed: 0, failed: 0, running: 0, pending: 0 }; + const items = jobs.map((job): ActivityItem => { + counts[job.status] += 1; + return { + id: job.id, + when: job.updatedAt, + whenTs: new Date(job.updatedAt).getTime(), + human: `Knowledge graph processing for ${job.notePath}`, + technical: `${job.type} · ${job.progress}% · browser worker`, + kind: job.status, + noteRef: job.notePath, + detail: job.error, + }; + }).sort((left, right) => right.whenTs - left.whenTs); + setActivity(items); + setSummary(counts); + setLoading(false); + return; + } try { const [logsRes, jobsRes] = await Promise.all([ fetch(`${api}/api/v1/automation-logs?limit=100`), @@ -179,7 +201,19 @@ export default function ActivityPage() { setLoading(false); }, [api]); - useEffect(() => { loadActivity(); }, [loadActivity]); + useEffect(() => { + void loadActivity(); + if (api !== "__browser__") return; + const refresh = () => { void loadActivity(); }; + window.addEventListener("bb:browser-worker-updated", refresh); + window.addEventListener("bb:browser-knowledge-updated", refresh); + const interval = window.setInterval(refresh, 2_000); + return () => { + window.removeEventListener("bb:browser-worker-updated", refresh); + window.removeEventListener("bb:browser-knowledge-updated", refresh); + window.clearInterval(interval); + }; + }, [api, loadActivity]); const filtered = filter === "all" ? activity : activity.filter((a) => a.kind === filter); diff --git a/apps/web/src/app/(pages)/insights/page.tsx b/apps/web/src/app/(pages)/insights/page.tsx index cee33ac..3d6da0e 100644 --- a/apps/web/src/app/(pages)/insights/page.tsx +++ b/apps/web/src/app/(pages)/insights/page.tsx @@ -68,6 +68,11 @@ export default function InsightsPage() { const loadInsights = useCallback(async () => { setLoading(true); + if (api === "__browser__") { + setInsights([]); + setLoading(false); + return; + } try { const r = await fetch(`${api}/api/v1/insights?limit=50`); if (r.ok) { @@ -82,6 +87,10 @@ export default function InsightsPage() { useEffect(() => { loadInsights(); }, [loadInsights]); const generateNow = async () => { + if (api === "__browser__") { + setFeedback("AI insight generation requires the self-hosted API and worker."); + return; + } setGenerating(true); setFeedback(null); try { @@ -102,6 +111,7 @@ export default function InsightsPage() { }; const dismissInsight = async (id: number, action: "apply" | "ignore") => { + if (api === "__browser__") return; try { const r = await fetch(`${api}/api/v1/insights/${id}/${action}`, { method: "POST" }); if (r.ok) setFeedback(action === "apply" ? t("insightApplied") : t("insightIgnored")); @@ -111,6 +121,7 @@ export default function InsightsPage() { }; const createNote = async (insight: InsightItem) => { + if (api === "__browser__") return; try { await fetch(`${api}/api/v1/insights/${insight.id}/create-note`, { method: "POST" }); setFeedback(t("createNoteJobSent")); @@ -119,6 +130,7 @@ export default function InsightsPage() { }; const createReview = async (insight: InsightItem) => { + if (api === "__browser__") return; try { await fetch(`${api}/api/v1/insights/${insight.id}/create-review`, { method: "POST" }); setFeedback(t("createReviewJobSent")); diff --git a/apps/web/src/app/(pages)/layout.tsx b/apps/web/src/app/(pages)/layout.tsx index 944b99c..23212c8 100644 --- a/apps/web/src/app/(pages)/layout.tsx +++ b/apps/web/src/app/(pages)/layout.tsx @@ -11,17 +11,21 @@ import { SettingsPanel } from "@/components/settings-panel"; import { OnboardingModal } from "@/components/onboarding-modal"; import { GuidePanel } from "@/components/guide-panel"; import { GraphScreen } from "@/components/graph-screen"; +import { BROWSER_STORAGE_MODE } from "@/lib/browser-storage"; function Shell({ children }: { children: React.ReactNode }) { const w = useWorkspace(); const prevActive = useRef(w.active); - const [authState, setAuthState] = useState<"checking" | "allowed">("checking"); + const [authState, setAuthState] = useState<"checking" | "allowed">( + BROWSER_STORAGE_MODE ? "allowed" : "checking", + ); const [mobileNavOpen, setMobileNavOpen] = useState(false); // ponytail: basePath-aware return-to-login link (strip basePath so safeNext can re-apply it) const loginHref = () => `${appPath("/login")}?next=${encodeURIComponent(window.location.pathname.replace(new RegExp("^" + (process.env.NEXT_PUBLIC_BERRYBRAIN_API_URL || "")), "") || "/")}`; useEffect(() => { + if (BROWSER_STORAGE_MODE) return; let alive = true; fetch(`${w.api}/api/v1/auth/me`, { credentials: "include" }) .then((response) => { diff --git a/apps/web/src/app/(pages)/notifications/page.tsx b/apps/web/src/app/(pages)/notifications/page.tsx index 65790f5..ca60e30 100644 --- a/apps/web/src/app/(pages)/notifications/page.tsx +++ b/apps/web/src/app/(pages)/notifications/page.tsx @@ -44,6 +44,12 @@ export default function NotificationsPage() { const load = useCallback(async () => { setLoading(true); + if (api === "__browser__") { + setAlerts([]); + setFailedJobs([]); + setLoading(false); + return; + } try { const [summaryRes, jobsRes] = await Promise.all([ fetch(`${api}/api/v1/home/summary`), diff --git a/apps/web/src/app/(pages)/reviews/page.tsx b/apps/web/src/app/(pages)/reviews/page.tsx index f66a7da..c274bfc 100644 --- a/apps/web/src/app/(pages)/reviews/page.tsx +++ b/apps/web/src/app/(pages)/reviews/page.tsx @@ -30,6 +30,11 @@ export default function ReviewsPage() { const load = useCallback(async () => { setLoading(true); setError(""); + if (w.api === "__browser__") { + setItems([]); + setLoading(false); + return; + } try { const response = await fetch(`${w.api}/api/v1/reviews?due=true&limit=50`); if (!response.ok) throw new Error("Could not load today's review session."); @@ -48,7 +53,7 @@ export default function ReviewsPage() { async function grade(rating: Rating) { const current = items[0]; - if (!current || submitting) return; + if (!current || submitting || w.api === "__browser__") return; setSubmitting(rating); try { const response = await fetch(`${w.api}/api/v1/reviews/${current.id}/grade`, { diff --git a/apps/web/src/app/api/browser-ai/chat/route.ts b/apps/web/src/app/api/browser-ai/chat/route.ts new file mode 100644 index 0000000..820157e --- /dev/null +++ b/apps/web/src/app/api/browser-ai/chat/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + CloudProviderUrlError, + jsonHeaders, + providerName, + rejectCrossSite, + resolveCloudProviderUrl, + safeJson, + upstreamError, + validApiKey, +} from "../provider"; + +type Message = { role: "system" | "user" | "assistant"; content: string }; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function validMessages(value: unknown): value is Message[] { + if (!Array.isArray(value) || value.length === 0 || value.length > 12) return false; + let total = 0; + for (const item of value) { + if (!item || typeof item !== "object") return false; + const message = item as Partial; + if (!message.role || !["system", "user", "assistant"].includes(message.role)) return false; + if (typeof message.content !== "string" || !message.content.trim()) return false; + total += message.content.length; + } + return total <= 60_000; +} + +export async function POST(request: NextRequest) { + if (rejectCrossSite(request)) { + return NextResponse.json({ error: "Cross-site requests are not allowed." }, { status: 403, headers: jsonHeaders() }); + } + try { + const body = await safeJson(request); + const model = typeof body.model === "string" ? body.model.trim() : ""; + if (!validApiKey(body.apiKey) || !model || model.length > 200 || !validMessages(body.messages)) { + return NextResponse.json({ error: "Invalid cloud AI request." }, { status: 400, headers: jsonHeaders() }); + } + const providerUrl = await resolveCloudProviderUrl(body.providerUrl); + const response = await fetch(`${providerUrl}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${body.apiKey.trim()}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + model, + messages: body.messages, + temperature: 0.2, + max_tokens: 1_800, + stream: false, + }), + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(90_000), + }); + if (!response.ok) { + return NextResponse.json({ error: await upstreamError(response) }, { status: response.status, headers: jsonHeaders() }); + } + const payload = await response.json() as { + choices?: Array<{ message?: { content?: unknown } }>; + usage?: unknown; + }; + const content = payload.choices?.[0]?.message?.content; + if (typeof content !== "string" || !content.trim()) { + return NextResponse.json({ error: "Cloud provider returned an empty response." }, { status: 502, headers: jsonHeaders() }); + } + return NextResponse.json({ content, provider: providerName(providerUrl), providerUrl, model, usage: payload.usage || null }, { headers: jsonHeaders() }); + } catch (error) { + const message = error instanceof Error && error.name !== "TimeoutError" + ? error.message + : "Cloud provider did not respond in time."; + return NextResponse.json({ error: message }, { status: error instanceof CloudProviderUrlError ? 400 : 502, headers: jsonHeaders() }); + } +} diff --git a/apps/web/src/app/api/browser-ai/models/route.ts b/apps/web/src/app/api/browser-ai/models/route.ts new file mode 100644 index 0000000..faa4d10 --- /dev/null +++ b/apps/web/src/app/api/browser-ai/models/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + CloudProviderUrlError, + jsonHeaders, + providerName, + rejectCrossSite, + resolveCloudProviderUrl, + safeJson, + upstreamError, + validApiKey, +} from "../provider"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + if (rejectCrossSite(request)) { + return NextResponse.json({ connected: false, error: "Cross-site requests are not allowed." }, { status: 403, headers: jsonHeaders() }); + } + try { + const body = await safeJson(request, 4_096); + if (!validApiKey(body.apiKey)) { + return NextResponse.json({ connected: false, error: "A valid cloud API key is required." }, { status: 400, headers: jsonHeaders() }); + } + const providerUrl = await resolveCloudProviderUrl(body.providerUrl); + const response = await fetch(`${providerUrl}/models`, { + headers: { Authorization: `Bearer ${body.apiKey.trim()}`, Accept: "application/json" }, + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + return NextResponse.json({ connected: false, error: await upstreamError(response) }, { status: response.status, headers: jsonHeaders() }); + } + const payload = await response.json() as { data?: Array<{ id?: unknown }> }; + const models = (payload.data || []) + .map((item) => (typeof item.id === "string" ? item.id : "")) + .filter(Boolean) + .slice(0, 500); + return NextResponse.json({ connected: true, provider: providerName(providerUrl), providerUrl, models }, { headers: jsonHeaders() }); + } catch (error) { + const message = error instanceof Error && error.name !== "TimeoutError" + ? error.message + : "Cloud provider did not respond in time."; + return NextResponse.json({ connected: false, error: message }, { status: error instanceof CloudProviderUrlError ? 400 : 502, headers: jsonHeaders() }); + } +} diff --git a/apps/web/src/app/api/browser-ai/provider.ts b/apps/web/src/app/api/browser-ai/provider.ts new file mode 100644 index 0000000..f12c995 --- /dev/null +++ b/apps/web/src/app/api/browser-ai/provider.ts @@ -0,0 +1,119 @@ +import "server-only"; + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import type { NextRequest } from "next/server"; + +const BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home", ".lan"]; + +export class CloudProviderUrlError extends Error {} + +export function rejectCrossSite(request: NextRequest) { + const origin = request.headers.get("origin"); + if (!origin) return request.headers.get("sec-fetch-site") === "cross-site"; + try { + const originHost = new URL(origin).host; + const requestHost = request.headers.get("x-forwarded-host") || request.headers.get("host"); + return !requestHost || originHost !== requestHost; + } catch { + return true; + } +} + +export function validApiKey(value: unknown): value is string { + return typeof value === "string" && value.trim().length >= 20 && value.trim().length <= 512; +} + +function privateIpv4(address: string) { + const parts = address.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true; + const [a, b] = parts; + return a === 0 + || a === 10 + || a === 127 + || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) + || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && b === 168) + || (a === 198 && (b === 18 || b === 19)) + || a >= 224; +} + +function privateIp(address: string) { + const normalized = address.toLowerCase().split("%")[0]; + if (isIP(normalized) === 4) return privateIpv4(normalized); + if (isIP(normalized) !== 6) return true; + if (normalized.startsWith("::ffff:")) return privateIpv4(normalized.slice(7)); + return normalized === "::" + || normalized === "::1" + || normalized.startsWith("fc") + || normalized.startsWith("fd") + || /^fe[89ab]/.test(normalized) + || normalized.startsWith("2001:db8:"); +} + +export async function resolveCloudProviderUrl(value: unknown) { + if (typeof value !== "string" || value.length > 2_048) throw new CloudProviderUrlError("A valid cloud provider URL is required."); + let url: URL; + try { + url = new URL(value.trim()); + } catch { + throw new CloudProviderUrlError("A valid cloud provider URL is required."); + } + const hostname = url.hostname.toLowerCase().replace(/\.$/, ""); + if ( + url.protocol !== "https:" + || url.username + || url.password + || (url.port && url.port !== "443") + || hostname === "localhost" + || BLOCKED_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)) + ) { + throw new CloudProviderUrlError("Cloud provider URLs must use public HTTPS endpoints."); + } + let addresses: Array<{ address: string }>; + try { + addresses = isIP(hostname) + ? [{ address: hostname }] + : await lookup(hostname, { all: true, verbatim: true }); + } catch { + throw new CloudProviderUrlError("Cloud provider hostname could not be resolved."); + } + if (!addresses.length || addresses.some(({ address }) => privateIp(address))) { + throw new CloudProviderUrlError("Private or local cloud provider addresses are not allowed."); + } + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString().replace(/\/$/, ""); +} + +export function providerName(providerUrl: string) { + return new URL(providerUrl).hostname; +} + +export function jsonHeaders() { + return { + "Cache-Control": "no-store, max-age=0", + "Content-Type": "application/json", + "X-Content-Type-Options": "nosniff", + }; +} + +export async function safeJson(request: NextRequest, maxBytes = 64_000): Promise> { + const declaredLength = Number(request.headers.get("content-length") || "0"); + if (declaredLength > maxBytes) throw new Error("Request is too large."); + const raw = await request.text(); + if (raw.length > maxBytes) throw new Error("Request is too large."); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid request body."); + return parsed as Record; +} + +export async function upstreamError(response: Response) { + const payload = await response.json().catch(() => null) as { detail?: unknown; message?: unknown; error?: unknown } | null; + const candidate = payload?.detail || payload?.message || payload?.error; + return typeof candidate === "string" && candidate.length <= 500 + ? candidate + : `Cloud provider returned HTTP ${response.status}.`; +} diff --git a/apps/web/src/app/brain/page.tsx b/apps/web/src/app/brain/page.tsx index 1d2ce0e..ce9c328 100644 --- a/apps/web/src/app/brain/page.tsx +++ b/apps/web/src/app/brain/page.tsx @@ -3,12 +3,16 @@ import { useEffect, useState } from "react"; import { NoteWorkspace } from "@/components/note-workspace"; import { getApiUrl, appPath } from "@/contexts/workspace-context"; +import { BROWSER_STORAGE_MODE } from "@/lib/browser-storage"; export default function Brain() { const apiUrl = getApiUrl(); - const [state, setState] = useState<"checking" | "allowed">("checking"); + const [state, setState] = useState<"checking" | "allowed">( + BROWSER_STORAGE_MODE ? "allowed" : "checking", + ); useEffect(() => { + if (BROWSER_STORAGE_MODE) return; let alive = true; fetch(`${apiUrl}/api/v1/setup/status`, { credentials: "include" }) .then((response) => response.json()) @@ -31,7 +35,7 @@ export default function Brain() { return () => { alive = false; }; - }, []); + }, [apiUrl]); if (state !== "allowed") { return ( diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index f35eeed..b4efc33 100755 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,15 +1,17 @@ import type { Metadata, Viewport } from "next"; import { PwaRegister } from "@/components/pwa-register"; +import { ThirdPartyIntegrations } from "@/components/third-party-integrations"; import berrylogo from "../../public/berrylogo.png"; import "./globals.css"; const publicBase = process.env.NEXT_PUBLIC_BERRYBRAIN_ASSET_PREFIX || ""; +const landingOnly = process.env.NEXT_PUBLIC_BERRYBRAIN_LANDING_ONLY === "true"; export const metadata: Metadata = { title: "BerryBrain", description: "Local second brain for AI-assisted study.", applicationName: "BerryBrain", - manifest: `${publicBase}/manifest.webmanifest`, + manifest: landingOnly ? undefined : `${publicBase}/manifest.webmanifest`, icons: { icon: berrylogo.src, apple: `${publicBase}/apple-touch-icon.png`, @@ -50,6 +52,7 @@ export default function RootLayout({ {children} + ); diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx index 519d91b..cda0cf3 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -52,7 +52,7 @@ export function CommandPalette({ if (!open) return; const trimmed = query.trim(); - if (!trimmed || apiUrl === "__demo__") { + if (!trimmed || apiUrl === "__demo__" || apiUrl === "__browser__") { setResults(commands); setSelectedIndex(0); return; diff --git a/apps/web/src/components/editor/note-editor.tsx b/apps/web/src/components/editor/note-editor.tsx index 0f06f37..543a411 100644 --- a/apps/web/src/components/editor/note-editor.tsx +++ b/apps/web/src/components/editor/note-editor.tsx @@ -5,6 +5,12 @@ import { MarkdownPreview } from "./markdown-preview"; import { useState, useEffect, useRef, type KeyboardEvent } from "react"; import { createPortal } from "react-dom"; import { t } from "@/i18n"; +import { + BROWSER_STORAGE_MODE, + createBrowserAttachment, + deleteBrowserAttachment, + listBrowserAttachments, +} from "@/lib/browser-storage"; type AttachmentItem = { id: number; @@ -31,7 +37,7 @@ function Backlinks({ notePath }: { notePath: string }) { const w = useWorkspace(); const [links, setLinks] = useState([]); useEffect(() => { - if (w.demo) { + if (w.demo || BROWSER_STORAGE_MODE) { setLinks([]); return; } @@ -76,6 +82,16 @@ export function NoteEditor() { setAttachments([]); return; } + if (BROWSER_STORAGE_MODE) { + let objectUrls: string[] = []; + listBrowserAttachments(w.active.path) + .then((items) => { + objectUrls = items.map((item) => item.downloadUrl); + setAttachments(items); + }) + .catch(() => setAttachments([])); + return () => objectUrls.forEach((url) => URL.revokeObjectURL(url)); + } const encodedPath = encodeNotePath(w.active.path); fetch(`${w.api}/api/v1/notes/${encodedPath}/attachments`) .then((r) => (r.ok ? r.json() : null)) @@ -104,7 +120,7 @@ export function NoteEditor() { }, [menuOpen]); useEffect(() => { - if (!w.active || w.demo) { + if (!w.active || w.demo || BROWSER_STORAGE_MODE) { setPipelineProgress(null); return; } @@ -217,7 +233,9 @@ export function NoteEditor() { } function insertAttachmentMarkdown(attachment: AttachmentItem) { - const url = `${w.api}${attachment.downloadUrl}`; + const url = BROWSER_STORAGE_MODE + ? `berrybrain-attachment:${attachment.id}` + : `${w.api}${attachment.downloadUrl}`; const name = attachment.filename.replace(/]/g, ""); if (attachment.category === "image") { insertBlock(`![${name}](${url})\n`); @@ -246,6 +264,24 @@ export function NoteEditor() { setAttachmentStatus("Attachments are disabled in demo mode."); return; } + if (BROWSER_STORAGE_MODE) { + setAttachmentStatus(`Saving ${files.length} attachment${files.length > 1 ? "s" : ""} locally...`); + const uploaded: AttachmentItem[] = []; + try { + for (const file of Array.from(files)) { + uploaded.push(await createBrowserAttachment(w.active.path, file)); + } + setAttachments((current) => [...uploaded, ...current]); + setAttachmentStatus(`${uploaded.length} attachment${uploaded.length > 1 ? "s" : ""} saved in this browser.`); + } catch (error) { + uploaded.forEach((item) => URL.revokeObjectURL(item.downloadUrl)); + setAttachmentStatus(error instanceof Error ? error.message : "Attachment storage failed."); + } finally { + if (fileInputRef.current) fileInputRef.current.value = ""; + window.setTimeout(() => setAttachmentStatus(""), 4000); + } + return; + } setAttachmentStatus(`Uploading ${files.length} attachment${files.length > 1 ? "s" : ""}...`); const encodedPath = encodeNotePath(w.active.path); const uploaded: AttachmentItem[] = []; @@ -281,12 +317,24 @@ export function NoteEditor() { async function deleteAttachment(id: number) { if (w.demo) return; + if (BROWSER_STORAGE_MODE) { + const target = attachments.find((item) => item.id === id); + await deleteBrowserAttachment(id); + if (target) URL.revokeObjectURL(target.downloadUrl); + setAttachments((current) => current.filter((item) => item.id !== id)); + return; + } const response = await fetch(`${w.api}/api/v1/notes/attachments/${id}`, { method: "DELETE" }); if (response.ok) setAttachments((current) => current.filter((item) => item.id !== id)); } async function reprocessAttachment(id: number, extractor: string) { if (w.demo) return; + if (BROWSER_STORAGE_MODE) { + setAttachmentStatus("OCR and transcription require the self-hosted worker."); + window.setTimeout(() => setAttachmentStatus(""), 4000); + return; + } setAttachmentStatus("Queueing attachment reprocessing..."); const response = await fetch(`${w.api}/api/v1/notes/attachments/${id}/reprocess`, { method: "POST", @@ -484,6 +532,7 @@ export function NoteEditor() { void; onDelete: (id: number) => void; @@ -565,12 +616,12 @@ function AttachmentsPanel({
{attachments.map((attachment) => (
- + {attachment.filename} {attachment.category} {formatBytes(attachment.sizeBytes)} - - + } + {!browserMode && }
diff --git a/apps/web/src/components/graph-screen.tsx b/apps/web/src/components/graph-screen.tsx index afa569b..eb0bfe2 100644 --- a/apps/web/src/components/graph-screen.tsx +++ b/apps/web/src/components/graph-screen.tsx @@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from "react"; import { GraphCanvas, useGraphData, type GraphLayoutMode } from "./graph-view"; import { t } from "@/i18n"; import { apiFetch, appPath } from "@/contexts/workspace-context"; +import { askBrowserCloud } from "@/lib/browser-ai"; +import { saveBrowserInferenceInsight } from "@/lib/browser-storage"; const EDGE_COLORS: Record = { explicit_link: "#3C8F5A", @@ -55,7 +57,7 @@ type GraphNode = { }; type GraphEdge = { - id?: number; + id?: number | string; source: string; target: string; type: string; @@ -460,7 +462,7 @@ export function GraphScreen({ } useEffect(() => { - if (apiUrl === "__demo__") return; + if (apiUrl === "__demo__" || apiUrl === "__browser__") return; apiFetch(`${apiUrl}/api/v1/settings/graph/config`) .then((r) => (r.ok ? r.json() : Promise.reject())) .then((config) => { @@ -472,7 +474,7 @@ export function GraphScreen({ }, [apiUrl]); useEffect(() => { - if (apiUrl === "__demo__" || !selectedNode?.recordId || !showDetail) { + if (apiUrl === "__demo__" || apiUrl === "__browser__" || !selectedNode?.recordId || !showDetail) { setNodeSummary(null); return; } @@ -498,7 +500,7 @@ export function GraphScreen({ }, [apiUrl, selectedNode?.recordId, showDetail]); useEffect(() => { - if (apiUrl === "__demo__") { + if (apiUrl === "__demo__" || apiUrl === "__browser__") { setResearchModeEnabled(false); return; } @@ -517,7 +519,7 @@ export function GraphScreen({ } async function expandGraph() { - if (apiUrl === "__demo__") return; + if (apiUrl === "__demo__" || apiUrl === "__browser__") return; await apiFetch(`${apiUrl}/api/v1/graph/expand`, { method: "POST" }); reload(); } @@ -526,6 +528,61 @@ export function GraphScreen({ const text = query.trim(); if (!text) return; if (apiUrl === "__demo__") return; + if (apiUrl === "__browser__") { + setInferLoading(true); + setInference(null); + setInferenceSaveStatus(""); + try { + const nodes = (graphData?.nodes || []).slice(0, 120).map((node) => ({ + id: node.id, + type: node.type, + label: node.label, + summary: node.summary || "", + path: node.path || "", + })); + const edges = (graphData?.edges || []).slice(0, 180).map((edge) => ({ + source: edge.source, + target: edge.target, + type: edge.type, + reason: edge.reason || "", + evidence: edge.evidence || [], + confidence: edge.confidence || 0, + })); + const response = await askBrowserCloud([ + { + role: "system", + content: "Answer only from the supplied BerryBrain graph. Return JSON only: {status,answer,relatedNodeIds,evidence}. status must be answered or insufficient_evidence. Evidence must cite node labels or edge reasons. Never invent a relation.", + }, + { role: "user", content: JSON.stringify({ question: text, nodes, edges }) }, + ]); + const parsed = JSON.parse(response.content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "")) as { + status?: string; + answer?: string; + relatedNodeIds?: string[]; + evidence?: string[]; + }; + const relatedIds = new Set(Array.isArray(parsed.relatedNodeIds) ? parsed.relatedNodeIds : []); + const relatedNodes = (graphData?.nodes || []).filter((node) => relatedIds.has(node.id)); + setInference({ + status: parsed.status === "answered" ? "answered" : "insufficient_evidence", + question: text, + answer: parsed.answer || "There is not enough evidence in this graph to answer that question.", + relatedNodes: relatedNodes.map((node) => ({ id: node.id, title: node.title, label: node.label, type: node.type, path: node.path })), + evidence: (Array.isArray(parsed.evidence) ? parsed.evidence : []).slice(0, 8), + provider: response.provider, + model: response.model, + }); + } catch (error) { + setInference({ + status: "error", + question: text, + answer: error instanceof Error ? error.message : "Could not query the configured cloud provider.", + }); + } finally { + setInferLoading(false); + } + return; + } setInferLoading(true); setInference(null); setInferenceSaveStatus(""); @@ -561,6 +618,25 @@ export function GraphScreen({ setInferenceSaving(true); setInferenceSaveStatus("Saving inference as insight..."); try { + if (apiUrl === "__browser__") { + if (inference.status === "insufficient_evidence" || !inference.evidence?.length) { + setInferenceSaveStatus("Not saved: this answer does not have enough evidence."); + return; + } + const insight = await saveBrowserInferenceInsight({ + question: text, + answer: inference.answer, + relatedNodeIds: relatedInferenceNodes.map((node) => node.id), + evidence: inference.evidence.map(formatInferenceEvidence).filter(Boolean), + provider: inference.provider || "cloud", + model: inference.model || "cloud-model", + }); + setInference((current) => current ? { ...current, status: "saved_as_insight" } : current); + setInferenceSaveStatus(`Saved as insight: ${insight.title}`); + window.dispatchEvent(new CustomEvent("bb:browser-knowledge-updated")); + reload(); + return; + } const response = await apiFetch(`${apiUrl}/api/v1/insights/from-inference`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/apps/web/src/components/graph-view.tsx b/apps/web/src/components/graph-view.tsx index f32d9ee..9ceb5e2 100644 --- a/apps/web/src/components/graph-view.tsx +++ b/apps/web/src/components/graph-view.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { t } from "@/i18n"; +import { getBrowserGraphData } from "@/lib/browser-storage"; type GNode = { id: string; @@ -22,7 +23,7 @@ type GNode = { createdByModel?: string; }; type GEdge = { - id?: number; + id?: number | string; source: string; target: string; type: string; @@ -81,11 +82,24 @@ export function useGraphData(apiUrl: string) { useEffect(() => { // ponytail: demo has no backend, render empty graph instead of erroring if (apiUrl === "__demo__") { setData({ nodes: [], edges: [], stats: {} }); return; } + if (apiUrl === "__browser__") { + const load = () => getBrowserGraphData().then(setData).catch(() => setError(true)); + void load(); + window.addEventListener("bb:browser-knowledge-updated", load); + return () => window.removeEventListener("bb:browser-knowledge-updated", load); + } fetch(`${apiUrl}/api/v1/graph`) .then(r => r.json()).then(setData).catch(() => setError(true)); }, [apiUrl]); const reload = useCallback(() => { if (apiUrl === "__demo__") return; + if (apiUrl === "__browser__") { + setError(false); + getBrowserGraphData() + .then(setData) + .catch(() => setError(true)); + return; + } setError(false); fetch(`${apiUrl}/api/v1/graph`).then(r => r.json()).then(setData).catch(() => setError(true)); }, [apiUrl]); diff --git a/apps/web/src/components/home/home-view.tsx b/apps/web/src/components/home/home-view.tsx index 0f19579..ddc4d60 100644 --- a/apps/web/src/components/home/home-view.tsx +++ b/apps/web/src/components/home/home-view.tsx @@ -5,8 +5,9 @@ import type { ReactNode } from "react"; import { useWorkspace, appPath } from "@/contexts/workspace-context"; import { t, tf } from "@/i18n"; import { ThemedProgressBar } from "./themed-progress-bar"; +import { getBrowserCloudConfig, getBrowserGraphData, listBrowserCognitiveJobs } from "@/lib/browser-storage"; -type StatusKind = "running" | "completed" | "failed" | "offline" | "queued" | "waiting_provider"; +type StatusKind = "idle" | "running" | "completed" | "failed" | "offline" | "queued" | "waiting_provider"; const QUICK_NOTE_MAX_LENGTH = 2_000; @@ -162,6 +163,123 @@ export function HomeView() { setLoading(false); return; } + if (w.api === "__browser__") { + Promise.all([getBrowserCloudConfig(), getBrowserGraphData(), listBrowserCognitiveJobs()]) + .then(([config, graph, jobs]) => { + const notePaths = new Set(w.notes.map((note) => note.path)); + const currentJobs = jobs.filter((job) => notePaths.has(job.notePath)); + const localNotes = w.notes.slice(0, 8).map((note) => ({ + title: note.title, + path: note.path, + folder: note.folder, + status: "saved locally", + })); + const pending = currentJobs.filter((job) => job.status === "pending").length; + const active = currentJobs.filter((job) => job.status === "running").length; + const failed = currentJobs.filter((job) => job.status === "failed").length; + const completedPaths = new Set(currentJobs.filter((job) => job.status === "completed").map((job) => job.notePath)); + const completed = completedPaths.size; + const total = w.notes.length; + const percent = total ? Math.round((completed / total) * 100) : 100; + const processingComplete = total === 0 || completed === total; + const concepts = graph.nodes.filter((node) => node.type === "concept"); + const insightNodes = graph.nodes.filter((node) => node.type === "insight"); + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + setSummary({ + ...DEMO_HOME_SUMMARY, + status: { + ...DEMO_HOME_SUMMARY.status, + worker: active ? "processing in browser" : pending ? "browser worker queued" : "browser worker ready", + cloudProvider: config?.provider || "not configured", + cloudModel: config?.model || "", + cloudStatus: config ? (active ? "running" : "connected") : "offline", + cloudConfigured: Boolean(config), + remoteContentConsent: Boolean(config), + pendingJobs: pending, + activeJobs: active, + lastProcessingAt: currentJobs.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.updatedAt || null, + }, + progress: { + ...DEMO_HOME_SUMMARY.progress, + mode: "determinate", + percent, + active, + pending, + completed, + failed, + currentStep: active + ? "Expanding knowledge graph with cloud AI" + : pending + ? "Queued for browser processing" + : failed + ? "Cognitive processing needs attention" + : processingComplete + ? "Knowledge graph is up to date" + : "Preparing notes for cognitive processing", + lastResult: completed ? `${completed} notes assimilated in this browser` : "Waiting for the first assimilated note", + status: failed ? "failed" : active || pending ? "running" : processingComplete ? "completed" : "idle", + }, + stats: { + notes: { total: w.notes.length, createdToday: 0, unassimilated: Math.max(0, w.notes.length - completed) }, + connections: { + total: graph.edges.length, + createdToday: 0, + averageConfidence: graph.edges.length + ? graph.edges.reduce((sum, edge) => sum + edge.confidence, 0) / graph.edges.length + : 0, + }, + concepts: { total: concepts.length, newToday: 0, withoutPermanentNote: concepts.length }, + study: { dueReviews: 0, activeReviews: 0, suggestedReviews: 0, weakConcepts: 0, openGaps: 0 }, + jobs: { pending, active, failed, completedToday: completed, total }, + ai: { provider: config?.provider || "not configured", model: config?.model || "", metadata: graph.nodes.length, embeddings: 0, jobsProcessed: completed, errors: failed }, + }, + recentNotes: localNotes, + recentInsights: insightNodes.slice(-5).reverse().map((node, index) => ({ + id: index + 1, + type: "knowledge insight", + title: node.title || node.label, + description: node.summary || "", + priority: 2, + confidence: node.confidence, + provider: node.provider || config?.provider || "cloud", + model: node.createdByModel, + status: node.status, + })), + recentConnections: graph.edges.slice(-5).reverse().map((edge, index) => ({ + id: index + 1, + type: edge.type, + confidence: edge.confidence, + confidencePercent: Math.round(edge.confidence * 100), + reason: edge.reason, + status: edge.status, + source: { title: nodeById.get(edge.source)?.label || edge.source, path: nodeById.get(edge.source)?.path || "" }, + target: { title: nodeById.get(edge.target)?.label || edge.target, path: nodeById.get(edge.target)?.path || "" }, + })), + graphSummary: { + nodes: graph.nodes.length, + edges: graph.edges.length, + orphans: graph.stats.orphan_count, + clusters: 0, + centralNotes: [], + }, + needsAttention: failed + ? [{ kind: "worker", title: `${failed} cognitive job${failed === 1 ? "" : "s"} failed`, description: "Open Activity to inspect the provider error. The browser worker retries failed notes after reload.", action: "Open Activity" }] + : config && !processingComplete && !active && !pending + ? [{ kind: "worker", title: `${total - completed} note${total - completed === 1 ? "" : "s"} waiting for assimilation`, description: "The browser worker will reconcile the local vault automatically while this tab remains open.", action: "Open Activity" }] + : [], + }); + setPipelineProgress(currentJobs.filter((job) => job.status === "running" || job.status === "pending").map((job) => ({ + notePath: job.notePath, + completed: job.progress, + total: 100, + percent: job.progress, + currentStep: job.status === "running" ? "Cloud AI cognitive analysis" : "Queued in browser", + }))); + }) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + return; + } fetch(`${w.api}/api/v1/home/summary`) .then((r) => { if (!r.ok) throw new Error("home-summary"); @@ -174,10 +292,11 @@ export function HomeView() { .then((r) => r.ok ? r.json() : null) .then((d) => { if (d?.notes) setPipelineProgress(d.notes); }) .catch(() => {}); - }, [w.api, w.demo]); + }, [w.api, w.demo, w.notes]); const updateConnectionStatus = useCallback( async (id: number, action: "confirm" | "ignore") => { + if (w.api === "__browser__") return; const response = await fetch(`${w.api}/api/v1/connections/id/${id}/${action}`, { method: "POST", }); @@ -195,6 +314,19 @@ export function HomeView() { loadSummary(); }, [loadSummary]); + useEffect(() => { + if (w.api !== "__browser__") return; + const refresh = () => loadSummary(); + window.addEventListener("bb:browser-worker-updated", refresh); + window.addEventListener("bb:browser-knowledge-updated", refresh); + window.addEventListener("bb:cloud-configured", refresh); + return () => { + window.removeEventListener("bb:browser-worker-updated", refresh); + window.removeEventListener("bb:browser-knowledge-updated", refresh); + window.removeEventListener("bb:cloud-configured", refresh); + }; + }, [loadSummary, w.api]); + function updateStarterText(value: string) { setStarterText(value.slice(0, QUICK_NOTE_MAX_LENGTH)); } @@ -411,7 +543,7 @@ function AutopilotProgressCard({ summary, status, onOpenMonitor }: { summary: Ho
diff --git a/apps/web/src/components/observability-panel.tsx b/apps/web/src/components/observability-panel.tsx index 4bdd294..d240108 100644 --- a/apps/web/src/components/observability-panel.tsx +++ b/apps/web/src/components/observability-panel.tsx @@ -39,7 +39,7 @@ export function ObservabilityPanel({ open, apiUrl, onClose }: Props) { useEffect(() => { if (!open) return; - if (apiUrl === "__demo__") { + if (apiUrl === "__demo__" || apiUrl === "__browser__") { setJobs([]); setLogs([]); setWorker(null); @@ -71,6 +71,7 @@ export function ObservabilityPanel({ open, apiUrl, onClose }: Props) { const isFailedJob = (job: any) => job.status === "failed" || job.status === "dead_letter"; const loadData = async () => { + if (apiUrl === "__browser__") return; const [jRes, lRes, wRes, sRes] = await Promise.all([ fetch(`${apiUrl}/api/v1/jobs?limit=50`), fetch(`${apiUrl}/api/v1/automation-logs?limit=50`), @@ -88,6 +89,7 @@ export function ObservabilityPanel({ open, apiUrl, onClose }: Props) { }; async function retryJob(jobId: number) { + if (apiUrl === "__browser__") return; setRetryingJobId(jobId); setJobActionStatus(""); try { diff --git a/apps/web/src/components/onboarding-modal.tsx b/apps/web/src/components/onboarding-modal.tsx index 45c319e..87d2e7a 100755 --- a/apps/web/src/components/onboarding-modal.tsx +++ b/apps/web/src/components/onboarding-modal.tsx @@ -2,9 +2,23 @@ import { useEffect, useMemo, useState } from "react"; import { getApiUrl } from "@/contexts/workspace-context"; +import { + BROWSER_STORAGE_MODE, + getBrowserCloudConfig, + queueUnprocessedBrowserNotes, + saveBrowserCloudConfig, +} from "@/lib/browser-storage"; +import { testBrowserCloudConnection } from "@/lib/browser-ai"; const NVIDIA_NIM_URL = "https://integrate.api.nvidia.com/v1"; -const RECOMMENDED_MODEL = "qwen/qwen3.5-397b-instruct"; +const CLOUD_PROVIDERS: Record = { + [NVIDIA_NIM_URL]: "NVIDIA NIM", + "https://api.openai.com/v1": "OpenAI", + "https://api.deepseek.com/v1": "DeepSeek", + "https://api.groq.com/openai/v1": "Groq", + "https://openrouter.ai/api/v1": "OpenRouter", + "": "Custom OpenAI-compatible provider", +}; type MeResponse = { user?: { email?: string; displayName?: string }; @@ -54,10 +68,11 @@ export function OnboardingModal({ demo = false }: { demo?: boolean }) { const [show, setShow] = useState(false); const [step, setStep] = useState(0); const [phase, setPhase] = useState<"tour" | "ai">("tour"); - const [mode, setMode] = useState<"local" | "cloud">("local"); - const [modeSelected, setModeSelected] = useState(false); + const [mode, setMode] = useState<"local" | "cloud">(() => BROWSER_STORAGE_MODE ? "cloud" : "local"); + const [modeSelected, setModeSelected] = useState(BROWSER_STORAGE_MODE); const [help, setHelp] = useState<"local" | "cloud" | null>(null); const [apiUrl, setApiUrl] = useState(NVIDIA_NIM_URL); + const [customApiUrl, setCustomApiUrl] = useState(""); const [apiKey, setApiKey] = useState(""); const [model, setModel] = useState(""); const [localUrl, setLocalUrl] = useState("http://host.docker.internal:11434"); @@ -72,6 +87,43 @@ export function OnboardingModal({ demo = false }: { demo?: boolean }) { if (typeof window === "undefined") return; let alive = true; + if (BROWSER_STORAGE_MODE) { + getBrowserCloudConfig() + .then((config) => { + if (!alive) return; + const tourSeen = localStorage.getItem("bb_tour_seen") === "1"; + if (config) { + if (CLOUD_PROVIDERS[config.apiUrl]) setApiUrl(config.apiUrl); + else { + setApiUrl(""); + setCustomApiUrl(config.apiUrl); + } + setApiKey(config.apiKey); + setModel(config.model); + setModels([config.model]); + } + setStep(0); + setPhase(tourSeen ? "ai" : "tour"); + setShow(!tourSeen || !config); + }) + .catch(() => { + if (!alive) return; + setStep(0); + setPhase("tour"); + setShow(true); + }); + const openBrowserTour = () => { + setStep(0); + setPhase("tour"); + setShow(true); + }; + window.addEventListener("bb:open-tour", openBrowserTour); + return () => { + alive = false; + window.removeEventListener("bb:open-tour", openBrowserTour); + }; + } + function openTour() { fetch(`${getApiUrl()}/api/v1/auth/me`, { credentials: "include" }) .then((r) => (r.ok ? r.json() : null)) @@ -133,20 +185,28 @@ export function OnboardingModal({ demo = false }: { demo?: boolean }) { const steps = useMemo(() => baseSteps, []); const isConfigStep = phase === "ai"; + const cloudUrl = (apiUrl || customApiUrl).trim(); const aiConfigured = modeSelected && - (mode === "local" - ? Boolean(localUrl.trim()) && Boolean(localModel.trim()) - : Boolean(apiUrl.trim()) && Boolean(apiKey.trim()) && Boolean(model.trim())); + (mode === "local" + ? Boolean(localUrl.trim()) && Boolean(localModel.trim()) + : Boolean(cloudUrl) && Boolean(apiKey.trim()) && Boolean(model.trim())); const total = steps.length; const progress = isConfigStep ? 100 : Math.round(((step + 1) / total) * 100); const isLastTourStep = step === steps.length - 1; async function loadModels() { - const url = apiUrl.trim() || NVIDIA_NIM_URL; + const url = cloudUrl; setLoadingModels(true); setModelsError(""); try { + if (BROWSER_STORAGE_MODE) { + const data = await testBrowserCloudConnection(url, apiKey.trim()); + const ids = data.models; + setModels(ids); + if (!model.trim()) setModel(ids[0] || ""); + return; + } const r = await fetch(`${getApiUrl()}/api/v1/settings/ai/models`, { method: "POST", credentials: "include", @@ -158,9 +218,8 @@ export function OnboardingModal({ demo = false }: { demo?: boolean }) { const ids: string[] = Array.isArray(data?.models) ? data.models.map((m: { id?: string }) => m?.id).filter(Boolean) : []; - if (!ids.includes(RECOMMENDED_MODEL)) ids.unshift(RECOMMENDED_MODEL); setModels(ids); - if (!model.trim()) setModel(RECOMMENDED_MODEL); + if (!model.trim()) setModel(ids[0] || ""); } catch (err) { setModelsError(err instanceof Error ? err.message : "Failed to load models"); } finally { @@ -171,6 +230,34 @@ export function OnboardingModal({ demo = false }: { demo?: boolean }) { async function finish(provider: "local" | "cloud" = mode) { setSaving(true); setSaveError(""); + if (BROWSER_STORAGE_MODE) { + try { + const connection = await testBrowserCloudConnection(cloudUrl, apiKey.trim()); + await saveBrowserCloudConfig({ + provider: CLOUD_PROVIDERS[cloudUrl] || connection.provider, + apiUrl: connection.providerUrl, + apiKey, + model, + }); + await queueUnprocessedBrowserNotes(); + localStorage.setItem("bb_ai_provider", "cloud"); + localStorage.setItem("bb_graph_ai_provider", "cloud"); + localStorage.setItem("bb_ai_api_url", connection.providerUrl); + localStorage.setItem("bb_graph_ai_api_url", connection.providerUrl); + localStorage.setItem("bb_ai_model", model.trim()); + localStorage.setItem("bb_graph_ai_model", model.trim()); + localStorage.setItem("bb_remote_content_consent", "true"); + localStorage.setItem("bb_tour_seen", "1"); + localStorage.setItem("bb_onboarding_completed", "true"); + window.dispatchEvent(new CustomEvent("bb:cloud-configured")); + setShow(false); + } catch (error) { + setSaveError(error instanceof Error ? error.message : "The cloud provider could not be configured."); + } finally { + setSaving(false); + } + return; + } const url = apiUrl.trim() || NVIDIA_NIM_URL; const values: Record = { ai_provider: provider, @@ -229,10 +316,14 @@ export function OnboardingModal({ demo = false }: { demo?: boolean }) {

- {isConfigStep ? "AI setup" : steps[step].eyebrow} + {isConfigStep ? (BROWSER_STORAGE_MODE ? "Required cloud setup" : "AI setup") : steps[step].eyebrow}

- {isConfigStep ? "Choose how BerryBrain uses AI." : steps[step].title} + {isConfigStep + ? BROWSER_STORAGE_MODE + ? "Connect a cloud AI provider to continue." + : "Choose how BerryBrain uses AI." + : steps[step].title}

{!isConfigStep && ( @@ -262,9 +353,11 @@ export function OnboardingModal({ demo = false }: { demo?: boolean }) { ) : (

- Local mode keeps processing on your machine through Ollama. Cloud mode uses an OpenAI-compatible provider for graph enrichment and insights. + {BROWSER_STORAGE_MODE + ? "Your notes stay in IndexedDB in this browser. Your chosen cloud provider supplies cognitive processing while the tab is open; its API key stays in this browser and is excluded from exports." + : "Local mode keeps processing on your machine through Ollama. Cloud mode uses an OpenAI-compatible provider for graph enrichment and insights."}

-
+ {!BROWSER_STORAGE_MODE &&
-
+
} + + {BROWSER_STORAGE_MODE && ( +
+ Cloud AI is mandatory in the hosted web app. Processing resumes when BerryBrain is open; no note database or provider key is stored by BerryBrain servers. +
+ )} {mode === "local" && modeSelected && (
@@ -339,19 +438,33 @@ export function OnboardingModal({ demo = false }: { demo?: boolean }) {
)} - {mode === "cloud" && ( + {(mode === "cloud" || BROWSER_STORAGE_MODE) && (
+ {apiUrl === "" && ( + + )}
{modelsError && {modelsError}} - Recommended: {RECOMMENDED_MODEL} + Models are loaded from your cloud provider account.
)} @@ -400,7 +513,7 @@ export function OnboardingModal({ demo = false }: { demo?: boolean }) {
- {isConfigStep ? "AI setup" : `Step ${step + 1} of ${total}`} + {isConfigStep ? (BROWSER_STORAGE_MODE ? "Cloud AI required" : "AI setup") : `Step ${step + 1} of ${total}`}
)}
diff --git a/apps/web/src/components/panel/right-panel.tsx b/apps/web/src/components/panel/right-panel.tsx index 79a8447..749687e 100644 --- a/apps/web/src/components/panel/right-panel.tsx +++ b/apps/web/src/components/panel/right-panel.tsx @@ -24,7 +24,7 @@ export function RightPanel() { const [connections, setConnections] = useState([]); useEffect(() => { - if (!w.active || w.demo) { setSteps([]); return; } + if (!w.active || w.demo || w.api === "__browser__") { setSteps([]); return; } const fetchStatus = () => { fetch(`${w.api}/api/v1/notes/${w.active!.path.split("/").map(encodeURIComponent).join("/")}/status`) .then(r => r.json()).then(d => { setSteps(d.steps || []); setStepInfo(d); }).catch(() => {}); @@ -35,7 +35,7 @@ export function RightPanel() { }, [w.active?.path, w.api, w.demo]); useEffect(() => { - if (!w.active || w.demo) { setConnections([]); return; } + if (!w.active || w.demo || w.api === "__browser__") { setConnections([]); return; } fetch(`${w.api}/api/v1/connections/${w.active.path.split("/").map(encodeURIComponent).join("/")}`) .then(r => r.json()) .then(d => setConnections(d.connections || [])) @@ -50,7 +50,7 @@ export function RightPanel() { }; const updateConnection = async (id: number, action: "confirm" | "ignore") => { - if (w.demo) return; + if (w.demo || w.api === "__browser__") return; await fetch(`${w.api}/api/v1/connections/id/${id}/${action}`, { method: "POST" }); if (!w.active) return; const r = await fetch(`${w.api}/api/v1/connections/${w.active.path.split("/").map(encodeURIComponent).join("/")}`); @@ -64,6 +64,10 @@ export function RightPanel() { w.toast("Reprocessing is disabled in demo mode.", "info"); return; } + if (w.api === "__browser__") { + w.toast("Cognitive processing requires the self-hosted API and worker.", "info"); + return; + } const encoded = w.active.path.split("/").map(encodeURIComponent).join("/"); const response = await fetch(`${w.api}/api/v1/notes/${encoded}/reprocess`, { method: "POST" }); if (!response.ok) { @@ -79,6 +83,10 @@ export function RightPanel() { w.toast("Graph expansion is disabled in demo mode.", "info"); return; } + if (w.api === "__browser__") { + w.toast("Graph expansion requires the self-hosted API and worker.", "info"); + return; + } await fetch(`${w.api}/api/v1/graph/expand`, { method: "POST" }); }; @@ -102,7 +110,7 @@ export function RightPanel() { {w.active ? ( <> {steps.length > 0 && ( -
+
@@ -123,9 +131,9 @@ export function RightPanel() {
- - - + + +
diff --git a/apps/web/src/components/public-site/docs.tsx b/apps/web/src/components/public-site/docs.tsx index aa6e151..e80bae5 100644 --- a/apps/web/src/components/public-site/docs.tsx +++ b/apps/web/src/components/public-site/docs.tsx @@ -48,14 +48,19 @@ The landing page explains the project, links to GitHub, and provides **Login** f of that self-hosted instance. Public signup is disabled; an unconfigured deployment directs the owner to the one-time local setup. -### Production URL -The public landing/app can be served at: +### Official project site +The hosted project site is available at: \`\`\`txt -https://optlabs.com.br/berrybrain +https://berrybrain.netlify.app \`\`\` -Set the web env values to: +It serves the landing page, this documentation, and the FAQ. The hosted workspace is disabled +while durable persistence and background processing mature; use the self-hosted stack for the +complete product. + +### Self-hosted subpath +To serve your own instance under /berrybrain, set the web environment values to: \`\`\`bash NEXT_PUBLIC_BERRYBRAIN_BASE_PATH=/berrybrain @@ -175,7 +180,8 @@ your notes, attachments, extracted text, embeddings, backups, and local model fi Required software: -- 64-bit Linux host or Linux VM with a recent Docker Engine and Docker Compose v2. +- 64-bit Linux with Docker Engine and Docker Compose v2, or Windows with Docker Desktop, + WSL 2, and Linux containers. - A modern Chromium, Firefox, or Safari browser. - HTTPS for public deployments and PWA installation outside \`localhost\`. - Ollama plus an installed model for Local mode, **or** an OpenAI-compatible provider URL, @@ -191,7 +197,7 @@ reverse proxy for public deployments.`, md: `## Installation (self-hosting) ### Prerequisites -- A Linux host with Docker and Docker Compose. +- Linux with Docker Engine and Docker Compose, or Windows with Docker Desktop and WSL 2. - A domain (for TLS) or a local network address for testing. - A strong local owner password for first-run setup. @@ -265,6 +271,10 @@ On first login the **AI setup** modal opens automatically. Choose: Until you finish this step, the setup reappears on every load. This guarantees the system is never silently unconfigured. +The official hosted site does not expose setup, provider configuration, or a workspace. These +steps apply only to a self-hosted instance. An experimental browser-storage implementation remains +in the source for development and automated tests, but it is not a supported hosted deployment. + ### Guided tour A short tour runs **once** on first use, explaining capture, autopilot, graph, insights, and session controls. **Skip** moves directly to AI setup; it does not dismiss onboarding. The @@ -311,6 +321,9 @@ Security behavior: 5. Click **Load models**, then select a model returned by your provider. 6. Finish. +The official hosted site does not process notes or accept provider keys. In a self-hosted instance, +NVIDIA NIM is optional: choose any supported OpenAI-compatible cloud endpoint or Local Ollama. + ### Provider setup is required BerryBrain does not allow onboarding to finish without an explicit Local or Cloud choice. Local mode requires an installed Ollama model name. Cloud mode requires a provider URL, API @@ -610,7 +623,7 @@ claims supported by notes, concepts, connections, or processed attachments.`, md: `## Settings - **Appearance**: theme (light/dark). -- **Language**: interface in pt-BR or en (notes unchanged). +- **Language**: the interface is currently English; user notes remain in their original language. - **Fonts**: UI and editor font families and sizes. - **AI**: switch between Local (Ollama) and Cloud (API), manage keys and models. - **Cognitive layer**: retrieval mode, chunks, graph inference, confidence, and external vector stores. @@ -642,18 +655,16 @@ Expose only the web entrypoint; keep the API internal.`, title: "Verification & release status", md: `## Verification & release status -Current local verification evidence from 13 July 2026: +Verification is automated by repository CI: -- **API**: 156 unit and integration tests pass. -- **Worker**: 34 tests pass, including disposable-database integration paths. -- **Browser**: 13 production Playwright checks pass, including owner login, keyboard flow, mobile layout, and degraded Home behavior. -- **Static gates**: Ruff, formatting, MyPy, TypeScript, lint, and production build pass. -- **Containers**: local Trivy policy reports zero fixable HIGH/CRITICAL findings. -- **Supply chain**: SPDX SBOM generation and a signed immutable release workflow are defined. +- **API and Worker**: unit, integration, disposable-database, formatting, type, and coverage gates. +- **Browser**: production Playwright flows for public pages, owner access, workspace behavior, + browser-storage experiments, and the hosted landing-only boundary. +- **Containers**: Compose validation, security scanning, and image build checks. +- **Supply chain**: CodeQL, secret scanning, SPDX SBOM generation, and signed release workflows. -This evidence validates the local worktree. It is not a published release certificate. Branch -protection, required remote checks, signed registry artifacts, ten consecutive green \`main\` -runs, and an external clean-machine audit remain release governance gates.`, +Use the current GitHub commit checks and release audit as the source of truth. Static test counts +are intentionally omitted because they become stale whenever coverage grows.`, }, { id: "troubleshooting", @@ -812,11 +823,11 @@ const FAQ_ITEMS: FaqItem[] = [ }, { q: "Is my data private?", - a: "Yes. Notes live in your own vault. External AI, email, and enrichment are opt-in and fully visible in the provider trace.", + a: "Your self-hosted notes live in your own vault. If you configure cloud AI or external enrichment, relevant content can leave the instance and the provider trace records that use. The official hosted site does not accept notes or provider keys.", }, { q: "Do I need an AI provider to use it?", - a: "No. Without AI you still get deterministic insights and the lexical knowledge graph. Cloud or local models unlock richer embeddings, connections, and graph insights.", + a: "Yes. First-run onboarding requires an explicit Local Ollama or Cloud API configuration because the cognitive pipeline depends on a model. Deterministic parsing supplements AI processing; it does not replace provider setup.", }, { q: "What is Ollama and do I need it?", @@ -836,7 +847,11 @@ const FAQ_ITEMS: FaqItem[] = [ }, { q: "Can I self-host?", - a: "Yes. Deploy with Docker Compose and expose only the web/reverse-proxy entrypoint.", + a: "Yes. Linux can use Docker Engine and Docker Compose. Windows can use Docker Desktop with WSL 2 and Linux containers. Expose only the web/reverse-proxy entrypoint.", + }, + { + q: "Can I use the hosted web app?", + a: "Not yet. The official Netlify site currently provides the landing page, Docs, FAQ, and self-hosting downloads. Workspace, login, setup, provider proxies, and browser processing remain disabled there.", }, { q: "How do I request data access or deletion?", @@ -844,7 +859,15 @@ const FAQ_ITEMS: FaqItem[] = [ }, { q: "Which languages are supported?", - a: "The interface supports pt-BR and en. Note content is never translated.", + a: "The interface is currently English. Notes remain in the language in which the user wrote them and are not translated automatically. OCR supports languages installed in the configured OCR engine.", + }, + { + q: "How do I reset a forgotten password?", + a: "Use Forgot password when SMTP is configured. Otherwise run the documented seed_admin.py recovery command inside the API container; do not delete the vault or database just to reset access.", + }, + { + q: "How do backups and deletion work?", + a: "Self-hosted owners can create validated backups and can erase knowledge while keeping Settings or factory-reset the instance. Provider keys and vault files require deliberate confirmation and should never be committed to Git.", }, { q: "Is there an API?", diff --git a/apps/web/src/components/public-site/public-pages.tsx b/apps/web/src/components/public-site/public-pages.tsx index d57031c..d6838a4 100644 --- a/apps/web/src/components/public-site/public-pages.tsx +++ b/apps/web/src/components/public-site/public-pages.tsx @@ -8,8 +8,13 @@ import berryPrint3 from "../../../public/berrybrain-print3.png"; import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import { readCsrf } from "@/components/public-site/user-menu"; import { getApiUrl, appPath } from "@/contexts/workspace-context"; +import { BROWSER_STORAGE_MODE } from "@/lib/browser-storage"; const GITHUB_URL = "https://github.com/imsouza/berrybrain"; +const LANDING_ONLY = process.env.NEXT_PUBLIC_BERRYBRAIN_LANDING_ONLY === "true"; +const SOURCE_ZIP_URL = `${GITHUB_URL}/archive/refs/heads/main.zip`; +const SOURCE_TAR_URL = `${GITHUB_URL}/archive/refs/heads/main.tar.gz`; +const CONTAINER_URL = `${GITHUB_URL}/pkgs/container/berrybrain-web`; const legalContent: Record = { security: { @@ -26,6 +31,8 @@ const legalContent: Record = { title: "Privacy", body: [ "BerryBrain is local-first. User notes remain in the configured vault unless the user enables external providers.", + "Google Analytics is disabled by default on self-hosted instances and requires explicit visitor consent on the official website. Analytics is configured without advertising signals or ad personalization.", + "The Donate link opens Ko-fi in a separate tab. BerryBrain does not embed the Ko-fi widget or load Ko-fi scripts on its pages.", "When cloud AI, email, or external enrichment is configured, BerryBrain records provider, model, purpose, status, and evidence so the user can understand what left the local system.", "Account data is separated from note content. Security events may include timestamps, IP-derived request metadata, session state, and administrative actions needed to protect the service.", "Knowledge data is processed to build notes, concepts, graph edges, insights, and retrieval indexes. The product should never hide whether a result came from local processing or a configured external provider.", @@ -36,6 +43,7 @@ const legalContent: Record = { title: "GDPR and LGPD", body: [ "BerryBrain is designed around data minimization, transparency, and user-controlled processing. Notes and graph data are treated as personal knowledge data.", + "Optional website analytics remains disabled until consent is granted. Consent can be declined without losing access to BerryBrain.", "Self-hosted operators control access, correction, export, and deletion of their local instance data. Local vault files remain under the operator's storage control.", "Processing purposes include local authentication, instance security, note indexing, graph construction, retrieval, insight generation, and optional provider integrations configured by the local owner.", "For LGPD and GDPR requests, include enough context to verify ownership. Do not include passwords, API keys, tokens, or private notes in email.", @@ -67,6 +75,7 @@ const nav = [ ["Product", "/#product"], ["Architecture", "/#architecture"], ["Reliability", "/#reliability"], + ["Download", "/#download"], ["Docs", "/docs"], ["FAQ", "/faq"], ] as const; @@ -79,8 +88,10 @@ const footerGroups = [ ["Architecture", "/#architecture"], ["Reliability", "/#reliability"], ["Docs", "/docs"], - ["Open BerryBrain", "/brain"], + ["FAQ", "/faq"], + ["Download", "/#download"], ["GitHub", GITHUB_URL], + ["♥ Donate", "https://ko-fi.com/berrybrain"], ], }, { @@ -88,6 +99,7 @@ const footerGroups = [ links: [ ["Security", "legal:security"], ["Privacy", "legal:privacy"], + ["Privacy choices", "consent:analytics"], ["GDPR/LGPD", "legal:gdpr-lgpd"], ], }, @@ -152,6 +164,7 @@ const mobileNavLinks = [ ...nav, ["Security", "legal:security"], ["Privacy", "legal:privacy"], + ["Privacy choices", "consent:analytics"], ["GDPR/LGPD", "legal:gdpr-lgpd"], ["Terms", "legal:terms"], ["Contact", "legal:contact"], @@ -190,6 +203,14 @@ export function PublicShell({ const closeModal = useCallback(() => setModal(null), []); useEffect(() => { + if (LANDING_ONLY) { + setAccessState("configured"); + return; + } + if (BROWSER_STORAGE_MODE) { + setAccessState("configured"); + return; + } let alive = true; async function loadAccess() { try { @@ -245,6 +266,10 @@ export function PublicShell({ + ) : href.startsWith("http") ? ( + + {label} + ) : ( {label} @@ -253,7 +278,16 @@ export function PublicShell({ )}
- {accessState === "checking" ? ( + {LANDING_ONLY ? ( + + Web app + Web app in development + + ) : accessState === "checking" ? ( ) : accessState === "setup" ? ( @@ -307,7 +341,17 @@ export function PublicShell({
    {mobileNavLinks.map(([label, href]) => (
  • - {href.startsWith("legal:") ? ( + {href === "consent:analytics" ? ( + + ) : href.startsWith("legal:") ? (
+
+
+
+
+

Download

+

Run BerryBrain on infrastructure you control.

+
+

+ The hosted workspace is in development. Today, use Docker Desktop on Windows or Docker Engine on Linux. Both run the same web, API, and cognitive worker stack from the public repository. +

+
+
+ +
+ +

Linux + Docker Engine

+

Download the source archive and run the API, worker, and web containers with Docker Compose.

+ +
+
+ +

Source archive

+

Get the current main branch as a ZIP for inspection, local changes, or a clean self-hosted install.

+ +
+
+ +

Containers and source

+

Inspect published container packages, compose files, checks, license, and the complete installation documentation.

+ +
+
+
+ No native Windows installer is published. Windows self-hosting currently requires Docker Desktop; Linux requires Docker Engine and Docker Compose. Review the non-commercial license before deployment. +
+
+
+

- {accessState === "setup" ? "Create the owner account, then make the vault yours." : "Your second brain is ready when you are."} + {LANDING_ONLY ? "Self-host BerryBrain today." : accessState === "setup" ? "Create the owner account, then make the vault yours." : "Your second brain is ready when you are."}

- {accessState === "setup" + {LANDING_ONLY + ? "The hosted web app remains disabled while its persistence and background processing mature. Docker provides the complete supported experience today." + : accessState === "setup" ? "First run takes you through a one-time local owner setup, followed by model and provider configuration." : "Open the workspace to continue writing, connecting, and reviewing. Deployment, security, and recovery details remain available in the docs."}

@@ -1081,7 +1185,17 @@ function Footer({ onOpenModal }: { onOpenModal: (key: string) => void }) {
    {group.links.map(([label, href]) => (
  • - {href.startsWith("legal:") ? ( + {href === "consent:analytics" ? ( + + ) : href.startsWith("legal:") ? ( diff --git a/apps/web/src/components/public-site/user-menu.tsx b/apps/web/src/components/public-site/user-menu.tsx index 06a2f09..6eb38ff 100755 --- a/apps/web/src/components/public-site/user-menu.tsx +++ b/apps/web/src/components/public-site/user-menu.tsx @@ -3,6 +3,7 @@ import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { getApiUrl, appPath } from "@/contexts/workspace-context"; +import { BROWSER_STORAGE_MODE } from "@/lib/browser-storage"; export type MeUser = { id: number; @@ -29,6 +30,11 @@ export function UserMenu() { const [ready, setReady] = useState(false); const loadMe = useCallback(async () => { + if (BROWSER_STORAGE_MODE) { + setUser(null); + setReady(true); + return; + } if (!hasSessionCookie()) { setUser(null); setReady(true); @@ -71,6 +77,14 @@ export function UserMenu() { if (!ready) return
    ; + if (BROWSER_STORAGE_MODE) { + return ( + + Open app + + ); + } + if (!user) { return (
    diff --git a/apps/web/src/components/pwa-register.tsx b/apps/web/src/components/pwa-register.tsx index d7d4f5e..dc2d6e9 100755 --- a/apps/web/src/components/pwa-register.tsx +++ b/apps/web/src/components/pwa-register.tsx @@ -5,6 +5,17 @@ import { useEffect } from "react"; export function PwaRegister() { useEffect(() => { if (!("serviceWorker" in navigator)) return; + if (process.env.NEXT_PUBLIC_BERRYBRAIN_LANDING_ONLY === "true") { + navigator.serviceWorker.getRegistrations() + .then((registrations) => Promise.all(registrations.map((registration) => registration.unregister()))) + .catch(() => {}); + if ("caches" in window) { + caches.keys() + .then((keys) => Promise.all(keys.filter((key) => key.startsWith("berrybrain-")).map((key) => caches.delete(key)))) + .catch(() => {}); + } + return; + } const host = window.location.hostname; const localHost = host === "localhost" || host === "127.0.0.1"; if (!window.isSecureContext && !localHost) return; diff --git a/apps/web/src/components/settings-panel.tsx b/apps/web/src/components/settings-panel.tsx index 6425468..f2ee159 100644 --- a/apps/web/src/components/settings-panel.tsx +++ b/apps/web/src/components/settings-panel.tsx @@ -1,8 +1,20 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { LangKind, getLang, t, tf } from "../i18n"; import { readCsrf } from "./public-site/user-menu"; +import { + BROWSER_STORAGE_MODE, + browserStorageStatus, + clearBrowserCloudConfig, + exportBrowserBackup, + getBrowserCloudConfig, + importBrowserBackup, + queueUnprocessedBrowserNotes, + saveBrowserCloudConfig, + wipeBrowserStorage, +} from "@/lib/browser-storage"; +import { testBrowserCloudConnection } from "@/lib/browser-ai"; type ThemeKind = "light" | "dark"; @@ -294,6 +306,9 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos const [diagLoading, setDiagLoading] = useState(false); const [diagClearing, setDiagClearing] = useState(false); const [diagClearResult, setDiagClearResult] = useState(""); + const [portabilityStatus, setPortabilityStatus] = useState(""); + const [storageStatus, setStorageStatus] = useState<{ persisted: boolean; usage: number; quota: number } | null>(null); + const importInputRef = useRef(null); const selectedProviderLabel = useMemo(() => CLOUD_PROVIDERS[s.ai_api_url] || "Custom provider", [s.ai_api_url]); const nimApiKey = s.ai_api_key; @@ -309,6 +324,48 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos setIsAdmin(false); return; } + if (BROWSER_STORAGE_MODE) { + setIsAdmin(false); + setSettingsLoading(false); + browserStorageStatus().then(setStorageStatus).catch(() => setStorageStatus(null)); + getBrowserCloudConfig() + .then((config) => { + setApiKeyConfigured(Boolean(config)); + setGraphApiKeyConfigured(Boolean(config)); + setProviderStatus(config ? { + state: "connected", + provider: config.provider, + providerMode: "cloud", + keyConfigured: true, + modelConfigured: true, + model: config.model, + graphProviderMode: "cloud", + graphKeyConfigured: true, + graphModelConfigured: true, + graphModel: config.model, + lastTestStatus: "connected", + remoteContentConsent: true, + } : null); + if (!config || editedRef.current) return; + setS((previous) => ({ + ...previous, + ai_provider: "cloud", + graph_ai_provider: "cloud", + ai_api_url: CLOUD_PROVIDERS[config.apiUrl] ? config.apiUrl : "", + ai_custom_url: CLOUD_PROVIDERS[config.apiUrl] ? "" : config.apiUrl, + graph_ai_api_url: config.apiUrl, + ai_api_key: config.apiKey, + graph_ai_api_key: config.apiKey, + ai_model: config.model, + graph_ai_model: config.model, + kb_embedding_provider: "cloud", + remote_content_consent: "true", + })); + cloudConnectionVerifiedRef.current = true; + }) + .catch(() => setProviderStatus(null)); + return; + } let cancelled = false; setSettingsLoading(true); fetch(`${apiUrl}/api/v1/auth/me`, { credentials: "include" }) @@ -352,7 +409,7 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos useEffect(() => { if (!open) return; - if (apiUrl === "__demo__") { + if (apiUrl === "__demo__" || BROWSER_STORAGE_MODE) { setDiagnostics(null); return; } @@ -400,6 +457,16 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos else localStorage.setItem(`bb_${key}`, String(next[key])); if (!SECRET_SETTING_KEYS.has(key) || String(next[key]).trim()) values[key] = String(next[key]); }); + if (BROWSER_STORAGE_MODE) { + const providerUrl = (next.ai_api_url || next.ai_custom_url).trim(); + await saveBrowserCloudConfig({ + provider: CLOUD_PROVIDERS[providerUrl] || new URL(providerUrl).hostname, + apiUrl: providerUrl, + apiKey: next.ai_api_key, + model: next.ai_model, + }); + return; + } if (!isAdmin || apiUrl === "__demo__") return; const response = await fetch(`${apiUrl}/api/v1/settings/batch`, { method: "PUT", @@ -414,26 +481,39 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos setSaving(true); setSaveStatus(""); try { + let queuedNotes = 0; const baseUrl = (s.ai_api_url || s.ai_custom_url).trim(); const isNvidiaNim = isNvidiaNimEndpoint(baseUrl); const hasCloudKey = Boolean(s.ai_api_key.trim()) || apiKeyConfigured; const hasCloudModel = Boolean(s.ai_model.trim()); const inferNimActivation = isNvidiaNim && providerChoiceRef.current !== "local"; - const wantsCloud = s.ai_provider === "cloud" || inferNimActivation; + const wantsCloud = BROWSER_STORAGE_MODE || s.ai_provider === "cloud" || inferNimActivation; let next = s; + if (BROWSER_STORAGE_MODE) { + next = { + ...s, + ai_provider: "cloud", + graph_ai_provider: "cloud", + graph_ai_api_url: baseUrl, + graph_ai_model: s.ai_model, + kb_embedding_provider: "cloud", + remote_content_consent: "true", + }; + } + if (wantsCloud && (!baseUrl || !hasCloudKey || !hasCloudModel)) { setSaveStatus("Cloud setup is incomplete. Add an API URL, API key, and model before saving."); return; } - const needsConsent = wantsCloud && s.remote_content_consent !== "true"; + const needsConsent = !BROWSER_STORAGE_MODE && wantsCloud && s.remote_content_consent !== "true"; if (needsConsent) { const confirmed = window.confirm( - "Enable NVIDIA NIM processing?\n\nBerryBrain will send note, attachment, and graph content to the configured NVIDIA API for AI processing. This consent is saved and will not be requested again unless cloud processing is disabled.", + "Enable cloud AI processing?\n\nBerryBrain will send note, attachment, and graph content to the configured cloud provider for AI processing. This consent is saved and will not be requested again unless cloud processing is disabled.", ); if (!confirmed) { - setSaveStatus("Save cancelled. NVIDIA NIM was not enabled."); + setSaveStatus("Save cancelled. Cloud AI was not enabled."); return; } next = { @@ -452,14 +532,23 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos if (needsConnectionTest && !(await testCloudConnection(next, false))) return; await persist(next); + if (BROWSER_STORAGE_MODE) queuedNotes = await queueUnprocessedBrowserNotes(); applyTheme(next); if (next.ai_api_key) setApiKeyConfigured(true); if (next.graph_ai_api_key) setGraphApiKeyConfigured(true); - setS({ ...next, ai_api_key: "", graph_ai_api_key: "" }); + setS(BROWSER_STORAGE_MODE ? next : { ...next, ai_api_key: "", graph_ai_api_key: "" }); editedRef.current = false; providerChoiceRef.current = null; cloudConnectionEditedRef.current = false; - setSaveStatus(wantsCloud ? "Settings saved. NVIDIA NIM is active." : "Settings saved."); + setSaveStatus( + wantsCloud + ? `Settings saved. Cloud AI is active.${queuedNotes ? ` ${queuedNotes} note${queuedNotes === 1 ? "" : "s"} queued for cognitive processing.` : ""}` + : "Settings saved.", + ); + if (BROWSER_STORAGE_MODE) { + localStorage.setItem("bb_onboarding_completed", "true"); + window.dispatchEvent(new CustomEvent("bb:cloud-configured")); + } await refreshProviderStatus(); } catch (error) { setSaveStatus(error instanceof Error ? error.message : "Settings could not be saved."); @@ -477,6 +566,24 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos setConnectionStatus("Provider testing is disabled in demo mode."); return false; } + if (BROWSER_STORAGE_MODE) { + setLoadingModels(true); + setConnectionStatus(""); + try { + const providerUrl = (next.ai_api_url || next.ai_custom_url).trim(); + const payload = await testBrowserCloudConnection(providerUrl, next.ai_api_key); + setCloudModels(payload.models.map((id) => ({ id }))); + setConnectionStatus(`Connection verified. ${payload.models.length} models available.`); + cloudConnectionVerifiedRef.current = true; + return true; + } catch (error) { + setConnectionStatus(`Connection failed: ${error instanceof Error ? error.message : "Provider unavailable."}`); + setSaveStatus("Settings were not saved because the cloud provider could not be verified."); + return false; + } finally { + setLoadingModels(false); + } + } const baseUrl = next.ai_api_url || next.ai_custom_url; setLoadingModels(true); setConnectionStatus(""); @@ -520,6 +627,16 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos async function clearCloudKey() { if (!window.confirm("Clear the saved cloud API key? AI cloud processing will stop until a new key is configured.")) return; + if (BROWSER_STORAGE_MODE) { + await clearBrowserCloudConfig(); + setApiKeyConfigured(false); + setGraphApiKeyConfigured(false); + setS((previous) => ({ ...previous, ai_api_key: "", graph_ai_api_key: "" })); + setConnectionStatus("Cloud API key cleared from this browser."); + localStorage.removeItem("bb_onboarding_completed"); + window.dispatchEvent(new CustomEvent("bb:open-tour")); + return; + } const response = await fetch(`${apiUrl}/api/v1/settings/ai/key`, { method: "DELETE", credentials: "include", @@ -572,6 +689,29 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos ); if (!confirmed) return; setConnectionStatus("Wiping BerryBrain data..."); + if (BROWSER_STORAGE_MODE) { + try { + const preservedCloudConfig = resetSettings ? null : await getBrowserCloudConfig(); + await wipeBrowserStorage(); + if (resetSettings) resetLocalSettings(); + else { + preserveLocalSettings(); + if (preservedCloudConfig) { + await saveBrowserCloudConfig({ + provider: preservedCloudConfig.provider, + apiUrl: preservedCloudConfig.apiUrl, + apiKey: preservedCloudConfig.apiKey, + model: preservedCloudConfig.model, + }); + } + } + setConnectionStatus(resetSettings ? "Browser data wiped. Settings reset. Reloading..." : "Browser data wiped. Settings preserved. Reloading..."); + window.setTimeout(() => window.location.reload(), 700); + } catch (error) { + setConnectionStatus(error instanceof Error ? error.message : "Browser data could not be wiped."); + } + return; + } const response = await fetch(`${apiUrl}/api/v1/settings/danger/wipe`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -593,6 +733,10 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos setMaintenanceStatus("Maintenance actions are disabled in demo mode."); return; } + if (BROWSER_STORAGE_MODE) { + setMaintenanceStatus("This maintenance action requires the self-hosted worker."); + return; + } const labels: Record = { "rebuild-brain": "Rebuild second brain", "cleanup-legacy-insights": "Cleanup legacy technical insights", @@ -636,6 +780,41 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos finally { setDiagClearing(false); } } + async function exportAllBrowserData() { + setPortabilityStatus("Creating verified backup..."); + try { + const blob = await exportBrowserBackup(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + anchor.href = url; + anchor.download = `berrybrain-backup-${stamp}.berrybrain.json`; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + setPortabilityStatus("Backup exported with SHA-256 integrity verification."); + } catch (error) { + setPortabilityStatus(error instanceof Error ? error.message : "Backup could not be exported."); + } + } + + async function importAllBrowserData(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + const confirmed = window.confirm( + "Import this BerryBrain backup?\n\nAll current browser notes, graph data, attachments, jobs, insights, and Settings will be replaced. Export the current workspace first if you may need it.", + ); + if (!confirmed) return; + setPortabilityStatus("Validating and importing backup..."); + try { + await importBrowserBackup(file); + setPortabilityStatus("Backup restored. Reloading..."); + window.setTimeout(() => window.location.reload(), 700); + } catch (error) { + setPortabilityStatus(error instanceof Error ? error.message : "Backup could not be imported."); + } + } + if (!open) return null; return ( @@ -706,7 +885,7 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos
-
+ {!BROWSER_STORAGE_MODE &&
update("attachment_ocr_language", value)} placeholder="eng or por+eng" /> @@ -719,33 +898,32 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos update("attachment_transcription_model", value)} placeholder="/opt/berrybrain/models/faster-whisper-tiny.en" /> -
+
}
- - update("ai_provider", value as Settings["ai_provider"])}> - + } - update("graph_ai_provider", value as Settings["graph_ai_provider"])}> - + }
-
+
@@ -755,15 +933,17 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos )}
update("ai_api_key", value)} - placeholder={apiKeyConfigured ? "Saved securely — enter a new key to replace it" : "Paste your NVIDIA NIM API key"} + placeholder={apiKeyConfigured ? "Saved in this browser — enter a new key to replace it" : "Paste your cloud provider API key"} />
- - update("kb_vector_store", value as Settings["kb_vector_store"])}> - + } -
+ {!BROWSER_STORAGE_MODE &&
update("qdrant_url", value)} placeholder="http://localhost:6333" /> @@ -811,13 +991,13 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos update("chroma_collection", value)} placeholder="berrybrain" /> -
- - update("kb_embedding_provider", value as Settings["kb_embedding_provider"])}> - + } update("kb_embedding_model", value)} placeholder="Example: nvidia/llama-3.2-nv-embedqa-1b-v2 or bge-m3" /> @@ -866,7 +1046,7 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos
-
+ {!BROWSER_STORAGE_MODE &&
update("ollama_base_url", value)} placeholder="http://host.docker.internal:11434" /> @@ -885,13 +1065,13 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos -
+
}
- +
-
+ {!BROWSER_STORAGE_MODE &&
runMaintenance("rebuild-brain")}>Rebuild second brain runMaintenance("cleanup-legacy-insights")}>Cleanup legacy insights @@ -899,9 +1079,9 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos runMaintenance("reindex-knowledge-base")}>Reindex knowledge base
{maintenanceStatus &&

{maintenanceStatus}

} -
+
} -
+ {!BROWSER_STORAGE_MODE &&
{diagLoading ? (

{t("loadingDiagnostics")}

) : diagnostics ? ( @@ -935,7 +1115,35 @@ export function SettingsPanel({ open, onClose, apiUrl }: { open: boolean; onClos ) : (

{t("loadingDiagnostics")}

)} -
+
} + + {BROWSER_STORAGE_MODE && ( +
+
+

{storageStatus?.persisted ? "Persistent browser storage granted." : "Persistent storage is not guaranteed by this browser."}

+ {storageStatus?.quota ? ( +

+ {formatBytes(storageStatus.usage)} used of approximately {formatBytes(storageStatus.quota)}. +

+ ) : null} +
+
+ Export all data + importInputRef.current?.click()}>Import all data +
+ +

+ The backup includes notes, attachments, graph records, insights, jobs, metadata, and non-sensitive local preferences. Analytics consent and login sessions are excluded. +

+ {portabilityStatus &&

{portabilityStatus}

} +
+ )}
@@ -969,6 +1177,13 @@ function Section({ title, description, children }: { title: string; description: ); } +function formatBytes(value: number) { + if (!Number.isFinite(value) || value <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + function Field({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) { return (