diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 78041cbd2..d712ed921 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -125,6 +125,10 @@ sentry log list --query "severity:error" # SDK sends to both. sentry local run -- npm run dev # or: python manage.py runserver, etc. +# From a CLI source checkout, run the Local UI in a second terminal, then open it. +pnpm --filter local dev +sentry local run --open -- npm run dev + # Watch only AI/agent (gen_ai, mcp) spans while iterating on an agent. sentry local -f ai diff --git a/apps/local/.gitignore b/apps/local/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/apps/local/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/local/.oxlintrc.json b/apps/local/.oxlintrc.json new file mode 100644 index 000000000..6fa991dad --- /dev/null +++ b/apps/local/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/apps/local/components.json b/apps/local/components.json new file mode 100644 index 000000000..8bfc737f9 --- /dev/null +++ b/apps/local/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/local/index.html b/apps/local/index.html new file mode 100644 index 000000000..731528709 --- /dev/null +++ b/apps/local/index.html @@ -0,0 +1,14 @@ + + + + + + + + Sentry CLI + + +
+ + + diff --git a/apps/local/package.json b/apps/local/package.json new file mode 100644 index 000000000..ec2b184fd --- /dev/null +++ b/apps/local/package.json @@ -0,0 +1,47 @@ +{ + "name": "local", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest run", + "test:integration": "vitest run src/App.integration.test.tsx" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "dependencies": { + "@shikijs/core": "^3.23.0", + "@shikijs/engine-javascript": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@radix-ui/react-slot": "^1.3.3", + "@tailwindcss/vite": "^4.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.43.0", + "next-themes": "^0.4.6", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@spotlightjs/spotlight": "^4.11.7", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "eventsource": "^4.1.0", + "jsdom": "^28.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2", + "vitest": "^4.1.9" + } +} diff --git a/apps/local/public/favicon.svg b/apps/local/public/favicon.svg new file mode 100644 index 000000000..7ad47f504 --- /dev/null +++ b/apps/local/public/favicon.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/local/public/sentry-cli-light.svg b/apps/local/public/sentry-cli-light.svg new file mode 100644 index 000000000..e124651b6 --- /dev/null +++ b/apps/local/public/sentry-cli-light.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/local/public/sentry-cli.svg b/apps/local/public/sentry-cli.svg new file mode 100644 index 000000000..74e2b4731 --- /dev/null +++ b/apps/local/public/sentry-cli.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx new file mode 100644 index 000000000..34a2272e9 --- /dev/null +++ b/apps/local/src/App.integration.test.tsx @@ -0,0 +1,631 @@ +import { createSpotlightBuffer } from '@spotlightjs/spotlight/sdk' +import { EventSource as NodeEventSource } from 'eventsource' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { ThemeProvider } from '@/components/theme-provider.tsx' +import App from './App.tsx' +import { + REMOTE_STREAM_STORAGE_KEY, + STREAM_STORAGE_KEY, +} from './lib/spotlight.ts' +import { + buildApp, + tryListen, +} from '../../../packages/cli/src/commands/local/server.ts' + +const SENTRY_CONTENT_TYPE = 'application/x-sentry-envelope' + +type EnvelopeOptions = { + level?: string + operation?: string + spanId?: string + spans?: unknown[] + statusCode?: number + startTimestamp?: number + traceId?: string + type?: string +} + +function envelope(transaction: string, options: EnvelopeOptions = {}): string { + return [ + JSON.stringify({ event_id: crypto.randomUUID() }), + JSON.stringify({ type: options.type ?? 'transaction' }), + JSON.stringify({ + transaction, + timestamp: 1_700_000_000, + ...(options.startTimestamp ? { start_timestamp: options.startTimestamp } : {}), + ...(options.level ? { level: options.level } : {}), + ...(options.traceId || options.operation + ? { + contexts: { + trace: { + ...(options.traceId ? { trace_id: options.traceId } : {}), + ...(options.spanId ? { span_id: options.spanId } : {}), + ...(options.operation ? { op: options.operation } : {}), + }, + }, + } + : {}), + ...(options.statusCode ? { request: { status_code: options.statusCode } } : {}), + ...(options.spans ? { spans: options.spans } : {}), + }), + '', + ].join('\n') +} + +async function startReceiver() { + return tryListen(buildApp(createSpotlightBuffer(10)), 0, '127.0.0.1') +} + +async function stopReceiver( + server: Awaited>['server'] +) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) +} + +async function sendEnvelope( + port: number, + transaction: string, + options?: EnvelopeOptions +) { + const response = await fetch(`http://127.0.0.1:${port}/stream`, { + method: 'POST', + headers: { 'Content-Type': SENTRY_CONTENT_TYPE }, + body: envelope(transaction, options), + }) + expect(response.status).toBe(204) +} + +function renderViewer(port: number) { + window.history.replaceState( + null, + '', + `/#stream=${encodeURIComponent(`http://127.0.0.1:${port}/stream`)}` + ) + return render( + + + + ) +} + +function renderBareViewer() { + window.history.replaceState(null, '', '/') + return render( + + + + ) +} + +class OpeningEventSource extends EventTarget { + onopen: ((event: Event) => void) | null = null + onerror: ((event: Event) => void) | null = null + + constructor(_url: string) { + super() + queueMicrotask(() => this.onopen?.(new Event('open'))) + } + + close() {} +} + +class ControllableEventSource extends EventTarget { + static instances: ControllableEventSource[] = [] + onopen: ((event: Event) => void) | null = null + onerror: ((event: Event) => void) | null = null + closed = false + + constructor(_url: string) { + super() + ControllableEventSource.instances.push(this) + } + + close() { + this.closed = true + } +} + +describe('local receiver to viewer integration', () => { + beforeEach(() => { + window.localStorage.clear() + window.sessionStorage.clear() + vi.stubGlobal('EventSource', NodeEventSource) + vi.stubGlobal('matchMedia', () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + })) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + window.history.replaceState(null, '', '/') + }) + + test('keeps the selected live transaction open in the detail pane', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + + const shell = screen.getByTestId('app-shell') + expect(shell.className).not.toContain('px-') + expect(shell.className).not.toContain('py-') + expect(screen.getByRole('banner').className).not.toContain('border-b') + + await sendEnvelope(port, 'GET /live') + await screen.findByLabelText('View transaction event') + await sendEnvelope(port, 'GET /live-2') + + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + const eventList = screen.getByTestId('event-list') + expect(eventList.className).toContain('flex-1') + expect(eventList.className).not.toContain('max-h-[42rem]') + expect(screen.getByRole('complementary', { name: 'Events' })).not.toBeNull() + const detail = screen.getByTestId('event-detail') + expect(detail.textContent).toContain('GET /live') + expect(detail.textContent).not.toContain('GET /live-2') + expect(detail.textContent).not.toContain('GETGET /live') + + const events = screen.getAllByLabelText('View transaction event') + fireEvent.click(events[1]!) + + await waitFor(() => { + expect(screen.getByTestId('event-detail').textContent).toContain('GET /live-2') + }) + expect(screen.getByRole('tab', { name: 'Overview' })).not.toBeNull() + fireEvent.click(screen.getByRole('tab', { name: 'JSON' })) + const code = screen.getByTestId('highlighted-json') + expect(code.textContent).toContain('GET /live-2') + fireEvent.click(screen.getByRole('button', { name: 'Copy JSON' })) + expect(screen.getByRole('status', { name: 'JSON copied' })).not.toBeNull() + await waitFor(() => { + expect(screen.getByTestId('highlighted-json').querySelector('.shiki')).not.toBeNull() + }) + fireEvent.click(events[0]!) + fireEvent.click(screen.getByRole('tab', { name: 'JSON' })) + expect(screen.getByRole('button', { name: 'Copy JSON' }).textContent).toContain('Copy JSON') + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('offers a useful connection landing for a bare viewer visit', () => { + renderBareViewer() + + expect(screen.getByText('Looking for Sentry Local')).not.toBeNull() + const endpoint = screen.getByLabelText('Receiver endpoint') as HTMLInputElement + expect(endpoint.value).toBe('http://localhost:8969/stream') + expect(screen.getByRole('button', { name: 'Connect' })).not.toBeNull() + expect(screen.getByText('Advanced connection')).not.toBeNull() + expect(screen.getByRole('button', { name: 'Copy local serve command' })).not.toBeNull() + }) + + test('connects a bare viewer to a custom loopback receiver and saves it after opening', async () => { + const { server, port } = await startReceiver() + + try { + renderBareViewer() + fireEvent.change(screen.getByLabelText('Receiver endpoint'), { + target: { value: `http://127.0.0.1:${port}/stream` }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + await screen.findByText('Connected to local receiver') + expect(window.localStorage.getItem('sentry.local.stream-url')).toBe( + `http://127.0.0.1:${port}/stream` + ) + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('keeps a successfully opened remote receiver only for this browser session', async () => { + vi.stubGlobal('EventSource', OpeningEventSource) + renderBareViewer() + await screen.findByText('Connected to local receiver') + window.localStorage.clear() + fireEvent.click(screen.getByRole('button', { name: 'Change receiver' })) + + fireEvent.change(screen.getByLabelText('Receiver endpoint'), { + target: { value: 'https://receiver.example/stream?token=abc' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + await waitFor(() => { + expect(window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY)).toBe( + 'https://receiver.example/stream?token=abc' + ) + }) + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBeNull() + }) + + test('keeps endpoint validation visible after a failed receiver has been stopped', async () => { + ControllableEventSource.instances = [] + vi.stubGlobal('EventSource', ControllableEventSource) + renderBareViewer() + + const source = ControllableEventSource.instances[0] + expect(source).toBeDefined() + await act(async () => source?.onerror?.(new Event('error'))) + await screen.findByRole('alert', { name: 'Receiver connection error' }) + + fireEvent.change(screen.getByLabelText('Receiver endpoint'), { + target: { value: 'http://receiver.example/stream' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(screen.getByRole('alert').textContent).toContain( + 'Enter a loopback stream or an HTTPS remote stream' + ) + + await act(async () => source?.onerror?.(new Event('error'))) + expect(screen.getByRole('alert').textContent).toContain( + 'Enter a loopback stream or an HTTPS remote stream' + ) + }) + + test('keeps receiver selection open when an event arrives during editing', async () => { + ControllableEventSource.instances = [] + vi.stubGlobal('EventSource', ControllableEventSource) + renderBareViewer() + + const source = ControllableEventSource.instances[0] + expect(source).toBeDefined() + await act(async () => source?.onopen?.(new Event('open'))) + await screen.findByText('Connected to local receiver') + fireEvent.click(screen.getByRole('button', { name: 'Change receiver' })) + + await act(async () => { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: 'transaction' }, { transaction: 'GET /arrived-while-editing' }]], + ]), + }) + ) + }) + + expect(screen.getByLabelText('Receiver endpoint')).not.toBeNull() + }) + + test('keeps a healthy receiver open after an invalid replacement endpoint', async () => { + ControllableEventSource.instances = [] + vi.stubGlobal('EventSource', ControllableEventSource) + renderBareViewer() + + const source = ControllableEventSource.instances[0] + expect(source).toBeDefined() + await act(async () => source?.onopen?.(new Event('open'))) + await screen.findByText('Connected to local receiver') + fireEvent.click(screen.getByRole('button', { name: 'Change receiver' })) + fireEvent.change(screen.getByLabelText('Receiver endpoint'), { + target: { value: 'http://receiver.example/stream' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + expect(source?.closed).toBe(false) + expect(screen.getByRole('status', { name: 'Connected to local receiver' })).not.toBeNull() + }) + + test('replays an envelope buffered before the viewer opens', async () => { + const { server, port } = await startReceiver() + + try { + await sendEnvelope(port, 'GET /buffered') + renderViewer(port) + + await screen.findByText('Connected to local receiver') + expect((await screen.findByTestId('event-detail')).textContent).toContain( + 'GET /buffered' + ) + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('does not mark the receiver replay as new events', async () => { + const { server, port } = await startReceiver() + + try { + await sendEnvelope(port, 'GET /buffered-first') + await sendEnvelope(port, 'GET /buffered-second') + renderViewer(port) + + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + expect(screen.queryByRole('button', { name: /View .* new event/ })).toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('filters the feed by event class', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /healthy') + await sendEnvelope(port, 'GET /broken', { type: 'event', level: 'error' }) + + await screen.findByLabelText('View transaction event') + await screen.findByLabelText('View event event') + + fireEvent.click(screen.getByRole('button', { name: 'Errors (1)' })) + + expect(screen.queryByLabelText('View transaction event')).toBeNull() + expect(screen.getByLabelText('View event event').textContent).toContain( + '/broken' + ) + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('marks an error entry in the mixed event feed', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /broken', { type: 'event', level: 'error' }) + + expect((await screen.findByLabelText('View event event')).textContent).toContain('Error') + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('filters retained events from the top navigation search', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /customers/42') + await sendEnvelope(port, 'POST /orders') + + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + fireEvent.change(screen.getByRole('searchbox', { name: 'Search events' }), { + target: { value: 'customers' }, + }) + + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(1) + expect(screen.getByTestId('event-list').textContent).toContain('/customers/42') + expect(screen.getByTestId('event-list').textContent).not.toContain('/orders') + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('clears the local viewer feed from receiver options', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /clear-me') + await screen.findByLabelText('View transaction event') + + fireEvent.click(screen.getByRole('button', { name: 'Receiver options' })) + fireEvent.click(screen.getByRole('button', { name: 'Clear all events' })) + + expect(screen.queryByLabelText('View transaction event')).toBeNull() + expect(screen.getByText('Waiting for events')).not.toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('summarizes other retained events in the selected trace overview', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /trace/first', { traceId: 'shared-trace' }) + await sendEnvelope(port, 'GET /trace/second', { traceId: 'shared-trace' }) + + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + + const details = screen.getByRole('table', { name: 'Event details' }) + expect(details.textContent).toContain('Related events') + expect(details.textContent).toContain('1') + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('offers new events without interrupting the selected detail', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /selected') + fireEvent.click(await screen.findByLabelText('View transaction event')) + await sendEnvelope(port, 'GET /new') + + await waitFor(() => { + expect(screen.getAllByLabelText('View transaction event')).toHaveLength(2) + }) + + expect(screen.getByTestId('event-detail').textContent).toContain('GET /selected') + const newEvents = screen.getByRole('button', { name: 'View 1 new event' }) + fireEvent.click(newEvents) + + expect(screen.getByTestId('event-detail').textContent).toContain('GET /new') + expect(screen.queryByRole('button', { name: 'View 1 new event' })).toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('only offers new events that match the active filter', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /first-error', { type: 'event', level: 'error' }) + await screen.findByLabelText('View event event') + fireEvent.click(screen.getByRole('button', { name: 'Errors (1)' })) + await sendEnvelope(port, 'GET /healthy') + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'All (2)' })).not.toBeNull() + }) + expect(screen.queryByRole('button', { name: /View 1 new event/ })).toBeNull() + + await sendEnvelope(port, 'GET /second-error', { type: 'event', level: 'error' }) + + const newEvents = await screen.findByRole('button', { name: 'View 1 new event' }) + fireEvent.click(newEvents) + expect(screen.getByTestId('event-detail').textContent).toContain('GET /second-error') + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('shows a copyable, grouped inspector table in the selected event overview', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /summary', { + operation: 'http.server', + startTimestamp: 1_699_999_999.99383, + statusCode: 201, + traceId: 'summary-trace', + }) + + const details = await screen.findByRole('table', { name: 'Event details' }) + expect(details.textContent).toContain('201') + expect(details.textContent).toContain('6.17ms') + expect(details.textContent).toContain('http.server') + expect(screen.getByRole('rowheader', { name: 'Method' })).not.toBeNull() + expect(screen.getByRole('rowheader', { name: 'Trace ID' })).not.toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Copy Route' })) + expect(screen.getByRole('status', { name: 'Route copied' })).not.toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('shows a trace waterfall for a transaction with child spans', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /waterfall', { + operation: 'http.server', + spanId: 'root-span', + traceId: 'waterfall-trace', + startTimestamp: 1_699_999_999.988, + spans: [ + { + span_id: 'db-span', + parent_span_id: 'root-span', + op: 'db.query', + description: 'SELECT * FROM orders', + start_timestamp: 1_699_999_999.99, + timestamp: 1_699_999_999.994, + }, + { + span_id: 'http-span', + parent_span_id: 'root-span', + op: 'http.client', + description: 'GET https://inventory.test/items/42', + start_timestamp: 1_699_999_999.995, + timestamp: 1_699_999_999.998, + }, + ], + }) + + fireEvent.click(await screen.findByRole('tab', { name: 'Trace' })) + + expect(screen.getByRole('region', { name: 'Trace waterfall' })).not.toBeNull() + expect(screen.getByRole('columnheader', { name: 'Span' })).not.toBeNull() + expect( + screen.getByRole('columnheader', { name: 'Timeline from 0ms to 12ms' }) + ).not.toBeNull() + expect(screen.getByRole('columnheader', { name: 'Duration' })).not.toBeNull() + expect(screen.getByLabelText('Trace summary').textContent).toContain('3 spans') + expect(screen.getByText('db.query')).not.toBeNull() + expect(screen.getByText('http.client')).not.toBeNull() + expect(screen.getByRole('cell', { name: 'Duration 4.00ms' })).not.toBeNull() + expect(screen.getByTestId('waterfall-bar-db-span')).not.toBeNull() + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('moves between detail tabs with arrow keys', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + await screen.findByText('Connected to local receiver') + await sendEnvelope(port, 'GET /tabs') + + const overview = await screen.findByRole('tab', { name: 'Overview' }) + fireEvent.keyDown(overview, { key: 'ArrowRight' }) + + expect(screen.getByRole('tab', { name: 'JSON' }).getAttribute('aria-selected')).toBe( + 'true' + ) + } finally { + cleanup() + await stopReceiver(server) + } + }) + + test('reserves space for the CLI logo', async () => { + const { server, port } = await startReceiver() + + try { + renderViewer(port) + + const logo = await screen.findByAltText('Sentry CLI') + expect(logo.getAttribute('width')).toBe('117') + expect(logo.getAttribute('height')).toBe('20') + } finally { + cleanup() + await stopReceiver(server) + } + }) +}) diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx new file mode 100644 index 000000000..70afb5669 --- /dev/null +++ b/apps/local/src/App.tsx @@ -0,0 +1,700 @@ +import { Check, Copy, Search, Terminal } from 'lucide-react' +import { Fragment, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' +import { JsonView } from '@/components/json-view.tsx' +import { ConnectionLanding } from '@/components/connection-landing.tsx' +import { ReceiverControls } from '@/components/receiver-controls.tsx' +import { TraceWaterfall } from '@/components/trace-waterfall.tsx' +import { Badge } from '@/components/ui/badge.tsx' +import { + getConnectionPresentation, + type ConnectionState, +} from '@/lib/presentation.ts' +import { copyText } from '@/lib/clipboard.ts' +import { + appendBounded, + DEFAULT_STREAM_URL, + decodeEnvelope, + parseStreamEndpoint, + REMOTE_STREAM_STORAGE_KEY, + resolveInitialStreamUrl, + SENTRY_ENVELOPE_EVENT, + STREAM_STORAGE_KEY, + type EventMetadata, + type LocalFeedItem, +} from '@/lib/spotlight.ts' +import { buildTraceGroups, type TraceGroup } from '@/lib/trace-model.ts' + +type EventEntryProps = { + item: LocalFeedItem + isSelected: boolean + onSelect: (id: string) => void +} + +type EventFilter = 'all' | 'errors' | 'transactions' | 'logs' + +const eventFilters: { id: EventFilter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'errors', label: 'Errors' }, + { id: 'transactions', label: 'Transactions' }, + { id: 'logs', label: 'Logs' }, +] + +function matchesEventFilter(item: LocalFeedItem, filter: EventFilter): boolean { + if (filter === 'all') { + return true + } + + if (filter === 'errors') { + return isErrorEvent(item) + } + + return item.type === (filter === 'transactions' ? 'transaction' : 'log') +} + +function isErrorEvent(item: LocalFeedItem): boolean { + const { level, statusCode } = getMetadata(item) + return level === 'error' || level === 'fatal' || (statusCode !== undefined && statusCode >= 500) +} + +function matchesSearch(item: LocalFeedItem, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return true + } + + const metadata = getMetadata(item) + return [ + item.type, + item.text, + metadata.title, + metadata.level, + metadata.method, + metadata.route, + metadata.traceId, + metadata.spanId, + metadata.operation, + metadata.origin, + ].some((value) => value?.toLowerCase().includes(normalizedQuery)) +} + +function formatTimestamp(timestamp: LocalFeedItem['timestamp']): string { + if (timestamp === undefined) { + return 'unknown time' + } + const milliseconds = + typeof timestamp === 'number' && timestamp < 1_000_000_000_000 + ? timestamp * 1_000 + : timestamp + const date = new Date(milliseconds) + return Number.isNaN(date.getTime()) ? String(timestamp) : date.toLocaleTimeString() +} + +function formatDuration(durationMs: number | undefined): string | undefined { + if (durationMs === undefined) { + return undefined + } + return `${durationMs.toFixed(durationMs < 10 ? 2 : 0)}ms` +} + +function getMetadata(item: LocalFeedItem): EventMetadata { + return item.metadata ?? { title: item.type } +} + +function getStatusClass(statusCode: number | undefined): string { + if (statusCode === undefined) { + return 'text-muted-foreground' + } + if (statusCode >= 500) { + return 'text-red-500 dark:text-red-400' + } + if (statusCode >= 400) { + return 'text-amber-600 dark:text-amber-400' + } + return 'text-emerald-600 dark:text-emerald-400' +} + +function getSavedStream(): string | null { + try { + return window.localStorage.getItem(STREAM_STORAGE_KEY) + } catch { + return null + } +} + +function getSavedRemoteStream(): string | null { + try { + return window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY) + } catch { + return null + } +} + +function saveStreamUrl(streamUrl: string): void { + try { + window.localStorage.setItem(STREAM_STORAGE_KEY, streamUrl) + } catch { + // Private browsing or browser policy can disable storage; the current tab still works. + } +} + +function saveRemoteStreamUrl(streamUrl: string): void { + try { + window.sessionStorage.setItem(REMOTE_STREAM_STORAGE_KEY, streamUrl) + } catch { + // Browser policies can disable storage; the active tab still works. + } +} + +function EventEntry({ item, isSelected, onSelect }: EventEntryProps) { + const metadata = getMetadata(item) + const duration = formatDuration(metadata.durationMs) + const isError = isErrorEvent(item) + + return ( +
  • + +
  • + ) +} + +type EventDetailProps = { + item: LocalFeedItem + trace?: TraceGroup + relatedItems: LocalFeedItem[] +} + +type DetailTab = 'overview' | 'trace' | 'json' + +type DetailField = { + label: string + value: string +} + +type DetailGroup = { + label: string + fields: DetailField[] +} + +function EventDetail({ item, trace, relatedItems }: EventDetailProps) { + const [tab, setTab] = useState('overview') + const [copiedField, setCopiedField] = useState() + const detailTabs: DetailTab[] = trace?.spans.length ? ['overview', 'trace', 'json'] : ['overview', 'json'] + const metadata = getMetadata(item) + const duration = formatDuration(metadata.durationMs) + const detailGroups: DetailGroup[] = [ + { + label: 'Request', + fields: [ + ...(metadata.method ? [{ label: 'Method', value: metadata.method }] : []), + ...(metadata.route ? [{ label: 'Route', value: metadata.route }] : []), + ...(metadata.statusCode !== undefined + ? [{ label: 'Status', value: String(metadata.statusCode) }] + : []), + ...(duration ? [{ label: 'Duration', value: duration }] : []), + ], + }, + { + label: 'Trace context', + fields: [ + ...(metadata.traceId ? [{ label: 'Trace ID', value: metadata.traceId }] : []), + ...(metadata.spanId ? [{ label: 'Span ID', value: metadata.spanId }] : []), + ...(metadata.operation ? [{ label: 'Operation', value: metadata.operation }] : []), + ...(metadata.origin ? [{ label: 'Origin', value: metadata.origin }] : []), + ...(relatedItems.length > 0 + ? [ + { + label: 'Related events', + value: `${relatedItems.length} other event${relatedItems.length === 1 ? '' : 's'} in this trace`, + }, + ] + : []), + ], + }, + ] + const hasDetailFields = detailGroups.some((group) => group.fields.length > 0) + + const handleTabKeyDown = (event: KeyboardEvent) => { + const currentIndex = detailTabs.indexOf(tab) + const nextIndex = + event.key === 'ArrowRight' + ? (currentIndex + 1) % detailTabs.length + : event.key === 'ArrowLeft' + ? (currentIndex - 1 + detailTabs.length) % detailTabs.length + : event.key === 'Home' + ? 0 + : event.key === 'End' + ? detailTabs.length - 1 + : undefined + + if (nextIndex === undefined) { + return + } + + event.preventDefault() + const nextTab = detailTabs[nextIndex]! + setTab(nextTab) + requestAnimationFrame(() => { + document.getElementById(`event-detail-tab-${nextTab}`)?.focus() + }) + } + + return ( +
    +
    +
    + {metadata.operation ?? item.type} + {metadata.title} +
    +
    +
    + + {trace?.spans.length ? ( + + ) : null} + +
    + +
    +
    + {tab === 'overview' ? ( +
    +
    + {hasDetailFields ? ( + + + + {detailGroups.map((group) => + group.fields.length > 0 ? ( + + + + + {group.fields.map((field) => { + const copied = copiedField === field.label + return ( + + + + + ) + })} + + ) : null + )} + +
    Captured event details
    + {group.label} +
    + {field.label} + +
    + {field.value} + +
    +
    + ) : ( +

    No additional event details.

    + )} + {copiedField ? {copiedField} copied : null} +
    +
    + ) : tab === 'trace' && trace ? ( +
    + +
    + ) : ( +
    + +
    + )} +
    + ) +} + +export default function App() { + const [streamUrl, setStreamUrl] = useState(() => + resolveInitialStreamUrl(window.location.hash, getSavedStream(), getSavedRemoteStream()) + ) + const [connection, setConnection] = useState('connecting') + const [draftEndpoint, setDraftEndpoint] = useState(streamUrl) + const [isEditingReceiver, setIsEditingReceiver] = useState(false) + const [isConnectionEnabled, setIsConnectionEnabled] = useState(true) + const [connectionError, setConnectionError] = useState() + const [items, setItems] = useState([]) + const [selectedItemId, setSelectedItemId] = useState() + const [lastViewedItemId, setLastViewedItemId] = useState() + const [filter, setFilter] = useState('all') + const [searchQuery, setSearchQuery] = useState('') + const [message, setMessage] = useState() + const fallbackEventId = useRef(0) + const presentation = getConnectionPresentation(connection) + const traces = useMemo(() => buildTraceGroups(items), [items]) + const selectedItem = items.find((item) => item.id === selectedItemId) ?? items[0] + const selectedTrace = selectedItem?.metadata?.traceId + ? traces.find((trace) => trace.id === selectedItem.metadata?.traceId) + : undefined + const visibleItems = items.filter((item) => matchesEventFilter(item, filter)) + const searchedItems = visibleItems.filter((item) => matchesSearch(item, searchQuery)) + const relatedItems = selectedItem?.metadata?.traceId + ? items.filter( + (item) => + item.id !== selectedItem.id && + item.metadata?.traceId === selectedItem.metadata?.traceId + ) + : [] + const lastViewedIndex = lastViewedItemId + ? items.findIndex((item) => item.id === lastViewedItemId) + : -1 + const unseenItems = + lastViewedItemId === undefined + ? [] + : lastViewedIndex === -1 + ? items + : items.slice(lastViewedIndex + 1) + const newItems = unseenItems.filter( + (item) => matchesEventFilter(item, filter) && matchesSearch(item, searchQuery) + ) + const newItemCount = newItems.length + + const markItemsSeen = () => { + setLastViewedItemId(items.at(-1)?.id) + } + + const selectItem = (id: string) => { + setSelectedItemId(id) + markItemsSeen() + } + + const selectFilter = (nextFilter: EventFilter) => { + setFilter(nextFilter) + const nextSelectedItem = items.find( + (item) => matchesEventFilter(item, nextFilter) && matchesSearch(item, searchQuery) + ) + if (nextSelectedItem) { + setSelectedItemId(nextSelectedItem.id) + } + markItemsSeen() + } + + const clearItems = () => { + setItems([]) + setSelectedItemId(undefined) + setLastViewedItemId(undefined) + setSearchQuery('') + } + + const connectToDraft = () => { + const endpoint = parseStreamEndpoint(draftEndpoint) + if (!endpoint) { + setConnectionError('Enter a loopback stream or an HTTPS remote stream ending in /stream.') + if (connection !== 'connected') { + setIsConnectionEnabled(false) + setConnection('failed') + } + return + } + setDraftEndpoint(endpoint.url) + setStreamUrl(endpoint.url) + setIsConnectionEnabled(true) + setConnection('connecting') + setConnectionError(undefined) + setIsEditingReceiver(false) + } + + useEffect(() => { + if (window.location.hash) { + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`) + } + }, []) + + useEffect(() => { + if (!isConnectionEnabled) { + return + } + + let isCurrent = true + const source = new EventSource(streamUrl) + const onEnvelope = (event: Event) => { + if (!isCurrent) { + return + } + try { + const messageEvent = event as MessageEvent + const eventId = messageEvent.lastEventId || `event-${fallbackEventId.current++}` + const decoded = decodeEnvelope(messageEvent.data, eventId) + setItems((current) => appendBounded(current, decoded)) + setMessage(undefined) + } catch { + setMessage('Received an event that could not be decoded.') + } + } + + source.addEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope) + source.onopen = () => { + if (!isCurrent) { + return + } + const endpoint = parseStreamEndpoint(streamUrl) + if (endpoint?.kind === 'loopback') { + saveStreamUrl(endpoint.url) + } else if (endpoint?.kind === 'remote') { + saveRemoteStreamUrl(endpoint.url) + } + setConnection('connected') + setConnectionError(undefined) + setMessage(undefined) + } + source.onerror = () => { + if (!isCurrent) { + return + } + setConnection('failed') + setConnectionError( + streamUrl === DEFAULT_STREAM_URL + ? 'Could not connect to the default receiver. Start it with the command below or enter another endpoint.' + : 'Could not connect to this receiver. Check the endpoint and try again.' + ) + } + + return () => { + isCurrent = false + source.removeEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope) + source.close() + } + }, [isConnectionEnabled, streamUrl]) + + return ( +
    +
    +
    +
    + Sentry CLI + +
    + +
    + +
    +
    + +
    +
    + + {connectionError && connection === 'failed' && items.length > 0 ? ( +
    + {connectionError} +
    + ) : null} + {message ? ( +
    + {message} +
    + ) : null} + + {isEditingReceiver || (items.length === 0 && (connection === 'connecting' || connection === 'failed')) ? ( + copyText('sentry local serve --open')} + /> + ) : items.length === 0 ? ( +
    +
    + ) : ( +
    + + {selectedItem ? ( + + ) : null} +
    + )} +
    +
    +
    +
    + ) +} diff --git a/apps/local/src/components/connection-landing.tsx b/apps/local/src/components/connection-landing.tsx new file mode 100644 index 000000000..1ffc889f5 --- /dev/null +++ b/apps/local/src/components/connection-landing.tsx @@ -0,0 +1,94 @@ +import { Check, ChevronDown, Copy, Radio } from 'lucide-react' +import { type FormEvent, useState } from 'react' + +export type ConnectionLandingProps = { + endpoint: string + error?: string + phase: 'probing' | 'failed' | 'editing' + onConnect: () => void + onCopyCommand: () => void + onEndpointChange: (value: string) => void +} + +/** A compact, explicit receiver picker for bare Local viewer visits. */ +export function ConnectionLanding({ + endpoint, + error, + phase, + onConnect, + onCopyCommand, + onEndpointChange, +}: ConnectionLandingProps) { + const [copied, setCopied] = useState(false) + const isProbing = phase === 'probing' + + const submit = (event: FormEvent) => { + event.preventDefault() + onConnect() + } + + return ( +
    +
    +
    +
    + + {error ?

    {error}

    : null} + +
    + +
    + onEndpointChange(event.target.value)} + className="h-9 min-w-0 flex-1 border border-border bg-background px-3 font-mono text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
    +
    + +
    + + +

    + Loopback streams are remembered. Remote streams must use HTTPS, need CORS access for this viewer, and are kept only for this browser session. +

    +
    + +
    + sentry local serve --open + +
    +
    +
    + ) +} diff --git a/apps/local/src/components/json-view.tsx b/apps/local/src/components/json-view.tsx new file mode 100644 index 000000000..4ddfe2a6f --- /dev/null +++ b/apps/local/src/components/json-view.tsx @@ -0,0 +1,93 @@ +import { createHighlighterCore } from '@shikijs/core' +import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript' +import json from '@shikijs/langs/json' +import githubDark from '@shikijs/themes/github-dark' +import githubLight from '@shikijs/themes/github-light' +import { Check, Copy } from 'lucide-react' +import { useEffect, useState } from 'react' +import { copyText } from '@/lib/clipboard.ts' + +type JsonViewProps = { + code: string +} + +const highlighter = createHighlighterCore({ + engine: createJavaScriptRegexEngine(), + langs: [json], + themes: [githubLight, githubDark], +}) + +/** Render JSON with Shiki only after its containing event has been expanded. */ +export function JsonView({ code }: JsonViewProps) { + const [html, setHtml] = useState() + const [copied, setCopied] = useState(false) + + useEffect(() => { + let disposed = false + + void highlighter + .then((instance) => + instance.codeToHtml(code, { + lang: 'json', + themes: { + light: 'github-light', + dark: 'github-dark', + }, + defaultColor: false, + }) + ) + .then((result) => { + if (!disposed) { + setHtml(result) + } + }) + .catch(() => { + // Keep the readable, escaped source fallback if syntax highlighting cannot load. + }) + + return () => { + disposed = true + } + }, [code]) + + const copyJson = () => { + copyText(code) + setCopied(true) + } + + const copyButton = ( + + ) + + if (!html) { + return ( +
    +
    {copyButton}
    + {copied ? JSON copied : null} +
    +          {code}
    +        
    +
    + ) + } + + return ( +
    +
    {copyButton}
    + {copied ? JSON copied : null} +
    +
    + ) +} diff --git a/apps/local/src/components/receiver-controls.tsx b/apps/local/src/components/receiver-controls.tsx new file mode 100644 index 000000000..2847bdd96 --- /dev/null +++ b/apps/local/src/components/receiver-controls.tsx @@ -0,0 +1,104 @@ +import { ChevronDown, Moon, Sun, Trash2 } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { useTheme } from 'next-themes' +import type { ConnectionPresentation } from '@/lib/presentation.ts' + +type ReceiverControlsProps = { + connection: ConnectionPresentation + eventCount: number + onClear: () => void +} + +/** Status and viewer-only actions kept together without exposing receiver mutations. */ +export function ReceiverControls({ connection, eventCount, onClear }: ReceiverControlsProps) { + const [isOpen, setIsOpen] = useState(false) + const rootRef = useRef(null) + const { resolvedTheme, setTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + useEffect(() => { + const closeOnOutsidePointer = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false) + } + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + document.addEventListener('mousedown', closeOnOutsidePointer) + document.addEventListener('keydown', closeOnEscape) + return () => { + document.removeEventListener('mousedown', closeOnOutsidePointer) + document.removeEventListener('keydown', closeOnEscape) + } + }, []) + + const dotClassName = + connection.tone === 'success' + ? 'bg-emerald-500 shadow-[0_0_10px_oklch(0.72_0.19_160)]' + : connection.tone === 'warning' + ? 'bg-amber-500' + : 'bg-muted-foreground' + + return ( +
    +
    + + + + {connection.label.replace(' to local receiver', '')} + + {connection.label} + + +
    + {isOpen ? ( +
    + + +
    + ) : null} +
    + ) +} diff --git a/apps/local/src/components/theme-provider.tsx b/apps/local/src/components/theme-provider.tsx new file mode 100644 index 000000000..e48a960b7 --- /dev/null +++ b/apps/local/src/components/theme-provider.tsx @@ -0,0 +1,6 @@ +import type { ThemeProviderProps } from 'next-themes' +import { ThemeProvider as NextThemesProvider } from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/apps/local/src/components/theme-toggle.tsx b/apps/local/src/components/theme-toggle.tsx new file mode 100644 index 000000000..0086118ce --- /dev/null +++ b/apps/local/src/components/theme-toggle.tsx @@ -0,0 +1,21 @@ +import { Moon, Sun } from 'lucide-react' +import { useTheme } from 'next-themes' +import { Button } from '@/components/ui/button.tsx' + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + return ( + + ) +} diff --git a/apps/local/src/components/trace-waterfall.tsx b/apps/local/src/components/trace-waterfall.tsx new file mode 100644 index 000000000..4c58e42c9 --- /dev/null +++ b/apps/local/src/components/trace-waterfall.tsx @@ -0,0 +1,193 @@ +import type { TraceGroup, TraceSpan } from '@/lib/trace-model.ts' + +type TraceWaterfallProps = { + trace: TraceGroup +} + +type PositionedSpan = { + span: TraceSpan + depth: number + isLastChild: boolean +} + +function formatDuration(durationMs: number | undefined): string { + if (durationMs === undefined) return '—' + return `${durationMs.toFixed(durationMs < 10 ? 2 : 0)}ms` +} + +function getSpanPosition( + span: TraceSpan, + trace: TraceGroup +): { left: string; width: string } | undefined { + if ( + trace.startTimestamp === undefined || + trace.durationMs === undefined || + trace.durationMs <= 0 || + span.startTimestamp === undefined + ) { + return undefined + } + + const elapsedMs = (span.startTimestamp - trace.startTimestamp) * 1_000 + const left = Math.max(0, Math.min(100, (elapsedMs / trace.durationMs) * 100)) + const width = Math.max( + 1, + Math.min(100 - left, ((span.durationMs ?? 0) / trace.durationMs) * 100) + ) + + return { left: `${left}%`, width: `${width}%` } +} + +function flattenSpans(spans: TraceSpan[], depth = 0): PositionedSpan[] { + return spans.flatMap((span, index) => [ + { span, depth, isLastChild: index === spans.length - 1 }, + ...flattenSpans(span.children, depth + 1), + ]) +} + +function isFailingSpan(span: TraceSpan): boolean { + const { level, statusCode } = span.item.metadata ?? {} + return level === 'error' || level === 'fatal' || (statusCode !== undefined && statusCode >= 500) +} + +function SpanRow({ + positionedSpan, + trace, + index, +}: { + positionedSpan: PositionedSpan + trace: TraceGroup + index: number +}) { + const { span, depth, isLastChild } = positionedSpan + const position = getSpanPosition(span, trace) + const failed = isFailingSpan(span) + + return ( +
    +
    + {depth > 0 ? ( + <> +
    +
    + + ) +} + +/** A compact, read-only timeline for the spans contained in one local trace. */ +export function TraceWaterfall({ trace }: TraceWaterfallProps) { + if (trace.spans.length === 0) { + return ( +
    + This trace has no timestamped spans to display yet. +
    + ) + } + + const spans = flattenSpans(trace.roots) + + return ( +
    +
    +

    Trace

    +
    +

    {trace.title}

    + + {formatDuration(trace.durationMs)} + +
    +
    + {trace.spans.length} span{trace.spans.length === 1 ? '' : 's'} + {trace.errorCount > 0 ? {trace.errorCount} error{trace.errorCount === 1 ? '' : 's'} : null} + {trace.logCount > 0 ? {trace.logCount} log{trace.logCount === 1 ? '' : 's'} : null} + Trace ID {trace.id.slice(0, 8)} +
    +
    +
    +
    +
    + Span +
    +
    + Timeline + +
    +
    + Duration +
    +
    + {spans.map((positionedSpan, index) => ( + + ))} +
    +
    + ) +} diff --git a/apps/local/src/components/ui/badge.tsx b/apps/local/src/components/ui/badge.tsx new file mode 100644 index 000000000..c97f945ba --- /dev/null +++ b/apps/local/src/components/ui/badge.tsx @@ -0,0 +1,30 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import type * as React from 'react' +import { cn } from '@/lib/utils.ts' + +const badgeVariants = cva( + 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap', + { + variants: { + variant: { + default: 'border-transparent bg-secondary text-secondary-foreground', + success: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + warning: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-300', + destructive: 'border-destructive/20 bg-destructive/10 text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +function Badge({ + className, + variant, + ...props +}: React.ComponentProps<'span'> & VariantProps) { + return +} + +export { Badge } diff --git a/apps/local/src/components/ui/button.tsx b/apps/local/src/components/ui/button.tsx new file mode 100644 index 000000000..64e43f17e --- /dev/null +++ b/apps/local/src/components/ui/button.tsx @@ -0,0 +1,42 @@ +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import type * as React from 'react' +import { cn } from '@/lib/utils.ts' + +const buttonVariants = cva( + 'inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + ghost: 'hover:bg-accent hover:text-accent-foreground', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + }, + size: { + default: 'h-9 px-4 py-2', + icon: 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : 'button' + + return +} + +export { Button } diff --git a/apps/local/src/components/ui/card.tsx b/apps/local/src/components/ui/card.tsx new file mode 100644 index 000000000..2a6084769 --- /dev/null +++ b/apps/local/src/components/ui/card.tsx @@ -0,0 +1,29 @@ +import type * as React from 'react' +import { cn } from '@/lib/utils.ts' + +function Card({ className, ...props }: React.ComponentProps<'section'>) { + return ( +
    + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
    +} + +function CardTitle({ className, ...props }: React.ComponentProps<'h2'>) { + return

    +} + +function CardDescription({ className, ...props }: React.ComponentProps<'p'>) { + return

    +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return

    +} + +export { Card, CardContent, CardDescription, CardHeader, CardTitle } diff --git a/apps/local/src/index.css b/apps/local/src/index.css new file mode 100644 index 000000000..355a4442e --- /dev/null +++ b/apps/local/src/index.css @@ -0,0 +1,97 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.985 0.002 285); + --foreground: oklch(0.18 0.02 285); + --card: oklch(1 0 0); + --card-foreground: oklch(0.18 0.02 285); + --primary: oklch(0.56 0.25 292); + --primary-foreground: oklch(0.99 0.005 285); + --secondary: oklch(0.95 0.015 285); + --secondary-foreground: oklch(0.3 0.025 285); + --muted: oklch(0.95 0.012 285); + --muted-foreground: oklch(0.48 0.02 285); + --accent: oklch(0.94 0.02 285); + --accent-foreground: oklch(0.25 0.025 285); + --destructive: oklch(0.59 0.22 25); + --border: oklch(0.9 0.012 285); + --input: oklch(0.9 0.012 285); + --ring: oklch(0.56 0.25 292); + --radius: 0.5rem; +} + +.dark { + --background: oklch(0.16 0.018 285); + --foreground: oklch(0.96 0.008 285); + --card: oklch(0.2 0.018 285); + --card-foreground: oklch(0.96 0.008 285); + --primary: oklch(0.72 0.22 292); + --primary-foreground: oklch(0.2 0.018 285); + --secondary: oklch(0.27 0.02 285); + --secondary-foreground: oklch(0.95 0.008 285); + --muted: oklch(0.25 0.018 285); + --muted-foreground: oklch(0.7 0.018 285); + --accent: oklch(0.3 0.022 285); + --accent-foreground: oklch(0.96 0.008 285); + --destructive: oklch(0.7 0.2 25); + --border: oklch(0.31 0.018 285); + --input: oklch(0.31 0.018 285); + --ring: oklch(0.72 0.22 292); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); +} + +@layer base { + * { + @apply border-border; + } + + html { + @apply bg-background; + } + + body { + @apply min-w-80 bg-background text-foreground antialiased; + } +} + +@layer components { + .json-view pre { + @apply m-0 bg-transparent p-0 font-mono text-inherit; + } + + .json-view .shiki { + background-color: transparent !important; + padding: 0 !important; + } + + .json-view .shiki span { + color: var(--shiki-light); + } + + .dark .json-view .shiki span { + color: var(--shiki-dark); + } +} diff --git a/apps/local/src/lib/clipboard.ts b/apps/local/src/lib/clipboard.ts new file mode 100644 index 000000000..137e9f0d5 --- /dev/null +++ b/apps/local/src/lib/clipboard.ts @@ -0,0 +1,21 @@ +/** Copy text while retaining a small fallback for browsers without Clipboard API access. */ +export function copyText(value: string): void { + const fallbackCopy = () => { + const input = document.createElement('textarea') + input.value = value + input.setAttribute('readonly', '') + input.style.position = 'fixed' + input.style.opacity = '0' + document.body.appendChild(input) + input.select() + document.execCommand?.('copy') + input.remove() + } + + if (navigator.clipboard?.writeText) { + void navigator.clipboard.writeText(value).catch(fallbackCopy) + return + } + + fallbackCopy() +} diff --git a/apps/local/src/lib/presentation.test.ts b/apps/local/src/lib/presentation.test.ts new file mode 100644 index 000000000..57f0597a2 --- /dev/null +++ b/apps/local/src/lib/presentation.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'vitest' +import { getConnectionPresentation } from './presentation.ts' + +describe('getConnectionPresentation', () => { + test('describes an active local receiver as connected', () => { + expect(getConnectionPresentation('connected')).toEqual({ + label: 'Connected to local receiver', + tone: 'success', + }) + }) + + test('explains when no local stream has been provided', () => { + expect(getConnectionPresentation('missing')).toEqual({ + label: 'Waiting for a local stream', + tone: 'neutral', + }) + }) + + test('makes a failed receiver visible in the status control', () => { + expect(getConnectionPresentation('failed')).toEqual({ + label: 'Receiver unavailable', + tone: 'warning', + }) + }) +}) diff --git a/apps/local/src/lib/presentation.ts b/apps/local/src/lib/presentation.ts new file mode 100644 index 000000000..66360c2dd --- /dev/null +++ b/apps/local/src/lib/presentation.ts @@ -0,0 +1,23 @@ +export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'missing' | 'failed' + +export type ConnectionPresentation = { + label: string + tone: 'neutral' | 'success' | 'warning' +} + +export function getConnectionPresentation( + connection: ConnectionState +): ConnectionPresentation { + switch (connection) { + case 'connected': + return { label: 'Connected to local receiver', tone: 'success' } + case 'connecting': + return { label: 'Connecting to local receiver', tone: 'neutral' } + case 'reconnecting': + return { label: 'Reconnecting to local receiver', tone: 'warning' } + case 'missing': + return { label: 'Waiting for a local stream', tone: 'neutral' } + case 'failed': + return { label: 'Receiver unavailable', tone: 'warning' } + } +} diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts new file mode 100644 index 000000000..2a1b44811 --- /dev/null +++ b/apps/local/src/lib/spotlight.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from "vitest"; +import { + appendBounded, + DEFAULT_STREAM_URL, + decodeEnvelope, + parseStreamEndpoint, + getPreferredStreamUrl, + resolveInitialStreamUrl, + getStreamUrlFromHash, + getStreamUrlFromStorage, +} from "./spotlight.js"; + +describe("getStreamUrlFromHash", () => { + test("classifies the default stream as loopback", () => { + expect(DEFAULT_STREAM_URL).toBe("http://localhost:8969/stream"); + expect(parseStreamEndpoint(DEFAULT_STREAM_URL)).toEqual({ + url: DEFAULT_STREAM_URL, + kind: "loopback", + }); + }); + + test("accepts an HTTPS remote stream with a query token", () => { + expect( + parseStreamEndpoint("https://receiver.example/stream?token=abc") + ).toEqual({ + url: "https://receiver.example/stream?token=abc", + kind: "remote", + }); + }); + + test.each([ + "http://receiver.example/stream", + "https://user:pass@receiver.example/stream", + "https://receiver.example/other", + "https://receiver.example/stream#fragment", + ])("rejects unsafe custom endpoint %s", (url) => { + expect(parseStreamEndpoint(url)).toBeUndefined(); + }); + + test("accepts a loopback stream URL", () => { + expect( + getStreamUrlFromHash( + "#stream=http%3A%2F%2Flocalhost%3A8969%2Fstream" + ) + ).toBe("http://localhost:8969/stream"); + }); + + test("rejects a non-loopback stream URL", () => { + expect( + getStreamUrlFromHash("#stream=http%3A%2F%2Fexample.com%2Fstream") + ).toBeUndefined(); + }); + + test("restores a valid saved loopback stream URL", () => { + expect(getStreamUrlFromStorage("http://127.0.0.1:8969/stream")).toBe( + "http://127.0.0.1:8969/stream" + ); + }); + + test("rejects a saved non-loopback stream URL", () => { + expect(getStreamUrlFromStorage("http://example.com/stream")).toBeUndefined(); + }); + + test("prefers a fresh CLI fragment over a saved stream URL", () => { + expect( + getPreferredStreamUrl( + "#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream", + "http://127.0.0.1:8969/stream" + ) + ).toBe("http://localhost:9000/stream"); + }); + + test("uses CLI pairing, saved endpoints, then the default in priority order", () => { + expect( + resolveInitialStreamUrl( + "#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream", + null, + null + ) + ).toBe("http://localhost:9000/stream"); + expect( + resolveInitialStreamUrl("", "http://127.0.0.1:8970/stream", null) + ).toBe("http://127.0.0.1:8970/stream"); + expect(resolveInitialStreamUrl("", null, null)).toBe(DEFAULT_STREAM_URL); + }); +}); + +describe("decodeEnvelope", () => { + test("unpacks batched span and log items from the Local receiver", () => { + expect( + decodeEnvelope( + '[{},[[{"type":"span"},{"version":2,"items":[{"trace_id":"trace-123","span_id":"span-456","parent_span_id":"root-span","name":"agent.run","start_timestamp":1700000000,"end_timestamp":1700000000.004,"attributes":{"sentry.op":{"value":"gen_ai.invoke_agent","type":"string"}}}]}],[{"type":"log"},{"version":2,"items":[{"trace_id":"trace-123","timestamp":1700000000.005,"level":"error","body":"Fixture request failed"}]}]]]', + "event-1" + ) + ).toMatchObject([ + { + id: "event-1:0", + type: "span", + timestamp: 1700000000.004, + metadata: { + title: "agent.run", + traceId: "trace-123", + spanId: "span-456", + operation: "gen_ai.invoke_agent", + durationMs: 4, + }, + }, + { + id: "event-1:1", + type: "log", + timestamp: 1700000000.005, + metadata: { + title: "Fixture request failed", + traceId: "trace-123", + level: "error", + }, + }, + ]); + }); + + test("describes standalone spans and logs with their own payload fields", () => { + expect( + decodeEnvelope( + '[{},[[{"type":"span"},{"trace_id":"trace-123","span_id":"span-456","op":"db.query","description":"SELECT * FROM orders","start_timestamp":1700000000,"timestamp":1700000000.004}],[{"type":"log"},{"trace_id":"trace-123","level":"error","body":"Inventory lookup failed","timestamp":1700000000.005}]]]', + "event-1" + ) + ).toMatchObject([ + { + type: "span", + metadata: { + title: "SELECT * FROM orders", + traceId: "trace-123", + spanId: "span-456", + operation: "db.query", + durationMs: 4, + }, + }, + { + type: "log", + metadata: { + title: "Inventory lookup failed", + traceId: "trace-123", + level: "error", + }, + }, + ]); + }); + + test("keeps the decoded payload available for trace views", () => { + const [item] = decodeEnvelope( + '[{},[[{"type":"transaction"},{"transaction":"GET /orders/42","contexts":{"trace":{"trace_id":"trace-123","span_id":"root-span"}},"spans":[{"span_id":"db-span","parent_span_id":"root-span","op":"db.query","description":"SELECT * FROM orders","start_timestamp":1700000000.001,"timestamp":1700000000.003}]}]]]', + "event-1" + ); + + expect((item as { payload?: unknown }).payload).toEqual({ + transaction: "GET /orders/42", + contexts: { trace: { trace_id: "trace-123", span_id: "root-span" } }, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1700000000.001, + timestamp: 1700000000.003, + }, + ], + }); + }); + + test("turns envelope items into displayable feed entries", () => { + expect( + decodeEnvelope( + '[{"sdk":{"name":"sentry.node"}},[[{"type":"transaction"},{"transaction":"GET /api/users/42","start_timestamp":1700000000,"timestamp":1700000000.00668,"contexts":{"trace":{"trace_id":"trace-123","span_id":"span-456","op":"http.server","data":{"http.method":"GET","http.url":"http://127.0.0.1:3030/api/users/42","http.response.status_code":200,"sentry.origin":"auto.http.otel.http"}}}}]]]', + "event-1" + ) + ).toEqual([ + { + id: "event-1:0", + type: "transaction", + timestamp: 1700000000.00668, + metadata: { + title: "GET /api/users/42", + method: "GET", + route: "/api/users/42", + statusCode: 200, + durationMs: 6.68, + traceId: "trace-123", + spanId: "span-456", + operation: "http.server", + origin: "auto.http.otel.http", + }, + text: '{\n "transaction": "GET /api/users/42",\n "start_timestamp": 1700000000,\n "timestamp": 1700000000.00668,\n "contexts": {\n "trace": {\n "trace_id": "trace-123",\n "span_id": "span-456",\n "op": "http.server",\n "data": {\n "http.method": "GET",\n "http.url": "http://127.0.0.1:3030/api/users/42",\n "http.response.status_code": 200,\n "sentry.origin": "auto.http.otel.http"\n }\n }\n }\n}', + payload: { + transaction: "GET /api/users/42", + start_timestamp: 1700000000, + timestamp: 1700000000.00668, + contexts: { + trace: { + trace_id: "trace-123", + span_id: "span-456", + op: "http.server", + data: { + "http.method": "GET", + "http.url": "http://127.0.0.1:3030/api/users/42", + "http.response.status_code": 200, + "sentry.origin": "auto.http.otel.http", + }, + }, + }, + }, + }, + ]); + }); + + test("rejects malformed envelopes", () => { + expect(() => decodeEnvelope("not json", "event-1")).toThrow( + "Invalid Sentry envelope" + ); + }); +}); + +describe("appendBounded", () => { + test("keeps the newest 500 items", () => { + const entries = Array.from({ length: 501 }, (_, index) => ({ + id: String(index), + type: "transaction", + text: String(index), + })); + + const result = appendBounded([], entries); + + expect(result).toHaveLength(500); + expect(result[0]?.id).toBe("1"); + expect(result.at(-1)?.id).toBe("500"); + }); +}); diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts new file mode 100644 index 000000000..7d9b1df14 --- /dev/null +++ b/apps/local/src/lib/spotlight.ts @@ -0,0 +1,255 @@ +export const SENTRY_ENVELOPE_EVENT = "application/x-sentry-envelope" +export const STREAM_STORAGE_KEY = "sentry.local.stream-url" +export const REMOTE_STREAM_STORAGE_KEY = "sentry.local.remote-stream-url" +export const DEFAULT_STREAM_URL = "http://localhost:8969/stream" +const MAX_FEED_ITEMS = 500 + +export type StreamEndpointKind = "loopback" | "remote" + +export type StreamEndpoint = { + url: string + kind: StreamEndpointKind +} + +export type LocalFeedItem = { + id: string + type: string + timestamp?: number | string + text: string + payload: unknown + metadata?: EventMetadata +} + +export type EventMetadata = { + title: string + level?: string + method?: string + route?: string + statusCode?: number + durationMs?: number + traceId?: string + spanId?: string + operation?: string + origin?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined +} + +function getNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function getAttributeString(attributes: Record, key: string): string | undefined { + const attribute = attributes[key] + return isRecord(attribute) ? getString(attribute.value) : getString(attribute) +} + +function getRoute(url: string | undefined): string | undefined { + if (!url) { + return undefined + } + try { + const parsed = new URL(url) + return `${parsed.pathname}${parsed.search}` + } catch { + return url.startsWith("/") ? url : undefined + } +} + +function getEventMetadata(payload: unknown, type: string): EventMetadata { + const event = isRecord(payload) ? payload : {} + const transaction = getString(event.transaction) + const description = getString(event.description) + const message = getString(event.message) ?? getString(event.body) ?? getString(event.name) + const transactionMatch = transaction?.match(/^([A-Z]+)\s+(.+)$/) + const contexts = isRecord(event.contexts) ? event.contexts : {} + const trace = isRecord(contexts.trace) ? contexts.trace : event + const data = isRecord(trace.data) + ? trace.data + : isRecord(event.data) + ? event.data + : {} + const attributes = isRecord(event.attributes) ? event.attributes : {} + const request = isRecord(event.request) ? event.request : {} + const method = + getString(request.method) ?? getString(data["http.method"]) ?? transactionMatch?.[1] + const url = getString(request.url) ?? getString(data["http.url"]) + const route = getRoute(url) ?? transactionMatch?.[2] + const startTimestamp = getNumber(event.start_timestamp) + const timestamp = getNumber(event.timestamp) ?? getNumber(event.end_timestamp) + const durationMs = + startTimestamp !== undefined && timestamp !== undefined + ? Math.round((timestamp - startTimestamp) * 100_000) / 100 + : undefined + + return { + title: transaction ?? description ?? message ?? route ?? type, + level: getString(event.level), + method, + route, + statusCode: + getNumber(request.status_code) ?? getNumber(data["http.response.status_code"]), + durationMs, + traceId: getString(trace.trace_id) ?? getString(event.trace_id), + spanId: getString(trace.span_id) ?? getString(event.span_id), + operation: + getString(trace.op) ?? + getString(event.op) ?? + getString(event.transaction_op) ?? + getAttributeString(attributes, "sentry.op"), + origin: + getString(data["sentry.origin"]) ?? + getString(event.origin) ?? + getAttributeString(attributes, "sentry.origin"), + } +} + +function getBatchedPayloadItems(payload: unknown, type: string): unknown[] { + if ((type !== "span" && type !== "log") || !isRecord(payload) || !Array.isArray(payload.items)) { + return [payload] + } + return payload.items.filter(isRecord) +} + +function getItemTimestamp(payload: unknown): number | string | undefined { + if (!isRecord(payload)) { + return undefined + } + const timestamp = payload.timestamp ?? payload.end_timestamp + return typeof timestamp === "number" || typeof timestamp === "string" ? timestamp : undefined +} + +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase() + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" +} + +/** Normalize an explicit connection target and classify its persistence policy. */ +export function parseStreamEndpoint(stream: string | null): StreamEndpoint | undefined { + if (!stream) { + return undefined + } + try { + const url = new URL(stream) + if ( + url.pathname !== "/stream" || + url.username || + url.password || + url.hash + ) { + return undefined + } + if (isLoopbackHost(url.hostname)) { + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.search) { + return undefined + } + return { url: url.toString(), kind: "loopback" } + } + if (url.protocol === "https:") { + return { url: url.toString(), kind: "remote" } + } + return undefined + } catch { + return undefined + } +} + +function normalizeLoopbackStreamUrl(stream: string | null): string | undefined { + const endpoint = parseStreamEndpoint(stream) + return endpoint?.kind === "loopback" ? endpoint.url : undefined +} + +function normalizeRemoteStreamUrl(stream: string | null): string | undefined { + const endpoint = parseStreamEndpoint(stream) + return endpoint?.kind === "remote" ? endpoint.url : undefined +} + +/** Read the CLI-provided stream endpoint without allowing arbitrary targets. */ +export function getStreamUrlFromHash(hash: string): string | undefined { + return normalizeLoopbackStreamUrl( + new URLSearchParams(hash.replace(/^#/, "")).get("stream") + ) +} + +/** Validate a stream endpoint saved by an earlier CLI-launched tab. */ +export function getStreamUrlFromStorage(stream: string | null): string | undefined { + return normalizeLoopbackStreamUrl(stream) +} + +/** Validate an explicit remote stream saved for the current browser session. */ +export function getRemoteStreamUrlFromStorage( + stream: string | null +): string | undefined { + return normalizeRemoteStreamUrl(stream) +} + +/** Prefer a newly supplied CLI endpoint over a previously saved one. */ +export function getPreferredStreamUrl( + hash: string, + savedStream: string | null +): string | undefined { + return getStreamUrlFromHash(hash) ?? getStreamUrlFromStorage(savedStream) +} + +/** Resolve the bare viewer's receiver in the same safe order as the UI. */ +export function resolveInitialStreamUrl( + hash: string, + savedLoopbackStream: string | null, + savedRemoteStream: string | null +): string { + return ( + getStreamUrlFromHash(hash) ?? + getStreamUrlFromStorage(savedLoopbackStream) ?? + getRemoteStreamUrlFromStorage(savedRemoteStream) ?? + DEFAULT_STREAM_URL + ) +} + +/** Decode the event payload emitted by the local receiver's SSE endpoint. */ +export function decodeEnvelope(data: string, eventId: string): LocalFeedItem[] { + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + throw new Error("Invalid Sentry envelope") + } + + if (!Array.isArray(parsed) || !Array.isArray(parsed[1])) { + throw new Error("Invalid Sentry envelope") + } + + return parsed[1].flatMap((item, index) => { + if (!Array.isArray(item) || item.length < 2 || !isRecord(item[0])) { + return [] + } + const header = item[0] + const type = typeof header.type === "string" ? header.type : "unknown" + const payloads = getBatchedPayloadItems(item[1], type) + return payloads.map((payload, payloadIndex) => ({ + id: + payloads.length === 1 + ? `${eventId}:${index}` + : `${eventId}:${index}:${payloadIndex}`, + type, + timestamp: getItemTimestamp(payload), + text: JSON.stringify(payload, null, 2) ?? String(payload), + payload, + metadata: getEventMetadata(payload, type), + })) + }) +} + +/** Append received items while keeping the in-memory UI feed bounded. */ +export function appendBounded( + existing: readonly LocalFeedItem[], + incoming: readonly LocalFeedItem[], + limit = MAX_FEED_ITEMS +): LocalFeedItem[] { + return [...existing, ...incoming].slice(-limit) +} diff --git a/apps/local/src/lib/trace-model.test.ts b/apps/local/src/lib/trace-model.test.ts new file mode 100644 index 000000000..b48a63a5c --- /dev/null +++ b/apps/local/src/lib/trace-model.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "vitest" +import type { LocalFeedItem } from "./spotlight.js" +import { buildTraceGroups } from "./trace-model.js" + +function item(overrides: Partial): LocalFeedItem { + return { + id: "item-1", + type: "transaction", + text: "{}", + payload: {}, + metadata: { title: "GET /orders/42" }, + ...overrides, + } +} + +describe("buildTraceGroups", () => { + test("builds a timestamped span tree and correlates trace errors and logs", () => { + const traces = buildTraceGroups([ + item({ + id: "transaction", + timestamp: 1_700_000_000.012, + metadata: { + title: "GET /orders/42", + traceId: "trace-42", + spanId: "root-span", + operation: "http.server", + durationMs: 12, + }, + payload: { + transaction: "GET /orders/42", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.012, + contexts: { + trace: { trace_id: "trace-42", span_id: "root-span", op: "http.server" }, + }, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1_700_000_000.002, + timestamp: 1_700_000_000.005, + }, + { + span_id: "client-span", + parent_span_id: "root-span", + op: "http.client", + description: "GET https://inventory.test/items/42", + start_timestamp: 1_700_000_000.006, + timestamp: 1_700_000_000.01, + }, + ], + }, + }), + item({ + id: "log", + type: "log", + metadata: { title: "Inventory lookup finished", traceId: "trace-42", level: "info" }, + }), + item({ + id: "error", + type: "event", + metadata: { title: "Order failed", traceId: "trace-42", level: "error" }, + }), + ]) + + expect(traces).toHaveLength(1) + expect(traces[0]).toMatchObject({ + id: "trace-42", + title: "GET /orders/42", + itemCount: 3, + logCount: 1, + errorCount: 1, + startTimestamp: 1_700_000_000, + endTimestamp: 1_700_000_000.012, + durationMs: 12, + }) + expect(traces[0]?.roots).toMatchObject([ + { + id: "root-span", + operation: "http.server", + children: [ + { id: "db-span", operation: "db.query", durationMs: 3 }, + { id: "client-span", operation: "http.client", durationMs: 4 }, + ], + }, + ]) + }) + + test("keeps a span visible when its declared parent is absent", () => { + const traces = buildTraceGroups([ + item({ + metadata: { title: "orphaned work", traceId: "trace-orphan", spanId: "orphan-span" }, + payload: { + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.001, + contexts: { + trace: { + trace_id: "trace-orphan", + span_id: "orphan-span", + parent_span_id: "missing-parent", + op: "db.query", + }, + }, + }, + }), + ]) + + expect(traces[0]?.roots).toMatchObject([ + { id: "orphan-span", parentId: "missing-parent", operation: "db.query" }, + ]) + }) + + test("nests a standalone span using its top-level parent span id", () => { + const traces = buildTraceGroups([ + item({ + id: "root", + metadata: { + title: "POST /agent/run", + traceId: "trace-agent", + spanId: "root-span", + operation: "http.server", + }, + payload: { + transaction: "POST /agent/run", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.01, + contexts: { + trace: { trace_id: "trace-agent", span_id: "root-span", op: "http.server" }, + }, + }, + }), + item({ + id: "child", + type: "span", + metadata: { + title: "agent.run", + traceId: "trace-agent", + spanId: "child-span", + operation: "gen_ai.invoke_agent", + }, + payload: { + trace_id: "trace-agent", + span_id: "child-span", + parent_span_id: "root-span", + start_timestamp: 1_700_000_000.002, + end_timestamp: 1_700_000_000.006, + }, + }), + ]) + + expect(traces[0]?.roots).toMatchObject([ + { + id: "root-span", + children: [ + { id: "child-span", operation: "gen_ai.invoke_agent", durationMs: 4 }, + ], + }, + ]) + }) +}) diff --git a/apps/local/src/lib/trace-model.ts b/apps/local/src/lib/trace-model.ts new file mode 100644 index 000000000..999db4e65 --- /dev/null +++ b/apps/local/src/lib/trace-model.ts @@ -0,0 +1,235 @@ +import type { LocalFeedItem } from "./spotlight.js" + +export type TraceSpan = { + id: string + parentId?: string + operation?: string + description: string + startTimestamp?: number + endTimestamp?: number + durationMs?: number + item: LocalFeedItem + children: TraceSpan[] +} + +export type TraceGroup = { + id: string + title: string + items: LocalFeedItem[] + roots: TraceSpan[] + spans: TraceSpan[] + logCount: number + errorCount: number + itemCount: number + startTimestamp?: number + endTimestamp?: number + durationMs?: number +} + +type MutableTraceGroup = Omit & { + spanMap: Map +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined +} + +function getTimestamp(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + +function getTraceContext(payload: unknown): Record { + if (!isRecord(payload)) { + return {} + } + const contexts = isRecord(payload.contexts) ? payload.contexts : {} + return isRecord(contexts.trace) ? contexts.trace : {} +} + +function getPayloadSpan(payload: unknown, item: LocalFeedItem): TraceSpan | undefined { + const event = isRecord(payload) ? payload : {} + const trace = getTraceContext(payload) + const id = getString(trace.span_id) ?? item.metadata?.spanId + if (!id) { + return undefined + } + const startTimestamp = getTimestamp(event.start_timestamp) + const endTimestamp = + getTimestamp(event.timestamp) ?? + getTimestamp(event.end_timestamp) ?? + getTimestamp(item.timestamp) + const operation = getString(trace.op) ?? item.metadata?.operation + const description = + getString(event.transaction) ?? + item.metadata?.route ?? + item.metadata?.title ?? + operation ?? + item.type + + return { + id, + parentId: getString(trace.parent_span_id) ?? getString(event.parent_span_id), + operation, + description, + startTimestamp, + endTimestamp, + durationMs: getDurationMs(startTimestamp, endTimestamp), + item, + children: [], + } +} + +function getChildSpans(payload: unknown, item: LocalFeedItem): TraceSpan[] { + const event = isRecord(payload) ? payload : {} + if (!Array.isArray(event.spans)) { + return [] + } + + return event.spans.flatMap((value) => { + if (!isRecord(value)) { + return [] + } + const id = getString(value.span_id) + if (!id) { + return [] + } + const startTimestamp = getTimestamp(value.start_timestamp) + const endTimestamp = getTimestamp(value.timestamp) + const operation = getString(value.op) + return [ + { + id, + parentId: getString(value.parent_span_id), + operation, + description: getString(value.description) ?? operation ?? id, + startTimestamp, + endTimestamp, + durationMs: getDurationMs(startTimestamp, endTimestamp), + item, + children: [], + }, + ] + }) +} + +function getDurationMs(startTimestamp: number | undefined, endTimestamp: number | undefined): number | undefined { + if (startTimestamp === undefined || endTimestamp === undefined || endTimestamp < startTimestamp) { + return undefined + } + return Math.round((endTimestamp - startTimestamp) * 100_000) / 100 +} + +function isError(item: LocalFeedItem): boolean { + const { level, statusCode } = item.metadata ?? {} + return level === "error" || level === "fatal" || (statusCode !== undefined && statusCode >= 500) +} + +function sortByTimestamp(items: T[]): T[] { + return items.sort((left, right) => (left.startTimestamp ?? Infinity) - (right.startTimestamp ?? Infinity)) +} + +function updateTraceTiming(trace: MutableTraceGroup): void { + const timestamps = trace.items + .flatMap((item) => [getTimestamp(item.timestamp)]) + .concat( + [...trace.spanMap.values()].flatMap((span) => [span.startTimestamp, span.endTimestamp]) + ) + .filter((timestamp): timestamp is number => timestamp !== undefined) + + if (timestamps.length === 0) { + return + } + + trace.startTimestamp = Math.min(...timestamps) + trace.endTimestamp = Math.max(...timestamps) + trace.durationMs = getDurationMs(trace.startTimestamp, trace.endTimestamp) +} + +/** Build a small, read-only trace model once for every view in Local. */ +export function buildTraceGroups(items: readonly LocalFeedItem[]): TraceGroup[] { + const traces = new Map() + + for (const item of items) { + const traceId = item.metadata?.traceId + if (!traceId) { + continue + } + + const trace: MutableTraceGroup = traces.get(traceId) ?? { + id: traceId, + title: item.metadata?.title ?? item.type, + items: [], + logCount: 0, + errorCount: 0, + spanMap: new Map(), + } + if (!traces.has(traceId)) { + traces.set(traceId, trace) + } + + trace.items.push(item) + if (item.type === "log") { + trace.logCount += 1 + } + if (isError(item)) { + trace.errorCount += 1 + } + + if (item.type === "transaction" || item.type === "span") { + const rootSpan = getPayloadSpan(item.payload, item) + if (rootSpan) { + trace.spanMap.set(rootSpan.id, rootSpan) + } + } + for (const childSpan of getChildSpans(item.payload, item)) { + trace.spanMap.set(childSpan.id, childSpan) + } + updateTraceTiming(trace) + } + + return [...traces.values()] + .map((trace) => { + const spans = sortByTimestamp([...trace.spanMap.values()]) + const roots: TraceSpan[] = [] + for (const span of spans) { + span.children = [] + } + for (const span of spans) { + const parent = span.parentId ? trace.spanMap.get(span.parentId) : undefined + if (parent && parent !== span) { + parent.children.push(span) + } else { + roots.push(span) + } + } + for (const span of spans) { + sortByTimestamp(span.children) + } + + return { + id: trace.id, + title: trace.title, + items: trace.items, + roots: sortByTimestamp(roots), + spans, + logCount: trace.logCount, + errorCount: trace.errorCount, + itemCount: trace.items.length, + startTimestamp: trace.startTimestamp, + endTimestamp: trace.endTimestamp, + durationMs: trace.durationMs, + } + }) + .sort((left, right) => (right.endTimestamp ?? 0) - (left.endTimestamp ?? 0)) +} diff --git a/apps/local/src/lib/utils.ts b/apps/local/src/lib/utils.ts new file mode 100644 index 000000000..fed2fe91e --- /dev/null +++ b/apps/local/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/local/src/main.tsx b/apps/local/src/main.tsx new file mode 100644 index 000000000..17e14c367 --- /dev/null +++ b/apps/local/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './index.css' +import { ThemeProvider } from './components/theme-provider.tsx' + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/apps/local/tsconfig.app.json b/apps/local/tsconfig.app.json new file mode 100644 index 000000000..140f47f71 --- /dev/null +++ b/apps/local/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/apps/local/tsconfig.json b/apps/local/tsconfig.json new file mode 100644 index 000000000..d0b2bca50 --- /dev/null +++ b/apps/local/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/apps/local/tsconfig.node.json b/apps/local/tsconfig.node.json new file mode 100644 index 000000000..8455dcbc2 --- /dev/null +++ b/apps/local/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts new file mode 100644 index 000000000..401ef3775 --- /dev/null +++ b/apps/local/vite.config.ts @@ -0,0 +1,21 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [react(),tailwindcss()], + resolve: { + alias: { + '@': resolve(dirname(fileURLToPath(import.meta.url)), './src'), + }, + }, + server: { + port: 5173, + strictPort: true, + }, + test: { + environment: 'jsdom', + }, +}) diff --git a/docs/superpowers/plans/2026-09-09-local-viewer-connection-landing.md b/docs/superpowers/plans/2026-09-09-local-viewer-connection-landing.md new file mode 100644 index 000000000..f27592ccc --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-local-viewer-connection-landing.md @@ -0,0 +1,273 @@ +# Local Viewer Connection Landing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (\`- [ ]\`) syntax for tracking. + +**Goal:** Connect a bare Local viewer to its default receiver and provide an explicit, safe form for custom local and HTTPS remote streams. + +**Architecture:** \`lib/spotlight.ts\` owns normalized endpoint parsing and persistence classification. A presentational connection landing owns form rendering only. \`App.tsx\` resolves source priority, owns storage and the replaceable EventSource, and switches between the landing and the read-only workspace. + +**Tech Stack:** React 19, TypeScript, Vite, Tailwind CSS, Vitest, Testing Library, native EventSource. + +**Spec:** \`docs/superpowers/specs/2026-09-09-local-viewer-connection-design.md\` + +## Global Constraints + +- Preserve CLI \`#stream\` pairing and remove a supplied fragment from the address bar after reading it. +- The default endpoint is exactly \`http://localhost:8969/stream\`. +- Persist only successfully opened loopback endpoints in local storage and remote endpoints in session storage. +- Accept loopback HTTP/HTTPS or remote HTTPS URLs ending in \`/stream\`, reject credentials and fragments, and preserve explicit remote query parameters. +- Remote streams connect directly from the browser; do not change receiver CORS, add server proxies, mutation APIs, or custom EventSource headers. +- Preserve the bounded 500-item feed and all existing workspace behavior. + +--- + +## File Structure + +- \`apps/local/src/lib/spotlight.ts\`: stream parsing, classification, priority, and storage-key constants. +- \`apps/local/src/lib/spotlight.test.ts\`: pure parsing and priority assertions. +- \`apps/local/src/components/connection-landing.tsx\`: accessible form and default-probe/failure presentation. +- \`apps/local/src/App.tsx\`: endpoint state, storage, EventSource lifecycle, and visibility transitions. +- \`apps/local/src/App.integration.test.tsx\`: default probe, form, persistence, recovery, and workspace regression coverage. + +### Task 1: Add the stream endpoint model + +**Files:** +- Modify: \`apps/local/src/lib/spotlight.ts\` +- Modify: \`apps/local/src/lib/spotlight.test.ts\` + +**Interfaces:** +- Produces: \`DEFAULT_STREAM_URL\`, \`REMOTE_STREAM_STORAGE_KEY\`, \`StreamEndpointKind\`, \`StreamEndpoint\`, \`parseStreamEndpoint(value)\`, and \`resolveInitialStreamUrl(hash, savedLoopback, savedRemote)\`. +- Consumes: Existing loopback validation and hash parsing. + +- [x] **Step 1: Write failing pure tests** + +~~~ts +test('classifies the default stream as loopback', () => { + expect(DEFAULT_STREAM_URL).toBe('http://localhost:8969/stream') + expect(parseStreamEndpoint(DEFAULT_STREAM_URL)).toEqual({ + url: DEFAULT_STREAM_URL, + kind: 'loopback', + }) +}) + +test('accepts HTTPS remote streams with a query token', () => { + expect(parseStreamEndpoint('https://receiver.example/stream?token=abc')).toEqual({ + url: 'https://receiver.example/stream?token=abc', + kind: 'remote', + }) +}) + +test.each([ + 'http://receiver.example/stream', + 'https://user:pass@receiver.example/stream', + 'https://receiver.example/other', + 'https://receiver.example/stream#fragment', +])('rejects unsafe endpoint %s', (url) => { + expect(parseStreamEndpoint(url)).toBeUndefined() +}) +~~~ + +- [x] **Step 2: Run the test to verify it fails** + +Run: \`pnpm --filter local exec vitest run src/lib/spotlight.test.ts\` + +Expected: FAIL because the endpoint model exports do not exist. + +- [x] **Step 3: Add normalized parsing and priority** + +~~~ts +export const DEFAULT_STREAM_URL = 'http://localhost:8969/stream' +export const REMOTE_STREAM_STORAGE_KEY = 'sentry.local.remote-stream-url' +export type StreamEndpointKind = 'loopback' | 'remote' +export type StreamEndpoint = { url: string; kind: StreamEndpointKind } + +export function parseStreamEndpoint(value: string | null): StreamEndpoint | undefined { + if (!value) return undefined + try { + const url = new URL(value) + if (url.pathname !== '/stream' || url.username || url.password || url.hash) return undefined + if (isLoopbackHost(url.hostname) && ['http:', 'https:'].includes(url.protocol)) { + return { url: url.toString(), kind: 'loopback' } + } + return url.protocol === 'https:' ? { url: url.toString(), kind: 'remote' } : undefined + } catch { + return undefined + } +} +~~~ + +\`resolveInitialStreamUrl\` must prefer a valid fragment, then saved loopback, then saved remote, then the default. Existing hash and local-storage helpers remain URL-string compatibility wrappers. + +- [x] **Step 4: Add priority assertions and run the pure suite** + +~~~ts +expect(resolveInitialStreamUrl('#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream', null, null)) + .toBe('http://localhost:9000/stream') +expect(resolveInitialStreamUrl('', 'http://127.0.0.1:8970/stream', null)) + .toBe('http://127.0.0.1:8970/stream') +expect(resolveInitialStreamUrl('', null, null)).toBe(DEFAULT_STREAM_URL) +~~~ + +Run: \`pnpm --filter local exec vitest run src/lib/spotlight.test.ts\` + +Expected: PASS, including envelope decoding and bounded-feed coverage. + +- [x] **Step 5: Commit** + +~~~bash +git add apps/local/src/lib/spotlight.ts apps/local/src/lib/spotlight.test.ts +git commit -m "feat(local): Classify custom stream endpoints" +~~~ + +### Task 2: Create the connection landing + +**Files:** +- Create: \`apps/local/src/components/connection-landing.tsx\` +- Modify: \`apps/local/src/App.integration.test.tsx\` + +**Interfaces:** +- Produces: \`ConnectionLanding(props)\`. +- Consumes: \`phase: 'probing' | 'failed' | 'editing'\`, \`endpoint\`, optional \`error\`, and callbacks to edit/submit the endpoint and copy the CLI command. + +- [x] **Step 1: Write failing form interaction tests** + +~~~tsx +renderViewerWithoutSavedStream() +await screen.findByText('Looking for Sentry Local') +await screen.findByRole('alert', { name: 'Could not connect to the default receiver' }) + +const input = screen.getByLabelText('Receiver endpoint') +expect(input.getAttribute('value')).toBe('http://localhost:8969/stream') +fireEvent.change(input, { target: { value: 'http://127.0.0.1:9000/stream' } }) +fireEvent.click(screen.getByRole('button', { name: 'Connect' })) +~~~ + +Also assert one primary heading, a command-copy button, an Advanced disclosure, and readable validation for plain-HTTP remote input. + +- [x] **Step 2: Run the integration test to verify it fails** + +Run: \`pnpm --filter local exec vitest run src/App.integration.test.tsx\` + +Expected: FAIL because the landing, receiver field, and Connect action do not exist. + +- [x] **Step 3: Add the presentational component** + +~~~tsx +export type ConnectionLandingProps = { + phase: 'probing' | 'failed' | 'editing' + endpoint: string + error?: string + onEndpointChange: (value: string) => void + onConnect: () => void + onCopyCommand: () => void +} + +export function ConnectionLanding(props: ConnectionLandingProps) { + return ( +
    +

    Connect a receiver

    + + props.onEndpointChange(event.target.value)} + /> + +
    + ) +} +~~~ + +Use one centered compact panel for probing, failure, and editing. Submit on Enter. The Advanced disclosure explains remote HTTPS and CORS requirements. + +- [x] **Step 4: Rerun the integration test and commit** + +Run: \`pnpm --filter local exec vitest run src/App.integration.test.tsx\` + +Expected: PASS with selected-event, search, filtering, clear-feed, JSON, and waterfall regressions. + +~~~bash +git add apps/local/src/components/connection-landing.tsx apps/local/src/App.integration.test.tsx +git commit -m "feat(local): Add receiver connection landing" +~~~ + +### Task 3: Integrate storage and replaceable EventSource lifecycle + +**Files:** +- Modify: \`apps/local/src/App.tsx\` +- Modify: \`apps/local/src/App.integration.test.tsx\` + +**Interfaces:** +- Consumes: Task 1 endpoint model and Task 2 landing callbacks. +- Produces: EventSource replacement without reload and correct local/session persistence after \`open\`. + +- [x] **Step 1: Write failing lifecycle tests** + +~~~tsx +test('saves a successful custom loopback endpoint for a future bare visit', async () => { + const { port } = await startReceiver() + renderViewerWithoutSavedStream() + submitEndpoint(\`http://127.0.0.1:\${port}/stream\`) + await screen.findByText('Connected to local receiver') + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBe(\`http://127.0.0.1:\${port}/stream\`) +}) + +test('keeps an explicit remote endpoint in session storage only', () => { + renderViewerWithoutSavedStream() + submitEndpoint('https://receiver.example/stream?token=abc') + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY)).toBe( + 'https://receiver.example/stream?token=abc' + ) +}) +~~~ + +Add a failed-default-to-custom-recovery test and a connected-empty-state Change receiver test. + +- [x] **Step 2: Run the integration test to verify it fails** + +Run: \`pnpm --filter local exec vitest run src/App.integration.test.tsx\` + +Expected: FAIL because \`streamUrl\` is immutable and remote persistence does not exist. + +- [x] **Step 3: Make the endpoint state replaceable in \`App.tsx\`** + +~~~tsx +const [streamUrl, setStreamUrl] = useState(() => resolveInitialStreamUrl( + window.location.hash, getSavedLoopbackStream(), getSavedRemoteStream() +)) +const [connectionPhase, setConnectionPhase] = useState<'probing' | 'failed' | 'connected'>('probing') +const [draftEndpoint, setDraftEndpoint] = useState(streamUrl) + +function connectToDraft() { + const endpoint = parseStreamEndpoint(draftEndpoint) + if (!endpoint) return setConnectionError('Enter a loopback stream or an HTTPS remote stream ending in /stream.') + setStreamUrl(endpoint.url) + setConnectionPhase('probing') +} +~~~ + +On EventSource \`open\`, persist by endpoint kind. On \`error\`, retain the input and show the form without clearing feed items. Cleanup closes the old source. Keep fragment removal in its initial effect. Offer Change receiver from the connected empty workspace. + +- [x] **Step 4: Run all app quality gates** + +Run: \`pnpm --filter local exec vitest run && pnpm --filter local run lint && pnpm --filter local run build\` + +Expected: PASS with no lint warnings and a successful Vite build. + +- [x] **Step 5: Run CLI regression tests and manually verify both themes** + +Run: \`pnpm --filter sentry exec vitest run test/commands/local/server.test.ts test/commands/local/run.test.ts test/commands/local/ui.test.ts\` + +Expected: PASS; CLI fragments, hosted read-only CORS, IPv6, and NDJSON behavior remain unchanged. + +Run: \`pnpm --filter local dev\` + +Verify default success, default failure form, custom loopback reload persistence, remote session-only selection, invalid remote feedback, command copy, and landing/workspace readability in light and dark mode. + +- [x] **Step 6: Commit** + +~~~bash +git add apps/local/src/App.tsx apps/local/src/App.integration.test.tsx +git commit -m "feat(local): Connect viewers without CLI pairing" +~~~ diff --git a/docs/superpowers/specs/2026-09-09-local-viewer-connection-design.md b/docs/superpowers/specs/2026-09-09-local-viewer-connection-design.md new file mode 100644 index 000000000..e3cb58933 --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-local-viewer-connection-design.md @@ -0,0 +1,86 @@ +# Local Viewer Connection Landing + +## Goal + +Make a bare `local.sentry.dev` visit useful without weakening the existing +CLI-paired local receiver boundary. The common case should connect to a +running default receiver without a URL fragment; custom local and remote +streams remain an explicit user choice. + +## Connection policy + +The viewer resolves one endpoint in this order: + +1. A valid CLI-provided `#stream` fragment. +2. A saved loopback endpoint from local storage. +3. The default receiver, `http://localhost:8969/stream`. + +The UI removes the fragment from the address bar after reading it. A +successfully connected loopback endpoint is saved in local storage so a later +bare visit can reconnect. The default is saved only after it opens +successfully. + +Users can submit an endpoint through the connection landing page. Loopback +`http` or `https` endpoints with a `/stream` path are accepted and persisted. +Remote endpoints must use `https`, have no embedded user credentials, and are +kept only in session storage. Remote streams connect directly from the browser; +`local.sentry.dev` never proxies them or receives their endpoint. + +Remote servers are responsible for allowing the viewer origin with CORS. This +MVP does not support custom authorization headers because native `EventSource` +does not provide them. A user may paste an HTTPS stream URL with its own query +parameters when their receiver uses a URL-scoped token. + +## User experience + +When no receiver has opened yet, render one centered connection landing panel +instead of separate setup and empty-feed surfaces. + +- Start by showing that the default receiver is being checked. +- When it opens, move directly to the normal event workspace and its + `Waiting for events` state. +- When it cannot open, reveal a prefilled endpoint field, concise error text, + a Connect button, and a copyable `sentry local serve --open` command. +- An Advanced disclosure explains custom local versus remote endpoints and the + remote HTTPS requirement. +- A connected, empty workspace still offers a small Change receiver action, + rather than forcing users to edit the browser URL. + +The landing form has a visible label, validation feedback, keyboard submission, +and equivalent light and dark presentation. + +## Components and boundaries + +`apps/local/src/lib/spotlight.ts` owns endpoint parsing and classification. It +returns a normalized endpoint and persistence policy (`loopback` or `remote`) +without knowing about React or storage. + +`App.tsx` owns resolution order, storage reads/writes, EventSource lifecycle, +and the state transition between connecting, connected, and failed states. +The connection landing component is presentational: it receives the input, +status, validation message, and callbacks. It does not create EventSources or +access storage directly. + +The local CLI and receiver remain unchanged. Their existing `--open` fragment +continues to be the authoritative pairing path, and receiver CORS remains +limited to its existing read-only `/stream` policy for `local.sentry.dev`. + +## Failure handling + +Malformed URLs, non-loopback HTTP remote endpoints, credential-bearing URLs, +and paths other than `/stream` are rejected before a connection attempt. A +failed EventSource displays actionable status while retaining the entered URL. +Submitting another endpoint closes the prior EventSource before opening the +next one. Clearing the feed does not discard a selected receiver. + +## Verification + +- Pure tests cover source priority, loopback/remote classification, malformed + endpoint rejection, persistence policy, and query-token preservation. +- Integration tests cover default connection, fallback form, manual local + reconnection after reload, remote session-only behavior, error recovery, and + clean EventSource replacement. +- Run the local app test suite, lint, and production build, plus the focused + CLI local receiver tests. +- Manually verify the no-receiver, default-receiver, custom-receiver, and + failed-receiver views in both light and dark themes. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4b8b785ba..f1a71fb6a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -137,6 +137,10 @@ sentry log list --query "severity:error" # SDK sends to both. sentry local run -- npm run dev # or: python manage.py runserver, etc. +# From a CLI source checkout, run the Local UI in a second terminal, then open it. +pnpm --filter local dev +sentry local run --open -- npm run dev + # Watch only AI/agent (gen_ai, mcp) spans while iterating on an agent. sentry local -f ai diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index 86d030075..ff547b5ce 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -22,6 +22,7 @@ Start the local dev server and tail events - `-f, --filter ... - Only show items of this type (repeatable: error, transaction, log, ai)` - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Show a grouped attribute table (user vs SDK) under each transaction` +- `--open - Open Sentry Local UI in the browser` ### `sentry local run ` @@ -35,6 +36,7 @@ Run a command with the local dev server enabled - `-t, --timeout - Kill the child after N seconds (0 = no timeout; defaults to 30 s in --verify mode) - (default: "0")` - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Include selected event attributes in output` +- `--open - Open Sentry Local UI in the browser` **Examples:** diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index b2561b0ba..4e09c5fcd 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -44,6 +44,11 @@ import { parsePort, tryListen, } from "./server.js"; +import { + formatLocalServerUrl, + openLocalUiIfRequested, + validateOpenHost, +} from "./ui.js"; type RunFlags = { readonly port: number; @@ -53,6 +58,7 @@ type RunFlags = { readonly filter?: readonly FilterValue[]; readonly format: FormatValue; readonly attributes: boolean; + readonly open: boolean; }; /** Buffer size for the auto-started background server. */ @@ -197,7 +203,7 @@ async function startBackgroundServer({ const buffer = createSpotlightBuffer(BUFFER_SIZE); const app = buildApp(buffer); const { server, port: boundPort } = await tryListen(app, port, host); - const url = `http://${host}:${boundPort}`; + const url = formatLocalServerUrl(host, boundPort); const subscriptionId = buffer.subscribe((container) => { try { @@ -242,7 +248,7 @@ async function openEventTail({ useJson, showAttributes, }: EventTailOptions): Promise { - const url = `http://${host}:${port}`; + const url = formatLocalServerUrl(host, port); if (await isServerRunning(url)) { logger.info(`Connected to existing server at ${bold(url)}`); @@ -349,7 +355,8 @@ export const runCommand = buildCommand({ "SENTRY_SPOTLIGHT (server-side SDKs read this automatically), the\n" + "framework-prefixed client variants (NEXT_PUBLIC_, VITE_, etc.), and\n" + "SENTRY_TRACES_SAMPLE_RATE=1. Use --format json to stream versioned\n" + - "NDJSON observations to stdout for agents.\n\n" + + "NDJSON observations to stdout for agents. Use --open to launch the\n" + + "Sentry Local UI.\n\n" + "Example:\n" + " sentry local run -- npm run dev\n" + " sentry local run -- python manage.py runserver", @@ -407,6 +414,11 @@ export const runCommand = buildCommand({ brief: "Include selected event attributes in output", default: false, }, + open: { + kind: "boolean", + brief: "Open Sentry Local UI in the browser", + default: false, + }, }, aliases: { p: "port", @@ -419,6 +431,11 @@ export const runCommand = buildCommand({ }, auth: false, async *func(this: SentryContext, flags: RunFlags, ...rawArgs: string[]) { + if (flags.open && flags.verify) { + throw new ValidationError("--open cannot be used with --verify.", "open"); + } + validateOpenHost(flags.open, flags.host); + const stripped = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; const { args, commandSource } = await resolveArgs(stripped, this.cwd); @@ -427,7 +444,7 @@ export const runCommand = buildCommand({ return; } - let url = `http://${flags.host}:${flags.port}`; + let url = formatLocalServerUrl(flags.host, flags.port); const useJson = flags.format === "json"; const activeFilters = new Set(flags.filter); @@ -440,6 +457,8 @@ export const runCommand = buildCommand({ }); url = tail.url; + await openLocalUiIfRequested(flags.open, url); + const spotlightUrl = `${url}/stream`; const wrangler = await injectWranglerSpotlightBinding( args, @@ -575,7 +594,7 @@ async function* runWithVerify( flags.port, flags.host ); - const url = `http://${flags.host}:${boundPort}`; + const url = formatLocalServerUrl(flags.host, boundPort); logger.info(`Verify server listening on ${bold(url)}`); const spotlightUrl = `${url}/stream`; diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index cb3960488..3c23f3c58 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -37,6 +37,11 @@ import { SENTRY_CONTENT_TYPE, } from "../../lib/formatters/local.js"; import { logger, printJsonLine, printLine } from "../../lib/logger.js"; +import { + formatLocalServerUrl, + openLocalUiIfRequested, + validateOpenHost, +} from "./ui.js"; /** Default port for the local dev server. */ export const DEFAULT_PORT = 8969; @@ -99,6 +104,7 @@ type LocalFlags = { readonly filter: FilterValue[]; readonly format: FormatValue; readonly attributes: boolean; + readonly open: boolean; }; /** @@ -121,6 +127,26 @@ export function parsePort(value: string): number { /** Match localhost origins on any port (http or https), including IPv6. */ const LOCALHOST_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; +const LOCAL_UI_ORIGIN = "https://local.sentry.dev"; + +function isHostedUiStreamRequest(request: { + method: string; + path: string; + header: (name: string) => string | undefined; +}): boolean { + if ( + request.header("origin") !== LOCAL_UI_ORIGIN || + request.path !== "/stream" + ) { + return false; + } + + return ( + request.method === "GET" || + (request.method === "OPTIONS" && + request.header("access-control-request-method") === "GET") + ); +} /** * Build the Hono application. @@ -193,14 +219,60 @@ export function buildApp( await next(); }); - app.use( - "*", - cors({ - origin: (origin) => (LOCALHOST_ORIGIN_RE.test(origin) ? origin : null), - allowMethods: ["GET", "POST", "OPTIONS"], - allowHeaders: ["Content-Type", "Content-Encoding", "User-Agent"], - }) - ); + const localhostCors = cors({ + origin: (origin) => (LOCALHOST_ORIGIN_RE.test(origin) ? origin : null), + allowMethods: ["GET", "POST", "OPTIONS"], + allowHeaders: [ + "Content-Type", + "Content-Encoding", + "User-Agent", + "Last-Event-ID", + ], + }); + const hostedStreamCors = cors({ + origin: LOCAL_UI_ORIGIN, + allowMethods: ["GET", "OPTIONS"], + allowHeaders: ["Last-Event-ID"], + }); + + app.use("*", async (c, next) => { + const hostedUiOrigin = c.req.header("origin") === LOCAL_UI_ORIGIN; + const hostedStreamRequest = isHostedUiStreamRequest(c.req); + + // The hosted UI may only read the event stream. CORS alone cannot stop a + // simple cross-origin POST, so reject any other hosted-origin request. + if (hostedUiOrigin && !hostedStreamRequest) { + return c.body(null, 403); + } + + if (!hostedStreamRequest) { + return localhostCors(c, next); + } + + if (c.req.method === "OPTIONS") { + const isPrivateNetworkRequest = + c.req.header("access-control-request-private-network") === "true"; + c.header("Access-Control-Allow-Origin", LOCAL_UI_ORIGIN); + c.header("Access-Control-Allow-Methods", "GET, OPTIONS"); + c.header("Access-Control-Allow-Headers", "Last-Event-ID"); + if (isPrivateNetworkRequest) { + c.header("Access-Control-Allow-Private-Network", "true"); + } + c.header( + "Vary", + [ + "Origin", + "Access-Control-Request-Headers", + ...(isPrivateNetworkRequest + ? ["Access-Control-Request-Private-Network"] + : []), + ].join(", ") + ); + return c.body(null, 204); + } + + return await hostedStreamCors(c, next); + }); app.get("/health", (c) => c.text("OK")); @@ -721,7 +793,7 @@ export const serverCommand = buildCommand({ "Sentry SDKs in your dev stack and tails them to the terminal.\n\n" + "If a server is already listening on the port, the command connects\n" + "as an SSE consumer and tails events from it. Otherwise it starts\n" + - "its own server.\n\n" + + "its own server. Use --open to launch the Sentry Local UI.\n\n" + "Press Ctrl-C to stop.", }, parameters: { @@ -763,6 +835,11 @@ export const serverCommand = buildCommand({ "Show a grouped attribute table (user vs SDK) under each transaction", default: false, }, + open: { + kind: "boolean", + brief: "Open Sentry Local UI in the browser", + default: false, + }, }, aliases: { p: "port", @@ -775,11 +852,13 @@ export const serverCommand = buildCommand({ }, auth: false, async *func(this: SentryContext, flags: LocalFlags) { + validateOpenHost(flags.open, flags.host); const activeFilters = new Set(flags.filter); - const url = `http://${flags.host}:${flags.port}`; + const url = formatLocalServerUrl(flags.host, flags.port); if (await isServerRunning(url)) { logger.info(`Connected to existing server at ${bold(url)}`); + await openLocalUiIfRequested(flags.open, url); if (activeFilters.size > 0) { logger.info(`Filtering: ${[...activeFilters].join(", ")}`); } @@ -841,7 +920,7 @@ export const serverCommand = buildCommand({ flags.host ); - const listenUrl = `http://${flags.host}:${boundPort}`; + const listenUrl = formatLocalServerUrl(flags.host, boundPort); logger.info("Sentry Local Dev Server"); logger.info(` Ingest: ${bold(`${listenUrl}/stream`)}`); logger.info(` Events: ${bold(`${listenUrl}/stream`)} (SSE)`); @@ -861,6 +940,8 @@ export const serverCommand = buildCommand({ logger.info(""); logger.info("Press Ctrl-C to stop."); + await openLocalUiIfRequested(flags.open, listenUrl); + await waitForShutdown(server); logger.log("Server stopped."); }, diff --git a/packages/cli/src/commands/local/ui.ts b/packages/cli/src/commands/local/ui.ts new file mode 100644 index 000000000..dc36b5817 --- /dev/null +++ b/packages/cli/src/commands/local/ui.ts @@ -0,0 +1,84 @@ +import { openBrowser } from "../../lib/browser.js"; +import { getCliEnvironment } from "../../lib/constants.js"; +import { ValidationError } from "../../lib/errors.js"; +import { logger } from "../../lib/logger.js"; + +const DEVELOPMENT_UI_URL = "http://localhost:5173"; +const PRODUCTION_UI_URL = "https://local.sentry.dev"; + +function normalizeHostname(host: string): string { + return host.replace(/^\[|\]$/g, "").toLowerCase(); +} + +/** Whether a host is safe for the browser-only local UI connection. */ +export function isLoopbackHost(host: string): boolean { + return ["localhost", "127.0.0.1", "::1"].includes(normalizeHostname(host)); +} + +/** Reject a host that the browser-only UI cannot safely reach. */ +export function assertLoopbackHostForUi(host: string): void { + if (!isLoopbackHost(host)) { + throw new ValidationError( + "--open requires a loopback --host (localhost, 127.0.0.1, or ::1).", + "host" + ); + } +} + +/** Validate an --open host before the caller creates or attaches a receiver. */ +export function validateOpenHost(requested: boolean, host: string): void { + if (requested) { + assertLoopbackHostForUi(host); + } +} + +/** Format an HTTP origin, including the brackets required for IPv6 URLs. */ +export function formatLocalServerUrl(host: string, port: number): string { + const normalized = normalizeHostname(host); + const urlHost = normalized.includes(":") ? `[${normalized}]` : normalized; + return `http://${urlHost}:${port}`; +} + +/** Pick the independently served local UI URL for this CLI build. */ +export function getLocalUiBaseUrl( + environment: string = getCliEnvironment() +): string { + return environment === "development" ? DEVELOPMENT_UI_URL : PRODUCTION_UI_URL; +} + +/** Build the browser URL without exposing the local stream to the UI host. */ +export function buildLocalUiUrl(receiverUrl: string): string { + const receiver = new URL(receiverUrl); + assertLoopbackHostForUi(receiver.hostname); + + const streamUrl = new URL("/stream", receiver).toString(); + const uiUrl = new URL(getLocalUiBaseUrl()); + uiUrl.hash = new URLSearchParams({ stream: streamUrl }).toString(); + return uiUrl.toString(); +} + +/** Open the independently served local UI without affecting capture on failure. */ +export async function openLocalUi(receiverUrl: string): Promise { + const uiUrl = buildLocalUiUrl(receiverUrl); + let opened = false; + try { + opened = await openBrowser(uiUrl); + } catch (error) { + logger.debug("Could not open Sentry Local UI", error); + } + if (opened) { + logger.info("Opening Sentry Local UI..."); + return; + } + logger.warn(`Could not open browser. Visit ${uiUrl}`); +} + +/** Launch the UI only when the caller explicitly requested it. */ +export async function openLocalUiIfRequested( + requested: boolean, + receiverUrl: string +): Promise { + if (requested) { + await openLocalUi(receiverUrl); + } +} diff --git a/packages/cli/test/commands/local/run.test.ts b/packages/cli/test/commands/local/run.test.ts index bb61ad4e2..b73610bc7 100644 --- a/packages/cli/test/commands/local/run.test.ts +++ b/packages/cli/test/commands/local/run.test.ts @@ -16,6 +16,7 @@ import { shutdownServer, } from "../../../src/commands/local/run.js"; import { buildApp, tryListen } from "../../../src/commands/local/server.js"; +import { openBrowser } from "../../../src/lib/browser.js"; import { CliError, ValidationError } from "../../../src/lib/errors.js"; import { SENTRY_CONTENT_TYPE } from "../../../src/lib/formatters/local.js"; import { TEST_TMP_DIR } from "../../constants.js"; @@ -51,6 +52,10 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +vi.mock("../../../src/lib/browser.js", () => ({ + openBrowser: vi.fn().mockResolvedValue(true), +})); + type RunFunc = ( this: unknown, flags: { @@ -61,6 +66,7 @@ type RunFunc = ( format?: "human" | "json"; attributes?: boolean; filter?: ("error" | "transaction" | "log" | "ai")[]; + open?: boolean; }, ...args: string[] ) => Promise; @@ -161,6 +167,73 @@ describe("sentry local run", () => { ); }); + test("opens the development UI with the bound receiver stream", async () => { + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockClear(); + const func = (await runCommand.loader()) as unknown as RunFunc; + + await func.call( + makeContext(), + { + port: 0, + host: "127.0.0.1", + verify: false, + timeout: 0, + open: true, + }, + "true" + ); + + expect(openBrowserMock).toHaveBeenCalledWith( + expect.stringMatching( + /^http:\/\/localhost:5173\/#stream=http%3A%2F%2F127\.0\.0\.1%3A\d+%2Fstream$/ + ) + ); + }); + + test("rejects --open with --verify before starting a receiver", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + + await expect( + func.call( + makeContext(), + { + port: 0, + host: "localhost", + verify: true, + timeout: 0, + open: true, + }, + "true" + ) + ).rejects.toMatchObject({ + field: "open", + message: "--open cannot be used with --verify.", + }); + }); + + test("rejects a non-loopback --open host before starting a receiver", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + + await expect( + func.call( + makeContext(), + { + port: 0, + host: "0.0.0.0", + verify: false, + timeout: 0, + open: true, + }, + "true" + ) + ).rejects.toMatchObject({ + field: "host", + message: + "--open requires a loopback --host (localhost, 127.0.0.1, or ::1).", + }); + }); + test("injects SENTRY_SPOTLIGHT as a Wrangler Worker binding", async () => { await writeFile(join(tmpDir, "wrangler.jsonc"), "{}"); const fakeWrangler = join(tmpDir, "wrangler"); diff --git a/packages/cli/test/commands/local/server.test.ts b/packages/cli/test/commands/local/server.test.ts index 0d17b431d..870b98a60 100644 --- a/packages/cli/test/commands/local/server.test.ts +++ b/packages/cli/test/commands/local/server.test.ts @@ -7,18 +7,76 @@ import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; import { Hono } from "hono"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { buildApp, feedSSELine, isServerRunning, parsePort, SERVER_IDENTIFIER, + serverCommand, tryListen, } from "../../../src/commands/local/server.js"; +import { openBrowser } from "../../../src/lib/browser.js"; import { ValidationError } from "../../../src/lib/errors.js"; import { SENTRY_CONTENT_TYPE } from "../../../src/lib/formatters/local.js"; +vi.mock("../../../src/lib/browser.js", () => ({ + openBrowser: vi.fn().mockResolvedValue(true), +})); + +type ServeFunc = ( + this: object, + flags: { + port: number; + host: string; + quiet: boolean; + filter: ("error" | "transaction" | "log" | "ai")[]; + format: "human" | "json"; + attributes: boolean; + open: boolean; + } +) => Promise; + +function interceptShutdownSignal() { + let handler: (() => void) | undefined; + const originalOn = process.on.bind(process); + const originalOnce = process.once.bind(process); + const onSpy = vi.spyOn(process, "on").mockImplementation((( + event: string, + listener: () => void + ) => { + if (event === "SIGINT") { + handler = listener; + return process; + } + return originalOn(event, listener); + }) as typeof process.on); + const onceSpy = vi.spyOn(process, "once").mockImplementation((( + event: string, + listener: () => void + ) => { + if (event === "SIGINT") { + handler = listener; + return process; + } + return originalOnce(event, listener); + }) as typeof process.once); + + return { + trigger() { + if (!handler) { + throw new Error("Expected a SIGINT handler"); + } + handler(); + }, + restore() { + onSpy.mockRestore(); + onceSpy.mockRestore(); + }, + }; +} + describe("parsePort", () => { test("parses valid port numbers", () => { expect(parsePort("8969")).toBe(8969); @@ -43,6 +101,73 @@ describe("parsePort", () => { }); }); +describe("sentry local serve --open", () => { + const flags = { + host: "127.0.0.1", + quiet: true, + filter: [], + format: "human" as const, + attributes: false, + open: true, + }; + + test("opens the UI after starting an owned receiver", async () => { + const signal = interceptShutdownSignal(); + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockClear(); + const func = (await serverCommand.loader()) as unknown as ServeFunc; + + try { + const command = func.call({}, { ...flags, port: 0 }); + await vi.waitFor(() => expect(openBrowserMock).toHaveBeenCalledTimes(1)); + signal.trigger(); + await command; + } finally { + signal.restore(); + } + + expect(openBrowserMock).toHaveBeenCalledWith( + expect.stringMatching( + /^http:\/\/localhost:5173\/#stream=http%3A%2F%2F127\.0\.0\.1%3A\d+%2Fstream$/ + ) + ); + }); + + test("opens the UI when attaching to an existing receiver", async () => { + const signal = interceptShutdownSignal(); + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockClear(); + const savedFetch = globalThis.fetch; + const realFetch = (globalThis as { __originalFetch?: typeof fetch }) + .__originalFetch; + if (!realFetch) { + throw new Error("Expected the test preload to retain the native fetch"); + } + globalThis.fetch = realFetch; + const { server, port } = await tryListen( + buildApp(createSpotlightBuffer(10)), + 0, + "127.0.0.1" + ); + const func = (await serverCommand.loader()) as unknown as ServeFunc; + + try { + const command = func.call({}, { ...flags, port }); + await vi.waitFor(() => expect(openBrowserMock).toHaveBeenCalledTimes(1)); + signal.trigger(); + await command; + } finally { + signal.restore(); + globalThis.fetch = savedFetch; + await new Promise((resolve) => server.close(() => resolve())); + } + + expect(openBrowserMock).toHaveBeenCalledWith( + `http://localhost:5173/#stream=http%3A%2F%2F127.0.0.1%3A${port}%2Fstream` + ); + }); +}); + describe("feedSSELine", () => { function makeState() { return { eventType: "", dataLines: [], id: "" }; @@ -257,6 +382,71 @@ describe("buildApp", () => { ); }); + test("CORS allows the hosted local UI to read the event stream", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/stream", { + headers: { Origin: "https://local.sentry.dev" }, + }); + expect(res.headers.get("access-control-allow-origin")).toBe( + "https://local.sentry.dev" + ); + if (res.body) { + await res.body.cancel(); + } + }); + + test("CORS permits the SSE resume header from the hosted local UI", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/stream", { + method: "OPTIONS", + headers: { + Origin: "https://local.sentry.dev", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "Last-Event-ID", + "Access-Control-Request-Private-Network": "true", + }, + }); + expect(res.headers.get("access-control-allow-headers")).toContain( + "Last-Event-ID" + ); + expect(res.headers.get("access-control-allow-private-network")).toBe( + "true" + ); + expect(res.headers.get("vary")).toContain( + "Access-Control-Request-Private-Network" + ); + }); + + test("rejects hosted-origin writes to the local receiver", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/stream", { + method: "POST", + headers: { + Origin: "https://local.sentry.dev", + "Content-Type": SENTRY_CONTENT_TYPE, + }, + body: '{"type":"event"}\n{}', + }); + expect(res.status).toBe(403); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); + + test("CORS blocks lookalike hosted UI origins", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/health", { + headers: { Origin: "https://not-local.sentry.dev" }, + }); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); + test("CORS blocks non-localhost origins", async () => { const buffer = createSpotlightBuffer(10); const app = buildApp(buffer); diff --git a/packages/cli/test/commands/local/ui.test.ts b/packages/cli/test/commands/local/ui.test.ts new file mode 100644 index 000000000..6c41a1983 --- /dev/null +++ b/packages/cli/test/commands/local/ui.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test, vi } from "vitest"; +import { + assertLoopbackHostForUi, + buildLocalUiUrl, + formatLocalServerUrl, + getLocalUiBaseUrl, + openLocalUi, +} from "../../../src/commands/local/ui.js"; +import { openBrowser } from "../../../src/lib/browser.js"; + +vi.mock("../../../src/lib/browser.js", () => ({ + openBrowser: vi.fn(), +})); + +describe("local UI URLs", () => { + test("uses the Vite UI in development", () => { + expect(getLocalUiBaseUrl("development")).toBe("http://localhost:5173"); + }); + + test("uses the hosted UI outside development", () => { + expect(getLocalUiBaseUrl("production")).toBe("https://local.sentry.dev"); + expect(getLocalUiBaseUrl("nightly")).toBe("https://local.sentry.dev"); + }); + + test("formats IPv6 loopback URLs correctly", () => { + expect(formatLocalServerUrl("::1", 8969)).toBe("http://[::1]:8969"); + expect(buildLocalUiUrl("http://[::1]:8969")).toBe( + "http://localhost:5173/#stream=http%3A%2F%2F%5B%3A%3A1%5D%3A8969%2Fstream" + ); + }); + + test("refuses to send the browser to a non-loopback receiver", () => { + expect(() => buildLocalUiUrl("http://example.com:8969")).toThrow( + "--open requires a loopback --host" + ); + }); + + test("validates an --open host before a receiver is started", () => { + expect(() => assertLoopbackHostForUi("0.0.0.0")).toThrow( + "--open requires a loopback --host" + ); + }); + + test("does not fail capture when the browser cannot be opened", async () => { + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockResolvedValueOnce(false); + + await expect(openLocalUi("http://localhost:8969")).resolves.toBeUndefined(); + }); + + test("does not fail capture when browser launch rejects unexpectedly", async () => { + const openBrowserMock = vi.mocked(openBrowser); + openBrowserMock.mockRejectedValueOnce(new Error("browser unavailable")); + + await expect(openLocalUi("http://localhost:8969")).resolves.toBeUndefined(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08a9717ba..9ec4f7393 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,13 +38,13 @@ importers: dependencies: '@astrojs/starlight': specifier: ^0.41.5 - version: 0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3) + version: 0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3) '@sentry/astro': specifier: ^10.38.0 - version: 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0)) + version: 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) '@sentry/starlight-theme': specifier: ^0.8.0 - version: 0.8.1(@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3)) + version: 0.8.1(@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3)) '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(react@19.2.8) @@ -53,7 +53,7 @@ importers: version: 2.0.0(react@19.2.8) astro: specifier: ^7.1.1 - version: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) + version: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) sharp: specifier: ^0.35.0 version: 0.35.3(@types/node@24.13.3) @@ -65,6 +65,88 @@ importers: specifier: ^4.118.0 version: 4.118.0 + apps/local: + dependencies: + '@shikijs/core': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/engine-javascript': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/langs': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/themes': + specifier: ^3.23.0 + version: 3.23.0 + '@radix-ui/react-slot': + specifier: ^1.3.3 + version: 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^1.43.0 + version: 1.43.0(react@19.2.8) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + devDependencies: + '@spotlightjs/spotlight': + specifier: ^4.11.7 + version: 4.11.8(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(hono-rate-limiter@0.4.2(hono@4.12.34)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.1.0 + version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + eventsource: + specifier: ^4.1.0 + version: 4.1.0 + jsdom: + specifier: ^28.1.0 + version: 28.1.0 + oxlint: + specifier: ^1.79.0 + version: 1.82.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.2.2 + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + vitest: + specifier: ^4.1.9 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + packages/cli: devDependencies: '@anthropic-ai/sdk': @@ -234,7 +316,7 @@ importers: version: 1.4.2(typescript@5.9.3) vitest: specifier: ^4.1.9 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) wrap-ansi: specifier: ^10.0.0 version: 10.0.0 @@ -256,6 +338,9 @@ packages: express: optional: true + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@ai-sdk/provider-utils@2.2.8': resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} engines: {node: '>=18'} @@ -331,6 +416,20 @@ packages: '@apm-js-collab/tracing-hooks@0.13.0': resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@astrojs/compiler-binding-darwin-arm64@0.3.2': resolution: {integrity: sha512-MM8tn8CSimcfytaOla4b6acN8mKWiL/rlAA1fpT3/Wl7dNGSE4y8FjTN/zJVNnb63CsLWG5zZwCt01TXtDKh9g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -486,6 +585,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -555,6 +658,10 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bruits/satteri-darwin-arm64@0.9.5': resolution: {integrity: sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow==} cpu: [arm64] @@ -665,6 +772,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@ctrl/tinycolor@4.2.0': resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} @@ -846,6 +989,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@expressive-code/core@0.44.1': resolution: {integrity: sha512-3dDo9N8D7hYrLNNMMWFovg3+aDUtnQm7c7z0GZc1c0LEFVBc0Q6lKG+tVT28gDadOvsgOANfCn35fgpe97Pmgg==} @@ -1299,6 +1451,123 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + + '@oxlint/binding-android-arm-eabi@1.82.0': + resolution: {integrity: sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.82.0': + resolution: {integrity: sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.82.0': + resolution: {integrity: sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.82.0': + resolution: {integrity: sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.82.0': + resolution: {integrity: sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + resolution: {integrity: sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + resolution: {integrity: sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.82.0': + resolution: {integrity: sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.82.0': + resolution: {integrity: sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + resolution: {integrity: sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + resolution: {integrity: sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.82.0': + resolution: {integrity: sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.82.0': + resolution: {integrity: sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.82.0': + resolution: {integrity: sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.82.0': + resolution: {integrity: sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.82.0': + resolution: {integrity: sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.82.0': + resolution: {integrity: sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.82.0': + resolution: {integrity: sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.82.0': + resolution: {integrity: sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@pagefind/darwin-arm64@1.5.2': resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} cpu: [arm64] @@ -1356,78 +1625,174 @@ packages: '@posthog/types@1.399.0': resolution: {integrity: sha512-/WDwBzqIPko8VJ1B+0rlso2XQEz9+2sqtsY9Tqy3p1GhgTqsFakcz/PmMpAnA321LTEZVRcO6x5hAwABV4yrDw==} + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.3': resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.3': resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.3': resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.3': resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.3': resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1439,12 +1804,24 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.3': resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -1796,6 +2173,115 @@ packages: '@stricli/core@1.3.0': resolution: {integrity: sha512-LnBe2HntygaLDU5trtjiC3J4C/YkmIZuM0XB52IF4qaLqJH09kgD1fnZiS2gaYUnt0nAN2a+1b4PSOBcfPrN4Q==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: 8.0.16 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@trpc/server@11.18.0': resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==} hasBin: true @@ -1805,6 +2291,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1862,6 +2351,11 @@ packages: '@types/qrcode-terminal@0.12.2': resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} @@ -1935,6 +2429,22 @@ packages: vue-router: optional: true + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: 8.0.16 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + '@vitest/coverage-v8@4.1.10': resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} peerDependencies: @@ -2001,6 +2511,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -2039,6 +2553,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2059,6 +2577,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2166,6 +2687,9 @@ packages: bcp-47@2.1.1: resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + binpatch@0.4.2: resolution: {integrity: sha512-aShVdFVzDrqVRlyhDQabzKV6b9suWegARr0IPTer58eNQoo1KqpuCr+VtbwwuirmfXqmWuuxsoZlXJN1tTZBcw==} engines: {node: '>=22.15.0'} @@ -2268,6 +2792,9 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-boxes@4.0.1: resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} engines: {node: '>=18.20 <19 || >=20.10'} @@ -2417,9 +2944,17 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2429,6 +2964,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2472,6 +3010,9 @@ packages: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2510,6 +3051,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2518,6 +3063,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -2804,6 +3353,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemesplit@2.6.0: resolution: {integrity: sha512-rG9w2wAfkpg0DILa1pjnjNfucng3usON360shisqIMUBw/87pojcBSrHmeE4UwryAuBih7g8m1oilf5/u8EWdQ==} @@ -2902,6 +3454,10 @@ packages: resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2921,10 +3477,18 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -3039,6 +3603,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3065,6 +3632,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -3088,6 +3659,15 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3129,72 +3709,142 @@ packages: launch-editor@2.14.1: resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-arm64@1.33.0: resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-darwin-x64@1.33.0: resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-freebsd-x64@1.33.0: resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-arm64-gnu@1.33.0: resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss-win32-x64-msvc@1.33.0: resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} @@ -3217,6 +3867,15 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.43.0: + resolution: {integrity: sha512-ubtnda1fVK5ky0PNEpOmB0wiwhpZUyJiol4K14KCm+QKvuCkfUu54Et/rIjyupJpwiJ5Hg+BLQE86/GEsS+IgQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + macho-unsign@2.0.6: resolution: {integrity: sha512-YkIVGFnpVHJMMwfy4bHo79Vy05ddVk/PZGSCmmiCT4zepx+FMP/JAt9hOoXuc31s2bbcOtnzznOGca5fRhgZOg==} engines: {node: '>=18.12.0'} @@ -3500,6 +4159,12 @@ packages: resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} engines: {node: '>= 10'} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} @@ -3585,6 +4250,19 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + oxlint@1.82.0: + resolution: {integrity: sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3649,6 +4327,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3734,6 +4415,10 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + posthog-node@5.47.2: resolution: {integrity: sha512-u62sg9bAE+GO8NUzBnE0+PMgelTOoCfvFwcMvUK/zxvZLO8A25j3zbjXxJo2iBC3Cs6zzuJXENsFtsf58pk+tg==} engines: {node: ^20.20.0 || >=22.22.0} @@ -3748,6 +4433,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -3774,6 +4463,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} @@ -3799,6 +4492,14 @@ packages: react-devtools-core@7.0.1: resolution: {integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} @@ -3922,6 +4623,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -3936,6 +4642,10 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4139,10 +4849,23 @@ packages: engines: {node: '>=16'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -4188,6 +4911,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -4195,9 +4925,17 @@ packages: tokenx@1.6.0: resolution: {integrity: sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4250,6 +4988,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -4473,6 +5216,49 @@ packages: yaml: optional: true + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: 0.28.1 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@1.1.3: resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: @@ -4522,6 +5308,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -4532,6 +5322,18 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -4611,6 +5413,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -4695,6 +5504,8 @@ snapshots: optionalDependencies: express: 5.2.1 + '@acemir/cssom@0.9.31': {} + '@ai-sdk/provider-utils@2.2.8(zod@4.4.3)': dependencies: '@ai-sdk/provider': 1.1.3 @@ -4812,6 +5623,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@astrojs/compiler-binding-darwin-arm64@0.3.2': optional: true @@ -4907,13 +5738,13 @@ snapshots: hast-util-from-html: 2.0.3 satteri: 0.9.5 - '@astrojs/mdx@7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))': + '@astrojs/mdx@7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.2 '@astrojs/markdown-remark': 7.2.2 '@mdx-js/mdx': 3.1.1 acorn: 8.17.0 - astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) + astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) es-module-lexer: 2.3.1 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -4939,23 +5770,23 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.4.3 - '@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3)': + '@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3)': dependencies: '@astrojs/markdown-satteri': 0.3.5 - '@astrojs/mdx': 7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0)) + '@astrojs/mdx': 7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.5.2 '@types/hast': 3.0.5 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) - astro-expressive-code: 0.44.1(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0)) + astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + astro-expressive-code: 0.44.1(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) bcp-47: 2.1.1 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 hast-util-to-string: 3.0.1 hastscript: 9.0.1 - i18next: 26.3.6(typescript@5.9.3) + i18next: 26.3.6(typescript@6.0.3) js-yaml: 4.3.1 klona: 2.0.6 magic-string: 0.30.21 @@ -5061,6 +5892,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -5121,6 +5954,10 @@ snapshots: '@biomejs/cli-win32-x64@2.3.8': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bruits/satteri-darwin-arm64@0.9.5': optional: true @@ -5206,6 +6043,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@ctrl/tinycolor@4.2.0': {} '@emnapi/core@1.10.0': @@ -5323,6 +6184,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.15.1': {} + '@expressive-code/core@0.44.1': dependencies: '@ctrl/tinycolor': 4.2.0 @@ -5821,6 +6684,65 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.148.0': {} + + '@oxlint/binding-android-arm-eabi@1.82.0': + optional: true + + '@oxlint/binding-android-arm64@1.82.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.82.0': + optional: true + + '@oxlint/binding-darwin-x64@1.82.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.82.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.82.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.82.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.82.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.82.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.82.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.82.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.82.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.82.0': + optional: true + '@pagefind/darwin-arm64@1.5.2': optional: true @@ -5860,48 +6782,100 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@posthog/core@1.46.1': + '@posthog/core@1.46.1': + dependencies: + '@posthog/types': 1.399.0 + + '@posthog/types@1.399.0': {} + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@posthog/types': 1.399.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 - '@posthog/types@1.399.0': {} + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true '@rolldown/binding-android-arm64@1.0.3': optional: true + '@rolldown/binding-android-arm64@1.2.7': + optional: true + '@rolldown/binding-darwin-arm64@1.0.3': optional: true + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + '@rolldown/binding-darwin-x64@1.0.3': optional: true + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + '@rolldown/binding-freebsd-x64@1.0.3': optional: true + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.3': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.3': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.3': optional: true + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.3': optional: true + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.3': dependencies: '@emnapi/core': 1.10.0 @@ -5912,9 +6886,15 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.3': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + '@rolldown/pluginutils@1.0.1': {} '@rollup/pluginutils@5.4.0': @@ -5930,14 +6910,14 @@ snapshots: valibot: 1.4.2(typescript@5.9.3) zod: 4.4.3 - '@sentry/astro@10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))': + '@sentry/astro@10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': dependencies: '@sentry/browser': 10.69.0 '@sentry/conventions': 0.16.0 '@sentry/core': 10.69.0 '@sentry/node': 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) '@sentry/vite-plugin': 5.4.0 - astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) + astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) transitivePeerDependencies: - '@opentelemetry/core' - '@opentelemetry/exporter-trace-otlp-http' @@ -6164,9 +7144,9 @@ snapshots: optionalDependencies: react: 19.2.8 - '@sentry/starlight-theme@0.8.1(@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3))': + '@sentry/starlight-theme@0.8.1(@astrojs/starlight@0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3))': dependencies: - '@astrojs/starlight': 0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0))(typescript@5.9.3) + '@astrojs/starlight': 0.41.6(@astrojs/markdown-remark@7.2.2)(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))(typescript@6.0.3) '@fontsource-variable/rubik': 5.2.8 '@fontsource/ibm-plex-mono': 5.2.7 marked: 18.0.7 @@ -6306,6 +7286,95 @@ snapshots: '@stricli/core@1.3.0': {} + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@trpc/server@11.18.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -6315,6 +7384,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6354,7 +7425,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 22.20.1 + '@types/node': 24.13.3 form-data: 4.0.6 '@types/node@18.19.130': @@ -6377,13 +7448,17 @@ snapshots: '@types/qrcode-terminal@0.12.2': {} + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + '@types/react@19.2.18': dependencies: csstype: 3.2.3 '@types/sax@1.2.7': dependencies: - '@types/node': 22.20.1 + '@types/node': 24.13.3 '@types/semver@7.8.0': {} @@ -6401,6 +7476,11 @@ snapshots: optionalDependencies: react: 19.2.8 + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -6413,7 +7493,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -6424,13 +7504,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + + '@vitest/mocker@4.1.10(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -6481,6 +7569,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -6514,6 +7604,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -6531,6 +7623,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-iterate@2.0.1: {} @@ -6545,13 +7641,13 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.44.1(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0)): + astro-expressive-code@0.44.1(astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)): dependencies: - astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0) + astro: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) rehype-expressive-code: 0.44.1 url-extras: 0.1.0 - astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(tsx@4.23.5)(yaml@2.9.0): + astro@7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.3.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@astrojs/internal-helpers': 0.10.2 @@ -6601,8 +7697,8 @@ snapshots: ultrahtml: 1.7.0 unifont: 0.7.4 unstorage: 1.17.5 - vite: 8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)) + vite: 8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -6695,6 +7791,10 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + binpatch@0.4.2: {} binpunch@1.0.0: {} @@ -6788,6 +7888,10 @@ snapshots: cjs-module-lexer@2.2.0: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + cli-boxes@4.0.1: {} cli-cursor@4.0.0: @@ -6911,12 +8015,28 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.5.2 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -6945,6 +8065,8 @@ snapshots: direction@2.0.1: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -6981,10 +8103,17 @@ snapshots: encodeurl@2.0.0: {} + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} entities@6.0.1: {} + entities@8.1.0: {} + environment@1.1.0: {} error-stack-parser-es@1.0.5: {} @@ -7349,6 +8478,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphemesplit@2.6.0: dependencies: js-base64: 3.9.1 @@ -7582,6 +8713,12 @@ snapshots: hono@4.12.34: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} html-escaper@3.0.3: {} @@ -7600,6 +8737,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -7607,15 +8751,22 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@8.0.1: {} humanize-ms@1.2.1: dependencies: ms: 2.1.3 - i18next@26.3.6(typescript@5.9.3): + i18next@26.3.6(typescript@6.0.3): optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 iconv-lite@0.7.3: dependencies: @@ -7715,6 +8866,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-stream@4.0.1: {} @@ -7736,6 +8889,8 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} + jose@6.2.3: {} jpeg-js@0.4.4: {} @@ -7755,6 +8910,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.1 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-schema-to-zod@2.8.1: {} @@ -7780,39 +8962,88 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.9.0 + lightningcss-android-arm64@1.32.0: + optional: true + lightningcss-android-arm64@1.33.0: optional: true + lightningcss-darwin-arm64@1.32.0: + optional: true + lightningcss-darwin-arm64@1.33.0: optional: true + lightningcss-darwin-x64@1.32.0: + optional: true + lightningcss-darwin-x64@1.33.0: optional: true + lightningcss-freebsd-x64@1.32.0: + optional: true + lightningcss-freebsd-x64@1.33.0: optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + lightningcss-linux-arm64-gnu@1.33.0: optional: true + lightningcss-linux-arm64-musl@1.32.0: + optional: true + lightningcss-linux-arm64-musl@1.33.0: optional: true + lightningcss-linux-x64-gnu@1.32.0: + optional: true + lightningcss-linux-x64-gnu@1.33.0: optional: true + lightningcss-linux-x64-musl@1.32.0: + optional: true + lightningcss-linux-x64-musl@1.33.0: optional: true + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + lightningcss-win32-arm64-msvc@1.33.0: optional: true + lightningcss-win32-x64-msvc@1.32.0: + optional: true + lightningcss-win32-x64-msvc@1.33.0: optional: true + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 @@ -7846,6 +9077,12 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.43.0(react@19.2.8): + dependencies: + react: 19.2.8 + + lz-string@1.5.0: {} + macho-unsign@2.0.6: {} magic-string@0.30.21: @@ -8401,6 +9638,11 @@ snapshots: neotraverse@1.0.1: {} + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -8472,6 +9714,28 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + oxlint@1.82.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.82.0 + '@oxlint/binding-android-arm64': 1.82.0 + '@oxlint/binding-darwin-arm64': 1.82.0 + '@oxlint/binding-darwin-x64': 1.82.0 + '@oxlint/binding-freebsd-x64': 1.82.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.82.0 + '@oxlint/binding-linux-arm-musleabihf': 1.82.0 + '@oxlint/binding-linux-arm64-gnu': 1.82.0 + '@oxlint/binding-linux-arm64-musl': 1.82.0 + '@oxlint/binding-linux-ppc64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-musl': 1.82.0 + '@oxlint/binding-linux-s390x-gnu': 1.82.0 + '@oxlint/binding-linux-x64-gnu': 1.82.0 + '@oxlint/binding-linux-x64-musl': 1.82.0 + '@oxlint/binding-openharmony-arm64': 1.82.0 + '@oxlint/binding-win32-arm64-msvc': 1.82.0 + '@oxlint/binding-win32-ia32-msvc': 1.82.0 + '@oxlint/binding-win32-x64-msvc': 1.82.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -8548,6 +9812,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseurl@1.3.3: {} patch-console@2.0.0: {} @@ -8611,6 +9879,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + posthog-node@5.47.2: dependencies: '@posthog/core': 1.46.1 @@ -8619,6 +9893,12 @@ snapshots: dependencies: commander: 9.5.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -8638,6 +9918,8 @@ snapshots: proxy-from-env@1.1.0: {} + punycode@2.3.1: {} + pure-rand@8.4.2: {} qrcode-terminal@0.12.0: {} @@ -8665,6 +9947,13 @@ snapshots: - bufferutil - utf-8-validate + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + react-reconciler@0.33.0(react@19.2.8): dependencies: react: 19.2.8 @@ -8880,6 +10169,27 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + router@2.2.0: dependencies: debug: 4.4.3 @@ -8911,6 +10221,10 @@ snapshots: sax@1.6.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} section-matter@1.0.0: @@ -9189,8 +10503,16 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -9242,12 +10564,26 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.12: {} + + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + toidentifier@1.0.1: {} tokenx@1.6.0: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -9281,6 +10617,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + ufo@1.6.4: {} ultracite@6.3.10(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3)): @@ -9433,42 +10771,59 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0): + vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.25 rolldown: 1.0.3 tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.5 + yaml: 2.9.0 + + vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.28 + rolldown: 1.2.7 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.1 esbuild: 0.28.1 fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.23.5 yaml: 2.9.0 - vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0): + vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.0.3 + postcss: 8.5.28 + rolldown: 1.2.7 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 esbuild: 0.28.1 fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.23.5 yaml: 2.9.0 - vitefu@1.1.3(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)): optionalDependencies: - vite: 8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -9477,7 +10832,7 @@ snapshots: es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.1.4 pathe: 2.0.3 picomatch: 4.0.5 std-env: 4.2.0 @@ -9485,21 +10840,68 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.1 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 28.1.0 + transitivePeerDependencies: + - msw + + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 28.1.0 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -9562,6 +10964,10 @@ snapshots: ws@8.21.1: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xxhash-wasm@1.1.0: {} xz-decompress@0.2.3: {}