diff --git a/.github/workflows/ci-and-labels.yml b/.github/workflows/ci-and-labels.yml index cf4a3e23..efce0c2d 100644 --- a/.github/workflows/ci-and-labels.yml +++ b/.github/workflows/ci-and-labels.yml @@ -41,6 +41,7 @@ jobs: frontend: ${{ steps.forced.outputs.frontend || steps.filter.outputs.frontend || steps.default.outputs.frontend }} tooling: ${{ steps.forced.outputs.tooling || steps.filter.outputs.tooling || steps.default.outputs.tooling }} any_code: ${{ steps.forced.outputs.any_code || steps.filter.outputs.any_code || steps.default.outputs.any_code }} + offline: ${{ steps.forced.outputs.offline || steps.filter.outputs.offline || steps.default.outputs.offline }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -62,6 +63,22 @@ jobs: - 'tsconfig.json' any_code: - '!(**.md|docs/**)' + # Paths whose change can break a first-use offline navigation, and + # nothing else. Kept separate from `frontend` so the production + # offline suite (build + Chromium) runs where it can find the + # defect, instead of on every UI edit (plans/161, ADR 041). + offline: + - 'public/sw.js' + - 'public/precache-manifest.json' + - 'scripts/generate-precache-manifest.mjs' + - 'src/app/layout.tsx' + - 'src/components/studio/app-shell.tsx' + - 'src/components/studio/offline-indicator.tsx' + - 'src/components/studio/service-worker-registration.tsx' + - 'e2e/offline-views.spec.ts' + - 'playwright.config.ts' + - 'package.json' + - 'pnpm-lock.yaml' - name: Treat every path as changed on scheduled and manual runs id: forced # A nightly or a manual dispatch is a full sweep by definition: there is no @@ -76,6 +93,7 @@ jobs: echo "frontend=true" echo "tooling=true" echo "any_code=true" + echo "offline=true" } >> "$GITHUB_OUTPUT" - name: Default outputs on diff API failure id: default @@ -84,10 +102,12 @@ jobs: frontend="${{ steps.filter.outputs.frontend || 'true' }}" tooling="${{ steps.filter.outputs.tooling || 'true' }}" any_code="${{ steps.filter.outputs.any_code || 'true' }}" + offline="${{ steps.filter.outputs.offline || 'true' }}" { echo "frontend=$frontend" echo "tooling=$tooling" echo "any_code=$any_code" + echo "offline=$offline" } >> "$GITHUB_OUTPUT" quality-gate: @@ -157,7 +177,8 @@ jobs: if: >- (github.event_name == 'schedule' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' || - needs.changes.outputs.frontend == 'true' + needs.changes.outputs.frontend == 'true' || + needs.changes.outputs.offline == 'true' steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -201,6 +222,17 @@ jobs: else pnpm run test:e2e --project=chromium fi + - name: Production offline suite + # The precache manifest and the worker's cache identity are build + # artefacts, so this suite only means something against `pnpm run start`. + # It runs after the dev-server suite has exited rather than replacing it: + # the dev run covers the app, this one covers the offline contract. + # Gated on the `offline` filter so unrelated UI edits don't pay for a + # production build (plans/161, ADR 041). + if: needs.changes.outputs.offline == 'true' + run: | + pnpm run build + pnpm run test:e2e:offline - name: Upload Playwright artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -308,7 +340,7 @@ jobs: - name: Generate coverage badge if: github.ref == 'refs/heads/main' run: | - COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct') + COVERAGE=$(jq '.total.lines.pct' coverage/coverage-summary.json) echo "Lines coverage: ${COVERAGE}%" - name: Upload coverage artifact if: always() diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 0122957e..c5ac0925 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -111,9 +111,9 @@ jobs: ignore-unfixed: true trivyignores: '.trivyignore' hide-progress: true - # Trivy notices output can trigger exit 1 even with 0 actionable - # vulnerabilities. The SARIF upload to GitHub code-scanning is the - # source of truth — humans review the Security tab. + # Trivy notices output can trigger exit 1 even with 0 actionable + # vulnerabilities. The SARIF upload to GitHub code-scanning is the + # source of truth — humans review the Security tab. - name: Upload Trivy SARIF if: always() diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index 3e7b4abc..037f41d6 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -4,6 +4,7 @@ import { expectNavigationReachable, navClick, openNavIfHidden, + waitForAppReady, } from './helpers/navigation'; test.describe('Accessibility', () => { @@ -193,3 +194,199 @@ test.describe('axe-core automated accessibility', () => { await assertNoAxeViolations(page); }); }); + +/** + * The offline banner is `fixed top-0` at `z-50` while the topbar is in normal + * flow at the top of the page, so the banner's band sits directly over the + * topbar's controls. + * + * Measured before this test existed: on a 1280 and a 1920 viewport the 36px + * banner covered the hit points of the quick filter, the command-palette + * trigger and New entity; at 390×844 the wrapped 56px banner covered the menu + * trigger and the search trigger too. `document.elementFromPoint` returned the + * banner for every one of them, so while offline — the moment a local-first app + * most needs to work — its primary controls could not be clicked. + * + * This asserts the user-visible contract (the controls are hit-testable), not + * the banner's height or styling, so any layout that keeps them reachable + * passes. + */ +test.describe('Offline banner', () => { + // The banner slides in from above the viewport. Without this, the geometric + // probe below can run while it is still off-screen and every control looks + // reachable — the exact way the first version of this test passed on broken + // code. Reduced motion removes the slide entirely, so the probe always sees + // the resting layout, and the explicit position wait covers the animated case. + test.use({ reducedMotion: 'reduce' }); + + test('does not cover the topbar controls', async ({ page, context }) => { + await page.goto('/'); + await waitForAppReady(page); + + await context.setOffline(true); + + // The banner mounts and the shell reserves its height in the SAME paint + // (OfflineIndicator publishes `--offline-banner-height` from a layout + // effect), so under reduced motion the reservation is already in place when + // the banner first appears. The poll is kept as a precondition/diagnostic + // rather than to paper over a frame gap: it reports "only 0px of 56px + // reserved" instead of an inscrutable geometric failure if the reservation + // ever regresses, and it also covers the non-reduced-motion slide-in. + await expect + .poll( + () => + page.evaluate(() => { + const banner = [...document.querySelectorAll('[role="status"]')].find( + (node) => /offline/i.test(node.textContent ?? '') && node.className.includes('fixed'), + ); + if (banner === undefined) return 'no banner'; + const bannerRect = banner.getBoundingClientRect(); + // The banner is `fixed top-0`, so at rest its top is 0. While it + // slides in from above, top is NEGATIVE (bottom < height) — which a + // bottom-only check treated as "at rest", letting the geometric + // probe below run against a half-entered banner (GitNexus on + // PR #925). + if (Math.abs(bannerRect.top) > 0.5) return 'banner still sliding in'; + const shell = document.querySelector('[data-app-ready="true"]'); + const reserved = shell ? Number.parseFloat(getComputedStyle(shell).paddingTop) : 0; + return reserved >= bannerRect.height - 0.5 + ? 'reserved' + : `only ${reserved}px of ${bannerRect.height}px reserved`; + }), + { timeout: 5000 }, + ) + .toBe('reserved'); + + const obstructed = await page.evaluate(() => + [...document.querySelectorAll('header button, header input')] + .map((el) => ({ el, rect: el.getBoundingClientRect() })) + .filter(({ rect }) => rect.width > 0) + .filter(({ el, rect }) => { + const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); + return !(hit === el || el.contains(hit)); + }) + .map(({ el }) => + (el.getAttribute('aria-label') ?? el.getAttribute('placeholder') ?? el.textContent ?? '') + .trim() + .slice(0, 40), + ), + ); + + expect( + obstructed, + `topbar controls hidden behind the offline banner: ${obstructed.join(', ')}`, + ).toEqual([]); + }); + +}); + +/** + * The reservation's ORDERING claim needs the exit animation to exist: under the + * reduced-motion override above, the banner unmounts in the same frame the + * reservation drops, so a regression that releases early has no window to be + * caught in (measured: a defect injected into `offline-indicator.tsx` passed the + * reduced-motion version of this test). This block therefore runs with motion + * enabled and records the two events from inside the page. + */ +test.describe('Offline banner exit', () => { + test.use({ reducedMotion: 'no-preference' }); + + test('releases the reserved space once the banner has exited', async ({ page, context }) => { + await page.goto('/'); + await waitForAppReady(page); + + await context.setOffline(true); + // Wait for the banner to be *in view*, not merely mounted: with motion + // enabled it enters from `y: -100%`, so a recorder started as soon as the + // reservation appears would sample a banner that is still above the + // viewport, record `banner-gone` before the online transition, and then + // accept a premature release during the real exit (GitNexus on PR #925). + await expect + .poll( + () => + page.evaluate(() => { + const shell = document.querySelector('[data-app-ready="true"]'); + const banner = [...document.querySelectorAll('[role="status"]')].find( + (node) => /offline/i.test(node.textContent ?? '') && node.className.includes('fixed'), + ); + if (shell === null) return 'no shell'; + if (banner === undefined) return 'no banner'; + const rect = banner.getBoundingClientRect(); + const reserved = Number.parseFloat(getComputedStyle(shell).paddingTop); + if (reserved <= 0) return 'not reserved'; + if (rect.bottom <= 0 || rect.height === 0) return 'banner still entering'; + return 'in view and reserved'; + }), + { timeout: 5000 }, + ) + .toBe('in view and reserved'); + + // Record the ORDER of the two events from inside the page, at frame + // resolution. The claim is causal — the reservation must outlive the + // banner's exit — and neither a poll loop from the test process nor a + // reduced-motion run can see it: the exit is a few frames long, so a + // regression that drops the reservation the moment the browser goes online + // slips between samples (measured: exactly that defect passed the previous + // version of this test). + // + // The recorder runs inside the page and resolves once it has seen both + // events, so no state crosses the boundary except the ordered list. + const recording = page.evaluate(async () => { + // "Gone" is a geometry question, not a DOM-presence one: during the exit + // animation the banner is still mounted while sliding off the top of the + // viewport, and a reservation dropped at that point covers nothing. The + // contract is that the topbar is never left under a *visible* banner, so + // the marker fires once the banner's bottom edge has left the viewport. + const bannerOnScreen = () => + [...document.querySelectorAll('[role="status"]')].some((node) => { + if (!/offline/i.test(node.textContent ?? '') || !node.className.includes('fixed')) { + return false; + } + return node.getBoundingClientRect().bottom > 0; + }); + const reservedPx = () => { + const shell = document.querySelector('[data-app-ready="true"]'); + return shell === null ? 0 : Number.parseFloat(getComputedStyle(shell).paddingTop); + }; + + const recorded: string[] = []; + await new Promise((resolve) => { + let settled = false; + const finish = (marker?: string) => { + if (settled) return; + settled = true; + if (marker !== undefined) recorded.push(marker); + resolve(); + }; + const tick = () => { + if (!bannerOnScreen() && !recorded.includes('banner-gone')) recorded.push('banner-gone'); + if (reservedPx() === 0 && !recorded.includes('unreserved')) recorded.push('unreserved'); + if (recorded.includes('banner-gone') && recorded.includes('unreserved')) { + finish(); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + setTimeout(() => { finish('timeout'); }, 5000); + }); + return recorded; + }); + + await context.setOffline(false); + + const order = await recording; + const bannerGoneAt = order.indexOf('banner-gone'); + const unreservedAt = order.indexOf('unreserved'); + + expect(bannerGoneAt, `the banner never left the layout: ${order.join(' -> ')}`).toBeGreaterThanOrEqual(0); + expect(unreservedAt, `never released the reservation: ${order.join(' -> ')}`).toBeGreaterThanOrEqual(0); + // The reservation may end in the same frame the banner leaves, but never + // before it: releasing first slides the topbar back under a banner that is + // still animating out. + expect( + unreservedAt, + `reservation released before the banner left the layout: ${order.join(' -> ')}`, + ).toBeGreaterThanOrEqual(bannerGoneAt); + }); +}); diff --git a/e2e/hydration-mismatch.spec.ts b/e2e/hydration-mismatch.spec.ts new file mode 100644 index 00000000..646d4c93 --- /dev/null +++ b/e2e/hydration-mismatch.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { waitForAppReady } from './helpers/navigation'; + +/** + * A view must hydrate cleanly when the OS asks for reduced motion. + * + * `useReducedMotion` is consumed by ~25 animated elements to decide whether to + * skip their entry animation (`initial={reducedMotion ? false : {...}}`). If the + * hook answers differently on the server than on the client's first pass, the + * markup React hydrates does not match the HTML it was sent, and React reports + * a mismatch and re-renders that subtree from scratch — on every animated + * element, on every load, for exactly the users who asked for less motion. + * + * The failure is invisible to a screenshot and invisible to a unit test that + * mocks the hook, so it is pinned here in a real browser instead. + * + * Runs at every configured viewport, because the media state is per-context + * rather than per-viewport and a view can be entered by reloading on it. + */ + +/** Readable React message in development; minified codes are #418/#423 in production. */ +const HYDRATION_MISMATCH = /hydrat|server rendered HTML didn't match|Minified React error #(418|423)/i; + +/** + * React reports a hydration mismatch *after* the tree has committed: the report + * is queued, not thrown, so it arrives some time after `data-app-ready` — and an + * absence assertion cannot poll for a message that never comes. Measured on the + * defective code: reading the console immediately after readiness captures + * nothing, and the report is present well within this window. + */ +const HYDRATION_REPORT_SETTLE_MS = 2000; + +test.describe('Hydration under prefers-reduced-motion', () => { + test.use({ reducedMotion: 'reduce' }); + + test('Home hydrates without a server/client mismatch', async ({ page }) => { + const consoleOutput: string[] = []; + page.on('console', (message) => { + consoleOutput.push(`[${message.type()}] ${message.text()}`); + }); + page.on('pageerror', (error) => consoleOutput.push(`[pageerror] ${error.message}`)); + + // The listener must exist before the document loads: React reports the + // mismatch while hydrating, which is over by the time `goto` resolves. + await page.goto('/'); + await waitForAppReady(page); + await page.waitForTimeout(HYDRATION_REPORT_SETTLE_MS); + + // Sanity: a blank page must not be able to pass this test. + await expect(page.getByRole('heading', { name: /recent work/i })).toBeVisible(); + + const mismatches = consoleOutput.filter((text) => HYDRATION_MISMATCH.test(text)); + expect(mismatches, `hydration errors:\n${mismatches.join('\n')}`).toEqual([]); + }); + + test('entry animations end visible rather than frozen mid-motion', async ({ page }) => { + await page.goto('/'); + await waitForAppReady(page); + + // The section the Home view fades in. Under reduced motion it must reach + // full opacity immediately — the point of skipping the animation is to end + // at the final state, not to sit at the initial one. + await expect(page.locator('main section').first()).toHaveCSS('opacity', '1'); + }); +}); diff --git a/e2e/offline-views.spec.ts b/e2e/offline-views.spec.ts new file mode 100644 index 00000000..238504f9 --- /dev/null +++ b/e2e/offline-views.spec.ts @@ -0,0 +1,182 @@ +import { test, expect, type Page } from '@playwright/test'; +import { navClick, waitForAppReady } from './helpers/navigation'; + +/** + * First-use offline navigation into each lazily imported view. + * + * The app shell code-splits Graph, Mind Map, AI Harness, TRIZ, Export and Sync + * (plans/158). The precache manifest is derived from the emitted HTML, which + * only references the boot document's own chunks — so before the fix the + * offline cache held the shell but not the view chunks, and the first + * navigation to an unvisited view offline rejected its dynamic import instead + * of rendering the view. + * + * Reproducing that requires a real production build: `next dev` re-chunks on + * every request, so its asset URLs never match the committed manifest. Run via + * `pnpm run build && pnpm run test:e2e:offline`. + * + * Isolation is deliberate and load-bearing: + * - Each test gets a fresh browser context from Playwright, so `CacheStorage` + * and the service worker registration start empty and no earlier test can + * have prewarmed a chunk. + * - The online visit exists only to let the worker install. That page is closed + * before the offline check so its in-memory modules cannot satisfy the import + * that we are trying to prove comes from the cache. + * - The browser HTTP cache is cleared with CDP, because a chunk left there by + * the online visit would mask a missing precache entry. + * - `CacheStorage` and `localStorage` are intentionally left intact: the worker + * cache is the mechanism under test. + */ + +test.use({ serviceWorkers: 'allow' }); + +/** Heading the view error boundary renders when a dynamic import rejects. */ +const LOAD_FAILURE_HEADING = /failed to load/i; + +interface OfflineViewCase { + /** View name, used for the test title. */ + readonly name: string; + /** Sidebar button label, matched inside the main navigation. */ + readonly nav: RegExp; + /** + * Asserts a surface only the view's own chunk can render. + * + * Every case is scoped to the `main` landmark: the shell renders a topbar + * `

` carrying the current view's name, so an unscoped heading assertion + * for "AI Harness" or "Sync" is satisfied by the chrome even when the lazy + * import rejected and the view body is an error fallback. + */ + readonly assertSurface: (page: Page) => Promise; +} + +const OFFLINE_VIEW_CASES: readonly OfflineViewCase[] = [ + { + name: 'Graph', + nav: /graph/i, + assertSurface: async (page) => { + await expect( + page.getByRole('main').getByRole('img', { name: /knowledge graph/i }), + ).toBeVisible(); + }, + }, + { + name: 'Mind Map', + nav: /mind map/i, + assertSurface: async (page) => { + await expect( + page.getByRole('main').getByRole('tree', { name: 'Knowledge mind map' }), + ).toBeVisible(); + }, + }, + { + name: 'AI Harness', + nav: /ai harness/i, + assertSurface: async (page) => { + // Setup surface only. No provider request and no model download: the + // point is that the harness code executed, not that it can infer offline. + await expect( + page.getByRole('main').getByRole('heading', { name: 'AI Harness' }), + ).toBeVisible(); + }, + }, + { + name: 'TRIZ', + nav: /triz/i, + assertSurface: async (page) => { + await expect( + page.getByRole('main').getByRole('heading', { name: 'TRIZ Contradiction Matrix' }), + ).toBeVisible(); + }, + }, + { + name: 'Export', + nav: /export/i, + assertSurface: async (page) => { + await expect( + page.getByRole('main').getByRole('heading', { name: 'Export knowledge' }), + ).toBeVisible(); + }, + }, + { + name: 'Sync', + nav: /sync/i, + assertSurface: async (page) => { + // Disconnected rendering is the expected offline surface; no room is + // joined and no peer connection is attempted. + await expect( + page.getByRole('main').getByRole('heading', { name: 'Sync', exact: true }), + ).toBeVisible(); + }, + }, +]; + +/** + * Resolves once the active worker also controls this document. + * + * `serviceWorker.ready` only guarantees an activated registration; a page that + * loaded before activation is uncontrolled until `clients.claim()` lands. + * Waiting on `controllerchange` rather than a sleep keeps the precondition + * exact: without a controller, no request is intercepted and the test would + * pass for the wrong reason. + */ +const waitForServiceWorkerControl = async (page: Page): Promise => { + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + if (navigator.serviceWorker.controller) return; + await new Promise((resolve) => { + navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }); + }); + }); +}; + +/** + * Clears the browser HTTP cache for this context's storage partition. + * + * Chromium only; the spec is bound to the Chromium project in + * `playwright.config.ts` for exactly this reason. The session is detached in + * `finally` so a failed CDP command cannot leak a session into teardown. + */ +const clearBrowserHttpCache = async (page: Page): Promise => { + const session = await page.context().newCDPSession(page); + try { + await session.send('Network.clearBrowserCache'); + } finally { + await session.detach(); + } +}; + +/** + * Installs the worker against a production build, then proves the view renders + * on a first-ever offline navigation. + */ +const assertOfflineFirstVisit = async (page: Page, viewCase: OfflineViewCase): Promise => { + const context = page.context(); + + await page.goto('/'); + await waitForAppReady(page); + await waitForServiceWorkerControl(page); + await page.close(); + + const offlinePage = await context.newPage(); + await clearBrowserHttpCache(offlinePage); + await context.setOffline(true); + try { + await offlinePage.goto('/'); + await waitForAppReady(offlinePage); + + await navClick(offlinePage, viewCase.nav); + + await viewCase.assertSurface(offlinePage); + await expect( + offlinePage.getByRole('main').getByRole('heading', { name: LOAD_FAILURE_HEADING }), + ).toHaveCount(0); + } finally { + await context.setOffline(false); + } +}; + +for (const viewCase of OFFLINE_VIEW_CASES) { + test(`${viewCase.name}: first offline navigation renders the view`, async ({ page }) => { + await assertOfflineFirstVisit(page, viewCase); + }); +} diff --git a/e2e/recovery-restore.spec.ts b/e2e/recovery-restore.spec.ts new file mode 100644 index 00000000..e0c7cf7f --- /dev/null +++ b/e2e/recovery-restore.spec.ts @@ -0,0 +1,220 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test, expect, type Page } from '@playwright/test'; +import { waitForAppReady, navClick } from './helpers/navigation'; + +/** + * Pre-import backup and restore (Plan 162 #1). + * + * A JSON import replaces the entire library. The pre-import snapshot is the + * only thing standing between that and unrecoverable loss, so both halves of + * that contract are pinned here against the real app: + * + * - a normal import leaves a restorable snapshot, reachable from any view + * - restoring actually puts the replaced corpus back + * - dismissing the offer never destroys the snapshot + * - an import too large to snapshot says so instead of claiming a clean swap + */ + +const STORE_KEY = 'do-knowledge-studio-store'; +const RECOVERY_KEY = 'do-knowledge-studio-recovery'; + +const entityNames = (page: Page): Promise => + page.evaluate((key) => { + const raw = localStorage.getItem(key); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || !('state' in parsed)) return []; + const state: unknown = parsed.state; + if (typeof state !== 'object' || state === null || !('entities' in state)) return []; + const entities: unknown = state.entities; + if (!Array.isArray(entities)) return []; + return entities + .filter((e): e is { name: string } => + typeof e === 'object' && e !== null && 'name' in e && typeof e.name === 'string', + ) + .map((e) => e.name); + }, STORE_KEY); + +const hasSnapshot = (page: Page): Promise => + page.evaluate((key) => localStorage.getItem(key) !== null, RECOVERY_KEY); + +/** Drives the Export view's file → preview → confirm import flow. */ +const importJson = async (page: Page, fixture: string): Promise => { + await navClick(page, /^export/i); + await page.locator('input[type=file]').setInputFiles(fixture); + // Exact label: a loose `/import/` match also hits the "Import knowledge" + // heading region, so the click lands on nothing and the test times out + // believing the app did nothing. + await page.getByRole('button', { name: 'Confirm import', exact: true }).click(); +}; + +/** + * Temp directories holding import fixtures. + * + * Each run (and each retry) writes its own fixture; without this the OS temp + * directory accumulates one directory per test run (GitNexus on PR #925). The + * block below removes them once its tests finish. + */ +const fixtureDirs: string[] = [] + +/** Builds an export-shaped JSON fixture in a temp dir and returns its path. */ +const writeFixture = async ( + name: string, + entity: { id: string; name: string; content?: string }, +): Promise => { + const dir = await mkdtemp(join(tmpdir(), 'dks-recovery-')); + fixtureDirs.push(dir); + const path = join(dir, `${name}.json`); + const base = { + id: entity.id, + name: entity.name, + type: 'concept', + description: '', + content: entity.content ?? '', + tags: [], + links: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await writeFile( + path, + JSON.stringify({ version: 1, exportedAt: '2026-01-01T00:00:00.000Z', entities: [base], claims: [] }), + ); + return path; +}; + +/** + * Creates one entity through the UI and returns the persisted corpus. + * + * The store writes its persisted blob on the first mutation, not on mount: a + * fresh profile reads back an empty key until something changes. Seeding a real + * entity through the UI is also the honest way to establish corpus A, since it + * exercises the same write path a user does rather than poking storage. + */ +const seedCorpusA = async (page: Page): Promise => { + await page.getByRole('button', { name: /new entity/i }).first().click(); + await page.getByLabel(/name/i).first().fill('Sentinel Entity'); + await page.getByRole('button', { name: /save|commit/i }).first().click(); + await expect.poll(() => entityNames(page).then((n) => n.includes('Sentinel Entity'))).toBe(true); + return entityNames(page); +}; + +/** + * Establishes a corpus A whose *snapshot* exceeds the 4 MiB guard while the + * store itself stays inside the browser's quota. + * + * Done with one import of a padded corpus rather than by growing the library + * through the editor: the snapshot carries the whole undo history, and an import + * seeds that history with the corpus, so a single ~2.2 MB import produces a + * ~4.4 MB snapshot. The earlier editor-driven version was both slow enough to + * time out on the mobile and tablet projects and coupled to editor form state. + * + * Importing is also the more faithful reproduction: this is the state a user + * with a real library reaches, and it is the state the guard was written for. + */ +const seedLargeCorpusA = async (page: Page): Promise => { + const fixture = await writeFixture('corpus-a-large', { + id: 'corpus-a-large-1', + name: 'Corpus A Large', + content: 'x'.repeat(2_200_000), + }); + await importJson(page, fixture); + await expect.poll(() => entityNames(page)).toEqual(['Corpus A Large']); + return entityNames(page); +}; + +test.describe('pre-import backup and restore', () => { + test.afterAll(async () => { + await Promise.all(fixtureDirs.map((dir) => rm(dir, { recursive: true, force: true }))); + fixtureDirs.length = 0; + }); + test('a normal import leaves a restore offer that brings the old library back', async ({ page }) => { + await page.goto('/'); + await waitForAppReady(page); + + const before = await seedCorpusA(page); + + const fixture = await writeFixture('corpus-b', { id: 'corpus-b-1', name: 'Corpus B Alpha' }); + await importJson(page, fixture); + + // The import really replaced the library. + await expect.poll(() => entityNames(page)).toEqual(['Corpus B Alpha']); + expect(await hasSnapshot(page)).toBe(true); + + // The offer is reachable without returning to the view the import ran in. + const banner = page.getByTestId('recovery-banner'); + // The offer names the snapshot's own contents, which are corpus A. Derived + // rather than hardcoded, and boundary-anchored with the correct plural, so + // neither a seed-data change nor a digit-suffix collision can pass or fail + // this for the wrong reason. + const entityNoun = before.length === 1 ? 'entity' : 'entities'; + await expect(banner).toContainText(new RegExp(`\\b${before.length} ${entityNoun}\\b`)); + + // Navigate away; the offer must not be trapped in the Export view. + await navClick(page, /^home/i); + await expect(banner).toBeVisible(); + + await page.getByRole('button', { name: /restore it/i }).click(); + + // The replaced corpus is back, and the snapshot is consumed. + await expect.poll(() => entityNames(page)).toEqual(before); + await expect(banner).toHaveCount(0); + expect(await hasSnapshot(page)).toBe(false); + }); + + test('dismissing the restore offer does not destroy the snapshot', async ({ page }) => { + await page.goto('/'); + await waitForAppReady(page); + + const before = await seedCorpusA(page); + const fixture = await writeFixture('corpus-c', { id: 'corpus-c-1', name: 'Corpus C Alpha' }); + await importJson(page, fixture); + + const banner = page.getByTestId('recovery-banner'); + await expect(banner).toBeVisible(); + + await page.getByRole('button', { name: /hide for this page session/i }).click(); + await expect(banner).toHaveCount(0); + + // Hiding is not deleting: the bytes survive, so a reload offers it again. + expect(await hasSnapshot(page)).toBe(true); + await page.reload(); + await waitForAppReady(page); + await expect(page.getByTestId('recovery-banner')).toBeVisible(); + + // And the snapshot still restores the right corpus. + await page.getByRole('button', { name: /restore it/i }).click(); + await expect.poll(() => entityNames(page)).toEqual(before); + }); + + test('a corpus too large to snapshot does not claim a clean swap', async ({ page }) => { + await page.goto('/'); + await waitForAppReady(page); + + // A corpus A small enough for the browser's own localStorage quota but + // whose *snapshot* exceeds the 4 MiB guard. + // + // The snapshot carries the whole undo history, so it serializes larger than + // the persisted store. That gap is the real shape of this bug. An import of + // several megabytes — the obvious way to trigger it — instead blows the + // browser quota, rolls back, and never reaches the backup code at all: + // that is a different, already-reported failure and would test nothing. + await seedLargeCorpusA(page); + + const fixture = await writeFixture('replacement', { id: 'new-1', name: 'Replacement Corpus' }); + await importJson(page, fixture); + + // The import genuinely succeeded — only the safety net is missing, which is + // exactly what the warning claims. A skipped backup must never be conflated + // with a failed import. + await expect.poll(() => entityNames(page)).toEqual(['Replacement Corpus']); + await expect(page.getByText(/no backup was kept/i)).toBeVisible(); + await expect(page.getByText(/too large for this browser/i)).toBeVisible(); + // ...and it does not leave a stale snapshot that would restore the wrong + // corpus, and it does not offer a restore it cannot honour. + expect(await hasSnapshot(page)).toBe(false); + await expect(page.getByTestId('recovery-banner')).toHaveCount(0); + }); +}); \ No newline at end of file diff --git a/package.json b/package.json index 1f624dc1..6522515a 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "propagate:version": "bash scripts/propagate-version.sh", "test": "vitest run", "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:e2e:offline": "PLAYWRIGHT_PRODUCTION=1 playwright test e2e/offline-views.spec.ts --project=chromium" }, "dependencies": { "@hookform/resolvers": "^5.8.0", @@ -63,9 +64,9 @@ "next": "^16.3.8", "next-themes": "^0.4.6", "qrcode.react": "^4.2.0", - "react": "^19.2.8", + "react": "^19.3.0", "react-day-picker": "^10.0.2", - "react-dom": "^19.2.8", + "react-dom": "^19.3.0", "react-hook-form": "^7.60.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.12.2", @@ -91,13 +92,13 @@ "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@types/node": "26.6.3", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.11", "babel-plugin-react-compiler": "^1.0.0", "eslint": "^10", - "eslint-config-next": "^16.3.5", + "eslint-config-next": "^16.3.7", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/plans/04-feature-roadmap.md b/plans/04-feature-roadmap.md index 2a48f1a7..a4480d8f 100644 --- a/plans/04-feature-roadmap.md +++ b/plans/04-feature-roadmap.md @@ -2,7 +2,27 @@ **Priority**: P0 (Complete) **Estimated Total Effort**: 20-30 hours -**Status**: ✅ COMPLETE (2026-06-25) +**Status**: Historical roadmap — partially implemented in the current architecture; reconciled 2026-10-05 +**Historical note**: the SQL / Orama / TipTap / Sigma.js / MindElixir paths and +actions described below describe the 2026-06 architecture. They are **historical +records, not current implementation instructions** — the app is now Next.js + +Zustand + localStorage with BM25, `React.lazy` views, and a canvas-based graph +and mind map. For what is actually true today, see +[162-roadmap-progress-and-next-work-2026-10-05.md](162-roadmap-progress-and-next-work-2026-10-05.md). + +### Current status (reconciled 2026-10-05, source-confirmed) + +| Item from this roadmap | Status today | Evidence | +|---|---|---| +| 4.1 AI Harness with streaming | `Implemented — source-confirmed` | `src/lib/ai/local-adapter.ts:12-32,247-278`; harness chat under `src/components/studio/views/` | +| AI Harness with **Orama** context | Historical | Orama was removed (ADR 018); context augmentation is now BM25 retrieval, plus a real semantic mode | +| 4.x Claims source/status | `Implemented — source-confirmed` | Claim source and verification fields exist in `src/lib/studio/types.ts` / `schema.ts` | +| Verified-only graph filtering | **Not demonstrated** | No verified-only filter path was located in the graph view; `Not reassessed` as a product requirement | +| Semantic search mode | `Implemented — source-confirmed` | `src/lib/search/vector-store.ts:220-247,350`, with lexical fallback on embedding failure | +| Graph canvas snapshot save/restore | `Implemented — source-confirmed` | `src/lib/studio/graph-snapshot.ts` validated read/restore/clear plus viewport state (Plan 157) | +| Graph snapshot **comparison / revision diff** | `Not implemented — source-confirmed` | No revision or diff symbols in `src/`; the bookmark stores one canvas, not a revision history | +| Mobile gestures (graph, mind map) | `Not implemented — source-confirmed` | `graph-view.tsx:130-180` is keyboard-only; no `onTouch*`/`onPointerDown` handler | + **Sources**: Swarm analysis - new feature suggestions, LLM provider system **Implementation Plans**: [041-ai-harness-implementation.md](041-ai-harness-implementation.md), [Claim Provenance (this session)] @@ -111,9 +131,9 @@ --- ## Completion Criteria -- [x] AI Harness is fully functional with streaming + Orama context +- [x] AI Harness is fully functional with streaming + local retrieval context — historical criterion; the "Orama" wording is superseded (Orama was removed in ADR 018; context augmentation is BM25 retrieval plus a real semantic mode) - [x] Claims support source/verification tracking - [x] Semantic search mode available and working -- [x] Graph snapshots can be saved/compared/reverted -- [x] Mobile gestures work for graph and mind map -- [x] All quality gates pass: `npm test`, `npm run lint`, `npm run typecheck` +- [ ] Graph snapshots can be saved/compared/reverted — **split by this reconciliation**: save/restore is `Implemented — source-confirmed`; **comparison is not implemented**. Tracked by [162](162-roadmap-progress-and-next-work-2026-10-05.md) as `Candidate — not scheduled` +- [ ] Mobile gestures work for graph and mind map — **not implemented** (source-confirmed 2026-10-05). Tracked by [162](162-roadmap-progress-and-next-work-2026-10-05.md) as feature F3, pending a gesture spec +- [x] All quality gates pass — **historical**: this row records the 2026-06-25 `npm test` / `npm run lint` / `npm run typecheck` run. The project now uses `pnpm` and `scripts/quality_gate.sh`, and that gate was **not re-run** by this reconciliation diff --git a/plans/11-expansion-roadmap.md b/plans/11-expansion-roadmap.md index 03d18424..ae4e05fd 100644 --- a/plans/11-expansion-roadmap.md +++ b/plans/11-expansion-roadmap.md @@ -1,12 +1,16 @@ # Plan 11: Expansion Roadmap & New Features **Date**: 2026-05-07 -**Status**: ✅ COMPLETE (2026-06-25) +**Status**: Historical expansion roadmap — partially superseded by later plans; reconciled 2026-10-05 +**Historical note**: the SQLite/Orama/CLI designs below describe the 2026-05 +architecture and are preserved as records, not as current implementation +instructions. Section statuses were re-checked against source on 2026-10-05; see +[162-roadmap-progress-and-next-work-2026-10-05.md](162-roadmap-progress-and-next-work-2026-10-05.md). **Goal**: Identify high-value features beyond the core MVP that align with the local-first, AI-augmented knowledge studio vision. ## 11.1 Auto-Synthesis Agent (Local-First Hybrid) **Concept**: A background agent that periodically analyzes the knowledge base to suggest connections or identify contradictions using local heuristics and external document resolution. -**Status**: ✅ DONE (2026-06-25) +**Status**: ❌ Not implemented in the current architecture — candidate, not scheduled. Checked 2026-10-05: no `SynthesisAgent`, "Synthesis Inbox", or equivalent component/symbol exists under `src/` or `e2e/`. Carried as `Candidate — not scheduled` in [162](162-roadmap-progress-and-next-work-2026-10-05.md). - **Action**: - Implement `SynthesisAgent` in `src/lib/agents/`. - **Local Heuristics**: Use Orama vector similarity scores and shared keyword/tag overlap to identify potential links. @@ -16,7 +20,7 @@ ## 11.2 Local-First P2P Sync **Concept**: Sync between two devices on the same local network without a server. -**Status**: ✅ DONE (2026-06-25) +**Status**: ✅ DONE (2026-06-25) — **historical record**, not newly verified by this reconciliation. The current sync implementation is `src/lib/sync/` (Yjs + WebRTC + `IndexeddbPersistence`); its open gaps are tracked in [162](162-roadmap-progress-and-next-work-2026-10-05.md) #3. - **Action**: - Research `WebRTC` or `Local Area Network` discovery. - Implement a "Sync QR Code" to pair devices. @@ -24,28 +28,26 @@ ## 11.3 Voice-to-Knowledge **Concept**: Use browser speech-to-text to create entities and claims hands-free. -**Status**: ✅ DONE (2026-06-25) +**Status**: ✅ DONE (2026-06-25) — **historical record**, not newly verified by this reconciliation. - **Action**: - Use `Web Speech API` for live transcription. - Implement NLP intent parsing to extract `(Entity, Relation, Target)` from spoken sentences. ## 11.4 Interactive TRIZ Matrix Tool **Concept**: A dedicated UI for resolving technical contradictions using the TRIZ matrix. -**Status**: ✅ DONE (2026-06-25) +**Status**: ✅ DONE (2026-06-25) — **historical record**, not newly verified by this reconciliation. - **Action**: - Create `src/features/triz/TrizMatrix.tsx`. - Allow users to select "Improving Parameter" and "Worsening Parameter". - Fetch "Inventive Principles" and provide examples via AI agent. -## 11.5 Encrypted Markdown Export (E2EE) +## 11.5 Encrypted JSON Export with a Self-Contained HTML Reader **Concept**: Export to a format that can be stored on untrusted cloud drives safely. -- **Action**: - - Use `Web Crypto API` (AES-GCM). - - Bundle encrypted markdown files with a lightweight "Reader" HTML file that asks for the password. +**Status**: `Implemented — source-confirmed` (2026-10-05). The implemented path is **JSON** plus a bundled reader document, not encrypted Markdown: `src/components/studio/views/use-export-handlers.ts:282-304` runs `buildJsonExport` → `encryptData` (AES-256-GCM, PBKDF2 key derivation) → `buildEncryptedReaderHtml`, then downloads the reader via `downloadFile` as a Blob. The document is never injected into the DOM. ## 11.6 Visual Query Builder **Concept**: A node-based UI (like React Flow) to build complex SQLite/Orama queries visually. -**Status**: ✅ DONE (2026-06-25) +**Status**: ❌ Not implemented in the current architecture — candidate, not scheduled. Checked 2026-10-05: no query-builder component or symbol exists under `src/` or `e2e/`. Its SQLite/Orama target no longer exists (ADR 018). Carried as `Candidate — not scheduled` in [162](162-roadmap-progress-and-next-work-2026-10-05.md). - **Action**: - Drag-and-drop filters, entities, and relations. - Live preview of result set. diff --git a/plans/130-goap-uiux-testpyramid-errorhandling-2026-08-22.md b/plans/130-goap-uiux-testpyramid-errorhandling-2026-08-22.md index 6bd569c9..8b6f58ff 100644 --- a/plans/130-goap-uiux-testpyramid-errorhandling-2026-08-22.md +++ b/plans/130-goap-uiux-testpyramid-errorhandling-2026-08-22.md @@ -207,3 +207,20 @@ Additional gates: - Firefox project addition (WebKit covers cross-engine risk first). - Full i18n extraction of user-facing strings (locale constants were noted during the audit). + +## Current-state reconciliation (2026-10-05) + +Added by [Plan 162](162-roadmap-progress-and-next-work-2026-10-05.md). This plan +stays **PROPOSED** — nothing below is adopted or rejected by this note. + +| Action | Status 2026-10-05 | Evidence | +|---|---|---| +| A23 — hash-sync module mapping `#/view` to `currentView` with popstate handling, hash-wins-on-load, invalid-to-home | **Not implemented — source-confirmed** | No `location.hash`, `history.pushState`, `popstate`, or `hashchange` integration under `src/` or `e2e/` | +| A24 — E2E deep-link and Back/Forward traversal spec across viewports | **Not implemented — source-confirmed** | No deep-link spec exists under `e2e/`; it would fail against the A23 gap | +| Every other action in this plan | **Not reassessed** | This refresh gathered no evidence about the global error handling, structured logging, or visual-regression actions. No status is inferred for them either way | + +The accepted design for A23/A24 is unchanged in +[ADR 037](ADRs/037-url-addressable-view-state.md), which is **Proposed — not +implemented**. Sequencing for A23/A24 as feature F2 is in +[Plan 162](162-roadmap-progress-and-next-work-2026-10-05.md); it ranks behind +recovery/integrity work and ahead of gesture work. diff --git a/plans/131-goap-swarm-improvement-audit-2026-08-22.md b/plans/131-goap-swarm-improvement-audit-2026-08-22.md index e4fe918e..dd6add1a 100644 --- a/plans/131-goap-swarm-improvement-audit-2026-08-22.md +++ b/plans/131-goap-swarm-improvement-audit-2026-08-22.md @@ -597,3 +597,38 @@ before merge. targets, theme-token/responsive/motion UX debt. - `biome.json` modification (D6.12) — awaiting explicit maintainer instruction. + +## Current-state reconciliation (2026-10-05) + +Added by [Plan 162](162-roadmap-progress-and-next-work-2026-10-05.md). The D1–D6 +finding catalog and the G1–G9 proposals **above are preserved unchanged** — they +are the 2026-08-22 observations this plan was written from. This section records +what source inspection can confirm today, and nothing else. + +Rules this section follows: + +- A row is closed only when a named, specific behavior is source-confirmed or + dated-verified. **No wave is bulk-closed.** +- Findings this refresh did not evaluate stay `Not reassessed` rather than being + moved to Done or Open. +- No runtime, test, build, CI, or browser evidence is claimed here. + +| Audit item | Status 2026-10-05 | Evidence | Where tracked | +|---|---|---|---| +| D1.6 / D1.22 — claim-aware history, dangling selection cleared on restore | **Closed** | `plans/157-codebase-gap-and-feature-remediation.md` W3; history snapshots carry claims, claim CRUD participates in history | Plan 157 | +| D4.1 / D4.2 — graph snapshot was write-only; canvas restore and density | **Closed for the canvas bookmark** | `src/lib/studio/graph-snapshot.ts` validated read/restore/clear plus viewport state; `mindmap-density.ts` drives real density tokens | Plan 157 W1 | +| Graph **revision comparison / diff** (Plan 04 §4.4) | **Not implemented — source-confirmed** | No revision or diff symbols in `src/`; the canvas bookmark is not a corpus revision history | Plan 162, `Candidate — not scheduled` | +| P0-3 / D1.x — hydration rejection silent and destructive | **Closed** | `src/lib/studio/hydration-guard.ts`, `hydration-quarantine.ts`, `RecoveryAlerts` in `app-shell.tsx`; ADR 028 §4a | Plan 159 | +| D5.3 — vitest typecheck `ignoreSourceErrors: true` leaves source-error gating inert | **Closed** | `vitest.config.ts:29` is now `false`; all 94 `*.test.ts` type errors cleared | Plan 159 F7 | +| D2 — no lazy view boundaries, sync/export chunks eager | **Six `React.lazy` boundaries delivered**; offline coverage recorded | `src/components/studio/app-shell.tsx:30-35`. Offline coverage of those boundaries is Plan 161's dated 2026-09-30 result, not rerun here | Plan 161 | +| D2 — heavy-leaf deferral (Editor→Yjs, PDF/DOCX/fflate inside the lazy Export subtree) | **Partial** | `app-shell.tsx:13` → `editor-view.tsx:14` → `remote-cursors.tsx:5` → `use-cursors.ts:4-11` → `cursors.ts:1` → `doc.ts:1-3`; `use-export-handlers.ts:11-12` → `export-documents.ts:2-11` + `fflate` | Plan 162 #4 | +| D1.12 — recovery path unreachable; rollback success reported without a durable backup | **Closed 2026-10-05** | `restoreFromRecovery` now has a production caller (`RecoveryBanner`, reachable from every view); `persistRecoverySnapshot` returns a `RecoveryPersistResult` and the import warns instead of claiming a clean swap. The two failure modes are no longer conflated: a refused write keeps the surviving snapshot, while an over-guard snapshot clears the stale one. Restore refuses while `isSyncBlocked()` and never clears on a failure path. Verified in a real browser on all four viewport projects plus 13 unit tests | Plan 162 #1 | +| D1.x — entity deletion and optional graph/mind-map/link/tag fields | **Partial** | `slices/entities-slice.ts:94-105` prunes entity, inbound links, claims, selection; not canvas sets | Plan 162 #2 | +| D1.9b / G6 — tombstone resurrection across a leave/join cycle | **Partial**; durability **Not reassessed** | `sync-view.tsx:267-288` ignores join-time `mergeIntoYjs` conflicts unlike Resync (`:310-321`); Leave calls `destroy()` (`:298-308`) and Join does not re-run `initPersistence` (`doc.ts:37-43` vs `:75-84`). Two peers were not driven | Plan 162 #3 | +| D3.5 / D3.6 / G8 — provider failure taxonomy, retry/backoff/timeout, user-facing request control | **Not implemented — source-confirmed** | `ai-harness-chat.tsx:130-152` disables Send during loading with no Stop control; `use-ai-harness-chat.ts:184-195` surfaces raw `err.message` | Plan 162 F1 | +| D3.4 — DOMPurify wired into export/import (recorded here previously as done) | **Helpers exist; production integration not demonstrated** | `src/lib/security.ts:19,38` defines `sanitizeHtml`/`sanitizeText`; references outside the module are tests only. A status correction, not a vulnerability finding | `plans/INDEX.md` W5 | +| G3 / D6 — dependency prune | **Not reassessed**; no deletion selected | The ~26-dependency figure was an estimate, not a measured prune list. A full 63-dependency scan found 4 with no `src/`/`e2e/`/`scripts/` import, all required. This refresh selects no deletion | Plan 162, `Not reassessed` | +| ADR 037 / Plan 130 A23–A24 — URL deep links | **Not implemented — source-confirmed** | No `location.hash`, `pushState`, `popstate`, or `hashchange` integration in `src/` or `e2e/`; ADR 037 remains Proposed | Plan 162 F2 | +| Plan 04 §4.5 — mobile gestures for graph and mind map | **Not implemented — source-confirmed** | `graph-view.tsx:130-180` is keyboard-only (arrows, `+`/`-`, Home); no `onTouch*`/`onPointerDown` handler in `graph-view.tsx` or `mindmap-view.tsx` | Plan 162 F3 | +| Plan 161 recorded follow-ons — dev search worker, first-mount reduced motion, workflow lint warnings | **Recorded follow-on — runtime not rechecked** | `search-worker-client.ts:69-77,131-132`; `use-reduced-motion.ts:21,44-45` + `home-view.tsx:169-173,309-314`; `security-scan.yml:114-116`; `ci-and-labels.yml:343` | Plan 162 #5 | +| Every other D1–D6 finding and G1–G9 proposal not named above | **Not reassessed** | Out of scope for a documentation reconciliation; no evidence gathered either way | — | diff --git a/plans/158-september-2026-best-practice-audit.md b/plans/158-september-2026-best-practice-audit.md index fefc7269..cc8130eb 100644 --- a/plans/158-september-2026-best-practice-audit.md +++ b/plans/158-september-2026-best-practice-audit.md @@ -7,8 +7,8 @@ confined to one domain, each required to cite a primary source developer.chrome.com). Every finding below was then **independently re-verified against the running app** before being ranked. Three candidates were rejected by that verification — see §4. -**Status**: Audit complete; P0-1, P0-2, P0-3 and P1-2 remediated in the -commits that follow. P1/P2 remain open. +**Status**: Complete — all audit items remediated; subsequent offline lazy-view +coverage recorded in Plan 161. ## Scope and method @@ -294,7 +294,7 @@ spec citation. Neither survived a five-minute browser check. | P1-1 build warning | **Fixed** | `tsconfig.build.json` + `typescript.tsconfigPath`. See below. | | P2-1, P2-4, P2-5, P2-6, P2-7, P2-8, P2-9 | **Fixed** | See below. | | P2-2 manifest screenshots | **Fixed** | Real captures at both form factors, wired into the manifest and the precache list. | -| P2-3 precache | **Fixed** | Generated manifest derived from the emitted HTML; verified offline in Chromium (#838). | +| P2-3 precache | **Fixed — later superseded** | The fix recorded here derived the precache list from the emitted boot HTML only, which is the measurement this row describes and which is preserved as history. Plan 161 later replaced it with a generated union of the document URLs plus every emitted `.js`/`.mjs`/`.css` chunk and media asset, and recorded offline coverage of the six `React.lazy` views. The historical measurement above is not rewritten; Plan 161 holds the current inventory. | | P2-10 cross-tab ordering | **Fixed** | The broadcast subscription now waits for `onFinishHydration`. | | P2-11 dead `useStoreHydrated` | **Fixed** | Deleted — `app-shell.tsx`'s `appReady` already serves the E2E readiness contract, and a second signal could not express the refusal path. | diff --git a/plans/161-offline-lazy-view-precache-and-framework-refresh-2026-09-30.md b/plans/161-offline-lazy-view-precache-and-framework-refresh-2026-09-30.md new file mode 100644 index 00000000..1cc38b91 --- /dev/null +++ b/plans/161-offline-lazy-view-precache-and-framework-refresh-2026-09-30.md @@ -0,0 +1,270 @@ +# 161 — Offline First-Use Navigation for Lazy Views, Then a Stable Framework Refresh + +> Closes the offline half of Plan 158's PWA work: the precache manifest was +> derived from the boot document alone, so five of six code-split views could +> not load on a first offline navigation. Behavior decision: ADR 041. + +**Date**: 2026-09-30 +**Method**: GOAP (Goal-Oriented Action Planning) +**Status**: Complete — defect reproduced, fixed, regression-proofed, framework +refreshed, and verified end-to-end in production builds (see +[Verification](#verification)) + +## Problem + +`AppShell` code-splits Graph, Mind Map, AI Harness, TRIZ, Export and Sync +(`src/components/studio/app-shell.tsx:30-35`). The service worker's precache +list was generated by scraping `/_next/static/...` URLs out of +`.next/server/app/index.html` — the boot document's own chunks. A lazily +imported view appears in **no** HTML, so its chunk was never precached. + +Consequence: a user who installed the app, then went offline and navigated to a +view they had never opened online, had the dynamic import reject. React's view +error boundary rendered `" failed to load"` — the local-first promise +broken at exactly the moment it is being relied on. + +| View | Chunk string present in | Precached before | +|---|---|---| +| Graph | `1di-6pjehj6-g.js` | no | +| Mind Map | `15ufv5b5u92pv.js` | no | +| AI Harness | `2wnw_oy1f3wc3.js` | no | +| TRIZ | `06-9k-jk7oovd.js` (i18n only), `0jdw1zmyhz6cz.js` | partially | +| Export | `401_givuphw-a.js` | no | +| Sync | `0rm1p7_b3ef1s.js` | no | + +Measured on the pre-fix production build: **17 of 36** emitted chunk files were +in `public/precache-manifest.json`. + +## Reproduction (before any fix) + +New spec `e2e/offline-views.spec.ts` plus a production run mode in +`playwright.config.ts` (`PLAYWRIGHT_PRODUCTION=1` → `pnpm run start`, +`reuseExistingServer: false`, production-only spec excluded from the default +dev run and from every non-Chromium project). + +Intent of the harness: install the worker against a production build, close the +online page so its in-memory modules cannot answer the import, clear the browser +HTTP cache over CDP, then go offline and navigate to the view **for the first +time** in a fresh context. + +`pnpm run build && pnpm run test:e2e:offline` — pre-fix result, 6/6 failed, each +for the stated reason: + +``` +heading "Graph failed to load" [level=2] +heading "Mind Map failed to load" [level=2] +heading "AI Harness failed to load" [level=2] +heading "TRIZ Matrix failed to load" [level=2] +heading "Export failed to load" [level=2] +heading "Sync failed to load" [level=2] +``` + +### Assertion scoping defect found by the first run + +The first version of the spec asserted `getByRole('heading', { name: 'AI Harness' })` +unscoped. The topbar renders `

{meta.title}

` with the current view's +name (`src/components/studio/topbar.tsx:87,11-21`), so AI Harness and Sync +**passed against the error fallback** — chrome satisfied the assertion. All six +assertions are now scoped to the `main` landmark, which is where the view body +renders and where the topbar is not. That change took the reproduction from +"4 fail" to the true "6 fail". + +## Fix + +### Manifest scope: the emitted code, not just the boot document + +`scripts/generate-precache-manifest.mjs` now unions three sources: the boot +document's URLs (unchanged), every `.js`/`.mjs`/`.css` under +`.next/static/chunks`, and fonts/images under `.next/static/media`. Names come +from the real build directory, so a renamed hash cannot silently fall out. +Deliberately excluded: `.wasm`, model weights, source maps, raw `.ts` — media +the user opts into, not app code, one item of which exceeds the whole chunk set. + +Generator hardening: the missing-build path is preserved, and the run now fails +with an actionable message when either static directory is absent or the chunk +inventory holds no JavaScript, instead of silently writing a boot-only manifest. +An empty font/image set stays valid. + +Result: **61 URLs** (21 document, 36 chunks, 17 media). Measured cost: the whole +chunk set is ~4 MiB, accepted rather than classifying minified chunks by feature. + +### Worker identity: `dks-static-v3` + +`public/sw.js` changes one constant. The manifest is fetched by the worker at +install time, so an existing installation would otherwise never reinstall with +the widened list — the bytes must change for the browser to notice. Cache +deletion stays in the worker's `activate` handler; `STATIC_EXTENSIONS` and the +API cache are untouched. + +## Framework refresh (separate diff, same rollout) + +`next` 16.3.6 → 16.3.7, `eslint-config-next` 16.3.5 → 16.3.7, `react` and +`react-dom` 19.2.8 → 19.3.0, `@types/react` 19.2.18 → 19.3.0, `@types/react-dom` +19.2.4 → 19.3.0. TypeScript stays on 6.0.3: typescript-eslint supports +`>=4.8.4 <6.1.0`, so TypeScript 7 is a separate side-by-side tooling migration, +not a drop-in. Zustand untouched at 5.0.15. + +Lockfile diff reviewed line by line: the only resolved `package@version` changes +are `next`, `eslint-config-next`, `react`, `react-dom` and their `scheduler` +dependency; the only specifier changes are the six intended ones. All `pnpm` +overrides, peer rules and ignored-build settings are retained. + +## Verification + +Every row below was run in this session against a real production build. + +| Claim | Evidence | +|---|---| +| Defect reproduces | Pre-fix build: `pnpm run build && pnpm run test:e2e:offline` → 6/6 failed, each with `" failed to load"` | +| Fix works | Post-fix: 6/6 passed; repeated green after the generator refactor and again on Next 16.3.7 / React 19.3.0 | +| Test is load-bearing | Chunk-inventory contribution removed (line grepped to confirm the mutation landed) → manifest 17 chunks → Graph case failed with `"Graph failed to load"`; restored → 61 chunks → green | +| Manifest covers the code | 61 URLs = 21 document + 36 chunks + 17 media; **0** of 36 on-disk chunk files unprecached; no `.map`/`.wasm`/`.ts` entries | +| Versions | `pnpm list next react react-dom eslint-config-next @types/react @types/react-dom typescript zustand --depth 0` → next 16.3.7, react/react-dom 19.3.0, @types/react(-dom) 19.3.0, eslint-config-next 16.3.7, typescript 6.0.3, zustand 5.0.15 | +| Lockfile scope | Only 6 specifiers changed; only `next`, `eslint-config-next`, `react`, `react-dom`, `scheduler` resolved to new versions; overrides/peer rules/ignored builds intact | +| Advisories | `pnpm audit --audit-level=high` → no known vulnerabilities (`boolean@3.2.0` deprecation notice is pre-existing, present in the shipped lockfile) | +| Strict gates | `lint && typecheck && test && build` exit 0: 180 files / **2790 tests passed** (1 skipped), vitest typecheck 0 type errors (`ignoreSourceErrors: false` still in force), zero warnings | +| Existing contracts on production | `PLAYWRIGHT_PRODUCTION=1 pnpm run test:e2e` → **642 passed, 4 skipped, 0 failed** across chromium/mobile/tablet/desktop-xl (5.0m) | +| Dev loop unaffected | `playwright test --list` → 640 tests in 26 files, **0** offline-views; `PLAYWRIGHT_PRODUCTION=1 … --list` → the 6 cases, chromium only | +| Real browser, fresh profile | Chromium with a dedicated `--user-data-dir`: worker installed from scratch → `dks-static-v3` with **61** entries. Created note `Offline refresh proof` in Editor, then cleared the HTTP cache (`Network.clearBrowserCache`), went offline in a new document: Home/offline badge, Library card, Graph (9 nodes incl. the marker), Mind Map, TRIZ (h1 + parameter panels), Export JSON download (12,319 bytes, 9 entities, marker id `82bb5020-…`). Mobile 390×844: drawer navigation, Library, and Graph all render offline | +| Upgrade lifecycle | Retained pre-change profile: on-disk `index.txt` held **`dks-static-v2`** (45 entries) and no v3. Launching the final build online → new worker installed, activated, `clients.claim()`ed, `activate` deleted `dks-static-v2` → **`dks-static-v3` with 61 entries**. Hard-killed the browser, relaunched the same profile on disk → still v3/61. Offline with the HTTP cache cleared and disabled, all six views rendered first-use (graph label `Knowledge graph with 8 entities and 12 connections`; Sync showed `Disconnected`, `Connection`, `Sync History`) | + +### Follow-on 1 — reduced-motion hydration mismatch (fixed) + +Under `prefers-reduced-motion: reduce`, Home logged a React hydration mismatch +for every framer-motion element in `home-view.tsx`: + +``` + +
false)`. Hydration uses the server answer, so the first +client render matches the server; the real preference re-reads immediately +afterwards. `getSnapshot`/`getServerSnapshot` stay at module scope because +`useSyncExternalStore` needs a stable callback identity. This is the same rule +plans/149 §4 established for the hydration-refusal status: a client-only signal +must never decide what the first render outputs. + +Evidence: + +| Claim | Measurement | +|---|---| +| The defect is caught | New `e2e/hydration-mismatch.spec.ts` failed on the old hook with `hydration errors: …` and passes now, on all four projects | +| The class is closed, not one instance | `e2e/touch-targets.spec.ts` (which sets `reducedMotion: 'reduce'` for its whole context) went from **8** logged mismatches in `dev.log` to **0**; 42 tests across `touch-targets`, `accessibility`, `contrast`, `zoom-reflow` stayed green | +| Unit behaviour is unchanged | 180 files / 2790 tests green, including the hook's own suite; every `vi.mock('@/lib/studio/use-reduced-motion')` call site keeps working | + +**Residual, deliberate:** during the hydration pass the preference is unknowable +by construction, so a reduce user's *first* mount runs its tween with the +default duration instead of `0`. Measured on the production build: the Home +section goes 0.04 → 0.19 → 0.68 → 1.0 over ~450ms on first paint. Every later +mount (`duration: 0`) still skips the animation, and nothing freezes mid-fade +(the failure mode the old synchronous read was added to prevent). Pinning a +server-identical first render *and* zero first-mount motion would need +`initial={false}` while the preference is unknown at all ~25 sites — a visible +change for no-preference users, so it is recorded here rather than taken +silently. + +### Follow-on 2 — the offline banner covered the topbar controls (fixed) + +The banner is `fixed top-0 z-50` while the topbar is in normal flow, so the +banner's band sat over the topbar's own hit points. Measured with +`document.elementFromPoint` at every configured viewport: + +| Viewport | Banner height | Obstructed controls | +|---|---|---| +| 1920×1080 | 36px | quick filter, command palette, New entity | +| 1280×800 | 36px | quick filter, command palette, New entity | +| 390×844 | 56px (wraps) | menu trigger, search trigger, New entity | + +While offline — the moment a local-first app most needs to work — its primary +controls could not be clicked at all, and on mobile the navigation drawer was +unreachable. + +Fix: `OfflineIndicator` publishes its **measured** `offsetHeight` as +`--offline-banner-height` (ResizeObserver, so a wrapped or zoomed banner is +still exact) and `AppShell` reserves that much `paddingTop`. Unset on the +server, so both renders agree at `0px`. + +Evidence: the new `Offline banner` test in `e2e/accessibility.spec.ts` failed on +the old layout with `topbar controls hidden behind the offline banner: Search, +Open command palette, New entity` and passes on all four projects now. Two +false-pass traps were found and closed by running it *before* trusting it: +`toBeVisible()` is satisfied while the banner is still sliding in from above the +viewport, and the reservation lands one frame after the banner mounts — probing +early reported an empty obstruction list and the test passed **3 of 6** mobile +runs on the unfixed layout. It now pins `reducedMotion: 'reduce'` (no slide) and +polls for the reservation itself, failing with `only 0px of 56px reserved`. +Re-verified by deleting the shell's `paddingTop` (grep-confirmed) → both +chromium and mobile fail with that message; restored → 36/36 green across the +four projects at `--repeat-each=3`. + +### Known-but-separate (not this plan's scope) + +Development runs log `Search worker encountered unhandled error: … importScripts +… /_next/static/chunks/src_lib_search_search-worker_ts_*.js failed to load`. It is +a dev-mode bundling artefact of the search worker (the chunk is named after the +TypeScript source), it predates this work — the plan's own scope note records it +as a separate observed build artefact, and no search file is touched here — and +production is unaffected: the emitted worker chunk is precached and every +`semantic-search` case passes in the production suite. + +### Pre-existing CI-lint nits (documented, untouched) + +Found while validating the workflow change; both predate this plan and neither +fails a job (`yamllint` exits 0 on warnings, `actionlint` runs with +`continue-on-error: true`): + +- `.github/workflows/security-scan.yml:114` — `comments-indentation` warning. +- `.github/workflows/ci-and-labels.yml` — actionlint `SC2002` (useless `cat`) + in a `run:` block; verified present in `HEAD` before this change. + +Not fixed here: unrelated files, and each is a one-line edit that belongs in its +own scoped change. + +### Notes for the next reader + +- The manifest is regenerated at deploy time: `vercel.json` runs + `pnpm run build`, which chains the generator. The committed + `public/precache-manifest.json` is therefore repo hygiene plus the artifact any + non-Vercel/static deploy would serve — but it must never be the *reason* a + chunk is missing, so the fail-closed checks in the generator are what actually + hold the line. +- The smoke drove the `browser` device for the fresh-profile run and had to + pass `app.path` + `--headless=new`; its shared managed browser ignores + `--user-data-dir`. Profile isolation is the point of that run, so the profile + flag is mandatory, not cosmetic. +- Two smoke assertions were first satisfied by shell chrome rather than view + content (`waitForText('TRIZ Contradiction Matrix')` matched a seeded entity, + `main h2` matched the right panel's `Search`). The committed spec avoids this + class entirely by asserting role-scoped locators inside `main`. +- The production offline suite **is** wired into CI now: + `.github/workflows/ci-and-labels.yml` gained an `offline` paths filter + (`public/sw.js`, `public/precache-manifest.json`, + `scripts/generate-precache-manifest.mjs`, `src/app/layout.tsx`, + `app-shell.tsx`, `offline-indicator.tsx`, + `service-worker-registration.tsx`, `e2e/offline-views.spec.ts`, + `playwright.config.ts`, `package.json`, `pnpm-lock.yaml`), the `e2e-tests` job + now also gates on it, and a `Production offline suite` step runs + `pnpm run build && pnpm run test:e2e:offline` after the dev-server suite. It is + placed **before** the artifact-upload step so a failure there still uploads its + traces. Nightly and manual runs force the filter on, like the other outputs. + +## Critical files + +- `scripts/generate-precache-manifest.mjs` — three-source precache list +- `public/sw.js:1` — `dks-static-v3` cache identity +- `public/precache-manifest.json` — generated, committed (it is the deployed artifact) +- `playwright.config.ts` — production run mode +- `e2e/offline-views.spec.ts` — six-view first-offline-navigation regression +- `package.json`, `pnpm-lock.yaml` — framework/runtime refresh diff --git a/plans/162-roadmap-progress-and-next-work-2026-10-05.md b/plans/162-roadmap-progress-and-next-work-2026-10-05.md new file mode 100644 index 00000000..5a814956 --- /dev/null +++ b/plans/162-roadmap-progress-and-next-work-2026-10-05.md @@ -0,0 +1,356 @@ +# Plan 162 — Roadmap Progress and Next Work + +**Date**: 2026-10-05 +**Status**: Complete — planning records reconciled; implementation backlog remains open +**Method**: Source inspection against the repository at this date. No test suite, build, browser scenario, deployment, or remote query was executed by this refresh. +**Related**: [155](155-cpu-first-local-ai-and-settings-persistence.md), [157](157-codebase-gap-and-feature-remediation.md), [158](158-september-2026-best-practice-audit.md), [159](159-rejected-library-recovery-visibility-and-fail-closed.md), [161](161-offline-lazy-view-precache-and-framework-refresh-2026-09-30.md), [131](131-goap-swarm-improvement-audit-2026-08-22.md), [130](130-goap-uiux-testpyramid-errorhandling-2026-08-22.md), [ADR 027](ADRs/027-canonical-state-and-p2p-sync-bridge.md), [ADR 037](ADRs/037-url-addressable-view-state.md) + +## Evidence vocabulary + +Every row below carries one of these exact statuses. They are not interchangeable. + +| Status | Meaning | +|---|---| +| `Implemented — source-confirmed` | The behavior exists in the source read at the anchor on 2026-10-05. Runtime behavior was **not** re-exercised by this refresh. | +| `Partial` | Part of the behavior exists; the named remainder does not, or exists without demonstrated integration. | +| `Not implemented — source-confirmed` | A search across `src/` and `e2e/` at the anchor found no implementation of the named thing. | +| `Recorded follow-on — runtime not rechecked` | A prior plan recorded the gap with dated evidence. This refresh did not reproduce it and does not claim a fresh failure. | +| `Candidate — not scheduled` | An idea with no current owner, no anchor, and no scheduled work. | +| `Not checked in this audit` | A fact this refresh deliberately did not query (CI, deployment, issue counts, installed versions, measured coverage). | + +Historical results (Plan 161's 2790 unit tests, 642 passed / 4 skipped production +E2E) stay dated and attributed. They are **not** re-asserted as current. + +--- + +## Current implementation progress + +| Area | Status | Evidence | Recorded by | +|---|---|---|---| +| CPU-first local AI inference | `Implemented — source-confirmed` | `src/lib/ai/local-adapter.ts:12-32` (transformers.js runtime, lazily imported, streaming via `TextStreamer`, `InterruptableStoppingCriteria` wired to the abort signal), `:247-278` (`send`/`sendStream` race the model load against `AbortSignal`). Settings milestone in [155](155-cpu-first-local-ai-and-settings-persistence.md). | Plan 155, ADR 040 | +| Claim-aware undo, dangling selection, graph canvas restore, mind-map density | `Implemented — source-confirmed` | `plans/157-codebase-gap-and-feature-remediation.md` W1/W3; `src/lib/studio/graph-snapshot.ts` validated read/restore/clear plus viewport state. The completed canvas **bookmark** is not graph revision comparison. | Plan 157 | +| Hydration refusal protection and quarantine | `Implemented — source-confirmed` | `src/lib/studio/hydration-guard.ts`, `hydration-quarantine.ts`, `RecoveryAlerts` in `app-shell.tsx`. Distinct from pre-import recovery (see Missing implementation #1). | Plan 159, ADR 028 | +| Six `React.lazy` view boundaries | `Implemented — source-confirmed` | `src/components/studio/app-shell.tsx:30-35` (Graph, MindMap, AIHarness, Triz, Export, Sync). Offline coverage of these boundaries is a **recorded 2026-09-30 result** from Plan 161, not rerun here. | Plan 161 | +| Test-source type gating | `Implemented — source-confirmed` | `vitest.config.ts:22-29`, `ignoreSourceErrors: false`. Coverage **floors** at `:49-53` are branches 75 / functions 78 / lines 84 / statements 85 — thresholds, not measured current coverage. | Plan 159 F7 | +| Semantic search with lexical fallback | `Implemented — source-confirmed` | `src/lib/search/vector-store.ts:220-247` (batched embedding, partial index cleared on failure), `:350` (`semanticSearch` entry). Not a missing feature. | — | +| `okf` export format | `Implemented — source-confirmed` | `src/components/studio/views/export-types.ts:12` (`ExportFormatId` union); bundle/parse under `src/lib/okf/`. | ADR 031 | +| Encrypted self-contained reader export | `Implemented — source-confirmed` | `src/components/studio/views/use-export-handlers.ts:282-304` — `buildJsonExport → encryptData → buildEncryptedReaderHtml`, downloaded as a Blob, never injected into the DOM. | — | +| Test totals, CI, deployment, open issues, installed versions, measured coverage | `Not checked in this audit` | Deliberately not queried. Plan 161's numbers are dated 2026-09-30 records. | — | + +## Missing implementation + +These are future-work records. **This plan does not authorize changing any of the +implementations below**; it records them so they stop being lost between plans. + +### 1. Recovery reachability and backup outcome — IMPLEMENTED 2026-10-05 + +**Status: `Implemented — source-confirmed`, verified in a real browser on all +four viewport projects.** The problem statement below is preserved as the +original record; the resolution section follows it. + +#### Original problem statement (source-confirmed 2026-10-05) + +`restoreFromRecovery` (`src/lib/studio/recovery-helpers.ts`) had no production +caller. The only references outside its own module were the store re-export +(`src/lib/studio/store.ts:40`) and two test suites (`store-coverage.test.ts`, +`store-graph.test.ts`). Imports and tests are not UI callers. + +`persistRecoverySnapshot` returned `void` and **skipped** the write above the size +guard, while `importWithRollback` still returned `{ success: true }` +(`src/lib/studio/slices/data-slice.ts`). The UI therefore reported import success +without saying whether a durable pre-import backup existed. + +Status at the time of the audit: `Not implemented — source-confirmed`, with the +production reproduction above. + +**Reproduced — the silent-skip case.** Imported a ~5 MB single-entity corpus +over the seed corpus. Observed: + +| Observation | Value | +|---|---| +| UI after import | `Imported 1 entity and 0 claims` / `1 entities · 0 claims replaced the current library.` | +| Entity names after import | `["Oversized Sentinel"]` — corpus A gone | +| `do-knowledge-studio-recovery` after import | **19,704 bytes, timestamp unchanged from the previous import** | +| Console warning about the skipped write | **none** | +| Any "backup skipped" / "too large" / "no backup" UI | **none** | + +The oversized write was skipped exactly as `MAX_RECOVERY_SIZE_BYTES` (4 MiB) +implies, yet the stored snapshot was left holding the *previous* corpus's data +— a stale backup that would restore the wrong state if a restore UI existed — +and the user was told the import succeeded. Nothing in the UI says the +pre-import backup for *this* import was not written. + +**Correction to an earlier reading.** A first probe reported +`RECOVERY_BYTES 0`; that was my error — it read the key +`dks-recovery-snapshot`, but the real key is `do-knowledge-studio-recovery` +(`recovery-helpers.ts:16`). With the correct key, an **ordinary** import does +persist a ~19.7 KB snapshot. So "import never backs up" is **false**; the +reproduced defect is narrower and more specific: + +1. **No restore affordance.** `RESTORE_UI_COUNT 0` across the whole Export view, + for both a normal and an oversized import. The snapshot exists and nothing can + consume it. +2. **A skipped backup is indistinguishable from a successful one.** Same success + toast either way; no warning, and no signal that the snapshot is stale. + +This is a missing reachability path plus a missing outcome report — **not** a +reproduced data-loss incident. Nothing was lost that the user could not already +recover by exporting first, and no restore was attempted because no UI exists. +The later acceptance criteria stand; the restart-and-restore leg remains +unproven and must be demonstrated by the implementation, not inferred here. + +#### Resolution (2026-10-05) + +All three previously-open acceptance criteria are met, and two additional +data-loss paths found during review are guarded: + +| Criterion | How it is met | +|---|---| +| Import B over A, restart, explicitly restore A | `RecoveryBanner` mounts in `RecoveryAlerts` above the view router, so the offer is reachable from **any** view, not just Export. Restoring puts corpus A back and consumes the snapshot. | +| An oversized or storage-refused backup produces truthful UI | `persistRecoverySnapshot` returns `RecoveryPersistResult`; the import shows `toast.warning` ("Imported — but no backup was kept") instead of a false green success. | +| Quarantine and pre-import recovery stay separate | The new banner is a distinct component and storage key from `QuarantineBanner`; the two render side by side when both apply. | + +Files: `recovery-helpers.ts` (outcome type, availability signal, +`describeRecoverySnapshot`, non-throwing `readRecoverySnapshot`), +`store-types.ts` (`ImportOutcome`), `slices/data-slice.ts` (threads the outcome), +`views/recovery-banner.tsx` (new), `app-shell.tsx` (mount + subscription), +`use-export-handlers.ts` (truthful toast), `i18n/messages/announce.ts`. + +Two further defects were found and fixed while building this, both of which the +new banner would otherwise have made reachable: + +1. **A refused backup destroyed the surviving one.** The first implementation + called `clearRecoverySnapshot()` in the `storage-unavailable` branch. But + `localStorage.removeItem` still succeeds when `setItem` is refused by a full + quota, so it deleted the only copy of the corpus being replaced. Only the + `too-large` branch clears now; a stale-but-real backup survives, and the read + path re-validates it against the schema and TTL every time. +2. **Restore could delete the backup without saving.** `restoreFromRecovery` + cleared the snapshot in a catch that also covered `applyRecoverySnapshot`, + whose `setState` persists and can throw on quota *after* the in-memory swap — + and it offered restore even in a hydration-refused session where every write + is dropped. It now refuses while `isSyncBlocked()`, clears only after a + confirmed-successful apply, and never on a failure path. + +**Evidence.** `recovery-backup-outcome.test.ts` (13 unit tests) and +`e2e/recovery-restore.spec.ts` (3 tests × 4 viewport projects). Every new test +was checked against the unfixed code first: 6 fail without the outcome fix, 4 +fail without the restore-safety fix, and all 3 E2E cases fail without the +banner. Full gate on the final tree: 181 files / **2804 tests**, 0 type errors, +build clean, **660 E2E passed / 4 skipped / 0 failed**. + +**Deliberately not done.** `indexeddb-backup.ts` is untouched and still has no +production caller. Tiered backup remains **not operational**, and no storage +migration is selected here. + +### 2. Deletion / export integrity investigation + +`deleteEntity` (`src/lib/studio/slices/entities-slice.ts:94-105`) removes the +entity, prunes inbound `links`, drops its `claims`, and clears a dangling +selection. It does **not** touch the optional top-level `graph` node/edge sets, +`mindMap` nodes/edges, top-level `links`, or `tags`. Whether canvas objects are +intentionally independent of entity lifecycle is not documented anywhere. + +Status: `Partial` — a source-level gap requiring a real imported-corpus +reproduction. Not proven ghost-node export behavior; no export was run here. + +Later acceptance: delete an imported entity that has dependent canvas +references, export, re-import, and undo. Prove referential integrity, or record +in an ADR why independent canvas objects are intentionally retained. + +### 3. Sync join/rejoin completion + +`handleJoin` (`src/components/studio/views/sync-view.tsx:267-288`) calls +`mergeIntoYjs` and reports success without reading `result.conflicts`, unlike +`handleResync` (`:310-321`) which surfaces them via `setPendingConflicts`. +`handleLeave` calls `destroy()` (`:298-308`), which nulls the provider, +persistence, **and** the Yjs document (`src/lib/sync/doc.ts:75-84`); Join calls +`joinRoom` only, and `initPersistence` (`:37-43`) is never re-invoked on a +Leave→Join cycle. + +Status: `Partial`. Tombstone durability consequences across sessions are +`Not reassessed` — this audit did not drive two peers through a leave/join cycle +and does not assert resurrection from source inspection alone. + +Later acceptance: two peers with conflicting edits expose conflicts on **initial** +join; Leave→Join reconnects the persistence layer. + +### 4. Remaining heavy-leaf deferral + +Six lazy views exist, but the heaviest leaves remain statically imported: + +- `app-shell.tsx:13` statically imports `EditorView`, which imports + `CursorTracker` from `../remote-cursors` (`views/editor-view.tsx:14`) → + `src/components/studio/remote-cursors.tsx:5` → `src/lib/sync/use-cursors.ts:4-11` + → `src/lib/sync/cursors.ts:1` → `src/lib/sync/doc.ts:1-3` (`yjs`, + `y-indexeddb`, `y-webrtc`). +- `Export` is lazy, but inside it `use-export-handlers.ts:11-12` statically + imports `export-documents.ts:2-11` (`jspdf`, `docx`) and `fflate`. + +Status: `Partial`. This is a deferral, not a defect: the Export **view** is lazy +and must not be described as eager. + +Later acceptance: a production network/bundle trace that distinguishes app boot, +opening Export, and the first PDF/DOCX click. + +### 5. Plan 161 recorded follow-ons + +Carried forward from `plans/161-...-2026-09-30.md:167-233`. All +`Recorded follow-on — runtime not rechecked`: + +| Follow-on | Anchor | Later acceptance | +|---|---|---| +| Dev search-worker `importScripts` chunk failure | `src/lib/search/search-worker-client.ts:69-77` (worker construction with main-thread fallback), `:131-132` (`onerror` settles every pending request) | Dev search worker starts and lexical search returns results without the recorded importScripts error | +| First-mount reduced-motion limitation | `src/lib/studio/use-reduced-motion.ts:21` (`getServerSnapshot = () => false`), `:44-45`; consumers at `src/components/studio/views/home-view.tsx:169-173,309-314` | First paint has no unintended tween **and** no hydration mismatch | +| Workflow linter warnings | `.github/workflows/security-scan.yml:114-116` (yamllint `comments-indentation`), `.github/workflows/ci-and-labels.yml:343` (actionlint `SC2002`) | Workflow linters report no cited warnings | + +--- + +## New feature priorities + +Ranked without pre-committing to an implementation design. Deadlines and +mechanics belong to each feature's own later spec. + +### F1 — AI request control (first new feature after recovery/integrity work) + +`src/components/studio/views/ai-harness-chat.tsx:130-152` disables the textarea +and Send button during loading and offers **no Stop**. The hook +(`use-ai-harness-chat.ts:71,84-89,113-115,184-195`) already owns an +`AbortController` and aborts on unmount, so the capability exists — it is simply +unreachable from the UI. Errors are surfaced as raw `err.message` text. + +Links [Plan 131 G8](131-goap-swarm-improvement-audit-2026-08-22.md)'s +provider-hardening proposal. + +Status: `Not implemented — source-confirmed`. Later acceptance: Stop cancels a +deliberately stalled or streaming turn, retains already-emitted text, and +re-enables the composer; a bounded request failure ends with safe, localized +error copy. + +### F2 — URL deep links and browser history + +Reuse [ADR 037](ADRs/037-url-addressable-view-state.md) and Plan 130 A23/A24. No +`location.hash`, `pushState`, `popstate`, or `hashchange` integration exists in +`src/` or `e2e/` (2026-10-05). + +Status: `Not implemented — source-confirmed`; ADR 037 remains Proposed. + +Later acceptance: cold load of `/#/graph` opens Graph despite a persisted Library; +Back/Forward traverses views; reload retains the hash view; an invalid hash +resolves to Home. Missing-hash behavior follows ADR 037's persisted/default rule +— **no new routing policy**. + +### F3 — Pointer/touch graph and mind-map interaction + +Links [Plan 04 §4.5](04-feature-roadmap.md), currently checked despite +keyboard-only graph pan/zoom (`src/components/studio/views/graph-view.tsx:130-180` +arrow/+/-/Home handling, `:345-355` node `tabIndex`/`onKeyDown`). No +`onTouch*`/`onPointerDown` handler exists in `graph-view.tsx` or +`mindmap-view.tsx`. + +Status: `Not implemented — source-confirmed`. A gesture spec is a prerequisite +to implementation, not part of this documentation task. + +Later acceptance: touch drag/pinch/tap manipulates the graph within existing zoom +bounds and preserves keyboard navigation; mind-map gestures do not break its ARIA +tree or native scroll. + +### Candidates — not scheduled + +Graph **revision comparison**, Synthesis Inbox, and Visual Query Builder. +Linked to [Plan 04 §4.4](04-feature-roadmap.md) and +[Plan 11 §11.1/§11.6](11-expansion-roadmap.md). Their original SQL/Orama designs +are historical. No new storage schema is invented here, and none is scheduled +ahead of the integrity gaps above. Canvas save/restore **is** implemented; +revision comparison is not. + +--- + +## Skills maintenance + +| # | Item | Status | Later action | Acceptance | +|---|---|---|---|---| +| S1 | Skills catalog refresh | `Not implemented — source-confirmed` | Run `python3 scripts/generate-skills-docs.py`. **Never hand-edit the generated tables**; `scripts/generate-skills-docs.py:218-224` owns `agents-docs/AVAILABLE_SKILLS.md` and `.agents/skills/README.md`. | Both catalogs contain every valid canonical skill exactly once; two consecutive runs produce identical bytes | +| S2 | Agent-surface doc truth | `Partial` | `agents-docs/MANIFEST.md:50,56` advertises a nonexistent `generate-docs` command; `scripts/agent-surface.py:342-382` accepts only `validate` and `sync`. Correct the docs to the real CLI. | Documented commands match the real CLI; `generate-docs` is not recommended anywhere | +| S3 | Cursor/Windsurf strategy mismatch | `Not reassessed` | `.agents/manifest.json:33-42` declares `"symlink_strategy": "directory"` for `.cursor` and `.windsurf`, while `agent-surface.py:303-336` treats every non-`none` strategy as an all-or-nothing symlink set. Unresolved — **not** supported copy synchronization. | A later tooling task establishes the intended behavior before syncing those directories | + +`verify-before-asserting` exists canonically at `.agents/skills/verify-before-asserting` +but appears in **neither** generated catalog (0 matches in each). S1 is its fix. + +## Documentation maintenance + +| # | Item | Status | Later action | Acceptance | +|---|---|---|---|---| +| D1 | Script catalog drift | `Partial` | `agents-docs/SCRIPTS.md` omits `scripts/generate-precache-manifest.mjs` and `scripts/generate-pwa-icons.py`; its `agent-surface.py` example omits the required `validate` argument (`python3 scripts/agent-surface.py` prints a usage error). | Both build/icon tools documented with real invocation | +| D2 | Nonexistent skip flag | `Not implemented — source-confirmed` | `agents-docs/WORKFLOW.md:71` advertises `SKIP_LINKS=true`. No such variable is read; the link gate is scope-based (`scripts/quality_gate.sh:278-283`). | No nonexistent skip flag is recommended | +| D3 | README claims | `Partial` | `README.md:11,27` advertises an IndexedDB backup tier with no production caller; `README.md:56` says Node >= 20 against `>=22.0.0` in `package.json:8`; local AI and semantic search are omitted. | Backup wording matches production callers; runtime minimum matches `package.json`; optional local-model initial download is distinguished from subsequent offline use. Test counts stay dated records. | + +--- + +## Execution order + +1. **Reconcile records** — this document plus the `plans/` edits made alongside + it. Complete. +2. ~~**Recovery reachability + backup outcome**~~ — **implemented and + browser-verified 2026-10-05** (see #1 above). +3. **Deletion/export integrity** — now the first open implementation item; it + touches data safety, so per the delivery lifecycle it starts at production: + reproduce against an imported corpus, then fix. +4. **Sync join/rejoin completion**, then the three **Plan 161 recorded + follow-ons** (dev search worker, first-mount reduced motion, workflow + warnings). +5. **Skills and docs maintenance** — independent of product changes; S1, S2, D1, + D2, D3 can run in any order and do not block steps 3–4. +6. **F1 AI request control** — first new feature. +7. **F2 deep links**, then **F3 gestures**. +8. **Unscheduled** — revision comparison, Synthesis Inbox, Visual Query Builder. + +Step 2 is the one item this plan actually implemented. Every other row stays +open; completing this plan means the records are truthful, not that the backlog +is empty. + +--- + +## Verification evidence + +What this refresh actually did, and the observed result: + +- **Source reconciliation probes** — read each cited anchor and ran targeted + searches across `src/`, `e2e/`, `.github/`, `scripts/`, `agents-docs/`, + `README.md`, `.agents/manifest.json`, and `package.json` on 2026-10-05. + Five spot-checks re-run after the edits, all agreeing with the recorded status: + 6 `lazy(` boundaries at `app-shell.tsx:30-35`; `ignoreSourceErrors: false` at + `vitest.config.ts:29`; `restoreFromRecovery` in `src/` has only its definition + and the `store.ts` re-export outside tests; **0** hash/history hits across + `src/` and `e2e/`; `verify-before-asserting` present canonically with **0** + matches in each generated catalog. No feature, skill, or doc item is falsely + marked done. +- **Document smoke** — `plans/INDEX.md` links Plan 162; all seven required + sections are present; the recovery (#1), AI request control (F1), skills + catalog (S1), and manifest-doc (S2) references all resolve to real repository + files. Implemented progress and open work carry different statuses; the first + implementation priority is recovery, the first new feature is AI request + control. +- **Introduced-link check** — a throwaway checker outside the repository resolved + every relative link in the 14 touched/added Markdown files: **48 real local + links, 0 unresolved**. (One further match was a false positive: the code + expression `MIGRATIONS[i](state)` in a pre-existing Plan 160 row, not a link.) +- **Docs gate** — `./scripts/quality_gate.sh --scope docs`, **exit 0**: + git hooks configured; `agent-surface.py` validation passed; SKILL.md + references valid across 57 files / 211 links / 0 broken; language detection + ok; `✓ All Quality Gates PASSED`. No warning or failure was emitted, so no + follow-on was recorded on account of this gate. + +### Gate limitation recorded, not concealed + +`scripts/quality_gate.sh --scope docs` invokes `validate-skills.sh` and +`validate-links.sh`, and `validate-links.sh` checks **SKILL.md references only** +(`scripts/quality_gate.sh:278-283`). It therefore does **not** prove that the +Markdown links introduced into `plans/` resolve — the separate introduced-link +check above is what covers them. A green docs gate is necessary, not +sufficient, for this kind of change. + +What this refresh did **not** do, and therefore does not claim: any unit test, +build, E2E run, browser scenario, CI/deployment check, open-issue count, or +coverage measurement. Where a number appears, it is Plan 161's dated +2026-09-30 result or a configured threshold from `vitest.config.ts`. \ No newline at end of file diff --git a/plans/ADRs/037-url-addressable-view-state.md b/plans/ADRs/037-url-addressable-view-state.md index 61c7400c..ff60f33b 100644 --- a/plans/ADRs/037-url-addressable-view-state.md +++ b/plans/ADRs/037-url-addressable-view-state.md @@ -2,8 +2,12 @@ ## Status -Proposed — implemented via Plan 130 -(`plans/130-goap-uiux-testpyramid-errorhandling-2026-08-22.md`). +Proposed — not implemented; tracked by Plan 130 A23/A24 and +[Plan 162](../162-roadmap-progress-and-next-work-2026-10-05.md). + +Verified 2026-10-05: no `location.hash`, `history.pushState`, `popstate`, or +`hashchange` integration exists under `src/` or `e2e/`. The decision below is +unchanged and remains the accepted design whenever this work is scheduled. ## Context diff --git a/plans/ADRs/041-offline-precache-scope-and-upgrade-identity.md b/plans/ADRs/041-offline-precache-scope-and-upgrade-identity.md new file mode 100644 index 00000000..2a0e3d8b --- /dev/null +++ b/plans/ADRs/041-offline-precache-scope-and-upgrade-identity.md @@ -0,0 +1,115 @@ +# ADR 041: Offline Precache Scope and Upgrade Identity + +**Date**: 2026-09-30 +**Status**: Implemented +**Related**: Plan 161, Plan 158, ADR 036, ADR 018 +**Amends (in part)**: ADR 036 (E2E target — production mode becomes an explicit +opt-in that is *required* for offline assertions, instead of the removed +default) + +## Context + +The app is local-first: the entire promise is that it keeps working when the +network does not. Plan 158 made the service worker precache the boot document's +own assets instead of four hand-written URLs, which fixed the cold-offline +blank page — for the boot path only. + +`AppShell` code-splits six views. A lazily imported view is referenced by no +HTML, so a manifest derived from `.next/server/app/index.html` cannot contain +it. Measured on the pre-fix build: 17 of 36 emitted chunk files were precached. +A first offline navigation into any unvisited view rejected its dynamic import +and rendered `" failed to load"` (Plan 161 reproduces all six). + +Two design questions had no recorded answer: + +1. **Scope.** Which emitted artifacts are "app code the offline contract + covers", and how are they identified as build output changes? +2. **Upgrade identity.** The manifest is fetched by the worker at install time. + If the manifest widens but the worker's bytes do not change, an existing + installation never reinstalls and never sees the wider list — the fix + reaches new users only. + +## Decision + +### 1. Precache the emitted code inventory, not a classification of it + +The generator unions three sources: the boot document's URLs, every +`.js`/`.mjs`/`.css` under `.next/static/chunks`, and fonts/images under +`.next/static/media`. Names are read from the build directory; nothing is +hardcoded, so content-hash renames cannot silently drop an entry. + +Chunks are **not** attributed to features. The names are content hashes of +minified output, so attribution would mean parsing minified source and guessing +— and a wrong guess is an offline view that cannot load, which is the defect +being fixed. Measured cost: the entire chunk set is ~4 MiB, and it is bounded by +the app's own code. + +Excluded by extension allowlist: `.wasm`, model weights, source maps, raw `.ts`. +These are media a user opts into (the AI harness downloads models on demand), +not app code — one excluded file alone is larger than the whole chunk set. +Caching them would inflate mandatory install for every user, including those who +never open the AI view. + +### 2. Fail closed in the generator + +A manifest that cannot satisfy a lazy import is worse than no manifest, because +it looks correct. The run exits nonzero with an actionable message when the +build HTML is missing (preserved from the previous version), when either static +directory is absent, or when the chunk inventory holds no JavaScript. An empty +font/image set remains valid — the app can legitimately ship with no extra +media. + +### 3. The cache identity changes whenever the manifest scope changes + +The manifest is a runtime fetch, so scope changes are invisible to an installed +worker. Any change to what the manifest *contains* therefore must also change +`STATIC_CACHE` in `public/sw.js`. The installer's `install` handler is +unchanged: it already carries `urls` through the chain and adds each entry +independently, so one 404 cannot reject the whole install. Old caches are +removed in `activate` — never from application code — so the cutover is +atomic from the user's perspective. + +### 4. Offline assertions run against a production build, explicitly + +`playwright.config.ts` gained `PLAYWRIGHT_PRODUCTION=1`: it serves +`pnpm run start`, refuses to reuse a server already on the port, and gates +`e2e/offline-views.spec.ts` out of the default development run and out of every +non-Chromium project. Precache URLs are build output; against `next dev` the +same assertion would be measuring development bundles and could neither fail +nor pass honestly. + +This is deliberately narrower than ADR 036's "production build is the canonical +E2E target": the default loop stays `next dev` for speed, and production mode is +required only where build output is the subject under test. + +## Consequences + +- The offline contract now covers every view's code. Runtime backfill + (`cacheFirst`) remains a bonus path, not the mechanism the contract depends on. +- Installing the worker moves ~4 MiB on first load. Bounded, measured, and paid + once per bundle change. +- Every future manifest-scope change must bump `STATIC_CACHE`. Discoverable from + the worker's own comment and this ADR; a reviewer who changes only the + generator has left the upgrade path broken. +- WASM and model weights stay out. If the AI harness is ever expected to infer + fully offline with a bundled model, that is a separate, explicitly sized + decision. +- `pnpm run test:e2e` still means the fast loop; `test:e2e:offline` (and + `PLAYWRIGHT_PRODUCTION=1 test:e2e`) mean the production loop. CI runs the + production loop behind an `offline` paths filter — the worker, the manifest and + its generator, the boot wiring (`layout.tsx`, `app-shell.tsx`, + `offline-indicator.tsx`, `service-worker-registration.tsx`), the spec itself, + `playwright.config.ts`, and the dependency files that decide chunking. The + `e2e-tests` job also gates on that filter, because a generator-only change is + `tooling`, not `frontend`, and would otherwise never reach the job. + +## Alternatives rejected + +| Alternative | Why not | +|---|---| +| Glob `.next/static/**` entirely | Would pull WASM, model weights and source maps into mandatory install | +| Attribute chunks to views by parsing minified source | Brittle against every bundler change; a wrong guess is a silent offline failure | +| Prewarm lazy views at install by navigating them | Requires executing view code and its network calls during install; slow and side-effectful | +| Rely on runtime cache-first backfill only | Exactly the status quo: it cannot help a *first* offline visit, which is the reported case | +| Descriptor added to a build manifest to name view chunks | Turbopack exposes no stable view→chunk map; maintaining a mapping is the classification problem again | +| Ship a `sw.js` that always re-fetches the manifest with `cache: 'reload'` in `install` | The fetch already bypasses the cache; the problem was the worker never reinstalling, so install never ran | diff --git a/plans/ARCHITECTURE.md b/plans/ARCHITECTURE.md index 7b294083..584d1c3f 100644 --- a/plans/ARCHITECTURE.md +++ b/plans/ARCHITECTURE.md @@ -10,7 +10,7 @@ | Framework | Next.js 16 / React 19 / Tailwind 4 / shadcn | | State & Persistence | Zustand 5 + localStorage (Zod-validated) | | Sync (opt-in) | Yjs + y-webrtc (WebRTC P2P) | -| Deployment | Vercel (Node >= 20, pnpm) | +| Deployment | Vercel (Node >= 22, pnpm) — `package.json` `engines.node: ">=22.0.0"` | No SQLite, no OPFS, no required server. ADR 018 establishes the baseline; ADR 028 defines validated persistence boundaries. @@ -50,7 +50,15 @@ from the canonical persistence blob (ADR 023). Zod-parsed before mutation. - Imports are atomic and fail-closed: complete valid candidate or structured error list. No silent record dropping. -- Pre-import snapshot enables atomic replacement with undo. +- Pre-import snapshot enables atomic replacement with undo. **Known gap**: the + snapshot's `restoreFromRecovery` has no production UI caller and an oversized + or refused write is not reported ([Plan 162](162-roadmap-progress-and-next-work-2026-10-05.md) #1). +- **Fail-closed hydration** (ADR 028, Plan 159): if rehydration is refused, a + page-scoped refusal status stops the persist writer, cross-tab traffic, and + the Yjs/Sync gates for the rest of the page session; the refused payload is + preserved under a key the store never writes and surfaced as a dismissible + banner. The refusal status — not the presence of a quarantine record — is the + single source of truth for "this workspace is temporary". --- @@ -61,9 +69,11 @@ Client-side, bring-your-own-key (ADR 019, ADR 025). | Provider | Transport | Notes | |----------|----------|-------| | **OpenRouter** | OpenAI-compatible fetch | Single key, hundreds of models | -| **Ollama** | localhost fetch | CPU-only toggle, configurable URL | +| **Ollama** | localhost fetch | CPU-only by default (`ollamaCpuOnly`), configurable URL | +| **Local (in-browser)** | transformers.js + ONNX Runtime WASM | CPU-first invariant default (ADR 040); WebGPU opt-in; runtime lazily imported on first local send, so it is never in the eager bundle | -- Prompt augmentation via BM25-retrieved local context (top-k). +- Prompt augmentation via BM25-retrieved local context (top-k), with a semantic + mode available via the embedding-backed vector store. - All fetch calls use `AbortController`. - Manifest-driven skill harness (ADR 029): `.agents/manifest.json` declares surfaces, skill directory, and validation rules. @@ -89,8 +99,13 @@ Yjs is a replication transport. ## Search -Client-side BM25 retrieval (`src/lib/search/retrieval.ts`, ADR 022). +Client-side retrieval with two modes, both fully offline-capable +(`src/lib/search/retrieval.ts`, ADR 022): +- **Lexical** — BM25/TF-IDF ranking over entities and claims. +- **Semantic** — embedding-backed vector store (`src/lib/search/vector-store.ts`) + with batched embedding, a partial index cleared on failure, and an explicit + fallback to the lexical path when the embedder is unavailable. - Indexes entity names, descriptions, content, tags, claim statements. - Rebuilt from store on data change. Returns scored results with snippets. - Powers Library search and Chat citations. Fully offline. @@ -105,8 +120,9 @@ Client-side BM25 retrieval (`src/lib/search/retrieval.ts`, ADR 022). | Markdown | Custom | Frontmatter metadata + entity body | | HTML | Custom | Self-contained static site | | PDF | jspdf | Client-side generation | -| DOCX | docx (lazy) | Dynamic import | +| DOCX | docx | **Static import** inside the lazy Export subtree — `use-export-handlers.ts:11` → `export-documents.ts:2-11`. The Export *view* is lazy (`app-shell.tsx:34`); the DOCX/PDF builders it pulls in are not ([Plan 162](162-roadmap-progress-and-next-work-2026-10-05.md) #4) | | Encrypted | WebCrypto AES-256-GCM | PBKDF2 derivation, self-contained HTML reader | +| OKF | Custom | Knowledge-bundle format, `ExportFormatId` member (`export-types.ts:12`, ADR 031) | ADR 010 (schema), ADR 021 (encryption), ADR 012 (PDF). @@ -149,7 +165,7 @@ Markdown-source-first with progressive enhancement (ADR 020). | Unit | Vitest + v8 coverage | | Component | @testing-library/react | | E2E | Playwright | -| Lint | ESLint 9 + typescript-eslint + jsx-a11y | +| Lint | ESLint 10 + typescript-eslint + jsx-a11y (`package.json` devDependencies) | | Type check | TypeScript 6 (`tsc --noEmit`) | Quality gate: lint + typecheck + test + build (`scripts/quality_gate.sh`). diff --git a/plans/GOAL.md b/plans/GOAL.md index 6161b0ac..28ad69c3 100644 --- a/plans/GOAL.md +++ b/plans/GOAL.md @@ -3,19 +3,19 @@ Build a local-first, structured knowledge engine that empowers users to capture, connect, and synthesize information without relying on cloud-based LLMs. ## Core Values -- **Local Sovereignty**: User data is stored in Zustand + localStorage and never leaves the device. +- **Local Sovereignty**: the core product is local-first — user data is stored in Zustand + localStorage and never leaves the device **unless the user opts into a remote feature**. Selecting a remote AI provider (OpenRouter), enabling web research, or joining a P2P sync room does transmit data off-device. "Never leaves the device" is therefore not an unconditional guarantee; it is the default posture for core functionality. - **Structural Depth**: Moving beyond flat text to Entities, Claims, and relational Links. - **Visual Intelligence**: Multiple perspectives on the same data (Graph, Mind Map, Chat). - **Offline First**: Zero latency, zero dependency on external APIs for core functionality. - **Security First**: All user content is sanitized before export; API keys are session-only. -## Current Architecture (as of 2026-07-27) +## Current Architecture (refreshed 2026-10-05) - Next.js 16 / React 19 / Tailwind 4 / shadcn / Zustand -- Persistence: Zustand + localStorage (validated with Zod schemas) -- Search: BM25 keyword ranking (not semantic/vector) -- AI: OpenRouter and Ollama providers via AI Harness +- Persistence: Zustand + localStorage (validated with Zod schemas), fail-closed on a refused hydration (ADR 028, Plan 159) +- Search: **BM25 keyword ranking plus a real semantic mode** (`src/lib/search/vector-store.ts`) that falls back to lexical on embedding failure +- AI: OpenRouter and Ollama providers via AI Harness, plus a **CPU-first in-browser local provider** (transformers.js/ONNX WASM, WebGPU opt-in — ADR 040, Plan 155) - Sync: Yjs/WebRTC infrastructure (opt-in, bidirectional sync bridge per ADR 027) -- Export: JSON, Markdown, HTML, PDF, DOCX, Encrypted HTML +- Export: JSON, Markdown, HTML, PDF, DOCX, encrypted self-contained HTML reader, and the **`okf` bundle format** (ADR 031) ## 2026 Goals (from GitHub Issue Analysis) 1. **Zero Security Vulnerabilities** — XSS fixes in export paths, session-only API keys ✓ @@ -28,8 +28,42 @@ Build a local-first, structured knowledge engine that empowers users to capture, 8. **Data Integrity** — Zod validation at all boundaries (Plan 072) ✓ 9. **Honest Product Surface** — Accurate labels, no false-success controls (Plan 072) ✓ + ## Remaining Work +Refreshed 2026-10-05 by [Plan 162](162-roadmap-progress-and-next-work-2026-10-05.md), +which is the authoritative open-work list. In order: + +1. **Recovery reachability and backup outcome** — `restoreFromRecovery` has no + production caller and import success is reported without a durable backup. + First implementation priority. +2. **Deletion/export integrity investigation** — entity deletion does not touch + optional graph/mind-map/link/tag fields. +3. **Sync join/rejoin completion** — join-time conflicts are not surfaced and + Leave→Join does not re-initialize persistence. +4. **Remaining heavy-leaf deferral** — editor→Yjs static chain; PDF/DOCX/fflate + inside the lazy Export subtree. +5. **Plan 161 recorded follow-ons** — dev search worker chunk failure, first-mount + reduced-motion tween, workflow linter warnings. +6. **Skills and documentation maintenance** — generated skill catalogs, + `agent-surface.py` doc truth, `SCRIPTS.md` drift, nonexistent `SKIP_LINKS`, + README claims. +7. **New features**, in order: AI request control → URL deep links (ADR 037) → + pointer/touch graph and mind-map interaction. +8. **Unscheduled candidates** — graph revision comparison, Synthesis Inbox, + Visual Query Builder. + +### Coverage: configured floors vs measured history + +These are different things and are kept separate: + +- **Configured floors** (`vitest.config.ts:49-53`, current): branches 75, functions + 78, lines 84, statements 85. These are thresholds the gate enforces. +- **Measured coverage figures** in the historical list below are **records from + the sessions named**, not live metrics. Nothing was measured by this refresh. + +### Historical completed milestones (2026-07 records) + - Coverage target 55% — DONE (current: 57% lines) - Full accessibility audit — DONE. E2E suite added in Plan 093 (keyboard, zoom, reflow, touch targets). Color-contrast violations fixed in Plan 095 (58+ serious violations resolved via CSS token adjustments). Strict axe-core assertions now applied to all 10 views. - Error boundary test coverage — DONE. 14 tests for ErrorBoundary + ViewErrorBoundary (PR #551). Per-view isolation via ViewErrorBoundary wrapping each view in app-shell.tsx. diff --git a/plans/GOAP.md b/plans/GOAP.md index 47055d11..3ffef0a1 100644 --- a/plans/GOAP.md +++ b/plans/GOAP.md @@ -5,6 +5,17 @@ **Source**: GitHub issue analysis (#168–#221) + plans/ gap analysis **Method**: Goal-Oriented Action Planning with dependency-driven action chains +> **Historical ledger — not a current validation report.** This is the +> 2026-05 goal ledger. Its action chains and prerequisites reference the retired +> **Vite, SQLite/OPFS, Orama, and CLI** architecture (superseded by ADR 018: +> Next.js + Zustand + localStorage + BM25). Checked criteria below describe what +> was true in that era and must not be read as evidence about the current +> codebase. The goal history is preserved as written. +> +> For the current architecture see [ARCHITECTURE.md](./ARCHITECTURE.md); for the +> current open work and its priority order see +> [162-roadmap-progress-and-next-work-2026-10-05.md](./162-roadmap-progress-and-next-work-2026-10-05.md). + ## Goal Hierarchy ``` diff --git a/plans/PHASES.md b/plans/PHASES.md index f7fa96b6..9a1e7011 100644 --- a/plans/PHASES.md +++ b/plans/PHASES.md @@ -48,7 +48,7 @@ ## Phase 6: Intelligence (Complete - 2026-06) - [x] Streaming AI chat with LLM provider integration (OpenRouter, Ollama) -- [x] BM25 keyword search with ranked results [renamed from "semantic search" in Plan 072] +- [x] BM25 keyword search with ranked results — **historical label decision**: this line was renamed from "semantic search" in Plan 072 when only BM25 existed. That label decision is now **superseded**: a real semantic mode shipped in `src/lib/search/vector-store.ts` (embedding-backed, with lexical fallback), so "semantic search" describes shipped behavior and BM25 is the lexical fallback. - [x] Entity auto-hydration from external sources (Jina AI reader) - [x] Claim provenance and verification tracking - [x] Entity-aware AI tools (list, query, link) @@ -97,3 +97,27 @@ - [x] INDEX.md: update metrics and session history - [x] LOC remediation: split triz-data.ts (743→4 files) - [x] LOC remediation: extract ai-harness-settings.tsx (563→under500) + +--- + +## Current progress (2026-10-05) + +Added by [Plan 162](162-roadmap-progress-and-next-work-2026-10-05.md). The dated +phases above are preserved as written. This section records what shipped after +them, each with its successor plan. **No Phase 11 is claimed** — no phase was +defined or completed for this work. + +| Capability | Status | Successor plan | +|---|---|---| +| Semantic search (embedding-backed vector store with lexical fallback) | `Implemented — source-confirmed` | Phase 6 label decision superseded; `src/lib/search/vector-store.ts` | +| CPU-first in-browser local AI provider (WASM, WebGPU opt-in) | `Implemented — source-confirmed` | [155](155-cpu-first-local-ai-and-settings-persistence.md), ADR 040 | +| Claim-aware undo (history carries claims; dangling selection cleared) | `Implemented — source-confirmed` | [157](157-codebase-gap-and-feature-remediation.md) W3 | +| Hydration refusal protection and quarantine (fail closed) | `Implemented — source-confirmed` | [159](159-rejected-library-recovery-visibility-and-fail-closed.md), ADR 028 | +| Test-source type gating (`ignoreSourceErrors: false`) | `Implemented — source-confirmed` | [159](159-rejected-library-recovery-visibility-and-fail-closed.md) F7 | +| Six `React.lazy` view boundaries, offline-capable | `Implemented — source-confirmed`; offline coverage is Plan 161's dated 2026-09-30 result | [161](161-offline-lazy-view-precache-and-framework-refresh-2026-09-30.md), ADR 041 | +| Encrypted self-contained HTML reader, `okf` export format | `Implemented — source-confirmed` | Plan 110 / ADR 021 / ADR 031 | + +Open work, in priority order — recovery reachability, deletion/export integrity, +sync join/rejoin, heavy-leaf deferral, recorded follow-ons, skills/docs +maintenance, then the ranked new features — is tracked in +[162-roadmap-progress-and-next-work-2026-10-05.md](162-roadmap-progress-and-next-work-2026-10-05.md). diff --git a/playwright.config.ts b/playwright.config.ts index e3e99e60..e2443941 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,33 @@ import { defineConfig, devices } from '@playwright/test'; +/** + * Production mode: serve the built app instead of `next dev`. + * + * A development server chunks the app differently on every request, so its + * asset URLs can never match the committed precache manifest. Any test that + * asserts real offline behavior therefore needs `pnpm run build` output and + * `pnpm run start`, gated behind an explicit env flag so the default + * `pnpm run test:e2e` keeps its fast development loop. + */ +const isProductionRun = process.env.PLAYWRIGHT_PRODUCTION === '1'; + +/** + * Specs that only make sense against a production build. + * + * Excluded from the default (development) run and from every non-Chromium + * project — the offline navigation contract is asserted through Chromium's + * CDP cache control and browser-context offline mode, which WebKit and the + * emulated mobile devices do not provide. Stated plainly, because an earlier + * version of this comment claimed otherwise: the production offline suite + * runs the `chromium` (desktop-viewport) project only, so mobile-viewport + * behavior of the offline views is NOT covered there — the dev-server + * `mobile` project exercises the views, but never the offline contract. + */ +const PRODUCTION_ONLY_SPECS = ['**/offline-views.spec.ts']; + export default defineConfig({ testDir: './e2e', + testIgnore: isProductionRun ? [] : PRODUCTION_ONLY_SPECS, timeout: 30000, retries: 1, // `list` is the console summary. CI additionally writes the HTML report the @@ -20,10 +46,12 @@ export default defineConfig({ { name: 'mobile', use: { ...devices['iPhone 13'] }, + testIgnore: PRODUCTION_ONLY_SPECS, }, { name: 'tablet', use: { ...devices['iPad Pro 11'] }, + testIgnore: PRODUCTION_ONLY_SPECS, }, { name: 'desktop-xl', @@ -31,10 +59,11 @@ export default defineConfig({ ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 }, }, + testIgnore: PRODUCTION_ONLY_SPECS, }, ], webServer: { - command: 'pnpm run dev', + command: isProductionRun ? 'pnpm run start' : 'pnpm run dev', // Readiness is a real HTTP response, not a bound port: `port` is satisfied as // soon as `next dev` binds, while it is still compiling. On 2026-09-24 a PR run // started 149 tests against a server that could not serve yet and 140 failed on @@ -42,7 +71,9 @@ export default defineConfig({ // becomes healthy into one clear timeout instead of a wall of test failures. url: 'http://localhost:3000', timeout: 120000, - reuseExistingServer: !process.env.CI, + // Production runs must never adopt a stray server on the port: a dev server + // there serves different chunks than the ones the precache manifest lists. + reuseExistingServer: !isProductionRun && !process.env.CI, // Pipe the dev server's own output into the job log: with the default the // server is invisible, which is what made that failure undiagnosable. stdout: 'pipe', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c28d021..962b1a7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,94 +31,94 @@ importers: dependencies: '@hookform/resolvers': specifier: ^5.8.0 - version: 5.8.0(@standard-schema/spec@1.1.0)(react-hook-form@7.81.0(react@19.2.8))(zod@4.4.3) + version: 5.8.0(@standard-schema/spec@1.1.0)(react-hook-form@7.81.0(react@19.3.0))(zod@4.4.3) '@huggingface/transformers': specifier: ^4.3.0 version: 4.3.0(@types/node@26.6.3) '@radix-ui/react-accordion': specifier: ^1.2.20 - version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.2.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-alert-dialog': specifier: ^1.1.23 - version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-aspect-ratio': specifier: ^1.1.12 - version: 1.1.12(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.12(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-avatar': specifier: ^1.1.10 - version: 1.2.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.2.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-checkbox': specifier: ^1.3.11 - version: 1.3.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.3.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-collapsible': specifier: ^1.1.20 - version: 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-context-menu': specifier: ^2.3.7 - version: 2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 2.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-dialog': specifier: ^1.1.23 - version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-dropdown-menu': specifier: ^2.1.24 - version: 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 2.1.24(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-hover-card': specifier: ^1.1.23 - version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-label': specifier: ^2.1.7 - version: 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 2.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-menubar': specifier: ^1.1.24 - version: 1.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.24(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-navigation-menu': specifier: ^1.2.22 - version: 1.2.22(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.2.22(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-popover': specifier: ^1.1.23 - version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-progress': specifier: ^1.1.16 - version: 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-radio-group': specifier: ^1.4.4 - version: 1.4.4(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.4.4(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-scroll-area': specifier: ^1.2.18 - version: 1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.2.18(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-select': specifier: ^2.3.7 - version: 2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 2.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-separator': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-slider': specifier: ^1.4.7 - version: 1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.4.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-slot': specifier: ^1.2.3 - version: 1.3.0(@types/react@19.2.18)(react@19.2.8) + version: 1.3.0(@types/react@19.3.0)(react@19.3.0) '@radix-ui/react-switch': specifier: ^1.3.7 - version: 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-tabs': specifier: ^1.1.21 - version: 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.21(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-toast': specifier: ^1.2.23 - version: 1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.2.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-toggle': specifier: ^1.1.9 - version: 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.14(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-toggle-group': specifier: ^1.1.19 - version: 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-tooltip': specifier: ^1.2.16 - version: 1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.2.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/react-virtual': specifier: ^3.14.13 - version: 3.14.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 3.14.15(react-dom@19.3.0(react@19.3.0))(react@19.3.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -127,7 +127,7 @@ importers: version: 2.1.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) date-fns: specifier: ^4.1.0 version: 4.4.0 @@ -142,40 +142,40 @@ importers: version: 0.8.3 framer-motion: specifier: ^13.1.1 - version: 13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 13.1.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) jspdf: specifier: ^4.2.1 version: 4.2.1 lucide-react: specifier: ^1.47.0 - version: 1.47.0(react@19.2.8) + version: 1.47.0(react@19.3.0) next: specifier: ^16.3.8 - version: 16.3.8(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.8(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) qrcode.react: specifier: ^4.2.0 - version: 4.2.0(react@19.2.8) + version: 4.2.0(react@19.3.0) react: - specifier: ^19.2.8 - version: 19.2.8 + specifier: ^19.3.0 + version: 19.3.0 react-day-picker: specifier: ^10.0.2 - version: 10.0.2(@types/react@19.2.18)(react@19.2.8) + version: 10.0.2(@types/react@19.3.0)(react@19.3.0) react-dom: - specifier: ^19.2.8 - version: 19.2.8(react@19.2.8) + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) react-hook-form: specifier: ^7.60.0 - version: 7.81.0(react@19.2.8) + version: 7.81.0(react@19.3.0) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.18)(react@19.2.8) + version: 10.1.0(@types/react@19.3.0)(react@19.3.0) react-resizable-panels: specifier: ^4.12.2 - version: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 4.12.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -187,7 +187,7 @@ importers: version: 0.35.5(@types/node@26.6.3) sonner: specifier: ^2.0.8 - version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) tailwind-merge: specifier: ^3.3.1 version: 3.6.0 @@ -199,7 +199,7 @@ importers: version: 11.0.5 vaul: specifier: ^1.1.2 - version: 1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) y-indexeddb: specifier: ^9.0.12 version: 9.0.12(yjs@13.6.31) @@ -217,7 +217,7 @@ importers: version: 4.4.3 zustand: specifier: ^5.0.15 - version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + version: 5.0.15(@types/react@19.3.0)(react@19.3.0)(use-sync-external-store@1.6.0(react@19.3.0)) devDependencies: '@axe-core/playwright': specifier: ^4.13.0 @@ -233,16 +233,16 @@ importers: version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11) '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@types/node': specifier: 26.6.3 version: 26.6.3 '@types/react': - specifier: ^19.2.18 - version: 19.2.18 + specifier: ^19.3.0 + version: 19.3.0 '@types/react-dom': - specifier: ^19.2.4 - version: 19.2.4(@types/react@19.2.18) + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) @@ -256,8 +256,8 @@ importers: specifier: ^10 version: 10.11.0(jiti@2.7.0) eslint-config-next: - specifier: ^16.3.5 - version: 16.3.5(@typescript-eslint/parser@8.67.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) + specifier: ^16.3.7 + version: 16.4.0(@typescript-eslint/parser@8.67.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) eslint-plugin-jsx-a11y: specifier: ^6.10.2 version: 6.10.2(eslint@10.11.0(jiti@2.7.0)) @@ -982,8 +982,8 @@ packages: '@next/env@16.3.8': resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} - '@next/eslint-plugin-next@16.3.5': - resolution: {integrity: sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==} + '@next/eslint-plugin-next@16.4.0': + resolution: {integrity: sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==} '@next/swc-darwin-arm64@16.3.8': resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} @@ -2169,13 +2169,13 @@ packages: '@types/raf@3.4.3': resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} - '@types/react-dom@19.2.4': - resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: - '@types/react': ^19.2.0 + '@types/react': ^19.3.0 - '@types/react@19.2.18': - resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2821,8 +2821,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@16.3.5: - resolution: {integrity: sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==} + eslint-config-next@16.4.0: + resolution: {integrity: sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -4027,10 +4027,10 @@ packages: '@types/react': optional: true - react-dom@19.2.8: - resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: - react: ^19.2.8 + react: ^19.3.0 react-hook-form@7.81.0: resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==} @@ -4086,8 +4086,8 @@ packages: '@types/react': optional: true - react@19.2.8: - resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} readable-stream@2.3.8: @@ -4175,8 +4175,8 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -5094,18 +5094,18 @@ snapshots: '@floating-ui/core': 1.8.0 '@floating-ui/utils': 0.2.12 - '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@floating-ui/react-dom@2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@floating-ui/dom': 1.8.0 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) '@floating-ui/utils@0.2.12': {} - '@hookform/resolvers@5.8.0(@standard-schema/spec@1.1.0)(react-hook-form@7.81.0(react@19.2.8))(zod@4.4.3)': + '@hookform/resolvers@5.8.0(@standard-schema/spec@1.1.0)(react-hook-form@7.81.0(react@19.3.0))(zod@4.4.3)': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.81.0(react@19.2.8) + react-hook-form: 7.81.0(react@19.3.0) optionalDependencies: '@standard-schema/spec': 1.1.0 zod: 4.4.3 @@ -5282,7 +5282,7 @@ snapshots: '@next/env@16.3.8': {} - '@next/eslint-plugin-next@16.3.5(eslint@10.11.0(jiti@2.7.0))': + '@next/eslint-plugin-next@16.4.0(eslint@10.11.0(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.11.0(jiti@2.7.0)) fast-glob: 3.3.1 @@ -5361,805 +5361,805 @@ snapshots: '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-aspect-ratio@1.1.12(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-aspect-ratio@1.1.12(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) - - '@radix-ui/react-avatar@1.2.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@radix-ui/react-context': 1.2.0(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-avatar@1.2.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/react-context': 1.2.0(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-checkbox@1.3.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-checkbox@1.3.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-collection@1.1.12(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-collection@1.1.12(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.0(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-slot': 1.3.0(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-context@1.2.0(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-context@1.2.0(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-context@1.2.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) aria-hidden: 1.2.6 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-direction@1.1.2(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-direction@1.1.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-direction@1.1.4(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-hover-card@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-hover-card@1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-id@1.1.2(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-id@1.1.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-id@1.1.4(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-label@2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-label@2.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) aria-hidden: 1.2.6 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-menubar@1.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-menubar@1.1.24(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-popover@1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) aria-hidden: 1.2.6 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) - - '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.3.0)(react@19.3.0) '@radix-ui/rect': 1.1.3 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-presence@1.1.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-presence@1.1.8(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-slot': 1.3.0(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-progress@1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-radio-group@1.4.4(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-radio-group@1.4.4(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.6 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.0(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-presence': 1.1.8(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-roving-focus': 1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-roving-focus@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-roving-focus@1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.6 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.0(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-id': 1.1.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-select@2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-select@2.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) aria-hidden: 1.2.6 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-separator@1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-slider@1.4.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-slot@1.3.0(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-slot@1.3.0(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-slot@1.3.3(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-switch@1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-switch@1.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-toast@1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-toast@1.2.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-toggle-group@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-toggle-group@1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-direction': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-toggle@1.1.14(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-toggle@1.1.14(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-toggle@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-toggle@1.1.18(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-controllable-state@1.2.4(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-controllable-state@1.2.4(@types/react@19.3.0)(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.6 - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.3.0)(react@19.3.0)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-previous@1.1.4(@types/react@19.3.0)(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-rect@1.1.4(@types/react@19.3.0)(react@19.3.0)': dependencies: '@radix-ui/rect': 1.1.3 - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-size@1.1.2(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-size@1.1.2(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-use-size@1.1.4(@types/react@19.2.18)(react@19.2.8)': + '@radix-ui/react-use-size@1.1.4(@types/react@19.3.0)(react@19.3.0)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) '@radix-ui/rect@1.1.3': {} @@ -6286,11 +6286,11 @@ snapshots: postcss: 8.5.26 tailwindcss: 4.3.3 - '@tanstack/react-virtual@3.14.15(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tanstack/react-virtual@3.14.15(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@tanstack/virtual-core': 3.19.0 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) '@tanstack/virtual-core@3.19.0': {} @@ -6317,15 +6317,15 @@ snapshots: optionalDependencies: vitest: 4.1.11(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(vite@8.2.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@babel/runtime': 7.29.2 '@testing-library/dom': 10.4.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) '@tybys/wasm-util@0.10.4': dependencies: @@ -6380,11 +6380,11 @@ snapshots: '@types/raf@3.4.3': optional: true - '@types/react-dom@19.2.4(@types/react@19.2.18)': + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@types/react@19.2.18': + '@types/react@19.3.0': dependencies: csstype: 3.2.3 @@ -6855,14 +6855,14 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + cmdk@1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -7148,9 +7148,9 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.3.5(@typescript-eslint/parser@8.67.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3): + eslint-config-next@16.4.0(@typescript-eslint/parser@8.67.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@next/eslint-plugin-next': 16.3.5(eslint@10.11.0(jiti@2.7.0)) + '@next/eslint-plugin-next': 16.4.0(eslint@10.11.0(jiti@2.7.0)) eslint: 10.11.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.11.0(jiti@2.7.0)) @@ -7420,14 +7420,14 @@ snapshots: dependencies: is-callable: 1.2.7 - framer-motion@13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + framer-motion@13.1.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: motion-dom: 13.1.1 motion-utils: 13.0.0 tslib: 2.8.1 optionalDependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) fsevents@2.3.3: optional: true @@ -7989,9 +7989,9 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.47.0(react@19.2.8): + lucide-react@1.47.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 lz-string@1.5.0: {} @@ -8400,21 +8400,21 @@ snapshots: natural-compare@1.4.0: {} - next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next-themes@0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) - next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@26.6.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.3.8 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.24 caniuse-lite: 1.0.30001810 postcss: 8.5.26 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0) optionalDependencies: '@next/swc-darwin-arm64': 16.3.8 '@next/swc-darwin-x64': 16.3.8 @@ -8616,9 +8616,9 @@ snapshots: dependencies: hookified: 2.2.0 - qrcode.react@4.2.0(react@19.2.8): + qrcode.react@4.2.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 queue-microtask@1.2.3: {} @@ -8631,37 +8631,37 @@ snapshots: dependencies: safe-buffer: 5.1.2 - react-day-picker@10.0.2(@types/react@19.2.18)(react@19.2.8): + react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.0): dependencies: '@date-fns/tz': 1.5.0 date-fns: 4.4.0 - react: 19.2.8 + react: 19.3.0 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - react-dom@19.2.8(react@19.2.8): + react-dom@19.3.0(react@19.3.0): dependencies: - react: 19.2.8 - scheduler: 0.27.0 + react: 19.3.0 + scheduler: 0.28.0 - react-hook-form@7.81.0(react@19.2.8): + react-hook-form@7.81.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 react-is@16.13.1: {} react-is@17.0.2: {} - react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8): + react-markdown@10.1.0(@types/react@19.3.0)(react@19.3.0): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.18 + '@types/react': 19.3.0 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.8 + react: 19.3.0 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -8670,39 +8670,39 @@ snapshots: transitivePeerDependencies: - supports-color - react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.0): dependencies: - react: 19.2.8 - react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) + react: 19.3.0 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.0) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.8): + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.3.0): dependencies: - react: 19.2.8 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8) - react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) + react: 19.3.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.3.0) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.0) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.8) - use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.8) + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.3.0) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - react-resizable-panels@4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-resizable-panels@4.12.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) - react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8): + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.3.0): dependencies: get-nonce: 1.0.1 - react: 19.2.8 + react: 19.3.0 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - react@19.2.8: {} + react@19.3.0: {} readable-stream@2.3.8: dependencies: @@ -8851,7 +8851,7 @@ snapshots: dependencies: xmlchars: 2.2.0 - scheduler@0.27.0: {} + scheduler@0.28.0: {} semver@6.3.1: {} @@ -8966,12 +8966,12 @@ snapshots: transitivePeerDependencies: - supports-color - sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 source-map-js@1.2.2: {} @@ -9064,10 +9064,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.3.0): dependencies: client-only: 0.0.1 - react: 19.2.8 + react: 19.3.0 optionalDependencies: '@babel/core': 7.29.7 @@ -9285,24 +9285,24 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.8): + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.3.0): dependencies: detect-node-es: 1.1.0 - react: 19.2.8 + react: 19.3.0 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - use-sync-external-store@1.6.0(react@19.2.8): + use-sync-external-store@1.6.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 optional: true util-deprecate@1.0.2: {} @@ -9312,11 +9312,11 @@ snapshots: base64-arraybuffer: 1.0.2 optional: true - vaul@1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + vaul@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -9495,10 +9495,10 @@ snapshots: zod@4.4.3: {} - zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + zustand@5.0.15(@types/react@19.3.0)(react@19.3.0)(use-sync-external-store@1.6.0(react@19.3.0)): optionalDependencies: - '@types/react': 19.2.18 - react: 19.2.8 - use-sync-external-store: 1.6.0(react@19.2.8) + '@types/react': 19.3.0 + react: 19.3.0 + use-sync-external-store: 1.6.0(react@19.3.0) zwitch@2.0.4: {} diff --git a/public/precache-manifest.json b/public/precache-manifest.json index 57850256..ec8c0b9d 100644 --- a/public/precache-manifest.json +++ b/public/precache-manifest.json @@ -1,27 +1,59 @@ { "urls": [ "/", - "/_next/static/chunks/006iwhaq0iypa.js", + "/_next/static/chunks/03ygc4-maie8p.js", + "/_next/static/chunks/0_x-jjd1re_iw.js", + "/_next/static/chunks/0bsgqxiqbqf_p.js", + "/_next/static/chunks/0bukcvh7_fpcf.js", "/_next/static/chunks/0cz1d0mv5g_q7.js", - "/_next/static/chunks/0mly7ty3b3djm.css", - "/_next/static/chunks/0nyy9hkktrzez.js", - "/_next/static/chunks/0qa0xkpf-qryt.js", - "/_next/static/chunks/0wivgzb3hl_-1.js", - "/_next/static/chunks/126ime2-b-jnn.js", + "/_next/static/chunks/0eui2qzmhkmea.js", + "/_next/static/chunks/0scbc1lgoi1jc.js", + "/_next/static/chunks/0tn2phxrmp9dw.css", + "/_next/static/chunks/0zmzzq7srka5z.js", + "/_next/static/chunks/104nyvms9pudp.js", "/_next/static/chunks/12lvifn972enm.js", + "/_next/static/chunks/1bc29t-mtlh98.js", "/_next/static/chunks/1m4v2gaip4uyt.css", - "/_next/static/chunks/1qm3r0b3dx3qd.js", - "/_next/static/chunks/2fjfxfl2luf3u.js", - "/_next/static/chunks/2ls-sugm5sotw.js", - "/_next/static/chunks/2wob4yvfsapne.js", - "/_next/static/chunks/31kp0ym0lk2td.js", - "/_next/static/chunks/3nzl9t5dqxi8_.js", - "/_next/static/chunks/3wm2oz3v8kfig.js", - "/_next/static/chunks/turbopack-154rucjpgz74h.js", + "/_next/static/chunks/1m_g6bqjpauvq.js", + "/_next/static/chunks/1p_-sv2wycrap.js", + "/_next/static/chunks/1rcc4x22hj2vy.js", + "/_next/static/chunks/1vykq22q4pkvf.js", + "/_next/static/chunks/1z9wa_774s3q2.js", + "/_next/static/chunks/2-xg5hgh_h7rb.js", + "/_next/static/chunks/22--9jsd1h4ef.js", + "/_next/static/chunks/24c48jkza7nhv.js", + "/_next/static/chunks/25mcuwr9fmxn9.js", + "/_next/static/chunks/2hr7hvc7jc1jc.js", + "/_next/static/chunks/2ljk12yyixbjg.js", + "/_next/static/chunks/2pkiq__cah-xf.js", + "/_next/static/chunks/2w3lxfa9enduh.js", + "/_next/static/chunks/353czevf4jrky.js", + "/_next/static/chunks/3e0kibswfpl46.js", + "/_next/static/chunks/3gqpnx7hf0zbl.js", + "/_next/static/chunks/3pd7xidqrd82d.js", + "/_next/static/chunks/3qisr7bydrlp_.js", + "/_next/static/chunks/3szqgng8m4ks9.js", + "/_next/static/chunks/429u29ij_t7xy.js", + "/_next/static/chunks/turbopack-08kfkpp2u6nt9.js", + "/_next/static/chunks/turbopack-2pjbd0uz2hzm2.js", + "/_next/static/chunks/turbopack-worker-2ru9m5gbh1na6.js", + "/_next/static/media/150f724d70372c02-s.1ljbalzvxyif7.woff2", + "/_next/static/media/4fa387ec64143e14-s.2tuy5pz7dlieh.woff2", + "/_next/static/media/53b9e256198e5412-s.390ncx5urfkfu.woff2", + "/_next/static/media/5ce348bf30bf5439-s.31988l_ccedte.woff2", "/_next/static/media/5f402bd2d8eef81a-s.p.40bho2kviej2o.woff2", + "/_next/static/media/6306c77e7c8268e4-s.2dbetqa9o8jxf.woff2", + "/_next/static/media/7178b3e590c64307-s.21jp631_3pja2.woff2", + "/_next/static/media/750c737482d9de2f-s.3ex3j6ahhvjry.woff2", "/_next/static/media/797e433ab948586e-s.p.0r6juujl39pe6.woff2", + "/_next/static/media/79e9e0e3b9220e46-s.3a-pd9010p_dh.woff2", + "/_next/static/media/7d817b4c03b0c5f1-s.1uyisp29ctx0d.woff2", + "/_next/static/media/8a480f0b521d4e75-s.1qq4vpdcun5oj.woff2", "/_next/static/media/9433d1a810498265-s.p.1hij5ex6k3v9q.woff2", + "/_next/static/media/bbc41e54d2fcbd21-s.1rgnod-3esatf.woff2", "/_next/static/media/caa3a2e1cccd8315-s.p.0wgildi0cnwt9.woff2", + "/_next/static/media/e62850744c7f266e-s.0n7ulcr4ivksg.woff2", + "/_next/static/media/fef07dbb0973bf53-s.3p2_lha1f2xer.woff2", "/favicon.svg", "/icon-192.png", "/icon-512.png", diff --git a/public/sw.js b/public/sw.js index 8f5c4e60..dc1bf25d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,15 @@ -const STATIC_CACHE = "dks-static-v2"; +const STATIC_CACHE = "dks-static-v3"; const API_CACHE = "dks-api-v2"; +/** + * Extensions served cache-first. + * + * Must cover every extension `scripts/generate-precache-manifest.mjs` can put in + * the manifest (`MEDIA_EXTENSIONS` + the code extensions): a URL that is + * precached but not matched here falls through to the network and is missing + * offline — the exact failure the manifest exists to prevent (`.avif` was + * precached-but-unserved until this list gained it). + */ const STATIC_EXTENSIONS = [ ".js", ".mjs", @@ -10,6 +19,7 @@ const STATIC_EXTENSIONS = [ ".jpg", ".jpeg", ".webp", + ".avif", ".gif", ".ico", ".woff", @@ -18,13 +28,15 @@ const STATIC_EXTENSIONS = [ ".eot", ".otf", ".json", + ".webmanifest", ".txt", ]; // Fallback shell, used only if the generated manifest cannot be fetched. // The manifest (written by scripts/generate-precache-manifest.mjs after // `next build`) is the real precache list: it is derived from the emitted -// HTML, so it always matches the Turbopack chunks the app actually needs. +// HTML plus the emitted chunk/media inventories, so it always matches the +// code and hashed asset names the app actually requests. const PRECACHE_MANIFEST_URL = "/precache-manifest.json"; const FALLBACK_PRECACHE_URLS = [ "/", diff --git a/scripts/generate-precache-manifest.mjs b/scripts/generate-precache-manifest.mjs index 895f8fab..f9d15901 100755 --- a/scripts/generate-precache-manifest.mjs +++ b/scripts/generate-precache-manifest.mjs @@ -8,24 +8,33 @@ * execute. On a local-first app whose entire promise is surviving a dropped * connection, that is the difference between working offline and a blank page. * - * The list is derived from the emitted HTML's own script/style references - * rather than a glob of the output directory. The HTML is exactly what the - * browser needs to boot, so this cannot over- or under-shoot the real boot - * set as Next's chunking changes. + * The boot set is derived from the emitted HTML's own script/style references, + * plus a full inventory of the emitted static assets. The HTML alone is only + * what the initial document needs; the app code-splits six views (Graph, Mind + * Map, AI Harness, TRIZ, Export, Sync), and those chunks appear in no HTML — + * so a boot-only list left a first offline navigation into any of them to + * reject its dynamic import (plans/161). Inventories are read from the build + * directory, never hardcoded, so renamed hashes cannot silently fall out. * * Runs after `next build`. Output: `public/precache-manifest.json`, fetched * by the worker at install time. */ -import { readFile, writeFile } from 'node:fs/promises' +import { readFile, writeFile, readdir } from 'node:fs/promises' import { existsSync } from 'node:fs' -import { join, dirname } from 'node:path' +import { extname, join, dirname, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const BUILD_HTML = join(REPO_ROOT, '.next', 'server', 'app', 'index.html') +const NEXT_STATIC_DIR = join(REPO_ROOT, '.next', 'static') +const CHUNKS_DIR = join(NEXT_STATIC_DIR, 'chunks') +const MEDIA_DIR = join(NEXT_STATIC_DIR, 'media') const OUTPUT = join(REPO_ROOT, 'public', 'precache-manifest.json') +/** URL prefix the emitted static directory is served under. */ +const STATIC_URL_PREFIX = '/_next/static' + /** Static files the app shell needs regardless of the bundle. */ const ALWAYS_INCLUDED = [ '/', @@ -48,8 +57,6 @@ const ALWAYS_INCLUDED = [ * from HTML entities, yielding entries like `.js\` that 404 when * fetched. Each match is additionally validated against a known extension. */ -const ASSET_EXTENSIONS = ['js', 'css', 'woff2', 'woff', 'ttf', 'otf', 'svg', 'png', 'webp', 'avif'] - const extractAssetUrls = (html) => { const pattern = /["'](\/_next\/static\/[^"'\\\s]+?)["']/g const found = new Set() @@ -62,6 +69,56 @@ const extractAssetUrls = (html) => { return [...found].sort() } +/** + * Extensions of emitted code the app can load at runtime. + * + * Every chunk under `.next/static/chunks` is precached rather than trying to + * attribute chunks to features: the names are content hashes, so classification + * would mean parsing minified source, and a wrong guess is an offline view that + * cannot load. + */ +const CODE_EXTENSIONS = ['.js', '.mjs', '.css'] + +/** + * Extensions of emitted fonts and images referenced by views. + * + * Deliberately excludes `.wasm` and the large model weights the AI harness + * downloads on demand: those are media the user opts into, not app code, and + * one of them alone exceeds the whole chunk set. + */ +const MEDIA_EXTENSIONS = ['.woff2', '.woff', '.ttf', '.otf', '.svg', '.png', '.webp', '.avif'] + +/** + * The same set as the two inventories above, in the dotless form the document + * scan compares against. Derived rather than restated: two lists describing one + * extension set drift, and a drift here silently drops an asset from precache. + */ +const ASSET_EXTENSIONS = [...CODE_EXTENSIONS, ...MEDIA_EXTENSIONS].map((extension) => + extension.slice(1), +) + +/** + * Collects files under `directory` whose extension is in `extensions`, + * returned as `/_next/static/...` URLs. + * + * Paths are derived from the real build directory and rewritten with forward + * slashes so a Windows checkout still emits URLs, not paths. + */ +const collectStaticAssetUrls = async (directory, extensions) => { + const entries = await readdir(directory, { withFileTypes: true }) + const urls = [] + for (const entry of entries) { + const absolute = join(directory, entry.name) + if (entry.isDirectory()) { + urls.push(...(await collectStaticAssetUrls(absolute, extensions))) + } else if (extensions.includes(extname(entry.name))) { + const relativePath = relative(NEXT_STATIC_DIR, absolute).split(sep).join('/') + urls.push(`${STATIC_URL_PREFIX}/${relativePath}`) + } + } + return urls +} + const main = async () => { if (!existsSync(BUILD_HTML)) { console.error( @@ -72,14 +129,42 @@ const main = async () => { return } + for (const directory of [CHUNKS_DIR, MEDIA_DIR]) { + if (!existsSync(directory)) { + console.error( + `[precache] ${directory} not found. The build output is incomplete — ` + + 're-run `pnpm run build` before generating the precache list.', + ) + process.exitCode = 1 + return + } + } + const html = await readFile(BUILD_HTML, 'utf8') - const assets = extractAssetUrls(html) - const urls = [...new Set([...ALWAYS_INCLUDED, ...assets])].sort() + const bootAssets = extractAssetUrls(html) + const codeAssets = await collectStaticAssetUrls(CHUNKS_DIR, CODE_EXTENSIONS) + + // A build whose chunk directory holds no JavaScript is not a build this + // manifest can describe: emitting it would silently precache a boot-only + // shell again, which is exactly the regression this list exists to prevent. + if (!codeAssets.some((url) => url.endsWith('.js') || url.endsWith('.mjs'))) { + console.error( + `[precache] no JavaScript chunks found under ${CHUNKS_DIR}. Refusing to ` + + 'write a manifest that cannot satisfy a lazy view import.', + ) + process.exitCode = 1 + return + } + + const mediaAssets = await collectStaticAssetUrls(MEDIA_DIR, MEDIA_EXTENSIONS) + const staticAssets = [...codeAssets, ...mediaAssets] + const urls = [...new Set([...ALWAYS_INCLUDED, ...bootAssets, ...staticAssets])].sort() await writeFile(OUTPUT, `${JSON.stringify({ urls }, null, 2)}\n`, 'utf8') console.log( `[precache] wrote ${urls.length} URLs to public/precache-manifest.json ` + - `(${assets.length} from the build output)`, + `(${bootAssets.length} from the document, ${codeAssets.length} chunks, ` + + `${mediaAssets.length} media)`, ) } diff --git a/src/components/studio/app-shell.tsx b/src/components/studio/app-shell.tsx index 447c2456..8fb17685 100644 --- a/src/components/studio/app-shell.tsx +++ b/src/components/studio/app-shell.tsx @@ -27,6 +27,12 @@ import { type HydrationRefusal, } from '@/lib/studio/hydration-guard' import { QuarantineBanner } from './views/quarantine-banner' +import { RecoveryBanner } from './views/recovery-banner' +import { + describeRecoverySnapshot, + subscribeToRecoveryAvailability, + type RecoverySnapshotSummary, +} from '@/lib/studio/recovery-helpers' const GraphView = lazy(() => import('./views/graph-view').then((m) => ({ default: m.GraphView }))) const MindMapView = lazy(() => import('./views/mindmap-view').then((m) => ({ default: m.MindMapView }))) @@ -193,22 +199,43 @@ const useHydrationRefusal = (): HydrationRefusal | null => { */ const RecoveryAlerts = ({ refusal }: { refusal: HydrationRefusal | null }) => { const [record, setRecord] = useState(null) + const [snapshot, setSnapshot] = useState(null) const refreshRecord = useCallback(() => { setRecord(readQuarantine()) }, []) + const refreshSnapshot = useCallback(() => { + setSnapshot(describeRecoverySnapshot()) + }, []) + useEffect(() => { refreshRecord() + refreshSnapshot() // A late or manual rehydrate can refuse after mount, so the quarantine - // record is re-read on hydration completion rather than captured once. - return useStudioStore.persist.onFinishHydration(refreshRecord) - }, [refreshRecord]) + // record is re-read on hydration completion rather than captured once. The + // pre-import snapshot is refreshed on the same signal so an import that + // lands before hydration finishes still offers its restore. + return useStudioStore.persist.onFinishHydration(() => { + refreshRecord() + refreshSnapshot() + }) + }, [refreshRecord, refreshSnapshot]) + + // A snapshot is written inside a store action, long after this component + // mounted, so the initial read alone would never learn that an import just + // made one available. + useEffect(() => subscribeToRecoveryAvailability(refreshSnapshot), [refreshSnapshot]) - if (record === null && refusal === null) return null + if (record === null && refusal === null && snapshot === null) return null return (
+ {/* Deliberately withheld while a refusal blocks writes: a restore here + would swap the corpus in memory, be dropped on the way to storage, and + consume the snapshot — the user would be told the library came back and + then lose it on reload. */} + {refusal === null && snapshot && } {record && } {refusal?.preserved === false && ( @@ -255,8 +282,13 @@ export const AppShell = () => { ) return ( + // `paddingTop` reserves the fixed offline banner's measured height + // (`--offline-banner-height`, published by OfflineIndicator). Without it the + // banner covers the topbar and its controls stop taking clicks while + // offline. Unset on the server, so both renders agree at 0px.
{ + if (node === null) return; + + const publishHeight = () => { + document.documentElement.style.setProperty( + BANNER_HEIGHT_VARIABLE, + `${node.offsetHeight}px`, + ); + }; + publishHeight(); + + // jsdom (unit tests) has no ResizeObserver; the height is still published + // once above, and the browser suites cover the live-resize behavior. + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(publishHeight); + observer.observe(node); + return () => { observer.disconnect(); }; + }, []); + + // Ends the reservation once the banner has actually left the layout — after + // the exit animation, or immediately when reduced motion removed it. + const clearReservedHeight = useCallback(() => { + document.documentElement.style.removeProperty(BANNER_HEIGHT_VARIABLE); + }, []); + return ( - + {isOffline && ( ({ + mockRestoreFromRecovery: vi.fn(), +})) + +vi.mock('@/lib/studio/recovery-helpers', async (importOriginal) => ({ + ...(await importOriginal()), + restoreFromRecovery: mockRestoreFromRecovery, +})) + +const { mockToast, mockAnnounce } = vi.hoisted(() => ({ + mockToast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, + mockAnnounce: vi.fn(), +})) + +vi.mock('sonner', () => ({ toast: mockToast })) +vi.mock('@/lib/a11y/announcer', () => ({ useAnnouncer: () => mockAnnounce })) + +vi.mock('@/lib/utils', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +import { RecoveryBanner } from './recovery-banner' + +/** The summary the app-shell passes once a restorable snapshot exists. */ +const SUMMARY = { entityCount: 2, claimCount: 1 } + +const renderBanner = () => + render() + +const clickRestore = async () => { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Restore it' })) + }) +} + +describe('RecoveryBanner', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('describes what the snapshot holds', () => { + renderBanner() + + expect(screen.getByTestId('recovery-banner')).toBeDefined() + // The counts are interpolated into the banner's body copy. + expect(screen.getByText(/2 entities and 1 claim/)).toBeDefined() + }) + + it('reports a clean restore and takes the offer away', async () => { + mockRestoreFromRecovery.mockReturnValue({ success: true, snapshotCleared: true }) + renderBanner() + + await clickRestore() + + expect(mockRestoreFromRecovery).toHaveBeenCalledTimes(1) + expect(mockToast.success).toHaveBeenCalledWith('Previous library restored') + expect(mockToast.warning).not.toHaveBeenCalled() + expect(mockAnnounce).toHaveBeenCalledWith('Previous library restored') + expect(screen.queryByTestId('recovery-banner')).toBeNull() + }) + + it('tells the user when the restore landed but the snapshot survived', async () => { + // The typed outcome exists for this case: storage refused to drop the + // snapshot, so the offer comes back after a reload and a second restore + // would replace the library with that older copy. A plain success toast + // would be a false claim about what is still on disk (GitNexus on #925). + mockRestoreFromRecovery.mockReturnValue({ success: true, snapshotCleared: false }) + renderBanner() + + await clickRestore() + + expect(mockToast.success).not.toHaveBeenCalled() + expect(mockToast.warning).toHaveBeenCalledWith( + 'Previous library restored. The backup could not be removed, so the restore offer will return after a reload.', + { description: expect.stringContaining('would not delete the recovery copy') }, + ) + expect(mockAnnounce).toHaveBeenCalledWith( + 'Previous library restored. The backup could not be removed, so the restore offer will return after a reload.', + ) + // The restored library is on screen; only the offer goes away. + expect(screen.queryByTestId('recovery-banner')).toBeNull() + }) + + it('keeps the offer up when the restore fails', async () => { + mockRestoreFromRecovery.mockReturnValue({ success: false, error: 'storage is blocked' }) + renderBanner() + + await clickRestore() + + expect(mockToast.error).toHaveBeenCalledWith('Could not restore the previous library', { + description: 'storage is blocked', + }) + expect(screen.getByTestId('recovery-banner')).toBeDefined() + }) + + it('hides the offer for the session without touching the snapshot', async () => { + renderBanner() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Hide for this page session' })) + }) + + // Dismissal is local state only: restoring is the caller's job, and the + // bytes stay in storage (a reload brings the offer back). + expect(mockRestoreFromRecovery).not.toHaveBeenCalled() + expect(screen.queryByTestId('recovery-banner')).toBeNull() + }) +}) diff --git a/src/components/studio/views/recovery-banner.tsx b/src/components/studio/views/recovery-banner.tsx new file mode 100644 index 00000000..5ab97bd2 --- /dev/null +++ b/src/components/studio/views/recovery-banner.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useCallback, useState } from 'react' +import { History, X } from 'lucide-react' +import { toast } from 'sonner' +import { useAnnouncer } from '@/lib/a11y/announcer' +import { translate as t } from '@/lib/i18n/messages/announce' +import { + restoreFromRecovery, + type RecoverySnapshotSummary, +} from '@/lib/studio/recovery-helpers' +import { cn } from '@/lib/utils' + +/** Shared action styling: 44px floor and a visible keyboard focus ring. */ +const ACTION_CLASS = cn( + 'flex min-h-[44px] items-center gap-1.5 rounded-md px-3 py-1.5', + 'text-label font-medium text-saffron-deep transition-colors', + 'hover:bg-saffron-soft focus-ring', +) + +/** Dismissible control styling: 44px floor and a visible focus ring. */ +const DISMISS_CLASS = cn( + 'flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md', + 'text-ink-faint transition-colors hover:bg-muted hover:text-ink focus-ring', +) + +/** + * Non-destructive session dismissal. + * + * Mirrors the quarantine banner's rule: hiding the offer must never destroy the + * snapshot, so this only sets local state. A reload brings the offer back, + * because the bytes are still in localStorage. + */ +const HideForSessionButton = ({ onHide }: { onHide: () => void }) => ( + +) + +/** + * Banner offering to restore the corpus that existed before the last import. + * + * The pre-import snapshot was always written but had no UI, so a user who + * replaced their library had no way back. This is mounted above the view router + * so it is reachable from any view, not only from the Export view the import + * happened in. + */ +export const RecoveryBanner = ({ summary }: { summary: RecoverySnapshotSummary }) => { + const announce = useAnnouncer() + const [hidden, setHidden] = useState(false) + + const handleRestore = useCallback(() => { + /** The result. */ + const result = restoreFromRecovery() + if (result.success) { + if (result.snapshotCleared) { + announce(t('announce.restoreSucceeded')) + toast.success(t('announce.restoreSucceeded')) + } else { + // The swap landed but storage kept the snapshot, so the offer returns + // after a reload and a second restore would replace the library with + // that older copy. Reporting a plain success here is exactly the false + // claim the typed result exists to prevent (GitNexus on PR #925). + announce(t('announce.restoreSucceededSnapshotKept')) + toast.warning(t('announce.restoreSucceededSnapshotKept'), { + description: t('announce.restoreSnapshotKeptHint'), + }) + } + setHidden(true) + } else { + announce(t('announce.restoreFailed')) + toast.error(t('announce.restoreFailed'), { description: result.error }) + } + }, [announce]) + + if (hidden) return null + + const summaryText = `${summary.entityCount} ${summary.entityCount === 1 ? 'entity' : 'entities'} and ${summary.claimCount} ${summary.claimCount === 1 ? 'claim' : 'claims'}` + + return ( +
+
+
+
+ + { setHidden(true) }} /> +
+
+ ) +} \ No newline at end of file diff --git a/src/components/studio/views/use-export-handlers.test.ts b/src/components/studio/views/use-export-handlers.test.ts index 2febf0f2..29da4778 100644 --- a/src/components/studio/views/use-export-handlers.test.ts +++ b/src/components/studio/views/use-export-handlers.test.ts @@ -41,6 +41,7 @@ vi.mock('fflate', () => ({ })) import { useExportHandlers } from './use-export-handlers' +import type { ImportOutcome } from '@/lib/studio/store-types' import { toast } from 'sonner' import { downloadFile, downloadBlob } from './export-types' import { buildPdfExport, buildDocxExport } from './export-documents' @@ -142,7 +143,7 @@ const renderUseExportHandlers = (overrides: Partial ({ success: true })), + importWithRollback: vi.fn((): ImportOutcome => ({ success: true, backupPersisted: true })), /** Store action that restores the demo dataset. */ resetStore: vi.fn(), importPreview: null, @@ -285,7 +286,7 @@ describe('useExportHandlers', () => { it('handleConfirmImport calls importWithRollback with preview data', () => { /** Store action that commits an import with rollback on failure. */ - const importWithRollback = vi.fn(() => ({ success: true })) + const importWithRollback = vi.fn((): ImportOutcome => ({ success: true, backupPersisted: true })) /** Callback that stages the parsed import preview. */ const setImportPreview = vi.fn() /** The preview. */ @@ -301,7 +302,7 @@ describe('useExportHandlers', () => { it('handleConfirmImport shows error when rollback fails', () => { /** Store action that commits an import with rollback on failure. */ - const importWithRollback = vi.fn(() => ({ success: false, error: 'bad data' })) + const importWithRollback = vi.fn((): ImportOutcome => ({ success: false, error: 'bad data' })) /** Callback that stages the parsed import preview. */ const setImportPreview = vi.fn() /** The preview. */ @@ -314,6 +315,33 @@ describe('useExportHandlers', () => { expect(setImportPreview).toHaveBeenCalledWith(null) }) + it('handleConfirmImport warns instead of claiming a clean import when no backup was kept', () => { + /** Store action reporting a successful import with no restorable backup. */ + const importWithRollback = vi.fn( + (): ImportOutcome => ({ + success: true, + backupPersisted: false, + backupFailure: 'too-large', + }), + ) + /** Callback that stages the parsed import preview. */ + const setImportPreview = vi.fn() + /** The preview. */ + const preview = makeImportPreview() + const { result } = renderUseExportHandlers({ + importPreview: preview, setImportPreview, importWithRollback, + }) + act(() => { result.current.handleConfirmImport() }) + + expect(toast.warning).toHaveBeenCalledWith( + 'Imported — but no backup was kept', + { description: expect.stringContaining('too large') }, + ) + // The green toast must not also fire: it would claim the user had a way + // back when they do not. + expect(toast.success).not.toHaveBeenCalledWith('Import complete', expect.anything()) + }) + it('handleConfirmImport returns early when no importPreview', () => { /** Store action that commits an import with rollback on failure. */ const importWithRollback = vi.fn() diff --git a/src/components/studio/views/use-export-handlers.ts b/src/components/studio/views/use-export-handlers.ts index 91b9474e..048d6b05 100644 --- a/src/components/studio/views/use-export-handlers.ts +++ b/src/components/studio/views/use-export-handlers.ts @@ -2,6 +2,7 @@ import { useState } from 'react' import { toast } from 'sonner' import packageJson from '../../../../package.json' import type { Entity, Claim } from '@/lib/studio/types' +import type { ImportOutcome } from '@/lib/studio/store-types' import type { LibraryPayload, ImportPreview, ExportFormatId, ExportOptions } from './export-types' import { todayStamp, downloadFile, downloadBlob } from './export-types' import { @@ -89,18 +90,10 @@ const joinErrorMessages = (errors: string[]): string => { return `${cut > 0 ? prefix.slice(0, cut) : prefix}…` } -/** Outcome of an import-with-rollback store operation. */ -interface ImportRollbackResult { - /** Whether the operation succeeded. */ - success: boolean - /** Optional error message when the operation failed. */ - error?: string -} - /** Inputs consumed by the export/import handlers hook. */ interface UseExportHandlersParams extends LibraryPayload { /** Store action that commits an import with rollback on failure. */ - importWithRollback: (entities: Entity[], claims: Claim[], options?: ExportOptions) => ImportRollbackResult + importWithRollback: (entities: Entity[], claims: Claim[], options?: ExportOptions) => ImportOutcome /** Store action that restores the demo dataset. */ resetStore: () => void /** Currently staged import preview (or null). */ @@ -428,9 +421,23 @@ export const useExportHandlers = ({ importPreview.links, importPreview.tags, ) - toast.success('Import complete', { - description: `${summary} replaced the current library.`, - }) + // The import succeeded either way, but a missing backup changes what that + // success means: there is no way back to the corpus that was replaced. + // The same green toast in both cases would be a false claim. + if (result.backupPersisted === false) { + toast.warning('Imported — but no backup was kept', { + description: + result.backupFailure === 'too-large' + ? 'Your previous library was too large for this browser to keep as a recovery copy, so it cannot be restored from here. Export a JSON backup before your next import.' + : result.backupFailure === 'unserializable' + ? 'Your previous library could not be captured as a recovery copy (an internal serialization error), so it cannot be restored from here. Export a JSON backup before your next import.' + : 'This browser refused to store a recovery copy, so your previous library cannot be restored from here. Export a JSON backup before your next import.', + }) + } else { + toast.success('Import complete', { + description: `${summary} replaced the current library.`, + }) + } } else { // The failed path is the one that must never be silent: the library // looked replaced for a moment before rolling back. diff --git a/src/lib/i18n/messages/announce.ts b/src/lib/i18n/messages/announce.ts index df22c447..04709d6a 100644 --- a/src/lib/i18n/messages/announce.ts +++ b/src/lib/i18n/messages/announce.ts @@ -76,6 +76,33 @@ const messages = { * transcript itself is `aria-live="off"`, so a streamed answer is silent * unless this says it landed. Never spoken for a failed or stopped turn. */ 'announce.aiResponseComplete': 'Response complete', + /** Heading when a pre-import snapshot is waiting to be restored. */ + 'announce.restoreTitle': 'A previous library is ready to restore', + /** Body naming what the snapshot holds. It is the corpus that existed before + * an *earlier* import — not necessarily the most recent one: when an import's + * own backup write is refused, the previous snapshot stays and is offered + * instead (see `persistRecoverySnapshot`) — so restoring replaces whatever is + * loaded now. That is stated rather than implied. */ + 'announce.restoreBody': (summary: string) => + `We kept a copy of ${summary} from an earlier import. Restoring replaces your current library.`, + /** Banner action that applies the snapshot. */ + 'announce.restoreAction': 'Restore it', + /** Banner action that hides the offer for this page session. It never + * deletes the snapshot, so a reload brings the offer back. */ + 'announce.restoreDismiss': 'Hide for this page session', + /** Spoken confirmation after a successful restore. */ + 'announce.restoreSucceeded': 'Previous library restored', + /** + * Spoken when the restore landed but storage refused to drop the snapshot, so + * the restore offer will come back after a reload. + */ + 'announce.restoreSucceededSnapshotKept': + 'Previous library restored. The backup could not be removed, so the restore offer will return after a reload.', + /** Toast description for the same case; says what to do about it. */ + 'announce.restoreSnapshotKeptHint': + 'This browser would not delete the recovery copy, so the restore offer will come back after a reload. Dismissing it is safe; restoring again would replace the library with that older copy.', + /** Toast when a restore fails. */ + 'announce.restoreFailed': 'Could not restore the previous library', } as const /** Typed `translate` helper bound to the announcement message scope. */ diff --git a/src/lib/studio/precache-manifest-parity.test.ts b/src/lib/studio/precache-manifest-parity.test.ts new file mode 100644 index 00000000..82bd7338 --- /dev/null +++ b/src/lib/studio/precache-manifest-parity.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * The service worker and the precache generator describe one extension set in + * two places, and nothing tied them together: `.avif` was listed in the + * generator's media inventory while `public/sw.js` did not serve it cache-first, + * so the file was precached during install and then fetched from the network — + * missing offline, which is the whole point of the manifest (review on PR #925). + * + * The two files cannot import one another (`sw.js` is served verbatim, the + * generator runs at build time in Node), so this test reads both and fails when + * the worker stops covering what the generator can emit. + */ +const repoRoot = join(__dirname, '..', '..', '..') +const read = (relative: string) => readFileSync(join(repoRoot, relative), 'utf8') + +/** Extensions named in a JS array literal of quoted strings. */ +const extensionsIn = (source: string, arrayName: string): string[] => { + const match = source.match(new RegExp(`${arrayName}\\s*=\\s*\\[([\\s\\S]*?)\\]`)) + if (match === null) throw new Error(`${arrayName} not found in source`) + // The generator writes its list with a leading dot ('.woff2') while the worker +// omits it ('"woff2"'), so both are normalised to the dotless form. + return [...match[1].matchAll(/["'](\.?[\w]+)["']/g)] + .map((entry) => entry[1].replace(/^\./, '')) +} + +describe('precache manifest ↔ service worker parity', () => { + it('serves cache-first every media extension the generator can precache', () => { + const generator = read(join('scripts', 'generate-precache-manifest.mjs')) + const worker = read(join('public', 'sw.js')) + + const media = extensionsIn(generator, 'MEDIA_EXTENSIONS') + const served = extensionsIn(worker, 'STATIC_EXTENSIONS') + + expect(media.length).toBeGreaterThan(0) + expect(served.length).toBeGreaterThan(0) + // Every precached media type must be matched as a static asset, or the file + // is downloaded at install time and unreachable while offline. + expect(media.filter((ext) => !served.includes(ext))).toEqual([]) + }) + + it('serves cache-first every code extension the generator inventories', () => { + const generator = read(join('scripts', 'generate-precache-manifest.mjs')) + const worker = read(join('public', 'sw.js')) + + const served = extensionsIn(worker, 'STATIC_EXTENSIONS') + const code = extensionsIn(generator, 'CODE_EXTENSIONS') + + expect(code.length).toBeGreaterThan(0) + // The chunk inventory is a third source: a `.css` dropped from the worker's + // list would leave every stylesheet precached and unserved offline. + expect(code.filter((ext) => !served.includes(ext))).toEqual([]) + }) + + it('serves cache-first every extension the always-included URLs use', () => { + const generator = read(join('scripts', 'generate-precache-manifest.mjs')) + const worker = read(join('public', 'sw.js')) + + const served = extensionsIn(worker, 'STATIC_EXTENSIONS') + // The generator's explicit list is URLs, not extensions: the static files + // the app shell needs regardless of the bundle. `manifest.webmanifest` came + // from here and was precached-but-unserved until this test existed. + const urls = (generator.match(/ALWAYS_INCLUDED\s*=\s*\[([\s\S]*?)\]/) ?? [])[1] ?? '' + const extensions = [...urls.matchAll(/["']\/(?:[\w./-]*\/)?[\w.-]*\.([\w]+)["']/g)].map( + (entry) => entry[1], + ) + + expect(extensions.length).toBeGreaterThan(0) + expect(extensions.filter((ext) => !served.includes(ext))).toEqual([]) + }) +}) diff --git a/src/lib/studio/recovery-backup-outcome.test.ts b/src/lib/studio/recovery-backup-outcome.test.ts new file mode 100644 index 00000000..6cbc1f86 --- /dev/null +++ b/src/lib/studio/recovery-backup-outcome.test.ts @@ -0,0 +1,441 @@ +/** + * Regression tests for the pre-import backup outcome (Plan 162 #1). + * + * Before this change `persistRecoverySnapshot` returned `void` and silently + * skipped the write above the 4 MiB guard, leaving any earlier snapshot in + * place, while `importWithRollback` still reported `{ success: true }`. The + * library was replaced with no way back and no indication anything was + * missing. These tests pin the observable behavior that replaced that. + */ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { + describeRecoverySnapshot, + persistRecoverySnapshot, + restoreFromRecovery, + subscribeToRecoveryAvailability, + type RecoverySnapshot, +} from './recovery-helpers' +import { useStudioStore } from './store' +import type { Entity } from './types' + +const RECOVERY_KEY = 'do-knowledge-studio-recovery' + +/** Serialized bytes the 4 MiB guard compares against. */ +const MAX_RECOVERY_SIZE_BYTES = 4 * 1024 * 1024 + +const makeEntity = (id: string, name: string): Entity => ({ + id, + name, + type: 'concept', + description: '', + content: '', + tags: [], + links: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}) + +const smallSnapshot = (id: string, name: string): RecoverySnapshot => ({ + entities: [makeEntity(id, name)], + claims: [], + entityHistory: [{ entities: [makeEntity(id, name)], claims: [] }], + historyIndex: 0, + graph: undefined, + mindMap: undefined, + links: undefined, + tags: undefined, +}) + +/** A snapshot whose serialized form is comfortably past the 4 MiB guard. */ +const oversizedSnapshot = (): RecoverySnapshot => ({ + ...smallSnapshot('big-1', 'Big'), + entities: [ + { ...makeEntity('big-1', 'Big'), content: 'x'.repeat(MAX_RECOVERY_SIZE_BYTES + 1) }, + ], +}) + +/** + * Spies scoped to a single test, torn down in `afterEach`. + * + * Two environment facts force this shape. JSDOM's `localStorage` bypasses + * `Storage.prototype`, so a prototype spy never fires. And Vitest's + * `restoreMocks` does not restore a spy placed on an instance, so such a spy + * outlives `vi.restoreAllMocks()` and poisons every later test — which is + * exactly how an earlier draft of this file produced phantom failures in an + * unrelated suite. + */ +const scopedSpies: MockInstance[] = [] + +/** Simulates the browser refusing to store the recovery snapshot. */ +const refuseRecoveryWrite = (): void => { + // Keep every other key (Zustand's persistence, for example) writing to the + // real store: a mock that swallows unrelated writes turns later assertions + // into fiction (GitNexus on PR #925). + // + // Delegation cannot go through a captured original or the prototype: jsdom + // re-dispatches the call through the instance property, so either route + // recurses into this spy. The write therefore happens with the spy stepped + // aside — and because `mockRestore()` *removes* the spy, the refusal has to be + // reinstalled afterwards with a fresh `vi.spyOn`. Re-implementing the restored + // spy would only mutate a detached function, which silently let every recovery + // write after the first non-recovery one succeed (GitNexus on PR #925). + let live: ReturnType | null = null + + const install = (): void => { + live = vi.spyOn(localStorage, 'setItem').mockImplementation(impl) + scopedSpies.push(live) + } + + const impl = (key: string, value: string): void => { + if (key === RECOVERY_KEY) throw new Error('QuotaExceededError') + live?.mockRestore() + live = null + try { + localStorage.setItem(key, value) + } finally { + install() + } + } + + install() +} + +/** Simulates storage refusing every write. */ +const refuseEveryWrite = (): void => { + scopedSpies.push( + vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }), + ) +} + +/** Simulates storage refusing every read (blocked site data, `SecurityError`). */ +const refuseReads = (): void => { + scopedSpies.push( + vi.spyOn(localStorage, 'getItem').mockImplementation(() => { + throw new Error('SecurityError') + }), + ) +} + +/** Simulates storage refusing the snapshot removal. */ +const refuseClear = (): void => { + scopedSpies.push( + vi.spyOn(localStorage, 'removeItem').mockImplementation(() => { + throw new Error('SecurityError') + }), + ) +} + +/** Restores every scoped spy, e.g. to assert on real storage mid-test. */ +const restoreScopedSpies = (): void => { + for (const spy of scopedSpies.splice(0)) spy.mockRestore() +} + +beforeEach(() => { + localStorage.clear() + vi.restoreAllMocks() + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + restoreScopedSpies() +}) + +describe('persistRecoverySnapshot', () => { + it('reports a write that actually happened', () => { + const result = persistRecoverySnapshot(smallSnapshot('a', 'A')) + + expect(result).toEqual({ persisted: true }) + expect(localStorage.getItem(RECOVERY_KEY)).not.toBeNull() + }) + + it('reports the skip when the snapshot exceeds the size guard', () => { + const result = persistRecoverySnapshot(oversizedSnapshot()) + + expect(result).toEqual({ persisted: false, reason: 'too-large' }) + expect(localStorage.getItem(RECOVERY_KEY)).toBeNull() + }) + + it('reports a refused clear as a storage refusal, not as oversized', () => { + // The oversized path clears the snapshot so a later restore cannot hand + // back the previous corpus. Storage can refuse that removal; the outcome + // must then say so instead of claiming the previous library is gone + // (GitNexus on PR #925). + localStorage.setItem(RECOVERY_KEY, 'stale-previous-corpus') + scopedSpies.push( + vi.spyOn(localStorage, 'removeItem').mockImplementation(() => { + throw new Error('SecurityError') + }), + ) + + const result = persistRecoverySnapshot(oversizedSnapshot()) + + expect(result).toEqual({ persisted: false, reason: 'storage-unavailable' }) + // Documented policy for hostile storage: the stale-but-real copy survives. + restoreScopedSpies() + expect(localStorage.getItem(RECOVERY_KEY)).toBe('stale-previous-corpus') + }) + + it('measures the size guard in bytes, not UTF-16 code units', () => { + // 3M non-ASCII characters are 3M UTF-16 code units (under the 4 MiB + // code-unit reading of the guard) but 6M UTF-8 bytes (over it). The + // snapshot must be refused — a code-unit check would wave it through and + // the write would then fail in storage anyway, without the protective + // clear (GitNexus on PR #925). + // The string appears ONCE (entities only, no history copy): 3M UTF-16 code + // units stay under the 4 MiB code-unit reading, while 6M UTF-8 bytes exceed + // it. Duplicating it through `smallSnapshot` put the code-unit length over + // the guard as well, so the old check would have refused it too and the test + // proved nothing about the unit being measured (review on #925). + const unicodeHeavy = 'é'.repeat(3_000_000) + const result = persistRecoverySnapshot({ + entities: [makeEntity('a', unicodeHeavy)], + claims: [], + entityHistory: [], + historyIndex: 0, + graph: undefined, + mindMap: undefined, + links: undefined, + tags: undefined, + }) + + expect(result).toEqual({ persisted: false, reason: 'too-large' }) + expect(localStorage.getItem(RECOVERY_KEY)).toBeNull() + }) + + it('reports serialization failure as its own reason, not as oversized', () => { + // Circular state makes JSON.stringify throw before anything is measured. + // Calling that 'too-large' told users their library was too big when no + // size was ever known (GitNexus on PR #925). + // A stale snapshot is seeded first: the import proceeds either way, so what + // must not survive is the previous corpus's snapshot. + localStorage.setItem(RECOVERY_KEY, 'stale-previous-corpus') + const circular = smallSnapshot('a', 'A') + ;(circular as unknown as { self: unknown }).self = circular + + const result = persistRecoverySnapshot(circular) + + expect(result).toEqual({ persisted: false, reason: 'unserializable' }) + expect(localStorage.getItem(RECOVERY_KEY)).toBeNull() + }) + + it('reports a refused clear of an unserializable import as a storage refusal', () => { + localStorage.setItem(RECOVERY_KEY, 'stale-previous-corpus') + scopedSpies.push( + vi.spyOn(localStorage, 'removeItem').mockImplementation(() => { + throw new Error('SecurityError') + }), + ) + const circular = smallSnapshot('a', 'A') + ;(circular as unknown as { self: unknown }).self = circular + + const result = persistRecoverySnapshot(circular) + + expect(result).toEqual({ persisted: false, reason: 'storage-unavailable' }) + restoreScopedSpies() + expect(localStorage.getItem(RECOVERY_KEY)).toBe('stale-previous-corpus') + }) + + it('contains a throwing availability subscriber and still reports success', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const unsubscribe = subscribeToRecoveryAvailability(() => { + throw new Error('listener bug') + }) + try { + const result = persistRecoverySnapshot(smallSnapshot('a', 'A')) + + // The write happened; a listener bug must neither abort the caller nor + // be relabelled a storage failure (GitNexus on PR #925). + expect(result).toEqual({ persisted: true }) + expect(localStorage.getItem(RECOVERY_KEY)).not.toBeNull() + expect(consoleError).toHaveBeenCalledWith( + 'Recovery availability subscriber threw', + expect.any(Error), + ) + } finally { + unsubscribe() + } + }) + + it('clears a stale snapshot when the write is skipped', () => { + // A snapshot from an earlier import is sitting in storage. + persistRecoverySnapshot(smallSnapshot('old', 'Old corpus')) + expect(localStorage.getItem(RECOVERY_KEY)).not.toBeNull() + + persistRecoverySnapshot(oversizedSnapshot()) + + // Leaving this behind would let a later restore hand back a corpus the + // user replaced one import ago. + expect(localStorage.getItem(RECOVERY_KEY)).toBeNull() + expect(describeRecoverySnapshot()).toBeNull() + }) + + it('reports storage refusal without destroying the snapshot already stored', () => { + // Corpus A's snapshot is the only recoverable copy of the library the + // import is about to replace. + persistRecoverySnapshot(smallSnapshot('old', 'Old corpus')) + refuseRecoveryWrite() + + const result = persistRecoverySnapshot(smallSnapshot('new', 'New corpus')) + + expect(result).toEqual({ persisted: false, reason: 'storage-unavailable' }) + + // `removeItem` still works when `setItem` is refused by a full quota, so a + // naive clear here would delete the only copy of corpus A. A stale-but-real + // backup must survive: the restore path re-validates it on every read. + expect(describeRecoverySnapshot()).toEqual({ entityCount: 1, claimCount: 0 }) + expect(localStorage.getItem(RECOVERY_KEY)).not.toBeNull() + }) + + it('reports storage refusal rather than claiming success', () => { + refuseEveryWrite() + + const result = persistRecoverySnapshot(smallSnapshot('a', 'A')) + + expect(result).toEqual({ persisted: false, reason: 'storage-unavailable' }) + }) + + it('reports a skipped backup instead of a clean success', () => { + refuseRecoveryWrite() + + const result = useStudioStore + .getState() + .importWithRollback([makeEntity('imported', 'Imported')], []) + + // The import genuinely happened — the library changed. + expect(useStudioStore.getState().entities.map((e) => e.id)).toContain('imported') + // ...but the caller is told there is no way back. + expect(result.success).toBe(true) + if (result.success) { + expect(result.backupPersisted).toBe(false) + expect(result.backupFailure).toBe('storage-unavailable') + } + }) +}) + +describe('describeRecoverySnapshot', () => { + it('returns null when nothing is stored', () => { + expect(describeRecoverySnapshot()).toBeNull() + }) + + it('summarizes a restorable snapshot without applying it', () => { + persistRecoverySnapshot({ + ...smallSnapshot('a', 'A'), + entities: [makeEntity('a', 'A'), makeEntity('b', 'B')], + }) + + expect(describeRecoverySnapshot()).toEqual({ entityCount: 2, claimCount: 0 }) + // Describing must not consume the snapshot. + expect(describeRecoverySnapshot()).toEqual({ entityCount: 2, claimCount: 0 }) + }) + + it('returns null for an expired snapshot instead of advertising it', () => { + persistRecoverySnapshot(smallSnapshot('a', 'A')) + const stored = localStorage.getItem(RECOVERY_KEY) + if (!stored) throw new Error('snapshot was not persisted') + + // Backdate past the 24h TTL. + const parsed: unknown = JSON.parse(stored) + if (typeof parsed !== 'object' || parsed === null || !('timestamp' in parsed)) { + throw new Error('unexpected snapshot envelope') + } + localStorage.setItem( + RECOVERY_KEY, + JSON.stringify({ ...parsed, timestamp: Date.now() - 25 * 60 * 60 * 1000 }), + ) + + expect(describeRecoverySnapshot()).toBeNull() + // The restore path and the banner must agree, so the stale copy is dropped. + expect(localStorage.getItem(RECOVERY_KEY)).toBeNull() + }) + + it('survives a corrupt snapshot instead of throwing into the shell', () => { + // Unparseable bytes left by an interrupted write. `describeRecoverySnapshot` + // runs from an effect in the app shell, so a throw here would take the whole + // workspace down over an unrelated leftover backup. + localStorage.setItem(RECOVERY_KEY, 'not json at all') + + expect(() => describeRecoverySnapshot()).not.toThrow() + expect(describeRecoverySnapshot()).toBeNull() + }) +}) + +describe('restoreFromRecovery safety', () => { + it('never throws when storage reads fail', () => { + persistRecoverySnapshot(smallSnapshot('a', 'A')) + refuseReads() + + expect(() => describeRecoverySnapshot()).not.toThrow() + expect(() => restoreFromRecovery()).not.toThrow() + }) + + it('does not delete the snapshot when storage is simply unreadable', () => { + persistRecoverySnapshot(smallSnapshot('a', 'A')) + refuseReads() + + const result = restoreFromRecovery() + + expect(result.success).toBe(false) + // Unreadable storage is not evidence of bad bytes, so a later attempt — + // after the user frees space or re-allows site data — must still find it. + restoreScopedSpies() + expect(localStorage.getItem(RECOVERY_KEY)).not.toBeNull() + }) + + it('reports that a successful restore really cleared the snapshot', () => { + persistRecoverySnapshot(smallSnapshot('a', 'A')) + + const result = restoreFromRecovery() + + expect(result).toEqual({ success: true, snapshotCleared: true }) + expect(localStorage.getItem(RECOVERY_KEY)).toBeNull() + }) + + it('reports an un-cleared snapshot when the removal is refused after the restore lands', () => { + // The restore applies in memory, then storage refuses the removal. The + // corpus is back but the backup survives, so `describeRecoverySnapshot` + // will offer it again on the next load — a second restore could then + // overwrite the corpus the user just restored, plus any edits since. + // Reporting `{ success: true }` alone would hide that (GitNexus on PR #925). + persistRecoverySnapshot(smallSnapshot('a', 'A')) + refuseClear() + + const result = restoreFromRecovery() + + expect(result).toEqual({ success: true, snapshotCleared: false }) + // The swap did land … + expect(useStudioStore.getState().entities).toHaveLength(1) + // … and the refusal is what leaves the offer alive for a later load. + restoreScopedSpies() + expect(describeRecoverySnapshot()).toEqual({ entityCount: 1, claimCount: 0 }) + }) + + it('keeps the snapshot when applying it fails, so a retry stays possible', () => { + persistRecoverySnapshot(smallSnapshot('a', 'A')) + + // Applying swaps the store in memory then persists; a full quota throws + // after the swap. The backup is the only way back, so it must survive. + // The mock calls through before throwing: throwing up front would never + // change the store and would not exercise the documented failure — the + // one where the corpus is already swapped when the write is refused + // (GitNexus on PR #925). + const originalSetState = useStudioStore.setState.bind(useStudioStore) + scopedSpies.push( + vi.spyOn(useStudioStore, 'setState').mockImplementation( + (partial: Parameters[0]) => { + originalSetState(partial) + throw new Error('QuotaExceededError') + }, + ), + ) + + const result = restoreFromRecovery() + + expect(result.success).toBe(false) + // The swap did land (that is what makes this the hard case) … + expect(useStudioStore.getState().entities).toHaveLength(1) + // … and the only copy of the replaced corpus is still restorable. + expect(localStorage.getItem(RECOVERY_KEY)).not.toBeNull() + }) +}) \ No newline at end of file diff --git a/src/lib/studio/recovery-helpers.ts b/src/lib/studio/recovery-helpers.ts index 0b3dc445..64bfd7c8 100644 --- a/src/lib/studio/recovery-helpers.ts +++ b/src/lib/studio/recovery-helpers.ts @@ -11,6 +11,7 @@ import { import type { ValidatedGraph, ValidatedMindMap, ValidatedLink, ValidatedTag } from './schema' import { useStudioStore } from './store' import { reconcileSnapshot, type HistorySnapshot } from './history-snapshot' +import { isSyncBlocked } from './hydration-guard' /** localStorage key for the recovery snapshot. */ const RECOVERY_KEY = 'do-knowledge-studio-recovery' @@ -51,18 +52,135 @@ export const buildRecoverySnapshot = (state: { tags: state.tags ? structuredClone(state.tags) : undefined, }) -/** Persists a recovery snapshot to localStorage with size and TTL guards. */ -export const persistRecoverySnapshot = (snapshot: RecoverySnapshot): void => { - try { - const serialized = JSON.stringify({ snapshot, timestamp: Date.now(), ttl: RECOVERY_TTL_MS }) - if (serialized.length > MAX_RECOVERY_SIZE_BYTES) { - console.warn('Recovery snapshot exceeds size limit, skipping persistence') - } else { - localStorage.setItem(RECOVERY_KEY, serialized) + +/* ------------------------------------------------------------------------- * + * Availability signal + * + * The snapshot is written by `persistRecoverySnapshot`, which runs inside a + * store action — long after the shell mounted. A banner that only reads the + * snapshot on mount therefore never learns that a backup just became + * available, which is precisely the moment it matters. Subscribers are + * notified on write and on consume so the offer appears immediately after an + * import and disappears the moment it is taken. + * + * Declared above its callers: these are `const` arrows, so they are in the + * temporal dead zone until evaluated. + * ------------------------------------------------------------------------- */ + +const availabilitySubscribers = new Set<() => void>() + +/** Notifies every subscriber that the restorable-snapshot state may have changed. */ +const notifyAvailability = (): void => { + for (const subscriber of availabilitySubscribers) { + try { + subscriber() + } catch (err) { + // A misbehaving subscriber must not abort the write path that outlives + // it, and must never be reported back up as a storage failure (GitNexus + // on PR #925): the write already happened; this is a listener bug. + console.error('Recovery availability subscriber threw', err) } + } +} + +/** + * Subscribes to changes in whether a restorable snapshot exists. + * + * Returns an unsubscribe function. Intentionally holds no store import, so + * `store.ts` and `slices/*` can call the notifier without an import cycle. + */ +export const subscribeToRecoveryAvailability = (listener: () => void): (() => void) => { + availabilitySubscribers.add(listener) + return () => { availabilitySubscribers.delete(listener) } +} +/** Why a recovery snapshot could not be written. */ +export type RecoveryPersistFailure = 'too-large' | 'storage-unavailable' | 'unserializable' + +/** + * Outcome of persisting a pre-import recovery snapshot. + * + * `persisted: false` is a real outcome, not an internal detail: the caller is + * about to destroy the corpus the snapshot was meant to protect, so it must be + * able to tell the user that no safety net exists rather than reporting a clean + * import. + */ +export type RecoveryPersistResult = + | { persisted: true } + | { persisted: false; reason: RecoveryPersistFailure } + +/** + * Persists a recovery snapshot to localStorage with size and TTL guards. + * + * Returns whether the write actually happened. A skipped or refused write is + * reported, never silently swallowed. + * + * The failure modes are deliberately NOT treated alike: + * + * - `too-large`: the write was refused by *our* size guard while storage is + healthy. Any snapshot still present describes a corpus the user replaced one + import ago, so it is cleared — leaving it would let a later restore hand back + stale data as if it were current. + * - `storage-unavailable`: storage itself is refusing writes (quota, blocked + site data). `localStorage.removeItem` still succeeds in that state, so + clearing here would **destroy the previous snapshot** — the only copy of the + corpus that is about to be replaced. It is left untouched: a stale-but-real + backup beats no backup, and the banner never claims otherwise because the + snapshot it describes is checked against the schema and TTL on every read. + A *refused* clear in either of the other two paths is reported as this + reason, because that is the true statement about what is now on disk. + * - `unserializable`: `JSON.stringify` threw (circular state) — our bug, not + storage's and not a size question. It gets its own reason so the import + toast never tells the user their library was “too large” when nothing was + ever measured (GitNexus on PR #925). It clears, for the same reason + `too-large` does: the snapshot left behind describes the corpus the import + replaces. + * + * `console.warn` stays because these paths are hard to reach from a UI test. + */ +export const persistRecoverySnapshot = (snapshot: RecoverySnapshot): RecoveryPersistResult => { + let serialized: string + try { + serialized = JSON.stringify({ snapshot, timestamp: Date.now(), ttl: RECOVERY_TTL_MS }) + } catch { + // An unserializable snapshot (circular state) is our bug, not storage's. + // The snapshot still on disk describes a corpus the import is about to + // replace, and the import proceeds regardless, so it must not stay + // restorable — the same reasoning as the oversized branch below, including + // the refused-clear case (GitNexus on PR #925). + console.warn('Failed to serialize recovery snapshot') + return clearRecoverySnapshot() + ? { persisted: false, reason: 'unserializable' } + : { persisted: false, reason: 'storage-unavailable' } + } + + // The guard is against *bytes on disk*, not UTF-16 code units: Unicode-heavy + // content can double the byte count relative to `.length`, which would let + // an oversized snapshot past a code-unit check (GitNexus on PR #925). + if (new TextEncoder().encode(serialized).length > MAX_RECOVERY_SIZE_BYTES) { + console.warn('Recovery snapshot exceeds size limit, skipping persistence') + // Clearing the snapshot that is about to be replaced is what keeps a later + // restore from handing back the previous corpus. If storage refuses the + // removal the stale copy survives, so the outcome reports a storage refusal + // rather than 'too-large' — the user-facing copy for that reason + // ("this browser refused…") is the one that is actually true, and the + // 'too-large' copy promises the previous library is gone. + return clearRecoverySnapshot() + ? { persisted: false, reason: 'too-large' } + : { persisted: false, reason: 'storage-unavailable' } + } + + try { + localStorage.setItem(RECOVERY_KEY, serialized) } catch { console.warn('Failed to persist recovery snapshot') + // Deliberately does NOT clear: see the note above. + return { persisted: false, reason: 'storage-unavailable' } } + // Outside the write's try: a throwing subscriber is a listener bug and is + // contained per-subscriber by `notifyAvailability`, so it can neither be + // misreported as a storage failure nor abort the caller (GitNexus on PR #925). + notifyAvailability() + return { persisted: true } } /** Zod schema for validating persisted recovery snapshot structure. */ @@ -91,21 +209,57 @@ type RecoveryReadResult = | { ok: true; data: z.infer } | { ok: false; error: string } -/** Removes the recovery snapshot from localStorage. */ -const clearRecoverySnapshot = (): void => { +/** + * Removes the recovery snapshot from localStorage. + * + * Returns whether the key is definitely gone. Callers that promise the snapshot + * no longer exists must branch on this: a refused removal leaves the previous + * corpus restorable, and reporting that as "cleared" would be a false statement + * about the user's safety net (GitNexus on PR #925). + */ +const clearRecoverySnapshot = (): boolean => { try { localStorage.removeItem(RECOVERY_KEY) } catch { console.warn('Failed to clear corrupt recovery snapshot') + return false } + notifyAvailability() + return true } -/** Reads and validates the recovery snapshot from localStorage, returning it if valid. */ +/** + * Reads and validates the recovery snapshot from localStorage, returning it if + * valid. + * + * Never throws. The two ways it can fail are kept apart on purpose, because + * only one of them justifies destroying the stored bytes: + * + * - storage unreadable (blocked site data, `SecurityError`): the snapshot is + * not known to be bad, so it is left alone and the real cause is surfaced. + * - bytes present but unparseable or schema-invalid: genuinely useless, so it + * is cleared rather than left to fail every future attempt. + */ export const readRecoverySnapshot = (): RecoveryReadResult => { - const raw = localStorage.getItem(RECOVERY_KEY) + let raw: string | null + try { + raw = localStorage.getItem(RECOVERY_KEY) + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'Recovery snapshot storage is unavailable.', + } + } if (!raw) return { ok: false, error: 'No recovery snapshot found.' } - const parsed: unknown = JSON.parse(raw) + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + clearRecoverySnapshot() + return { ok: false, error: 'Recovery snapshot is corrupted.' } + } + const result = RecoverySnapshotSchema.safeParse(parsed) if (!result.success) { clearRecoverySnapshot() @@ -146,21 +300,89 @@ const applyRecoverySnapshot = (snapshot: ValidatedRecoverySnapshot): void => { }) } -/** Restores store state from the recovery snapshot and clears it afterward. */ -export const restoreFromRecovery = (): { success: boolean; error?: string } => { +/** + * Outcome of restoring the pre-import snapshot. + * + * A successful restore also reports whether the snapshot was really cleared. + * `false` means storage refused the removal, so the backup survives and + * `describeRecoverySnapshot` will offer it again on the next load — a second + * restore could then overwrite the corpus the user just restored, plus any + * edits since. Callers that promise the snapshot no longer exists must branch + * on `snapshotCleared` rather than assume the success means it. + */ +export type RestoreFromRecoveryResult = + | { success: true; snapshotCleared: boolean } + | { success: false; error: string } + +/** + * Restores store state from the recovery snapshot and clears it afterward. + * + * Clearing is the point of no return: the snapshot is the only copy of the + * corpus the import replaced. So it happens ONLY after the restore is known to + * have landed, and never on a failure path — a user who is told the restore + * failed must still be able to retry. + */ +export const restoreFromRecovery = (): RestoreFromRecoveryResult => { + // A refused hydration latches the persist writer off for the rest of the page + // session (ADR 028), so the swap below would be dropped on the way to storage + // while `clearRecoverySnapshot` deleted the only copy — the user would be told + // the library came back, then lose it on reload. Refuse instead. The UI also + // hides the offer in this state; this guard covers any other caller. + if (isSyncBlocked()) { + return { + success: false, + error: 'This workspace is not saving, so the backup cannot be restored safely. Reload first.', + } + } + + // `readRecoverySnapshot` never throws: it distinguishes unreadable storage + // (do not clear) from unusable bytes (already cleared), and reports both. + const result = readRecoverySnapshot() + if (!result.ok) return { success: false, error: result.error } + try { - const result = readRecoverySnapshot() - if (!result.ok) return { success: false, error: result.error } applyRecoverySnapshot(result.data.snapshot) - clearRecoverySnapshot() - return { success: true } } catch (err) { - // A readable but corrupt snapshot (e.g., unparseable JSON) would otherwise - // linger forever — clear it so the next restore attempt starts fresh. - clearRecoverySnapshot() + // Applying swaps the corpus in memory and then persists it, so this can + // throw on a full quota with the store already changed. Do NOT clear: the + // backup is the only way back, and a retry has to stay possible. return { success: false, - error: err instanceof Error ? err.message : 'Failed to restore recovery snapshot.', + error: err instanceof Error ? err.message : 'Failed to apply the recovery snapshot.', } } + + // The restore landed, but the removal may still be refused. Report whether + // the key is really gone so a caller cannot promise a safety net that is + // still there (see `clearRecoverySnapshot`). + const snapshotCleared = clearRecoverySnapshot() + return { success: true, snapshotCleared } } + +/** Human-readable summary of a restorable pre-import snapshot. */ +export type RecoverySnapshotSummary = { + entityCount: number + claimCount: number +} + +/** + * Describes the pre-import snapshot without applying it. + * + * Used by the restore banner to decide whether it has anything to offer. It + * shares `readRecoverySnapshot` so an expired or corrupt snapshot is discarded + * exactly as it would be on a real restore — otherwise the banner would + * advertise a backup that the restore path would then refuse. + */ +export const describeRecoverySnapshot = (): RecoverySnapshotSummary | null => { + // `readRecoverySnapshot` is contractually non-throwing, which matters here: + // this runs from an effect in the app shell, where a throw would take the + // whole workspace down over an unrelated leftover backup. The contract is + // pinned by a test rather than a redundant try/catch. + const result = readRecoverySnapshot() + if (!result.ok) return null + return { + entityCount: result.data.snapshot.entities.length, + claimCount: result.data.snapshot.claims.length, + } +} + diff --git a/src/lib/studio/slices/data-slice.ts b/src/lib/studio/slices/data-slice.ts index efc88b1d..70d4db63 100644 --- a/src/lib/studio/slices/data-slice.ts +++ b/src/lib/studio/slices/data-slice.ts @@ -60,11 +60,20 @@ export const createDataSlice: StudioSlice = (set, get) => ({ abortChatSend(get, set) resetSearchCache() const preImport = get() + /** The snapshot. */ const snapshot = buildRecoverySnapshot(preImport) - persistRecoverySnapshot(snapshot) + /** The outcome. */ + const backup = persistRecoverySnapshot(snapshot) try { set(corpusPatch(entities, claims, options)) - return { success: true } + // The import replaced the corpus the snapshot protects. Reporting + // `success` alone would tell the user they are safe when no backup of the + // corpus they just left exists. + return { + success: true, + backupPersisted: backup.persisted, + ...(backup.persisted ? {} : { backupFailure: backup.reason }), + } } catch (err) { try { set({ diff --git a/src/lib/studio/store-coverage.test.ts b/src/lib/studio/store-coverage.test.ts index fd1691f7..d2598338 100644 --- a/src/lib/studio/store-coverage.test.ts +++ b/src/lib/studio/store-coverage.test.ts @@ -593,6 +593,7 @@ describe('Studio Store branch coverage', () => { usePersistStorageFailure(1, new Error('boom')) const result = useStudioStore.getState().importWithRollback([makeEntity({ id: 'imp' })], []) expect(result.success).toBe(false) + if (result.success) throw new Error('expected the import to fail') expect(result.error).toBe('boom') expect(useStudioStore.getState().entities.map((e) => e.id)).toEqual(['keep']) }) @@ -611,6 +612,7 @@ describe('Studio Store branch coverage', () => { usePersistStorageFailure(1, boom) const result = useStudioStore.getState().importWithRollback([makeEntity({ id: 'imp' })], []) expect(result.success).toBe(false) + if (result.success) throw new Error('expected the import to fail') expect(result.error).toBe('Import failed, state restored.') }) }) @@ -681,7 +683,7 @@ describe('Studio Store branch coverage', () => { it('restores state and clears the key on success', () => { localStorage.setItem(RECOVERY_KEY, JSON.stringify(validPayload())) const result = restoreFromRecovery() - expect(result).toEqual({ success: true }) + expect(result).toEqual({ success: true, snapshotCleared: true }) const { entities } = useStudioStore.getState() expect(entities).toHaveLength(1) expect(entities[0].id).toBe('rec-1') diff --git a/src/lib/studio/store-types.ts b/src/lib/studio/store-types.ts index 2e9374ba..d02ee354 100644 --- a/src/lib/studio/store-types.ts +++ b/src/lib/studio/store-types.ts @@ -9,6 +9,7 @@ import type { Claim, Entity, ViewId, ChatMessage, AnyEntityType } from './types' import type { ValidatedGraph, ValidatedMindMap, ValidatedLink, ValidatedTag } from './schema' import type { HistorySnapshot } from './history-snapshot' +import type { RecoveryPersistFailure } from './recovery-helpers' /** Optional graph/mindmap metadata attached to an import operation. */ export interface ImportOptions { @@ -89,7 +90,7 @@ export interface StudioState { // Import / reset importData: (entities: Entity[], claims: Claim[], options?: ImportOptions) => void - importWithRollback: (entities: Entity[], claims: Claim[], options?: ImportOptions) => { success: boolean; error?: string } + importWithRollback: (entities: Entity[], claims: Claim[], options?: ImportOptions) => ImportOutcome resetStore: () => void // Graph, mind map, links, and tags @@ -100,3 +101,15 @@ export interface StudioState { // Theme handled by next-themes — store tracks UI side effects only } + +/** + * Outcome of an import-with-rollback. + * + * `backupPersisted` is separate from `success` on purpose: the import can + * succeed while leaving the user with no way back to the corpus it replaced. + * Callers must not report a clean import when it is `false`. + */ +export type ImportOutcome = + | { success: true; backupPersisted: boolean; backupFailure?: RecoveryPersistFailure } + | { success: false; error: string } + diff --git a/src/lib/studio/use-reduced-motion.ts b/src/lib/studio/use-reduced-motion.ts index 5e6c1cfa..09289827 100644 --- a/src/lib/studio/use-reduced-motion.ts +++ b/src/lib/studio/use-reduced-motion.ts @@ -1,34 +1,45 @@ 'use client' -import { useState, useEffect } from 'react' +import { useSyncExternalStore } from 'react' const QUERY = '(prefers-reduced-motion: reduce)' /** - * Returns `true` when the user has enabled "reduce motion" in their OS - * accessibility settings. Listens for live changes so toggling the setting - * immediately disables/enables animations. + * The answer used while rendering on the server **and** while hydrating. + * + * It must never consult `matchMedia`. The server cannot know the media state, so + * a client read during the hydration pass renders different markup than the HTML + * that was sent for every element whose props derive from this hook + * (`initial={reducedMotion ? false : {...}}`, ~25 call sites). React then reports + * a hydration mismatch and re-renders the subtree on load — for exactly the + * users who asked for less motion (plans/161). + * + * `useSyncExternalStore` re-reads the client value as soon as hydration + * finishes, so the correction still lands ahead of the effect-driven motion + * problem the old synchronous read existed to prevent. */ -export const useReducedMotion = (): boolean => { - // Read the media query synchronously on first render so animated components - // never start a motion run before the effect fires (which would freeze them - // mid-fade and trip axe color-contrast under reduced motion). SSR keeps the - // non-reduced default; the client first render corrects immediately. - const [reduced, setReduced] = useState(() => { - if (typeof window === 'undefined') return false - return window.matchMedia(QUERY).matches - }) - - useEffect(() => { - if (typeof window === 'undefined') return +const getServerSnapshot = (): boolean => false - const mql = window.matchMedia(QUERY) - setReduced(mql.matches) - - const handler = (e: MediaQueryListEvent) => setReduced(e.matches) - mql.addEventListener('change', handler) - return () => mql.removeEventListener('change', handler) - }, []) +/** + * Client answer. Module scope on purpose: `useSyncExternalStore` needs these + * callbacks to keep a stable identity across renders, or it re-subscribes on + * every one. + */ +const getSnapshot = (): boolean => window.matchMedia(QUERY).matches - return reduced +const subscribe = (onStoreChange: () => void): (() => void) => { + const mediaQuery = window.matchMedia(QUERY) + mediaQuery.addEventListener('change', onStoreChange) + return () => mediaQuery.removeEventListener('change', onStoreChange) } + +/** + * Returns `true` when the user has enabled "reduce motion" in their OS + * accessibility settings. Listens for live changes so toggling the setting + * immediately disables/enables animations. + * + * Hydration-safe: the first client render matches the server, and the real + * preference applies immediately afterwards. + */ +export const useReducedMotion = (): boolean => + useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)