From 55f437de104a9f2e90ae917864aa4208bad45ad4 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Fri, 15 May 2026 02:55:34 +0000 Subject: [PATCH 01/14] Add plan for @amika/reviews package Plan for a TypeScript/React code review library at js/reviews. Bootstraps a pnpm workspace at the repo root, mirrors conventions from amika-mono/js/components, and breaks the work into 12 commits covering parser, store, React UI, deep linking, an example SPA, and a Playwright E2E suite. --- plans/2026-05-15-amika-reviews-package.md | 386 ++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 plans/2026-05-15-amika-reviews-package.md diff --git a/plans/2026-05-15-amika-reviews-package.md b/plans/2026-05-15-amika-reviews-package.md new file mode 100644 index 00000000..e49c50a1 --- /dev/null +++ b/plans/2026-05-15-amika-reviews-package.md @@ -0,0 +1,386 @@ +# `@amika/reviews` — code review library + +## Context + +We're adding a new TypeScript/React library, `@amika/reviews`, that provides a Github-style code-review UI plus a public imperative TS API. It is the first JS package in the `amika` repo, so this work also bootstraps a pnpm workspace at the repo root mirroring the conventions used in the sibling `amika-mono` monorepo (`/home/amika/workspace/amika-mono`). + +The library will: + +1. Accept patches/files in any of the 5 modes supported by `@pierre/diffs/react` (`MultiFileDiff`, `PatchDiff`, `FileDiff`, `File`, `UnresolvedFile`) via either drag-and-drop upload **or** plain-text strings. +2. Render a file tree (via `@pierre/trees/react`) and a per-file diff viewer. +3. Let users leave threaded comments (with replies) at four scopes: line / file / item (single patch or commit) / series (entire review). +4. Expose a public imperative TypeScript API (`ReviewAPI`) for navigation, comment CRUD, and reading comments — plus React hooks (`useReview`, `useComments`, `usePatches`, `useSelectedFile`). +5. Export comments + transcript in a versioned JSON schema. +6. Persist state to `localStorage` when a `persistKey` prop is supplied (opt-in). + +State is implemented in **plain React** — `useReducer` + split state/dispatch contexts — no Jotai. Justification: comment volume in a review is small (~hundreds), the diff renderer dominates render cost, and a vanilla store keeps the public API simpler. + +Both pierre libraries render through a shadow root and ship their own styling, exposed via `--trees-*` / diff CSS variables and an `unsafeCSS` escape hatch. `@amika/reviews` will not attempt to make them headless; instead, our own chrome (comment threads, side panels, dropzone) is authored with minimal CSS and theming-friendly class hooks. + +## Repository setup (new) + +The `amika` repo currently has no JS infrastructure. This work introduces: + +- Root `pnpm-workspace.yaml`: `packages: ["js/*"]`. +- Root `package.json`: `private: true`, `packageManager: "pnpm@10.18.2"`, with `pnpm.onlyBuiltDependencies` for `esbuild` (and any others surfaced during install). +- `.gitignore`: add `node_modules/`, `js/**/dist/`. +- `js/reviews/` package directory. + +Conventions copied directly from `amika-mono/js/components` (matched file-for-file unless noted): `tsconfig.json` (target ES2017, jsx react-jsx, moduleResolution bundler, strict), `eslint.config.mjs` (flat config, `js.configs.recommended` + `typescript-eslint`), `vitest.config.ts` (jsdom, `src/test/setup.ts` importing `@testing-library/jest-dom/vitest`), Prettier defaults. + +## Package shape — `js/reviews/package.json` + +```jsonc +{ + "name": "@amika/reviews", + "version": "0.0.1", + "private": true, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "format": "prettier --write .", + "formatcheck": "prettier --check .", + "test": "vitest run" + }, + "exports": { + ".": "./src/index.ts", + "./headless": "./src/headless.ts", + "./styles.css": "./src/styles.css" + }, + "dependencies": { + "@pierre/diffs": "latest", + "@pierre/trees": "latest", + "parse-diff": "^0.11.1" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { /* mirrors js/components: eslint, prettier, vitest, jsdom, testing-library, typescript-eslint, globals */ } +} +``` + +`./headless` re-exports only the store + types + parser + export — no React UI, no pierre deps pulled in. + +## Public TypeScript API + +### Data model + +```ts +type Side = "old" | "new"; + +type ReviewItem = + | { id: string; kind: "patch"; patchText: string; label?: string } + | { id: string; kind: "multi-file-diff"; before: FileMap; after: FileMap; label?: string } + | { id: string; kind: "file-diff"; metadata: FileDiffMetadata; label?: string } + | { id: string; kind: "file"; path: string; content: string; label?: string } + | { id: string; kind: "unresolved-file"; path: string; content: string; label?: string }; + +type CommentScope = + | { kind: "line"; itemId: string; path: string; line: number; side?: Side } + | { kind: "file"; itemId: string; path: string } + | { kind: "item"; itemId: string } + | { kind: "series" }; + +interface Comment { + id: CommentId; + parentId: CommentId | null; // replies live here; flat with parentId + scope: CommentScope; + body: string; + author?: string; + createdAt: string; // ISO-8601 + updatedAt: string; // ISO-8601 + resolved: boolean; +} + +interface ReviewState { + items: ReviewItem[]; + comments: Record; + selection: { itemId: string | null; path: string | null }; +} +``` + +### `ReviewAPI` + +```ts +interface ReviewAPI { + // Loading + loadFromText(input: string | string[]): ReviewItem[]; // assumes patch text + loadFromFiles(files: File[]): Promise; // .diff / .patch + addItem(item: ReviewItem): void; + reset(): void; + + // Navigation + getItems(): ReviewItem[]; + getItem(itemId: string): ReviewItem | undefined; + listFiles(itemId?: string): { itemId: string; path: string }[]; + getSelection(): { itemId: string | null; path: string | null }; + selectFile(itemId: string, path: string | null): void; + + // Comment CRUD + addComment(input: { scope: CommentScope; body: string; author?: string }): Comment; + reply(parentId: CommentId, body: string, author?: string): Comment; + editComment(id: CommentId, body: string): Comment; + deleteComment(id: CommentId): void; + setResolved(id: CommentId, resolved: boolean): Comment; + + // Read + getComment(id: CommentId): Comment | undefined; + getComments(filter?: { scope?: CommentScope["kind"]; itemId?: string; path?: string; line?: number }): Comment[]; + getThread(rootId: CommentId): Comment[]; // root + descendants in order + + // Deep linking + getLocation(): ReviewLocation; + navigate(loc: ReviewLocation): void; // selects item/file, scrolls to line/comment + locationToSearch(loc: ReviewLocation): string; // produces `?item=...&file=...&line=...` + locationFromSearch(search: string): ReviewLocation; + + // Snapshot + export(): ReviewExportV1; + import(snapshot: ReviewExportV1): void; + + // Observation + subscribe(listener: (state: ReviewState) => void): () => void; +} +``` + +### React surface + +```ts + {children} + +useReview(): ReviewAPI +useReviewState(): ReviewState +useComments(filter): Comment[] +usePatches(): ReviewItem[] +useSelectedFile(): { itemId, path, item } | null +useReviewLocation(): readonly [ReviewLocation, (next: ReviewLocation) => void] +``` + +When `linkSync` is `true`, the provider binds to `window.location` (search params), pushes on navigation, and reacts to `popstate`. Callers who use a router (Next.js, React Router) can pass an adapter `{ read, write }` to bridge instead. + +### Headless surface (`@amika/reviews/headless`) + +```ts +createReviewStore(options): ReviewAPI & { + getState(): ReviewState; +}; +``` + +Same API, but framework-agnostic. The React provider is a thin wrapper around `createReviewStore` that bridges to React via `useSyncExternalStore`. + +## Deep linking + +### URL scheme + +A single `ReviewLocation` describes "what's selected and what to scroll to": + +```ts +type ReviewLocation = + | { kind: "none" } + | { kind: "item"; itemId: string } + | { kind: "file"; itemId: string; path: string } + | { kind: "line"; itemId: string; path: string; line: number; side?: Side } + | { kind: "comment"; commentId: CommentId }; // self-resolves to its scope +``` + +Serialized as URL search params (single, flat, easy to embed in any host app): + +``` +?item= +?item=&file= +?item=&file=&line=42&side=new +?comment= +``` + +The comment form is the most useful in practice — one short link resolves to "open this exact item, this exact file, scroll to this line, highlight this thread." + +### Stable IDs + +For shareable links to survive reloads, item IDs must be deterministic: + +- `kind: "patch"`: `id = sha1(patchText).slice(0, 12)` by default, but callers may pass an explicit `id` or `commitSha` (preferred when available) via `addItem` / `loadFromText`. +- `kind: "multi-file-diff" | "file-diff" | "file" | "unresolved-file"`: deterministic hash of structural content, or caller-supplied id. +- Comment IDs are random (`cmt_`); they survive reloads because state is persisted via `persistKey`. Comment links across machines work only after the JSON export is shared and re-imported. + +### Behavior + +- `navigate(loc)` selects the right item/file and emits a scroll-into-view request for line / comment targets. The diff component is rendered inside a shadow root, so scroll-to-line uses the pierre API (or a `ref` on our annotation wrapper) rather than `document.querySelector`. +- "Copy link" affordances ship on: each item header, each file row in the tree, each comment thread, and each line gutter (small inline button on hover). They write a relative link (just the search portion) so the host page's origin/path is preserved. + +### React integration + +- `linkSync` prop (see React surface above) controls automatic bidirectional sync. +- `useReviewLocation()` is the manual hook for hosts that want full control. +- The `` UI surfaces the copy-link buttons but never decides the host URL — it always calls `api.locationToSearch(loc)` and lets the consumer combine with `window.location.pathname`. + +## Export schema — `ReviewExportV1` + +```jsonc +{ + "schemaVersion": 1, + "exportedAt": "2026-05-15T12:34:56.000Z", + "items": [ + { "id": "...", "kind": "patch", "patchText": "...", "label": "..." } + // other ReviewItem shapes preserved verbatim + ], + "comments": [ + { + "id": "cmt_...", + "parentId": null, + "scope": { "kind": "line", "itemId": "...", "path": "src/x.ts", "line": 42, "side": "new" }, + "body": "Why are we casting here?", + "author": "alice", + "createdAt": "...", + "updatedAt": "...", + "resolved": false + } + ] +} +``` + +Threading is reconstructed via `parentId`. The schema is intentionally flat for round-tripping. + +## Working style + +- The plan itself is checked in first (Commit 0), so future agents and humans can read what we agreed to. +- After each numbered commit below: run the relevant checks (typecheck/lint/test/Playwright for the commits that need it), commit with a clear message, and `git push` to `origin`. No batching — every green step is pushed. +- If a check fails after a commit attempt: fix forward in the *next* commit rather than amending. Keeps history honest and reviewable. + +## Commit-by-commit breakdown + +### Commit 0 — Check in this plan +Copy `/home/amika/.claude/plans/there-should-also-be-immutable-lynx.md` to `plans/2026-05-15-amika-reviews-package.md` inside the repo (creating the `plans/` directory). This freezes the agreed approach into git so subsequent commits can reference it. + +### Commit 1 — Bootstrap workspace and empty `@amika/reviews` +Repo root: `pnpm-workspace.yaml`, `package.json`, `.gitignore` additions. `js/reviews/`: `package.json`, `tsconfig.json`, `eslint.config.mjs`, `vitest.config.ts`, `src/test/setup.ts`, empty `src/index.ts`, `src/headless.ts`, `src/styles.css`, `README.md` (one-paragraph stub). Verify: `pnpm install && pnpm --filter @amika/reviews run typecheck lint formatcheck test` all succeed on an empty package. + +### Commit 2 — Types, parser, loaders +Files: `src/types.ts`, `src/parser.ts`, `src/io.ts`, `src/parser.test.ts`. Parser wraps `parse-diff` and normalizes to `Patch` items. `loadFromFiles` reads `File` objects via `text()`. Includes fixtures (`src/test/fixtures/*.patch`) for single-file, multi-file, rename, binary marker, malformed input. + +### Commit 3 — Store, reducer, persistence, export +Files: `src/store/reducer.ts`, `src/store/store.ts` (`createReviewStore`), `src/store/persistence.ts`, `src/store/export.ts`, `src/store/reducer.test.ts`, `src/store/store.test.ts`. Pure reducer; `createReviewStore` is framework-agnostic and is what the headless entrypoint re-exports. Persistence is a small adapter triggered when `persistKey` is supplied; uses `localStorage` with try/catch + `storage` event listener for cross-tab sync. + +### Commit 4 — React provider, hooks, public API exports +Files: `src/react/ReviewProvider.tsx`, `src/react/hooks.ts`, `src/react/context.ts`, `src/react/ReviewProvider.test.tsx`. Two contexts: state (via `useSyncExternalStore` over the store) and api (stable identity). `useComments` memoizes selection via a filter argument. `src/index.ts` re-exports the React surface + types; `src/headless.ts` re-exports only `createReviewStore` + types + parser + `export`. + +### Commit 5 — File tree panel +Files: `src/react/FileTreePanel.tsx`, test. Builds `paths` from `listFiles()`; wires `useFileTree({ paths, onSelectionChange })`. Uses `renderRowDecoration` to show per-file comment counts. Selection drives `selectFile()` on the API. + +### Commit 6 — Item viewer dispatch + line/file comments +Files: `src/react/ItemView.tsx` (switches on `item.kind` → renders the right pierre component), `src/react/LineCommentLayer.tsx` (builds `DiffLineAnnotation[]` / `LineAnnotation[]` from state), `src/react/CommentThread.tsx`, `src/react/CommentForm.tsx`, `src/react/FileCommentPanel.tsx`, tests. Click on a line → opens form → submit → adds to state → annotation appears. Side support: when the diff component reports `side`, we pass it through to the line scope. + +### Commit 7 — Item-level and series-level comments +Files: `src/react/ItemCommentPanel.tsx`, `src/react/SeriesCommentPanel.tsx`, threading UI shared via `CommentThread`. Decision documented in README: one uploaded `.diff`/`.patch` file = one `ReviewItem` (kind `patch`); the in-memory collection = the series. + +### Commit 8 — `` composition, upload dropzone, export button +Files: `src/react/CodeReview.tsx`, `src/react/UploadDropzone.tsx`, `src/styles.css`. Three-pane layout (tree / diff / sidebar). Drop zone accepts `.diff` / `.patch`; "Add item" menu surfaces the four other modes (`MultiFileDiff`, `FileDiff`, `File`, `UnresolvedFile`) for programmatic users; "Export JSON" button triggers `api.export()` and downloads. + +### Commit 9 — Deep linking (location API + `linkSync` + copy-link UI) +Files: `src/location/types.ts`, `src/location/serialize.ts`, `src/location/serialize.test.ts`, `src/store/store.ts` (extend with `navigate`, `getLocation`, scroll-request emitter), `src/react/useReviewLocation.ts`, `src/react/ReviewProvider.tsx` (add `linkSync` prop), `src/react/CopyLinkButton.tsx`, hook ins into existing item/file/line/thread components. Includes stable-id helper `src/util/hashItemId.ts` (sha-1 via WebCrypto). Tests: round-trip serialize/parse, `navigate({ kind: "comment", commentId })` resolves to correct underlying scope, `linkSync` writes/reads `window.location.search`, popstate updates selection. + +### Commit 10 — `@amika/reviews-example` SPA (checked in) +A real, committed Vite + React SPA at `js/reviews-example/`. Wires `` with two entry buttons: "Load fixture series" (loads `.patch` files from `public/fixtures/`) and "Drop your own." Useful both as runnable docs and as the host for Playwright E2E. + +Files: +- `js/reviews-example/package.json` (`@amika/reviews-example`, private, depends on `@amika/reviews: "workspace:*"`, scripts: `dev`, `build`, `preview`, `test:e2e`, `test:e2e:ui`). +- `js/reviews-example/vite.config.ts`, `tsconfig.json`, `index.html`, `src/{main.tsx,App.tsx}`. +- `js/reviews-example/public/fixtures/{simple.patch,multi-file.patch,rename.patch}` (small, hand-curated; same content used in unit-test fixtures, copied here for the SPA). + +### Commit 11 — Playwright E2E suite +Files: `js/reviews-example/playwright.config.ts` (`webServer: { command: "pnpm dev", url: "http://localhost:5173", reuseExistingServer: !process.env.CI }`), `js/reviews-example/tests/*.spec.ts`. Devices: chromium only for v0. Scenarios listed in **Testing strategy** below. + +### Commit 12 — README +Expanded `README.md` covering: install, two load flows (upload vs. text), 5 item kinds with snippets, public API reference, export schema, persistence, styling/theming notes (pointing at `--trees-*` and `unsafeCSS`), pointer to the SPA + Playwright suite. + +## Testing strategy + +Three layers, all run in CI: + +**1. Unit tests** — `vitest` in jsdom, scoped to `js/reviews/`. Pure-function coverage. Run via `pnpm --filter @amika/reviews run test`. +- Reducer: every action (add/edit/delete/reply/resolve, at every scope), idempotency of resolve, reply with non-existent parent rejected, scope filter correctness. +- Parser: fixtures for single-file, multi-file, rename, binary marker, malformed input. Snapshot the normalized `Patch[]`. +- Export/import: round-trip equality. +- Persistence: writes and re-hydrates from a faked `localStorage`; corrupt JSON is ignored gracefully. + +**2. Component tests** — `@testing-library/react` + `@testing-library/jest-dom` inside the same vitest run. +- ``: `useReview()` returns stable identity; state hook re-renders only on relevant slice changes; persistence prop integrates end-to-end with the storage mock. +- ``: builds correct path list from items; clicking a row calls `selectFile`; comment-count decoration updates when comments are added. +- `` / ``: typing + submit dispatches add, reply chain renders nested, edit/delete/resolve buttons behave. +- ``: simulated drop of `File` objects loads items into the store. +- ``: integration smoke — provider + tree + dropzone + sidebar render together and a comment can be created end-to-end from RTL events. + +Note: the pierre `` / `` internals render inside shadow roots, so RTL cannot reach inside them. We assert against our own DOM (forms, threads, side panels, decoration counts) and the public API state. The shadow-DOM-internal click-on-line interaction is covered in the Playwright layer below using real browser APIs. + +**3. End-to-end tests** — Playwright, against the checked-in `@amika/reviews-example` SPA. +- Boot: `webServer` runs `pnpm --filter @amika/reviews-example dev`. +- Scenarios: + 1. Load fixture series → file tree shows expected paths → diff renders. + 2. Click a line inside the diff (using `page.locator(...).locator(":scope >>> ...")` to pierce the shadow root, or the data-testid hooks we ship on our annotation wrappers) → comment form appears → submit → annotation visible. + 3. Reply to comment → threaded reply rendered. + 4. Add file-level comment in the sidebar → visible. + 5. Add patch- and series-level comments → visible in their panels. + 6. Click "Export JSON" → use Playwright's `page.waitForEvent('download')` → parse downloaded file → assert shape matches `ReviewExportV1` and contains the comments created above. + 7. With `?persistKey=demo` in the URL, reload → previously created comments survive. + 8. **Deep link — item**: navigate to `/?item=` → that item is selected, others collapsed. + 9. **Deep link — file**: navigate to `/?item=&file=src/x.ts` → tree highlights the row, diff renders. + 10. **Deep link — line**: navigate to `/?item=&file=src/x.ts&line=42&side=new` → page scrolls so line 42 is in view. + 11. **Deep link — comment**: create a comment, click its "copy link" button → assert clipboard contents are a `?comment=` URL; open that URL in a fresh page → comment is scrolled to and visually highlighted. + 12. **Browser back/forward**: select item A, then item B, then press Back → selection returns to A; `popstate` honored. + +Run via `pnpm --filter @amika/reviews-example test:e2e`. In CI: install browsers with `pnpm exec playwright install --with-deps chromium`. + +## Critical files to be created + +- `/home/amika/workspace/amika/plans/2026-05-15-amika-reviews-package.md` (Commit 0) +- `/home/amika/workspace/amika/pnpm-workspace.yaml` +- `/home/amika/workspace/amika/package.json` +- `/home/amika/workspace/amika/.gitignore` (modify) +- `/home/amika/workspace/amika/js/reviews/package.json` +- `/home/amika/workspace/amika/js/reviews/tsconfig.json` +- `/home/amika/workspace/amika/js/reviews/eslint.config.mjs` +- `/home/amika/workspace/amika/js/reviews/vitest.config.ts` +- `/home/amika/workspace/amika/js/reviews/src/{index.ts,headless.ts,styles.css,types.ts,parser.ts,io.ts}` +- `/home/amika/workspace/amika/js/reviews/src/store/{reducer.ts,store.ts,persistence.ts,export.ts}` +- `/home/amika/workspace/amika/js/reviews/src/react/{ReviewProvider.tsx,hooks.ts,context.ts,FileTreePanel.tsx,ItemView.tsx,LineCommentLayer.tsx,CommentThread.tsx,CommentForm.tsx,FileCommentPanel.tsx,ItemCommentPanel.tsx,SeriesCommentPanel.tsx,CodeReview.tsx,UploadDropzone.tsx,CopyLinkButton.tsx,useReviewLocation.ts}` +- `/home/amika/workspace/amika/js/reviews/src/location/{types.ts,serialize.ts}` +- `/home/amika/workspace/amika/js/reviews/src/util/hashItemId.ts` +- `/home/amika/workspace/amika/js/reviews/src/test/{setup.ts, fixtures/*.patch}` +- `/home/amika/workspace/amika/js/reviews/README.md` +- `/home/amika/workspace/amika/js/reviews-example/package.json` +- `/home/amika/workspace/amika/js/reviews-example/vite.config.ts` +- `/home/amika/workspace/amika/js/reviews-example/tsconfig.json` +- `/home/amika/workspace/amika/js/reviews-example/index.html` +- `/home/amika/workspace/amika/js/reviews-example/src/{main.tsx,App.tsx}` +- `/home/amika/workspace/amika/js/reviews-example/public/fixtures/*.patch` +- `/home/amika/workspace/amika/js/reviews-example/playwright.config.ts` +- `/home/amika/workspace/amika/js/reviews-example/tests/*.spec.ts` + +## Reused libraries / utilities + +- `@pierre/trees/react` — `useFileTree`, ``, `renderRowDecoration` for comment counts, `onSelectionChange` for file selection. +- `@pierre/diffs/react` — ``, ``, ``, ``, ``. `DiffLineAnnotation` and `LineAnnotation` for comment layering. Token callbacks not used in v0. +- `parse-diff` — small, mature unified-diff parser; output normalized into our internal `Patch` shape so we can swap implementations later. +- `useSyncExternalStore` (React 18+) — wires the framework-agnostic store into React without re-renders on unrelated state slices. +- Conventions / configs copied from `/home/amika/workspace/amika-mono/js/components/{tsconfig.json,eslint.config.mjs,vitest.config.ts,src/test/setup.ts}`. + +## Verification + +After each commit: + +1. `pnpm --filter @amika/reviews run typecheck lint formatcheck test` — all green (covers unit + RTL component tests). +2. After commit 9 the SPA also gets `pnpm --filter @amika/reviews-example run typecheck build`. +3. After commit 10 the Playwright suite runs: `pnpm exec playwright install --with-deps chromium && pnpm --filter @amika/reviews-example run test:e2e`. + +Final end-to-end smoke (after commit 11): boot `pnpm --filter @amika/reviews-example dev`, manually walk through all 7 Playwright scenarios in a real browser to sanity-check what automation can't capture (visual layout, focus behavior). + +## Known unknowns / deferred + +- **`FileDiffMetadata` shape**: not enumerated in `diffs.com/docs` excerpt — will read at implementation time from the published `@pierre/diffs/react` types. +- **Token-level commenting** (sub-line ranges) is out of scope for v0 — `onTokenClick` etc. noted as available for v1. +- **Server sync** (POSTing comments somewhere) — not in scope; export JSON is the bridge. From 6915075a173e6c417d596a2ab7055a92f08bffe8 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Fri, 15 May 2026 02:57:12 +0000 Subject: [PATCH 02/14] Bootstrap pnpm workspace and empty @amika/reviews Adds repo-root pnpm workspace (pnpm@10.18.2, packages: js/*) and the first JS package, @amika/reviews, with conventions copied from amika-mono/js/components: TS strict, ESLint flat config + typescript-eslint, Prettier, Vitest with jsdom + @testing-library/jest-dom setup. Empty src/index.ts, src/headless.ts, and src/styles.css will be filled in subsequent commits. All four scripts (typecheck, lint, formatcheck, test) pass on the empty package. --- .gitignore | 8 + js/reviews/README.md | 10 + js/reviews/eslint.config.mjs | 25 + js/reviews/package.json | 38 + js/reviews/src/headless.ts | 1 + js/reviews/src/index.ts | 1 + js/reviews/src/styles.css | 1 + js/reviews/src/test/setup.ts | 1 + js/reviews/tsconfig.json | 17 + js/reviews/vitest.config.ts | 10 + package.json | 10 + pnpm-lock.yaml | 2443 ++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + 13 files changed, 2567 insertions(+) create mode 100644 js/reviews/README.md create mode 100644 js/reviews/eslint.config.mjs create mode 100644 js/reviews/package.json create mode 100644 js/reviews/src/headless.ts create mode 100644 js/reviews/src/index.ts create mode 100644 js/reviews/src/styles.css create mode 100644 js/reviews/src/test/setup.ts create mode 100644 js/reviews/tsconfig.json create mode 100644 js/reviews/vitest.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore index 9fbb39ef..f4836886 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,11 @@ scratch/ .gocache/ .gotmp/ .gomodcache/ + +# JS workspaces +node_modules/ +js/**/dist/ +js/**/.vite/ +js/**/coverage/ +js/**/playwright-report/ +js/**/test-results/ diff --git a/js/reviews/README.md b/js/reviews/README.md new file mode 100644 index 00000000..cb933271 --- /dev/null +++ b/js/reviews/README.md @@ -0,0 +1,10 @@ +# @amika/reviews + +A TypeScript/React library for reviewing code: upload one or more `.diff` / +`.patch` files (or pass them as text), browse a file tree, view per-file +diffs, and leave threaded comments at line / file / patch / series scope. +Built on `@pierre/diffs/react` and `@pierre/trees/react`. + +This package is a work in progress. See +`plans/2026-05-15-amika-reviews-package.md` at the repo root for the full +build plan. diff --git a/js/reviews/eslint.config.mjs b/js/reviews/eslint.config.mjs new file mode 100644 index 00000000..11759e46 --- /dev/null +++ b/js/reviews/eslint.config.mjs @@ -0,0 +1,25 @@ +import js from "@eslint/js"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +const eslintConfig = defineConfig([ + { + ignores: ["node_modules/**", "dist/**"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node, + }, + }, + }, +]); + +export default eslintConfig; diff --git a/js/reviews/package.json b/js/reviews/package.json new file mode 100644 index 00000000..14b0a4bd --- /dev/null +++ b/js/reviews/package.json @@ -0,0 +1,38 @@ +{ + "name": "@amika/reviews", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "format": "prettier --write .", + "formatcheck": "prettier --check .", + "test": "vitest run" + }, + "exports": { + ".": "./src/index.ts", + "./headless": "./src/headless.ts", + "./styles.css": "./src/styles.css" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.2", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "globals": "^16.4.0", + "jsdom": "^26.1.0", + "prettier": "^3.6.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "typescript": "^5", + "typescript-eslint": "^8.55.0", + "vitest": "^3.2.4" + } +} diff --git a/js/reviews/src/headless.ts b/js/reviews/src/headless.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/js/reviews/src/headless.ts @@ -0,0 +1 @@ +export {}; diff --git a/js/reviews/src/index.ts b/js/reviews/src/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/js/reviews/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/js/reviews/src/styles.css b/js/reviews/src/styles.css new file mode 100644 index 00000000..cfbdcf6c --- /dev/null +++ b/js/reviews/src/styles.css @@ -0,0 +1 @@ +/* @amika/reviews — styles are added in later commits. */ diff --git a/js/reviews/src/test/setup.ts b/js/reviews/src/test/setup.ts new file mode 100644 index 00000000..f149f27a --- /dev/null +++ b/js/reviews/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/js/reviews/tsconfig.json b/js/reviews/tsconfig.json new file mode 100644 index 00000000..b0ba4c3c --- /dev/null +++ b/js/reviews/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/js/reviews/vitest.config.ts b/js/reviews/vitest.config.ts new file mode 100644 index 00000000..f648335f --- /dev/null +++ b/js/reviews/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + exclude: ["**/node_modules/**", "**/*.integration.test.ts"], + passWithNoTests: true, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 00000000..1413adb4 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "amika", + "private": true, + "packageManager": "pnpm@10.18.2", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..6f8084cd --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2443 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + js/reviews: + devDependencies: + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.4 + '@testing-library/jest-dom': + specifier: ^6.8.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + eslint: + specifier: ^9 + version: 9.39.4 + globals: + specifier: ^16.4.0 + version: 16.5.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + prettier: + specifier: ^3.6.2 + version: 3.8.3 + react: + specifier: ^19.2.3 + version: 19.2.6 + react-dom: + specifier: ^19.2.3 + version: 19.2.6(react@19.2.6) + typescript: + specifier: ^5 + version: 5.9.3 + typescript-eslint: + specifier: ^8.55.0 + version: 8.59.3(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: ^3.2.4 + version: 3.2.4(jsdom@26.1.0) + +packages: + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@typescript-eslint/eslint-plugin@8.59.3': + resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.3 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.3': + resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.3': + resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.3': + resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.3': + resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.3': + resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.3': + resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.3': + resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.3': + resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.3': + resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + 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'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + 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} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + 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==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.59.3: + resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.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 + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + 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==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/runtime@7.29.2': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@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/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/type-utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.3 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.3 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.3(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) + '@typescript-eslint/types': 8.59.3 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.3': + dependencies: + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 + + '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.3': {} + + '@typescript-eslint/typescript-estree@8.59.3(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.3(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.0 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.3': + dependencies: + '@typescript-eslint/types': 8.59.3 + eslint-visitor-keys: 5.0.1 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.3)': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + has-flag@4.0.0: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + nwsapi@2.2.23: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react@19.2.6: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + resolve-from@4.0.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@7.8.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.59.3(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.3 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.3: + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.4(jsdom@26.1.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.3) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.3 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + ws@8.20.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..4cbe01cf --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "js/*" From 0454b4c83a0f19bd0c41a1954657d01a4323a619 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Fri, 15 May 2026 02:59:46 +0000 Subject: [PATCH 03/14] Add core types, patch parser, and file/text loaders Introduces the public data model (ReviewItem, ParsedPatch, Hunk, etc.) and two entry points for loading patches: loadFromFiles for upload events and loadFromText for raw strings. parsePatch wraps parse-diff and normalizes to a library-agnostic shape so we can swap parsers later. Stable IDs come from a synchronous FNV-1a hash of the patch text (util/hashItemId), so shareable deep-links survive reloads. Bumps tsconfig target to ES2020 to allow BigInt literals. 11 unit tests over five fixtures (single, multi-file, rename, binary, malformed) all pass. --- js/reviews/package.json | 4 + js/reviews/src/io.ts | 21 ++++ js/reviews/src/parser.test.ts | 108 ++++++++++++++++++ js/reviews/src/parser.ts | 92 +++++++++++++++ js/reviews/src/test/fixtures/binary.patch | 3 + js/reviews/src/test/fixtures/malformed.patch | 3 + js/reviews/src/test/fixtures/multi-file.patch | 24 ++++ js/reviews/src/test/fixtures/rename.patch | 12 ++ .../src/test/fixtures/single-file.patch | 10 ++ js/reviews/src/types.ts | 89 +++++++++++++++ js/reviews/src/util/hashItemId.ts | 19 +++ js/reviews/tsconfig.json | 2 +- pnpm-lock.yaml | 46 ++++++-- 13 files changed, 422 insertions(+), 11 deletions(-) create mode 100644 js/reviews/src/io.ts create mode 100644 js/reviews/src/parser.test.ts create mode 100644 js/reviews/src/parser.ts create mode 100644 js/reviews/src/test/fixtures/binary.patch create mode 100644 js/reviews/src/test/fixtures/malformed.patch create mode 100644 js/reviews/src/test/fixtures/multi-file.patch create mode 100644 js/reviews/src/test/fixtures/rename.patch create mode 100644 js/reviews/src/test/fixtures/single-file.patch create mode 100644 js/reviews/src/types.ts create mode 100644 js/reviews/src/util/hashItemId.ts diff --git a/js/reviews/package.json b/js/reviews/package.json index 14b0a4bd..0c7201d2 100644 --- a/js/reviews/package.json +++ b/js/reviews/package.json @@ -23,6 +23,7 @@ "@eslint/js": "^9.39.2", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.2", + "@types/node": "^25.8.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -34,5 +35,8 @@ "typescript": "^5", "typescript-eslint": "^8.55.0", "vitest": "^3.2.4" + }, + "dependencies": { + "parse-diff": "^0.12.0" } } diff --git a/js/reviews/src/io.ts b/js/reviews/src/io.ts new file mode 100644 index 00000000..03bb41c9 --- /dev/null +++ b/js/reviews/src/io.ts @@ -0,0 +1,21 @@ +import { parsePatch, parsePatches } from "./parser.js"; +import type { ReviewItem } from "./types.js"; + +/** + * Read each File as text and parse as a patch. Use when the caller has File + * objects from an upload input or drag-and-drop event. + */ +export async function loadFromFiles(files: File[]): Promise { + const texts = await Promise.all(files.map((f) => f.text())); + return parsePatches(texts); +} + +/** + * Parse one or more patch strings without going through a File object. + * Use when the caller already has the raw `.diff` / `.patch` text (server + * response, embedded asset, etc). + */ +export function loadFromText(input: string | string[]): ReviewItem[] { + if (Array.isArray(input)) return parsePatches(input); + return [parsePatch(input)]; +} diff --git a/js/reviews/src/parser.test.ts b/js/reviews/src/parser.test.ts new file mode 100644 index 00000000..de008d7f --- /dev/null +++ b/js/reviews/src/parser.test.ts @@ -0,0 +1,108 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parsePatch, parsePatches } from "./parser.js"; +import { loadFromText } from "./io.js"; +import { hashItemId } from "./util/hashItemId.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const fixture = (name: string) => + readFileSync(join(HERE, "test/fixtures", name), "utf8"); + +describe("parsePatch", () => { + it("parses a single-file modification with one hunk", () => { + const item = parsePatch(fixture("single-file.patch")); + expect(item.kind).toBe("patch"); + if (item.kind !== "patch") throw new Error("unreachable"); + expect(item.parsed?.files).toHaveLength(1); + const f = item.parsed!.files[0]; + expect(f.from).toBe("src/hello.ts"); + expect(f.to).toBe("src/hello.ts"); + expect(f.status).toBe("modified"); + expect(f.hunks).toHaveLength(1); + const adds = f.hunks[0].lines.filter((l) => l.type === "add"); + const dels = f.hunks[0].lines.filter((l) => l.type === "del"); + expect(adds).toHaveLength(2); + expect(dels).toHaveLength(1); + expect(adds[0].newLine).toBeGreaterThan(0); + expect(dels[0].oldLine).toBeGreaterThan(0); + }); + + it("parses a multi-file patch with add, modify, and delete", () => { + const item = parsePatch(fixture("multi-file.patch")); + if (item.kind !== "patch") throw new Error("unreachable"); + expect(item.parsed?.files).toHaveLength(3); + const byStatus = Object.fromEntries( + item.parsed!.files.map((f) => [f.status, f]), + ); + expect(byStatus.added.to).toBe("src/b.ts"); + expect(byStatus.added.from).toBeNull(); + expect(byStatus.deleted.from).toBe("src/c.ts"); + expect(byStatus.deleted.to).toBeNull(); + expect(byStatus.modified.from).toBe("src/a.ts"); + }); + + it("parses a rename as `renamed` with distinct from/to paths", () => { + const item = parsePatch(fixture("rename.patch")); + if (item.kind !== "patch") throw new Error("unreachable"); + const f = item.parsed!.files[0]; + expect(f.status).toBe("renamed"); + expect(f.from).toBe("src/old-name.ts"); + expect(f.to).toBe("src/new-name.ts"); + }); + + it("classifies a hunk-less binary patch as `binary`", () => { + const item = parsePatch(fixture("binary.patch")); + if (item.kind !== "patch") throw new Error("unreachable"); + expect(item.parsed?.files).toHaveLength(1); + const f = item.parsed!.files[0]; + expect(f.status).toBe("binary"); + expect(f.hunks).toHaveLength(0); + }); + + it("does not throw on malformed input; produces zero files", () => { + const item = parsePatch(fixture("malformed.patch")); + if (item.kind !== "patch") throw new Error("unreachable"); + expect(item.parsed?.files).toHaveLength(0); + expect(item.patchText.length).toBeGreaterThan(0); + }); +}); + +describe("parsePatches / loadFromText", () => { + it("returns one ReviewItem per input string", () => { + const items = parsePatches([ + fixture("single-file.patch"), + fixture("rename.patch"), + ]); + expect(items).toHaveLength(2); + expect(items[0].id).not.toBe(items[1].id); + }); + + it("loadFromText accepts a single string", () => { + const items = loadFromText(fixture("single-file.patch")); + expect(items).toHaveLength(1); + }); + + it("loadFromText accepts an array of strings", () => { + const items = loadFromText([ + fixture("single-file.patch"), + fixture("multi-file.patch"), + ]); + expect(items).toHaveLength(2); + }); +}); + +describe("hashItemId", () => { + it("is deterministic for the same input", () => { + expect(hashItemId("hello world")).toBe(hashItemId("hello world")); + }); + + it("differs for different inputs", () => { + expect(hashItemId("a")).not.toBe(hashItemId("b")); + }); + + it("produces a 12-character hex string", () => { + expect(hashItemId("anything")).toMatch(/^[0-9a-f]{12}$/); + }); +}); diff --git a/js/reviews/src/parser.ts b/js/reviews/src/parser.ts new file mode 100644 index 00000000..fa6d6dd5 --- /dev/null +++ b/js/reviews/src/parser.ts @@ -0,0 +1,92 @@ +import parseDiff from "parse-diff"; +import { hashItemId } from "./util/hashItemId.js"; +import type { + FileStatus, + Hunk, + HunkLine, + ParsedFile, + ParsedPatch, + ReviewItem, +} from "./types.js"; + +/** + * Parse one unified-diff / patch string into a `patch` ReviewItem. The item's + * id is a content-hash of the patch text by default; callers wanting to use a + * commit SHA should override after the fact. + */ +export function parsePatch(text: string): ReviewItem { + const parsed = normalizeParsed(parseDiff(text)); + return { + id: hashItemId(text), + kind: "patch", + patchText: text, + parsed, + }; +} + +/** Parse multiple patches at once. Returns one ReviewItem per input string. */ +export function parsePatches(texts: string[]): ReviewItem[] { + return texts.map(parsePatch); +} + +function normalizeParsed(files: parseDiff.File[]): ParsedPatch { + return { files: files.map(toParsedFile) }; +} + +function toParsedFile(f: parseDiff.File): ParsedFile { + const from = normalizePath(f.from); + const to = normalizePath(f.to); + return { + from, + to, + status: deriveStatus(f, from, to), + hunks: f.chunks.map(toHunk), + }; +} + +function normalizePath(p: string | undefined): string | null { + if (!p || p === "/dev/null") return null; + // parse-diff leaves the "a/" / "b/" prefixes in place. + if (p.startsWith("a/") || p.startsWith("b/")) return p.slice(2); + return p; +} + +function deriveStatus( + f: parseDiff.File, + from: string | null, + to: string | null, +): FileStatus { + // parse-diff doesn't surface binary patches with hunks, so we infer. + if (f.chunks.length === 0 && from && to && from !== to) return "renamed"; + if (f.chunks.length === 0) return "binary"; + if (f.new || from === null) return "added"; + if (f.deleted || to === null) return "deleted"; + if (from && to && from !== to) return "renamed"; + return "modified"; +} + +function toHunk(c: parseDiff.Chunk): Hunk { + return { + oldStart: c.oldStart, + oldLines: c.oldLines, + newStart: c.newStart, + newLines: c.newLines, + lines: c.changes.map(toHunkLine), + }; +} + +function toHunkLine(ch: parseDiff.Change): HunkLine { + switch (ch.type) { + case "add": + return { type: "add", content: ch.content, newLine: ch.ln }; + case "del": + return { type: "del", content: ch.content, oldLine: ch.ln }; + case "normal": + return { + type: "context", + content: ch.content, + oldLine: ch.ln1, + newLine: ch.ln2, + }; + } +} diff --git a/js/reviews/src/test/fixtures/binary.patch b/js/reviews/src/test/fixtures/binary.patch new file mode 100644 index 00000000..2a992722 --- /dev/null +++ b/js/reviews/src/test/fixtures/binary.patch @@ -0,0 +1,3 @@ +diff --git a/assets/logo.png b/assets/logo.png +index 0000001..0000002 100644 +Binary files a/assets/logo.png and b/assets/logo.png differ diff --git a/js/reviews/src/test/fixtures/malformed.patch b/js/reviews/src/test/fixtures/malformed.patch new file mode 100644 index 00000000..133cb726 --- /dev/null +++ b/js/reviews/src/test/fixtures/malformed.patch @@ -0,0 +1,3 @@ +this is not a patch at all, just plain text +with multiple lines +and no diff markers anywhere diff --git a/js/reviews/src/test/fixtures/multi-file.patch b/js/reviews/src/test/fixtures/multi-file.patch new file mode 100644 index 00000000..0f78d97d --- /dev/null +++ b/js/reviews/src/test/fixtures/multi-file.patch @@ -0,0 +1,24 @@ +diff --git a/src/a.ts b/src/a.ts +index 0000001..0000002 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,2 +1,2 @@ +-export const A = 1; ++export const A = 2; + export const ALWAYS = true; +diff --git a/src/b.ts b/src/b.ts +new file mode 100644 +index 0000000..0000003 +--- /dev/null ++++ b/src/b.ts +@@ -0,0 +1,2 @@ ++export const B = 1; ++export const NEW = true; +diff --git a/src/c.ts b/src/c.ts +deleted file mode 100644 +index 0000004..0000000 +--- a/src/c.ts ++++ /dev/null +@@ -1,2 +0,0 @@ +-export const C = 1; +-export const OLD = true; diff --git a/js/reviews/src/test/fixtures/rename.patch b/js/reviews/src/test/fixtures/rename.patch new file mode 100644 index 00000000..1596bca7 --- /dev/null +++ b/js/reviews/src/test/fixtures/rename.patch @@ -0,0 +1,12 @@ +diff --git a/src/old-name.ts b/src/new-name.ts +similarity index 80% +rename from src/old-name.ts +rename to src/new-name.ts +index 0000001..0000002 100644 +--- a/src/old-name.ts ++++ b/src/new-name.ts +@@ -1,3 +1,3 @@ + export function thing() { +- return 1; ++ return 2; + } diff --git a/js/reviews/src/test/fixtures/single-file.patch b/js/reviews/src/test/fixtures/single-file.patch new file mode 100644 index 00000000..187f007c --- /dev/null +++ b/js/reviews/src/test/fixtures/single-file.patch @@ -0,0 +1,10 @@ +diff --git a/src/hello.ts b/src/hello.ts +index 0000001..0000002 100644 +--- a/src/hello.ts ++++ b/src/hello.ts +@@ -1,3 +1,4 @@ + export function hello(name: string) { +- return `Hello, ${name}`; ++ return `Hello, ${name}!`; ++ // greeting was made friendlier + } diff --git a/js/reviews/src/types.ts b/js/reviews/src/types.ts new file mode 100644 index 00000000..7fa80151 --- /dev/null +++ b/js/reviews/src/types.ts @@ -0,0 +1,89 @@ +/** + * Public type surface for @amika/reviews. Intentionally library-agnostic: + * the parsed-patch representation is our own normalized shape, not parse-diff's. + * FileDiffMetadata is treated as opaque caller-supplied data; it will be tightened + * to @pierre/diffs/react's exported type when that integration lands. + */ + +export type Side = "old" | "new"; + +export type ReviewItemId = string; +export type CommentId = string; + +export type FileMap = Record; + +/** Opaque pre-parsed diff metadata produced by @pierre/diffs. */ +export type FileDiffMetadata = Record; + +export type ReviewItem = + | { + id: ReviewItemId; + kind: "patch"; + patchText: string; + label?: string; + parsed?: ParsedPatch; + } + | { + id: ReviewItemId; + kind: "multi-file-diff"; + before: FileMap; + after: FileMap; + label?: string; + } + | { + id: ReviewItemId; + kind: "file-diff"; + metadata: FileDiffMetadata; + label?: string; + } + | { + id: ReviewItemId; + kind: "file"; + path: string; + content: string; + label?: string; + } + | { + id: ReviewItemId; + kind: "unresolved-file"; + path: string; + content: string; + label?: string; + }; + +export type ReviewItemKind = ReviewItem["kind"]; + +export interface ParsedPatch { + files: ParsedFile[]; +} + +export type FileStatus = + | "added" + | "deleted" + | "modified" + | "renamed" + | "binary"; + +export interface ParsedFile { + /** Pre-change path; null when the file is newly added. */ + from: string | null; + /** Post-change path; null when the file is deleted. */ + to: string | null; + status: FileStatus; + hunks: Hunk[]; +} + +export interface Hunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: HunkLine[]; +} + +export interface HunkLine { + type: "context" | "add" | "del"; + content: string; + oldLine?: number; + newLine?: number; +} diff --git a/js/reviews/src/util/hashItemId.ts b/js/reviews/src/util/hashItemId.ts new file mode 100644 index 00000000..4d418468 --- /dev/null +++ b/js/reviews/src/util/hashItemId.ts @@ -0,0 +1,19 @@ +/** + * Deterministic short hash used to derive stable IDs for review items from + * their content. FNV-1a 64-bit, truncated to 12 hex characters. This is + * collision-resistant enough for in-memory item identity (~2^-24 collision + * probability across hundreds of items) and is fully synchronous, unlike + * crypto.subtle.digest. Same input always produces the same output, so + * shareable deep-links survive reloads. + */ +export function hashItemId(input: string): string { + const bytes = new TextEncoder().encode(input); + let h = 0xcbf29ce484222325n; + const PRIME = 0x100000001b3n; + const MASK = 0xffffffffffffffffn; + for (let i = 0; i < bytes.length; i++) { + h ^= BigInt(bytes[i]); + h = (h * PRIME) & MASK; + } + return h.toString(16).padStart(16, "0").slice(0, 12); +} diff --git a/js/reviews/tsconfig.json b/js/reviews/tsconfig.json index b0ba4c3c..98ab3ef6 100644 --- a/js/reviews/tsconfig.json +++ b/js/reviews/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "strict": true, "noEmit": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f8084cd..02883a19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,10 @@ importers: .: {} js/reviews: + dependencies: + parse-diff: + specifier: ^0.12.0 + version: 0.12.0 devDependencies: '@eslint/js': specifier: ^9.39.2 @@ -19,6 +23,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/node': + specifier: ^25.8.0 + version: 25.8.0 '@types/react': specifier: ^19 version: 19.2.14 @@ -51,7 +58,7 @@ importers: version: 8.59.3(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: ^3.2.4 - version: 3.2.4(jsdom@26.1.0) + version: 3.2.4(@types/node@25.8.0)(jsdom@26.1.0) packages: @@ -484,6 +491,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.8.0': + resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -983,6 +993,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-diff@0.12.0: + resolution: {integrity: sha512-2Xr5mW4Bqd4CqYq2zttfw/RZraK+KcRuJvNkJzbDk3ea67Ap525XeTvBdtDE5tigJMVzIx/DMUzsShAf6+5SCA==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -1170,6 +1183,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1608,6 +1624,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@25.8.0': + dependencies: + undici-types: 7.24.6 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -1715,13 +1735,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.3)': + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@25.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.3 + vite: 7.3.3(@types/node@25.8.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -2156,6 +2176,8 @@ snapshots: dependencies: callsites: 3.1.0 + parse-diff@0.12.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -2329,17 +2351,19 @@ snapshots: typescript@5.9.3: {} + undici-types@7.24.6: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@25.8.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.3 + vite: 7.3.3(@types/node@25.8.0) transitivePeerDependencies: - '@types/node' - jiti @@ -2354,7 +2378,7 @@ snapshots: - tsx - yaml - vite@7.3.3: + vite@7.3.3(@types/node@25.8.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -2363,13 +2387,14 @@ snapshots: rollup: 4.60.4 tinyglobby: 0.2.16 optionalDependencies: + '@types/node': 25.8.0 fsevents: 2.3.3 - vitest@3.2.4(jsdom@26.1.0): + vitest@3.2.4(@types/node@25.8.0)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.3) + '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@25.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2387,10 +2412,11 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.3 - vite-node: 3.2.4 + vite: 7.3.3(@types/node@25.8.0) + vite-node: 3.2.4(@types/node@25.8.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 25.8.0 jsdom: 26.1.0 transitivePeerDependencies: - jiti From dad4cd9a3534bd6a68413540eadd070f00a10839 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Fri, 15 May 2026 04:06:31 +0000 Subject: [PATCH 04/14] Add reducer, framework-agnostic store, persistence, and export schema Pure reducer covers add/edit/delete/reply/resolve, idempotency on SET_RESOLVED, recursive descendant removal on DELETE_COMMENT, and import/load resets selection. createReviewStore is framework-agnostic (getState/subscribe + full ReviewAPI), takes injectable now() and newCommentId() for deterministic tests, and accepts a pluggable PersistenceAdapter (localStorage adapter included; key-scoped, ignores quota/SSR errors, listens for storage events). ReviewExportV1 is flat: items + comments arrays plus schemaVersion and exportedAt. import() validates the shape before dispatching. The package index now re-exports the full headless surface; @amika/reviews and @amika/reviews/headless deliver the same TS API today (UI lands in later commits). Coverage: 35 tests across reducer, store, parser. All green. --- js/reviews/src/headless.ts | 22 +- js/reviews/src/index.ts | 2 +- js/reviews/src/store/export.ts | 32 +++ js/reviews/src/store/persistence.ts | 73 ++++++ js/reviews/src/store/reducer.test.ts | 147 ++++++++++++ js/reviews/src/store/reducer.ts | 101 +++++++++ js/reviews/src/store/store.test.ts | 216 ++++++++++++++++++ js/reviews/src/store/store.ts | 323 +++++++++++++++++++++++++++ js/reviews/src/store/types.ts | 71 ++++++ 9 files changed, 985 insertions(+), 2 deletions(-) create mode 100644 js/reviews/src/store/export.ts create mode 100644 js/reviews/src/store/persistence.ts create mode 100644 js/reviews/src/store/reducer.test.ts create mode 100644 js/reviews/src/store/reducer.ts create mode 100644 js/reviews/src/store/store.test.ts create mode 100644 js/reviews/src/store/store.ts create mode 100644 js/reviews/src/store/types.ts diff --git a/js/reviews/src/headless.ts b/js/reviews/src/headless.ts index cb0ff5c3..50a45262 100644 --- a/js/reviews/src/headless.ts +++ b/js/reviews/src/headless.ts @@ -1 +1,21 @@ -export {}; +export { createReviewStore } from "./store/store.js"; +export type { CreateReviewStoreOptions, ReviewStore } from "./store/store.js"; +export type { + Comment, + CommentFilter, + CommentScope, + CommentScopeKind, + ReviewState, +} from "./store/types.js"; +export { + type ReviewExportV1, + exportReview, + isReviewExportV1, +} from "./store/export.js"; +export { createLocalStoragePersistence } from "./store/persistence.js"; +export type { PersistenceAdapter } from "./store/persistence.js"; + +export { parsePatch, parsePatches } from "./parser.js"; +export { loadFromFiles, loadFromText } from "./io.js"; +export { hashItemId } from "./util/hashItemId.js"; +export type * from "./types.js"; diff --git a/js/reviews/src/index.ts b/js/reviews/src/index.ts index cb0ff5c3..7a5a8f0d 100644 --- a/js/reviews/src/index.ts +++ b/js/reviews/src/index.ts @@ -1 +1 @@ -export {}; +export * from "./headless.js"; diff --git a/js/reviews/src/store/export.ts b/js/reviews/src/store/export.ts new file mode 100644 index 00000000..eeddf488 --- /dev/null +++ b/js/reviews/src/store/export.ts @@ -0,0 +1,32 @@ +import type { ReviewItem } from "../types.js"; +import type { Comment, ReviewState } from "./types.js"; + +export interface ReviewExportV1 { + schemaVersion: 1; + exportedAt: string; + items: ReviewItem[]; + comments: Comment[]; +} + +export function exportReview( + state: ReviewState, + now: () => string = () => new Date().toISOString(), +): ReviewExportV1 { + return { + schemaVersion: 1, + exportedAt: now(), + items: state.items, + comments: Object.values(state.comments), + }; +} + +export function isReviewExportV1(value: unknown): value is ReviewExportV1 { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + v.schemaVersion === 1 && + Array.isArray(v.items) && + Array.isArray(v.comments) && + typeof v.exportedAt === "string" + ); +} diff --git a/js/reviews/src/store/persistence.ts b/js/reviews/src/store/persistence.ts new file mode 100644 index 00000000..73b398ec --- /dev/null +++ b/js/reviews/src/store/persistence.ts @@ -0,0 +1,73 @@ +import type { ReviewState } from "./types.js"; +import { isReviewExportV1 } from "./export.js"; + +const KEY_PREFIX = "@amika/reviews/"; + +export interface PersistenceAdapter { + load(): Partial | null; + save(state: ReviewState): void; + subscribeRemote(listener: (state: Partial) => void): () => void; +} + +/** + * localStorage-backed adapter, scoped by `key`. Read/write failures (quota, + * SSR, disabled storage) are swallowed so the store still functions. + */ +export function createLocalStoragePersistence( + key: string, + storage: Storage | undefined = typeof window === "undefined" + ? undefined + : window.localStorage, +): PersistenceAdapter { + const storageKey = KEY_PREFIX + key; + + return { + load() { + if (!storage) return null; + try { + const raw = storage.getItem(storageKey); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!isReviewExportV1(parsed)) return null; + const comments: ReviewState["comments"] = {}; + for (const c of parsed.comments) comments[c.id] = c; + return { items: parsed.items, comments }; + } catch { + return null; + } + }, + + save(state) { + if (!storage) return; + try { + const payload = { + schemaVersion: 1 as const, + exportedAt: new Date().toISOString(), + items: state.items, + comments: Object.values(state.comments), + }; + storage.setItem(storageKey, JSON.stringify(payload)); + } catch { + // ignore quota / serialization failures + } + }, + + subscribeRemote(listener) { + if (typeof window === "undefined") return () => {}; + const handler = (e: StorageEvent) => { + if (e.key !== storageKey || e.newValue === null) return; + try { + const parsed = JSON.parse(e.newValue); + if (!isReviewExportV1(parsed)) return; + const comments: ReviewState["comments"] = {}; + for (const c of parsed.comments) comments[c.id] = c; + listener({ items: parsed.items, comments }); + } catch { + // ignore + } + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + }, + }; +} diff --git a/js/reviews/src/store/reducer.test.ts b/js/reviews/src/store/reducer.test.ts new file mode 100644 index 00000000..83448365 --- /dev/null +++ b/js/reviews/src/store/reducer.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { initialState, reducer } from "./reducer.js"; +import type { Comment, ReviewState } from "./types.js"; + +function makeComment(over: Partial = {}): Comment { + return { + id: "c1", + parentId: null, + scope: { kind: "series" }, + body: "hello", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + resolved: false, + ...over, + }; +} + +function withComments(...cs: Comment[]): ReviewState { + const comments: Record = {}; + for (const c of cs) comments[c.id] = c; + return { ...initialState, comments }; +} + +describe("reducer", () => { + it("LOAD_ITEMS replaces items and clears selection", () => { + const state: ReviewState = { + ...initialState, + selection: { itemId: "a", path: "x.ts" }, + }; + const next = reducer(state, { + type: "LOAD_ITEMS", + items: [{ id: "id1", kind: "file", path: "x.ts", content: "x" }], + }); + expect(next.items).toHaveLength(1); + expect(next.selection).toEqual({ itemId: null, path: null }); + }); + + it("ADD_ITEM appends without clearing selection", () => { + const state: ReviewState = { + ...initialState, + items: [{ id: "id1", kind: "file", path: "a.ts", content: "x" }], + selection: { itemId: "id1", path: "a.ts" }, + }; + const next = reducer(state, { + type: "ADD_ITEM", + item: { id: "id2", kind: "file", path: "b.ts", content: "y" }, + }); + expect(next.items).toHaveLength(2); + expect(next.selection.itemId).toBe("id1"); + }); + + it("ADD_COMMENT inserts top-level comments", () => { + const c = makeComment(); + const next = reducer(initialState, { type: "ADD_COMMENT", comment: c }); + expect(next.comments[c.id]).toEqual(c); + }); + + it("ADD_COMMENT rejects replies whose parent does not exist", () => { + const reply = makeComment({ id: "c2", parentId: "ghost" }); + const next = reducer(initialState, { + type: "ADD_COMMENT", + comment: reply, + }); + expect(next).toBe(initialState); + }); + + it("EDIT_COMMENT updates body and updatedAt", () => { + const c = makeComment(); + const state = withComments(c); + const next = reducer(state, { + type: "EDIT_COMMENT", + id: c.id, + body: "new", + updatedAt: "2026-02-01T00:00:00.000Z", + }); + expect(next.comments[c.id].body).toBe("new"); + expect(next.comments[c.id].updatedAt).toBe("2026-02-01T00:00:00.000Z"); + expect(next.comments[c.id].createdAt).toBe(c.createdAt); + }); + + it("EDIT_COMMENT is a no-op for unknown ids", () => { + const next = reducer(initialState, { + type: "EDIT_COMMENT", + id: "ghost", + body: "x", + updatedAt: "x", + }); + expect(next).toBe(initialState); + }); + + it("DELETE_COMMENT removes the comment and all descendants", () => { + const root = makeComment({ id: "root" }); + const child = makeComment({ id: "child", parentId: "root" }); + const grand = makeComment({ id: "grand", parentId: "child" }); + const unrelated = makeComment({ id: "other" }); + const state = withComments(root, child, grand, unrelated); + const next = reducer(state, { type: "DELETE_COMMENT", id: "root" }); + expect(Object.keys(next.comments)).toEqual(["other"]); + }); + + it("SET_RESOLVED toggles and is idempotent", () => { + const c = makeComment({ resolved: false }); + const state = withComments(c); + const resolved = reducer(state, { + type: "SET_RESOLVED", + id: c.id, + resolved: true, + updatedAt: "2026-02-01T00:00:00.000Z", + }); + expect(resolved.comments[c.id].resolved).toBe(true); + + const noop = reducer(resolved, { + type: "SET_RESOLVED", + id: c.id, + resolved: true, + updatedAt: "2026-03-01T00:00:00.000Z", + }); + expect(noop).toBe(resolved); + }); + + it("RESET returns the initial state", () => { + const state: ReviewState = { + items: [{ id: "id1", kind: "file", path: "a.ts", content: "x" }], + comments: { c1: makeComment() }, + selection: { itemId: "id1", path: "a.ts" }, + }; + const next = reducer(state, { type: "RESET" }); + expect(next).toEqual(initialState); + }); + + it("IMPORT replaces items + comments and clears selection", () => { + const state: ReviewState = { + ...initialState, + selection: { itemId: "x", path: "y" }, + }; + const next = reducer(state, { + type: "IMPORT", + payload: { + items: [{ id: "id1", kind: "file", path: "z.ts", content: "z" }], + comments: [makeComment({ id: "c1" }), makeComment({ id: "c2" })], + }, + }); + expect(next.items).toHaveLength(1); + expect(Object.keys(next.comments).sort()).toEqual(["c1", "c2"]); + expect(next.selection).toEqual({ itemId: null, path: null }); + }); +}); diff --git a/js/reviews/src/store/reducer.ts b/js/reviews/src/store/reducer.ts new file mode 100644 index 00000000..3323926b --- /dev/null +++ b/js/reviews/src/store/reducer.ts @@ -0,0 +1,101 @@ +import type { Action, Comment, ReviewState } from "./types.js"; + +export const initialState: ReviewState = { + items: [], + comments: {}, + selection: { itemId: null, path: null }, +}; + +export function reducer(state: ReviewState, action: Action): ReviewState { + switch (action.type) { + case "LOAD_ITEMS": + return { + ...state, + items: action.items, + selection: { itemId: null, path: null }, + }; + + case "ADD_ITEM": + return { ...state, items: [...state.items, action.item] }; + + case "RESET": + return initialState; + + case "SELECT_FILE": + return { + ...state, + selection: { itemId: action.itemId, path: action.path }, + }; + + case "ADD_COMMENT": { + if ( + action.comment.parentId !== null && + !state.comments[action.comment.parentId] + ) { + return state; + } + return { + ...state, + comments: { ...state.comments, [action.comment.id]: action.comment }, + }; + } + + case "EDIT_COMMENT": { + const existing = state.comments[action.id]; + if (!existing) return state; + const next: Comment = { + ...existing, + body: action.body, + updatedAt: action.updatedAt, + }; + return { ...state, comments: { ...state.comments, [action.id]: next } }; + } + + case "DELETE_COMMENT": { + if (!state.comments[action.id]) return state; + const toRemove = collectDescendants(state.comments, action.id); + const comments = { ...state.comments }; + for (const id of toRemove) delete comments[id]; + return { ...state, comments }; + } + + case "SET_RESOLVED": { + const existing = state.comments[action.id]; + if (!existing || existing.resolved === action.resolved) return state; + const next: Comment = { + ...existing, + resolved: action.resolved, + updatedAt: action.updatedAt, + }; + return { ...state, comments: { ...state.comments, [action.id]: next } }; + } + + case "IMPORT": { + const comments: Record = {}; + for (const c of action.payload.comments) comments[c.id] = c; + return { + items: action.payload.items, + comments, + selection: { itemId: null, path: null }, + }; + } + } +} + +function collectDescendants( + comments: Record, + rootId: string, +): Set { + const ids = new Set([rootId]); + let added = true; + while (added) { + added = false; + for (const c of Object.values(comments)) { + if (c.parentId && ids.has(c.parentId) && !ids.has(c.id)) { + ids.add(c.id); + added = true; + } + } + } + return ids; +} diff --git a/js/reviews/src/store/store.test.ts b/js/reviews/src/store/store.test.ts new file mode 100644 index 00000000..659de29d --- /dev/null +++ b/js/reviews/src/store/store.test.ts @@ -0,0 +1,216 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createReviewStore } from "./store.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const fixture = (name: string) => + readFileSync(join(HERE, "..", "test", "fixtures", name), "utf8"); + +interface TestStoreOpts { + initialIds?: number; + initialTime?: number; +} + +function makeStore(opts: TestStoreOpts = {}) { + let id = opts.initialIds ?? 1; + let t = opts.initialTime ?? Date.UTC(2026, 0, 1); + return createReviewStore({ + newCommentId: () => `cmt_${id++}`, + now: () => { + const iso = new Date(t).toISOString(); + t += 1000; + return iso; + }, + }); +} + +describe("createReviewStore — loading", () => { + it("loadFromText parses a patch and exposes items", () => { + const store = makeStore(); + const items = store.loadFromText(fixture("single-file.patch")); + expect(items).toHaveLength(1); + expect(store.getItems()).toEqual(items); + }); + + it("listFiles enumerates paths across items", () => { + const store = makeStore(); + store.loadFromText([ + fixture("single-file.patch"), + fixture("multi-file.patch"), + ]); + const files = store.listFiles(); + expect(files.length).toBeGreaterThanOrEqual(4); + const paths = files.map((f) => f.path).sort(); + expect(paths).toContain("src/hello.ts"); + expect(paths).toContain("src/a.ts"); + expect(paths).toContain("src/b.ts"); + expect(paths).toContain("src/c.ts"); + }); + + it("listFiles filters by itemId", () => { + const store = makeStore(); + const [a, b] = store.loadFromText([ + fixture("single-file.patch"), + fixture("multi-file.patch"), + ]); + expect(store.listFiles(a.id)).toHaveLength(1); + expect(store.listFiles(b.id).length).toBeGreaterThan(1); + }); +}); + +describe("createReviewStore — comments", () => { + it("addComment creates a top-level comment with author + timestamps", () => { + const store = makeStore(); + const [item] = store.loadFromText(fixture("single-file.patch")); + const c = store.addComment({ + scope: { + kind: "line", + itemId: item.id, + path: "src/hello.ts", + line: 2, + side: "new", + }, + body: "why?", + author: "alice", + }); + expect(c.id).toBe("cmt_1"); + expect(c.parentId).toBeNull(); + expect(c.author).toBe("alice"); + expect(c.createdAt).toBe(c.updatedAt); + expect(store.getComment(c.id)).toEqual(c); + }); + + it("reply creates a comment with inherited scope and parentId", () => { + const store = makeStore(); + const [item] = store.loadFromText(fixture("single-file.patch")); + const root = store.addComment({ + scope: { kind: "file", itemId: item.id, path: "src/hello.ts" }, + body: "root", + }); + const reply = store.reply(root.id, "reply"); + expect(reply.parentId).toBe(root.id); + expect(reply.scope).toEqual(root.scope); + }); + + it("getComments filters by scope kind, itemId, path, and line", () => { + const store = makeStore(); + const [item] = store.loadFromText(fixture("single-file.patch")); + store.addComment({ scope: { kind: "series" }, body: "series" }); + store.addComment({ + scope: { kind: "item", itemId: item.id }, + body: "item", + }); + store.addComment({ + scope: { kind: "file", itemId: item.id, path: "src/hello.ts" }, + body: "file", + }); + store.addComment({ + scope: { kind: "line", itemId: item.id, path: "src/hello.ts", line: 2 }, + body: "line", + }); + + expect(store.getComments({ scope: "series" })).toHaveLength(1); + expect(store.getComments({ itemId: item.id })).toHaveLength(3); + expect(store.getComments({ path: "src/hello.ts" })).toHaveLength(2); + expect(store.getComments({ line: 2 })).toHaveLength(1); + }); + + it("editComment updates body and bumps updatedAt", () => { + const store = makeStore(); + const c = store.addComment({ scope: { kind: "series" }, body: "v1" }); + const updated = store.editComment(c.id, "v2"); + expect(updated?.body).toBe("v2"); + expect(updated?.updatedAt).not.toBe(c.updatedAt); + }); + + it("deleteComment removes the comment and all descendants", () => { + const store = makeStore(); + const root = store.addComment({ scope: { kind: "series" }, body: "root" }); + const child = store.reply(root.id, "child"); + store.reply(child.id, "grand"); + const other = store.addComment({ + scope: { kind: "series" }, + body: "other", + }); + store.deleteComment(root.id); + expect(store.getComments().map((c) => c.id)).toEqual([other.id]); + }); + + it("setResolved toggles resolved flag", () => { + const store = makeStore(); + const c = store.addComment({ scope: { kind: "series" }, body: "x" }); + expect(store.setResolved(c.id, true)?.resolved).toBe(true); + expect(store.setResolved(c.id, false)?.resolved).toBe(false); + }); + + it("reply throws on unknown parent", () => { + const store = makeStore(); + expect(() => store.reply("ghost", "hi")).toThrow(); + }); + + it("getThread returns root + descendants in createdAt order", () => { + const store = makeStore(); + const root = store.addComment({ scope: { kind: "series" }, body: "root" }); + const a = store.reply(root.id, "a"); + const b = store.reply(root.id, "b"); + const aa = store.reply(a.id, "aa"); + expect(store.getThread(root.id).map((c) => c.id)).toEqual([ + root.id, + a.id, + b.id, + aa.id, + ]); + }); +}); + +describe("createReviewStore — export/import + persistence", () => { + it("export → import round-trips state", () => { + const a = makeStore(); + a.loadFromText(fixture("single-file.patch")); + const [item] = a.getItems(); + a.addComment({ + scope: { kind: "line", itemId: item.id, path: "src/hello.ts", line: 2 }, + body: "Q", + }); + const snapshot = a.export(); + + const b = makeStore({ initialIds: 999 }); + b.import(snapshot); + expect(b.getItems()).toEqual(a.getItems()); + expect(b.getComments()).toEqual(a.getComments()); + }); + + it("subscribe fires on every state change", () => { + const store = makeStore(); + let calls = 0; + const unsubscribe = store.subscribe(() => { + calls++; + }); + store.loadFromText(fixture("single-file.patch")); + store.addComment({ scope: { kind: "series" }, body: "x" }); + expect(calls).toBe(2); + unsubscribe(); + store.reset(); + expect(calls).toBe(2); + }); + + it("custom persistence adapter is read on init and written on changes", () => { + let saved: unknown = null; + const fakeAdapter = { + load: () => null, + save: (s: unknown) => { + saved = s; + }, + subscribeRemote: () => () => {}, + }; + const store = createReviewStore({ + now: () => "2026-01-01T00:00:00.000Z", + newCommentId: () => "cmt_x", + persistence: fakeAdapter, + }); + store.addComment({ scope: { kind: "series" }, body: "hi" }); + expect(saved).not.toBeNull(); + }); +}); diff --git a/js/reviews/src/store/store.ts b/js/reviews/src/store/store.ts new file mode 100644 index 00000000..cf832bf0 --- /dev/null +++ b/js/reviews/src/store/store.ts @@ -0,0 +1,323 @@ +import type { CommentId, ReviewItem, ReviewItemId } from "../types.js"; +import { loadFromFiles, loadFromText } from "../io.js"; +import { + type ReviewExportV1, + exportReview, + isReviewExportV1, +} from "./export.js"; +import { initialState, reducer } from "./reducer.js"; +import { + type PersistenceAdapter, + createLocalStoragePersistence, +} from "./persistence.js"; +import type { + Action, + Comment, + CommentFilter, + CommentScope, + ReviewState, +} from "./types.js"; + +export interface CreateReviewStoreOptions { + initialItems?: ReviewItem[]; + /** + * When set, state is persisted to localStorage under the matching key and + * re-hydrated on construction. Use any string; values from different keys + * never collide. + */ + persistKey?: string; + /** Default author attached to comments that don't specify their own. */ + author?: string; + /** Inject for tests; defaults to `() => new Date().toISOString()`. */ + now?: () => string; + /** Inject for tests; defaults to `crypto.randomUUID()` with a `cmt_` prefix. */ + newCommentId?: () => CommentId; + /** Inject a custom persistence adapter (e.g. for tests or non-browser hosts). */ + persistence?: PersistenceAdapter; +} + +export interface ReviewStore { + // Read + getState(): ReviewState; + subscribe(listener: (state: ReviewState) => void): () => void; + + // Loading + loadFromText(input: string | string[]): ReviewItem[]; + loadFromFiles(files: File[]): Promise; + addItem(item: ReviewItem): void; + reset(): void; + + // Navigation + getItems(): ReviewItem[]; + getItem(itemId: ReviewItemId): ReviewItem | undefined; + listFiles(itemId?: ReviewItemId): { itemId: ReviewItemId; path: string }[]; + getSelection(): { itemId: ReviewItemId | null; path: string | null }; + selectFile(itemId: ReviewItemId | null, path: string | null): void; + + // Comments + addComment(input: { + scope: CommentScope; + body: string; + author?: string; + }): Comment; + reply(parentId: CommentId, body: string, author?: string): Comment; + editComment(id: CommentId, body: string): Comment | undefined; + deleteComment(id: CommentId): void; + setResolved(id: CommentId, resolved: boolean): Comment | undefined; + getComment(id: CommentId): Comment | undefined; + getComments(filter?: CommentFilter): Comment[]; + getThread(rootId: CommentId): Comment[]; + + // Snapshot + export(): ReviewExportV1; + import(snapshot: ReviewExportV1): void; +} + +export function createReviewStore( + options: CreateReviewStoreOptions = {}, +): ReviewStore { + const now = options.now ?? (() => new Date().toISOString()); + const newCommentId = options.newCommentId ?? defaultCommentId; + const persistence = + options.persistence ?? + (options.persistKey + ? createLocalStoragePersistence(options.persistKey) + : null); + + let state: ReviewState = { + ...initialState, + items: options.initialItems ?? [], + }; + + if (persistence) { + const loaded = persistence.load(); + if (loaded) { + state = { + items: loaded.items ?? state.items, + comments: loaded.comments ?? state.comments, + selection: { itemId: null, path: null }, + }; + } + } + + const listeners = new Set<(state: ReviewState) => void>(); + + function setState(next: ReviewState) { + if (next === state) return; + state = next; + for (const l of listeners) l(state); + persistence?.save(state); + } + + function dispatch(action: Action) { + setState(reducer(state, action)); + } + + if (persistence) { + persistence.subscribeRemote((partial) => { + setState({ + ...state, + items: partial.items ?? state.items, + comments: partial.comments ?? state.comments, + }); + }); + } + + function getItem(itemId: ReviewItemId): ReviewItem | undefined { + return state.items.find((i) => i.id === itemId); + } + + function listFiles(itemId?: ReviewItemId) { + const items = itemId + ? state.items.filter((i) => i.id === itemId) + : state.items; + const out: { itemId: ReviewItemId; path: string }[] = []; + for (const item of items) { + for (const path of pathsForItem(item)) { + out.push({ itemId: item.id, path }); + } + } + return out; + } + + function commentMatchesFilter(c: Comment, f: CommentFilter): boolean { + if (f.scope && c.scope.kind !== f.scope) return false; + if (f.itemId !== undefined) { + if (c.scope.kind === "series") return false; + if ("itemId" in c.scope && c.scope.itemId !== f.itemId) return false; + } + if (f.path !== undefined) { + if (c.scope.kind !== "line" && c.scope.kind !== "file") return false; + if (c.scope.path !== f.path) return false; + } + if (f.line !== undefined) { + if (c.scope.kind !== "line") return false; + if (c.scope.line !== f.line) return false; + } + return true; + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + loadFromText(input) { + const items = loadFromText(input); + dispatch({ type: "LOAD_ITEMS", items }); + return items; + }, + + async loadFromFiles(files) { + const items = await loadFromFiles(files); + dispatch({ type: "LOAD_ITEMS", items }); + return items; + }, + + addItem(item) { + dispatch({ type: "ADD_ITEM", item }); + }, + + reset() { + dispatch({ type: "RESET" }); + }, + + getItems: () => state.items, + getItem, + listFiles, + getSelection: () => state.selection, + + selectFile(itemId, path) { + dispatch({ type: "SELECT_FILE", itemId, path }); + }, + + addComment(input) { + const ts = now(); + const comment: Comment = { + id: newCommentId(), + parentId: null, + scope: input.scope, + body: input.body, + author: input.author ?? options.author, + createdAt: ts, + updatedAt: ts, + resolved: false, + }; + dispatch({ type: "ADD_COMMENT", comment }); + return comment; + }, + + reply(parentId, body, author) { + const parent = state.comments[parentId]; + if (!parent) { + throw new Error(`reply: parent comment ${parentId} not found`); + } + const ts = now(); + const comment: Comment = { + id: newCommentId(), + parentId, + scope: parent.scope, + body, + author: author ?? options.author, + createdAt: ts, + updatedAt: ts, + resolved: false, + }; + dispatch({ type: "ADD_COMMENT", comment }); + return comment; + }, + + editComment(id, body) { + if (!state.comments[id]) return undefined; + dispatch({ type: "EDIT_COMMENT", id, body, updatedAt: now() }); + return state.comments[id]; + }, + + deleteComment(id) { + dispatch({ type: "DELETE_COMMENT", id }); + }, + + setResolved(id, resolved) { + if (!state.comments[id]) return undefined; + dispatch({ type: "SET_RESOLVED", id, resolved, updatedAt: now() }); + return state.comments[id]; + }, + + getComment: (id) => state.comments[id], + + getComments(filter) { + const all = Object.values(state.comments); + if (!filter) return all; + return all.filter((c) => commentMatchesFilter(c, filter)); + }, + + getThread(rootId) { + const root = state.comments[rootId]; + if (!root) return []; + const result: Comment[] = [root]; + const seen = new Set([rootId]); + let changed = true; + while (changed) { + changed = false; + for (const c of Object.values(state.comments)) { + if (c.parentId && seen.has(c.parentId) && !seen.has(c.id)) { + result.push(c); + seen.add(c.id); + changed = true; + } + } + } + result.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + return result; + }, + + export: () => exportReview(state, now), + + import(snapshot) { + if (!isReviewExportV1(snapshot)) { + throw new Error("import: invalid ReviewExportV1 payload"); + } + dispatch({ + type: "IMPORT", + payload: { items: snapshot.items, comments: snapshot.comments }, + }); + }, + }; +} + +function pathsForItem(item: ReviewItem): string[] { + switch (item.kind) { + case "patch": { + if (!item.parsed) return []; + const out: string[] = []; + for (const f of item.parsed.files) { + const p = f.to ?? f.from; + if (p) out.push(p); + } + return out; + } + case "multi-file-diff": { + const set = new Set([ + ...Object.keys(item.before), + ...Object.keys(item.after), + ]); + return [...set]; + } + case "file": + case "unresolved-file": + return [item.path]; + case "file-diff": + return []; + } +} + +function defaultCommentId(): CommentId { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return "cmt_" + crypto.randomUUID().replace(/-/g, "").slice(0, 16); + } + return "cmt_" + Math.random().toString(36).slice(2, 18); +} diff --git a/js/reviews/src/store/types.ts b/js/reviews/src/store/types.ts new file mode 100644 index 00000000..a4002ef6 --- /dev/null +++ b/js/reviews/src/store/types.ts @@ -0,0 +1,71 @@ +import type { CommentId, ReviewItem, ReviewItemId, Side } from "../types.js"; + +export type CommentScope = + | { + kind: "line"; + itemId: ReviewItemId; + path: string; + line: number; + side?: Side; + } + | { kind: "file"; itemId: ReviewItemId; path: string } + | { kind: "item"; itemId: ReviewItemId } + | { kind: "series" }; + +export type CommentScopeKind = CommentScope["kind"]; + +export interface Comment { + id: CommentId; + parentId: CommentId | null; + scope: CommentScope; + body: string; + author?: string; + createdAt: string; + updatedAt: string; + resolved: boolean; +} + +export interface ReviewState { + items: ReviewItem[]; + comments: Record; + selection: { itemId: ReviewItemId | null; path: string | null }; +} + +export interface CommentFilter { + scope?: CommentScopeKind; + itemId?: ReviewItemId; + path?: string; + line?: number; +} + +/** + * Reducer action payloads. All IDs and timestamps are supplied by the caller + * (createReviewStore) so the reducer itself stays pure and deterministic. + */ +export type Action = + | { type: "LOAD_ITEMS"; items: ReviewItem[] } + | { type: "ADD_ITEM"; item: ReviewItem } + | { type: "RESET" } + | { + type: "SELECT_FILE"; + itemId: ReviewItemId | null; + path: string | null; + } + | { type: "ADD_COMMENT"; comment: Comment } + | { + type: "EDIT_COMMENT"; + id: CommentId; + body: string; + updatedAt: string; + } + | { type: "DELETE_COMMENT"; id: CommentId } + | { + type: "SET_RESOLVED"; + id: CommentId; + resolved: boolean; + updatedAt: string; + } + | { + type: "IMPORT"; + payload: { items: ReviewItem[]; comments: Comment[] }; + }; From e9bdfdc5a402916ccb55644c8aaec99c0d97e730 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Fri, 15 May 2026 04:08:50 +0000 Subject: [PATCH 05/14] Add React provider, hooks, and public React surface ReviewProvider creates (or accepts) a store and exposes it via a single context. State subscription is via useSyncExternalStore so re-renders are driven directly by the store's listener set. Hooks: useReview (the API, stable identity), useReviewState (full state), usePatches, useComments (memoized by filter slices), useSelectedFile (resolves selection against current items, returns null when stale). Index re-exports the React surface alongside the headless API, so consumers can `import { ReviewProvider, useReview } from "@amika/reviews"` without pulling pierre deps until they reach for a UI component. 8 RTL tests cover stable identity, mount-only construction, reactive updates, filtered comment queries, and stale-selection resolution. Total 43 tests across the package, all green. --- js/reviews/src/react/ReviewProvider.test.tsx | 125 +++++++++++++++++++ js/reviews/src/react/ReviewProvider.tsx | 49 ++++++++ js/reviews/src/react/context.ts | 4 + js/reviews/src/react/hooks.ts | 78 ++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 js/reviews/src/react/ReviewProvider.test.tsx create mode 100644 js/reviews/src/react/ReviewProvider.tsx create mode 100644 js/reviews/src/react/context.ts create mode 100644 js/reviews/src/react/hooks.ts diff --git a/js/reviews/src/react/ReviewProvider.test.tsx b/js/reviews/src/react/ReviewProvider.test.tsx new file mode 100644 index 00000000..4398b419 --- /dev/null +++ b/js/reviews/src/react/ReviewProvider.test.tsx @@ -0,0 +1,125 @@ +import { act, render, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ReviewProvider } from "./ReviewProvider.js"; +import { + useComments, + usePatches, + useReview, + useReviewState, + useSelectedFile, +} from "./hooks.js"; +import { createReviewStore } from "../store/store.js"; + +function wrapper(store: ReturnType) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +function makeStore() { + let id = 1; + let t = Date.UTC(2026, 0, 1); + return createReviewStore({ + newCommentId: () => `cmt_${id++}`, + now: () => { + const iso = new Date(t).toISOString(); + t += 1000; + return iso; + }, + }); +} + +describe("ReviewProvider + hooks", () => { + it("useReview returns the same store across renders", () => { + const store = makeStore(); + const { result, rerender } = renderHook(() => useReview(), { + wrapper: wrapper(store), + }); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + expect(first).toBe(store); + }); + + it("useReview throws outside of a provider", () => { + expect(() => renderHook(() => useReview())).toThrow(); + }); + + it("useReviewState re-renders when state changes", () => { + const store = makeStore(); + const { result } = renderHook(() => useReviewState(), { + wrapper: wrapper(store), + }); + expect(result.current.items).toHaveLength(0); + act(() => { + store.addItem({ + id: "id1", + kind: "file", + path: "a.ts", + content: "x", + }); + }); + expect(result.current.items).toHaveLength(1); + }); + + it("usePatches mirrors store.getItems()", () => { + const store = makeStore(); + store.addItem({ id: "id1", kind: "file", path: "a.ts", content: "x" }); + const { result } = renderHook(() => usePatches(), { + wrapper: wrapper(store), + }); + expect(result.current).toHaveLength(1); + expect(result.current[0].id).toBe("id1"); + }); + + it("useComments filters and updates on additions", () => { + const store = makeStore(); + store.addItem({ id: "id1", kind: "file", path: "a.ts", content: "x" }); + const { result } = renderHook( + () => useComments({ scope: "file", itemId: "id1" }), + { wrapper: wrapper(store) }, + ); + expect(result.current).toHaveLength(0); + act(() => { + store.addComment({ + scope: { kind: "file", itemId: "id1", path: "a.ts" }, + body: "Q", + }); + }); + expect(result.current).toHaveLength(1); + }); + + it("useSelectedFile resolves a valid selection", () => { + const store = makeStore(); + store.addItem({ id: "id1", kind: "file", path: "a.ts", content: "x" }); + const { result } = renderHook(() => useSelectedFile(), { + wrapper: wrapper(store), + }); + expect(result.current).toBeNull(); + act(() => { + store.selectFile("id1", "a.ts"); + }); + expect(result.current?.path).toBe("a.ts"); + expect(result.current?.item.id).toBe("id1"); + }); + + it("useSelectedFile returns null for a stale selection", () => { + const store = makeStore(); + store.addItem({ id: "id1", kind: "file", path: "a.ts", content: "x" }); + store.selectFile("does-not-exist", "a.ts"); + const { result } = renderHook(() => useSelectedFile(), { + wrapper: wrapper(store), + }); + expect(result.current).toBeNull(); + }); + + it("renders children", () => { + const store = makeStore(); + const { container } = render( + +
hi
+
, + ); + expect(container.querySelector('[data-testid="child"]')).toBeTruthy(); + }); +}); diff --git a/js/reviews/src/react/ReviewProvider.tsx b/js/reviews/src/react/ReviewProvider.tsx new file mode 100644 index 00000000..7bb5c41c --- /dev/null +++ b/js/reviews/src/react/ReviewProvider.tsx @@ -0,0 +1,49 @@ +import { useMemo, type ReactNode } from "react"; +import { createReviewStore, type ReviewStore } from "../store/store.js"; +import type { ReviewItem } from "../types.js"; +import { ReviewStoreContext } from "./context.js"; + +export interface ReviewProviderProps { + /** Items the store starts with. Read once on mount; later changes are ignored. */ + initialItems?: ReviewItem[]; + /** Default author for comments that don't specify their own. */ + author?: string; + /** + * When set, the store persists to localStorage under this key and + * re-hydrates on mount. Omit for ephemeral sessions. + */ + persistKey?: string; + /** + * Inject a pre-built store. Lets the host control creation (e.g. for + * testing with deterministic ids, or for sharing one store between + * multiple providers). + */ + store?: ReviewStore; + children: ReactNode; +} + +/** + * React provider for @amika/reviews. Creates a framework-agnostic store on + * mount (or accepts a caller-supplied one) and exposes it via context so + * `useReview`, `useReviewState`, and the other hooks can subscribe. + */ +export function ReviewProvider({ + initialItems, + author, + persistKey, + store: storeProp, + children, +}: ReviewProviderProps) { + const store = useMemo( + () => storeProp ?? createReviewStore({ initialItems, author, persistKey }), + // Store is intentionally constructed once. Prop changes after mount + // are deliberately ignored so the store remains the source of truth. + [storeProp], + ); + + return ( + + {children} + + ); +} diff --git a/js/reviews/src/react/context.ts b/js/reviews/src/react/context.ts new file mode 100644 index 00000000..27628d0d --- /dev/null +++ b/js/reviews/src/react/context.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import type { ReviewStore } from "../store/store.js"; + +export const ReviewStoreContext = createContext(null); diff --git a/js/reviews/src/react/hooks.ts b/js/reviews/src/react/hooks.ts new file mode 100644 index 00000000..812d5715 --- /dev/null +++ b/js/reviews/src/react/hooks.ts @@ -0,0 +1,78 @@ +import { useContext, useMemo, useSyncExternalStore } from "react"; +import type { ReviewItem } from "../types.js"; +import type { ReviewStore } from "../store/store.js"; +import type { Comment, CommentFilter, ReviewState } from "../store/types.js"; +import { ReviewStoreContext } from "./context.js"; + +/** + * The store (and full ReviewAPI). Stable across renders. Throws if used + * outside of . + */ +export function useReview(): ReviewStore { + const store = useContext(ReviewStoreContext); + if (!store) { + throw new Error("useReview must be used inside "); + } + return store; +} + +/** Subscribe to the full review state. Re-renders on every change. */ +export function useReviewState(): ReviewState { + const store = useReview(); + return useSyncExternalStore(store.subscribe, store.getState, store.getState); +} + +/** All items currently loaded in the review (one per uploaded patch/file). */ +export function usePatches(): ReviewItem[] { + return useReviewState().items; +} + +/** + * Comments matching the supplied filter. The result is memoized against + * the relevant slices of state and filter so consumers can use it in + * dependency arrays safely. + */ +export function useComments(filter?: CommentFilter): Comment[] { + const state = useReviewState(); + const scope = filter?.scope; + const itemId = filter?.itemId; + const path = filter?.path; + const line = filter?.line; + return useMemo(() => { + const all = Object.values(state.comments); + if (!filter) return all; + return all.filter((c) => { + if (scope && c.scope.kind !== scope) return false; + if (itemId !== undefined) { + if (c.scope.kind === "series") return false; + if ("itemId" in c.scope && c.scope.itemId !== itemId) return false; + } + if (path !== undefined) { + if (c.scope.kind !== "line" && c.scope.kind !== "file") return false; + if (c.scope.path !== path) return false; + } + if (line !== undefined) { + if (c.scope.kind !== "line") return false; + if (c.scope.line !== line) return false; + } + return true; + }); + }, [state.comments, filter, scope, itemId, path, line]); +} + +/** + * Resolves the currently selected file (or null when nothing is selected + * or the selection is stale). + */ +export function useSelectedFile(): { + itemId: string; + path: string; + item: ReviewItem; +} | null { + const state = useReviewState(); + const { itemId, path } = state.selection; + if (!itemId || !path) return null; + const item = state.items.find((i) => i.id === itemId); + if (!item) return null; + return { itemId, path, item }; +} From e0518d19e3e8c9ca303b4c71c897a0abdbaebea8 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Fri, 15 May 2026 04:11:02 +0000 Subject: [PATCH 06/14] Add FileTreePanel backed by @pierre/trees/react FileTreePanel reads the unique set of file paths across all loaded items, renders them via useFileTree, and routes selection back to the store (or to an optional onSelectFile prop). Comment counts at file or line scope appear as row decorations on each file. Path collisions across items resolve to the most recently loaded item so a single tree view stays coherent when patches in a series touch the same file. (A future revision could namespace paths per item.) Two RTL smoke tests confirm the panel mounts in both loaded and empty states; deeper interaction lives in the Playwright suite since the tree's interior is rendered inside a shadow root. --- js/reviews/package.json | 1 + js/reviews/src/react/FileTreePanel.test.tsx | 39 ++++++++++++ js/reviews/src/react/FileTreePanel.tsx | 69 +++++++++++++++++++++ pnpm-lock.yaml | 30 +++++++++ 4 files changed, 139 insertions(+) create mode 100644 js/reviews/src/react/FileTreePanel.test.tsx create mode 100644 js/reviews/src/react/FileTreePanel.tsx diff --git a/js/reviews/package.json b/js/reviews/package.json index 0c7201d2..087f8205 100644 --- a/js/reviews/package.json +++ b/js/reviews/package.json @@ -37,6 +37,7 @@ "vitest": "^3.2.4" }, "dependencies": { + "@pierre/trees": "1.0.0-beta.3", "parse-diff": "^0.12.0" } } diff --git a/js/reviews/src/react/FileTreePanel.test.tsx b/js/reviews/src/react/FileTreePanel.test.tsx new file mode 100644 index 00000000..2b1b8827 --- /dev/null +++ b/js/reviews/src/react/FileTreePanel.test.tsx @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ReviewProvider } from "./ReviewProvider.js"; +import { FileTreePanel } from "./FileTreePanel.js"; +import { createReviewStore } from "../store/store.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const fixture = (name: string) => + readFileSync(join(HERE, "..", "test", "fixtures", name), "utf8"); + +describe("FileTreePanel", () => { + it("mounts without errors when items are loaded", () => { + const store = createReviewStore(); + store.loadFromText([ + fixture("single-file.patch"), + fixture("multi-file.patch"), + ]); + const { container } = render( + + + , + ); + // pierre renders into a shadow root, so we only assert host presence. + expect(container.querySelector(".tree")).toBeTruthy(); + }); + + it("mounts without errors when no items are loaded", () => { + const store = createReviewStore(); + const { container } = render( + + + , + ); + expect(container.querySelector(".empty")).toBeTruthy(); + }); +}); diff --git a/js/reviews/src/react/FileTreePanel.tsx b/js/reviews/src/react/FileTreePanel.tsx new file mode 100644 index 00000000..89c6b10f --- /dev/null +++ b/js/reviews/src/react/FileTreePanel.tsx @@ -0,0 +1,69 @@ +import { useMemo } from "react"; +import { FileTree, useFileTree } from "@pierre/trees/react"; +import { useComments, useReview, useReviewState } from "./hooks.js"; + +export interface FileTreePanelProps { + /** Optional class applied to the host element wrapping the tree. */ + className?: string; + /** + * Optional callback fired when a file is selected. Defaults to calling + * `store.selectFile(itemId, path)` against the most recently loaded item + * that contains the path. + */ + onSelectFile?: (itemId: string, path: string) => void; +} + +/** + * Left-rail file tree. Reads the current set of files from the store, renders + * them through `@pierre/trees/react`, and forwards selection back to the + * store so the diff viewer can react. Each file row carries a row decoration + * showing the count of comments scoped to that path (when non-zero). + */ +export function FileTreePanel({ className, onSelectFile }: FileTreePanelProps) { + const store = useReview(); + const state = useReviewState(); + const fileComments = useComments(); + + // Deduplicate paths across items; remember which item each path most + // recently came from so selection can route the right itemId back. + const { paths, itemForPath } = useMemo(() => { + const map = new Map(); // path -> itemId + for (const entry of store.listFiles()) { + map.set(entry.path, entry.itemId); + } + return { paths: [...map.keys()], itemForPath: map }; + // listFiles is derived from items, so we recompute when items change. + }, [store, state.items]); + + // Comment counts per path (only line + file scopes contribute). + const countsByPath = useMemo(() => { + const counts = new Map(); + for (const c of fileComments) { + if (c.scope.kind !== "line" && c.scope.kind !== "file") continue; + counts.set(c.scope.path, (counts.get(c.scope.path) ?? 0) + 1); + } + return counts; + }, [fileComments]); + + const { model } = useFileTree({ + paths, + initialExpansion: "open", + onSelectionChange: (selected) => { + if (selected.length === 0) return; + const path = selected[selected.length - 1]; + // Directories also fire selection events; route only files. + if (!itemForPath.has(path)) return; + const itemId = itemForPath.get(path)!; + if (onSelectFile) onSelectFile(itemId, path); + else store.selectFile(itemId, path); + }, + renderRowDecoration: ({ row }) => { + if (row.kind !== "file") return null; + const n = countsByPath.get(row.path); + if (!n) return null; + return { text: String(n), title: `${n} comment${n === 1 ? "" : "s"}` }; + }, + }); + + return ; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02883a19..783de0dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: js/reviews: dependencies: + '@pierre/trees': + specifier: 1.0.0-beta.3 + version: 1.0.0-beta.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) parse-diff: specifier: ^0.12.0 version: 0.12.0 @@ -325,6 +328,12 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@pierre/trees@1.0.0-beta.3': + resolution: {integrity: sha512-gfV7V1AoceIwTSFwiiWl/89gNtJROyo2dFeYYuAkT4F3AbE+ajCIGZEICBw1ygmVxVetF9Kq1Xpjz8BhXXZwTQ==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + '@rollup/rollup-android-arm-eabi@4.60.4': resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} cpu: [arm] @@ -1025,6 +1034,14 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@6.6.5: + resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==} + peerDependencies: + preact: '>=10 || >= 11.0.0-0' + + preact@11.0.0-beta.0: + resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1504,6 +1521,13 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@pierre/trees@1.0.0-beta.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + preact: 11.0.0-beta.0 + preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@rollup/rollup-android-arm-eabi@4.60.4': optional: true @@ -2200,6 +2224,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@6.6.5(preact@11.0.0-beta.0): + dependencies: + preact: 11.0.0-beta.0 + + preact@11.0.0-beta.0: {} + prelude-ls@1.2.1: {} prettier@3.8.3: {} From e12f25291766e7b071a855399bb7eec992f76600 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Fri, 15 May 2026 04:15:40 +0000 Subject: [PATCH 07/14] Add ItemView, line/file comment UI, and side-aware annotations ItemView dispatches by ReviewItem kind to the right @pierre/diffs/react component (PatchDiff, MultiFileDiff, FileDiff, File, UnresolvedFile), forwarding line-scope comments as DiffLineAnnotation / LineAnnotation. Side mapping: amika's "old" / "new" maps to pierre's "deletions" / "additions" at the annotation boundary so the diff renderer puts comments on the correct gutter. CommentForm is a small headless composer (textarea + submit + cancel). CommentThread renders root + replies with edit / delete / resolve actions and an inline reply form. FileCommentPanel composes the thread + a new- comment form scoped to a single file. tsconfig wires Vitest cleanup so each component test gets a fresh DOM (@testing-library/react auto-cleanup isn't detected under vitest). 57 tests pass: reducer, store, parser, annotations, plus full RTL coverage of CommentForm, CommentThread, FileCommentPanel, FileTreePanel mount, and ReviewProvider hooks. --- js/reviews/package.json | 1 + js/reviews/src/react/CommentForm.test.tsx | 38 +++ js/reviews/src/react/CommentForm.tsx | 64 ++++ js/reviews/src/react/CommentThread.test.tsx | 98 ++++++ js/reviews/src/react/CommentThread.tsx | 138 ++++++++ js/reviews/src/react/FileCommentPanel.tsx | 73 ++++ js/reviews/src/react/ItemView.tsx | 113 +++++++ js/reviews/src/react/annotations.test.ts | 92 +++++ js/reviews/src/react/annotations.ts | 64 ++++ js/reviews/src/store/store.ts | 2 +- js/reviews/src/test/setup.ts | 6 + js/reviews/src/types.ts | 12 +- pnpm-lock.yaml | 355 ++++++++++++++++++++ 13 files changed, 1053 insertions(+), 3 deletions(-) create mode 100644 js/reviews/src/react/CommentForm.test.tsx create mode 100644 js/reviews/src/react/CommentForm.tsx create mode 100644 js/reviews/src/react/CommentThread.test.tsx create mode 100644 js/reviews/src/react/CommentThread.tsx create mode 100644 js/reviews/src/react/FileCommentPanel.tsx create mode 100644 js/reviews/src/react/ItemView.tsx create mode 100644 js/reviews/src/react/annotations.test.ts create mode 100644 js/reviews/src/react/annotations.ts diff --git a/js/reviews/package.json b/js/reviews/package.json index 087f8205..fc438b68 100644 --- a/js/reviews/package.json +++ b/js/reviews/package.json @@ -37,6 +37,7 @@ "vitest": "^3.2.4" }, "dependencies": { + "@pierre/diffs": "^1.1.22", "@pierre/trees": "1.0.0-beta.3", "parse-diff": "^0.12.0" } diff --git a/js/reviews/src/react/CommentForm.test.tsx b/js/reviews/src/react/CommentForm.test.tsx new file mode 100644 index 00000000..6faf4797 --- /dev/null +++ b/js/reviews/src/react/CommentForm.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CommentForm } from "./CommentForm.js"; + +describe("CommentForm", () => { + it("disables submit when empty and enables when filled", () => { + const { getByRole } = render( {}} />); + const button = getByRole("button", { name: /comment/i }); + expect(button).toBeDisabled(); + fireEvent.change(getByRole("textbox"), { target: { value: "hi" } }); + expect(button).not.toBeDisabled(); + }); + + it("calls onSubmit with trimmed body and clears the input", () => { + const onSubmit = vi.fn(); + const { getByRole } = render(); + fireEvent.change(getByRole("textbox"), { target: { value: " hi " } }); + fireEvent.click(getByRole("button", { name: /comment/i })); + expect(onSubmit).toHaveBeenCalledWith("hi"); + expect(getByRole("textbox")).toHaveValue(""); + }); + + it("calls onCancel when cancel button is clicked", () => { + const onCancel = vi.fn(); + const { getByRole } = render( + {}} onCancel={onCancel} />, + ); + fireEvent.click(getByRole("button", { name: /cancel/i })); + expect(onCancel).toHaveBeenCalled(); + }); + + it("uses initialBody when provided", () => { + const { getByRole } = render( + {}} initialBody="hello" />, + ); + expect(getByRole("textbox")).toHaveValue("hello"); + }); +}); diff --git a/js/reviews/src/react/CommentForm.tsx b/js/reviews/src/react/CommentForm.tsx new file mode 100644 index 00000000..18039fce --- /dev/null +++ b/js/reviews/src/react/CommentForm.tsx @@ -0,0 +1,64 @@ +import { useState, type FormEvent } from "react"; + +export interface CommentFormProps { + /** Initial body when editing; empty for new comments. */ + initialBody?: string; + placeholder?: string; + submitLabel?: string; + onSubmit: (body: string) => void; + onCancel?: () => void; + className?: string; + autoFocus?: boolean; +} + +/** + * Bare comment composer: textarea + submit + optional cancel. Reused by new + * comments, replies, and edits. The component holds local draft state; submit + * only fires for non-empty bodies (whitespace trimmed). + */ +export function CommentForm({ + initialBody = "", + placeholder = "Leave a comment…", + submitLabel = "Comment", + onSubmit, + onCancel, + className, + autoFocus, +}: CommentFormProps) { + const [body, setBody] = useState(initialBody); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + const trimmed = body.trim(); + if (!trimmed) return; + onSubmit(trimmed); + setBody(""); + } + + return ( +
+