diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md new file mode 100644 index 0000000..78655d8 --- /dev/null +++ b/.claude/skills/testing/SKILL.md @@ -0,0 +1,60 @@ +--- +name: testing +description: NeedMark's procedure for adding, changing, running and pruning tests. Use whenever a change in this repository adds or alters behaviour, fixes a bug, touches tests under src/**/*.test.ts or tests/*.spec.ts, or when reviewing or trimming the test suites, so each check lands in the smallest layer that can catch it and the browser suite stays small. +--- + +# Testing in NeedMark + +The rules live in [`docs/testing.md`](../../../docs/testing.md); this skill is the order to apply them in. Read the Principles, Layers and sizes, and What not to test sections of that document before writing a test. + +## 1. Decide what to prove + +- Write the behaviour as one sentence a user or caller would recognise: "Leaving details hides the target outline", not "clearLocated hides outline". +- For a bug, that sentence is the expected behaviour the bug violates. You will reproduce it first. +- If the change only alters styling or copy, plan a demo check instead of a test. + +## 2. Pick the smallest layer + +Ask, in order, and stop at the first yes: + +1. Can it be called with plain data and no I/O? → small Vitest test next to the source file. +2. Does it need files or a localhost server, but no browser? → medium Vitest test (temporary directory, free port). +3. Does the outcome depend on layout, rendering, events, browser APIs, or the full widget–server–files flow? → Playwright spec. + +For a Playwright test, also decide whether WebKit could behave differently (rendering and capture, hit testing, carets and ranges, frames and shadow roots, stack formats, native dialogs, style inheritance, pointer and keyboard events, media). Only then add `{ tag: '@cross-engine' }`. + +## 3. Look for an existing test first + +- Search by behaviour: `grep -rn "" src --include='*.test.ts' tests/*.spec.ts`. +- If a test already drives the same flow and the new assertion belongs to the same behaviour, extend it. Otherwise add a new, focused test. +- If the same rule is already checked at a lower layer, do not add it in the browser. + +## 4. Write it + +- Arrange, act, assert, in straight lines; show the inputs that matter in the test body. +- Name the test after the behaviour and outcome. +- Playwright: import `test`, `expect` and helpers from `tests/support/review.ts`; locate by role, label and text; wait with web-first assertions or `expect.poll`, never with a fixed timeout; stub slow or flaky transports with `page.route`. +- Vitest: build data from `tests/fixtures.ts`; clean up temporary directories and servers in `afterEach`. +- For a bug, run the new test before the fix and confirm it fails for the reason you expect. + +## 5. Run + +- While iterating: `pnpm test `, or `pnpm build` then `pnpm test:e2e -g "" --project=chromium`. +- Before committing: `pnpm build`, `pnpm typecheck`, `pnpm test` and `pnpm test:e2e` all pass. A full E2E run takes about ten minutes; run it in the background and report the summary line. +- Check user-facing changes in the demo as described in docs/testing.md#manual-check. + +## 6. Leave the suite smaller or equal where you can + +- When your change makes a test redundant (same rule at a lower layer, or a flow another test already covers), delete or merge it in the same change. +- Remove assertions on styling or decoration you come across in the tests you touch. +- If you notice a flaky test, fix the race; do not add retries or longer timeouts. + +## Checklist + +- [ ] The behaviour is stated as a sentence and the test name says it. +- [ ] The test is at the smallest layer that can catch the failure. +- [ ] A bug fix has a test that failed before the fix. +- [ ] No styling, static copy or type-level guarantees are asserted. +- [ ] `@cross-engine` only where WebKit could behave differently. +- [ ] Redundant tests touched by the change were removed or merged. +- [ ] `pnpm build`, `pnpm typecheck`, `pnpm test`, `pnpm test:e2e` pass. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec2fa75..f7df178 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,3 +31,26 @@ jobs: - run: pnpm build - run: pnpm typecheck - run: pnpm test + + e2e: + name: E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium webkit + # Chromium runs headed for the native tab-picker tests, so it needs a virtual display. + - run: xvfb-run --auto-servernum pnpm test:e2e + - if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..9669e1e --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,32 @@ +name: Pages + +on: + push: + branches: [main] + paths: [site/**, .github/workflows/pages.yml] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 + with: + path: site + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index e8e426e..0e392c0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ test-results/ playwright-report/ *.tgz .DS_Store +examples/*/.needmark/ +examples/*/dist/ +examples/astro/.astro/ +examples/next/.next/ +examples/next/next-env.d.ts +examples/next/tsconfig.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md index 43340b6..057951e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,17 @@ Common rules for developing and maintaining NeedMark. They apply to every agent - `.mise.toml` and `package.json#packageManager` are the version source of truth. - Do not push, publish or create releases unless explicitly asked. Changing `package.json#version` on `main` publishes to npm ([`docs/releasing.md`](./docs/releasing.md)), so bump it only when a release is requested. +## Procedures and skills + +| Task | Reference | Claude Code skill | +| --- | --- | --- | +| Adding, changing, running or pruning tests | [`docs/testing.md`](./docs/testing.md) | `testing` | +| Writing or reviewing code comments in `src/` | This file, [Source layout](#source-layout) | `code-comments` | +| Checking a user-facing change by hand | [`docs/testing.md#manual-check`](./docs/testing.md#manual-check) | `testing` | +| Releasing to npm | [`docs/releasing.md`](./docs/releasing.md) | — | + +Skills live in `.claude/skills/`. They hold procedures only; rules and behaviour stay in this file and `docs/`, which a skill links to instead of repeating. + ## Product boundaries - Keep NeedMark independent of the hosted NeedBack service: no imports from its repository and no requirement on its API. @@ -21,7 +32,7 @@ Common rules for developing and maintaining NeedMark. They apply to every agent - `src/index.ts`, `src/proxy.ts` and `src/cli.ts` are the only public entries. Add another only with `package.json#exports` and `scripts/build.mjs`. - `src/shared/` is browser-safe code for both sides, `src/server/` is Node only, and `src/client/` is the browser bundle (`capture/`, `context/`). Shared code imports neither side nor `node:`; client code never imports server code or `node:`; server code never imports client code. `src/structure.test.ts` enforces this. -- Keep unit tests next to the file they test, and name files by responsibility inside their directory. +- Keep unit tests next to the file they test, and name files by responsibility inside their directory. Browser specs live in `tests/` and share setup through `tests/support/`. - Code comments explain why an implementation was chosen, in English and Japanese, and never restate the specification (`src/comments.test.ts` checks both languages are present; Claude Code loads the `code-comments` skill). **Tradeoff:** the guidelines below favor caution over speed. Use judgment for trivial tasks. @@ -58,9 +69,9 @@ Common rules for developing and maintaining NeedMark. They apply to every agent **Define success criteria. Loop until verified.** - Turn tasks into verifiable goals: "fix the bug" → "write a failing test that reproduces it, then make it pass"; "refactor X" → "tests pass before and after". -- Follow [`docs/testing.md`](./docs/testing.md) to choose test layers and for the manual check in the demo. -- Update existing tests in the same change when behavior changes intentionally. -- A task is not done until `pnpm typecheck`, `pnpm test`, `pnpm build` and `pnpm test:e2e` pass, and user-facing changes were checked in the demo. +- Follow [`docs/testing.md`](./docs/testing.md): test behaviour through public interfaces, at the smallest layer that can catch the failure, one behaviour per test. Browser tests are for what needs a real engine; tag them `@cross-engine` only when WebKit could behave differently. +- Update existing tests in the same change when behavior changes intentionally, and delete or merge tests the change makes redundant instead of adding more beside them. +- While iterating, run the tests the change touches. A task is not done until `pnpm typecheck`, `pnpm test`, `pnpm build` and `pnpm test:e2e` pass, and user-facing changes were checked in the demo. - For multi-step work, state a short plan with a verification for each step. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d01780d..57035d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,8 @@ The project rules for contributors and coding agents are in [AGENTS.md](AGENTS.m ## Pull requests - Keep each pull request focused on one change. -- Add or update tests as described in [docs/testing.md](docs/testing.md). -- Before opening it, make sure `pnpm build`, `pnpm typecheck`, `pnpm test` and `pnpm test:e2e` pass. For changes to packaging, the CLI or dependencies, also run `pnpm test:package-managers`. +- Add or update tests as described in [docs/testing.md](docs/testing.md): at the smallest layer that can catch the failure, one behaviour per test, and with a failing test first for bug fixes. Remove tests your change makes redundant. +- Before opening it, make sure `pnpm build`, `pnpm typecheck`, `pnpm test` and `pnpm test:e2e` pass (CI runs all four). Install the browsers once with `pnpm exec playwright install chromium webkit`. For changes to packaging, the CLI or dependencies, also run `pnpm test:package-managers`. - Check user-facing changes in the demo. - Leave `version` in `package.json` unchanged; maintainers bump it when releasing ([docs/releasing.md](docs/releasing.md)). diff --git a/README.ja.md b/README.ja.md index fbea660..2b4cf76 100644 --- a/README.ja.md +++ b/README.ja.md @@ -22,37 +22,21 @@ Node.js 22.12以降が必要です。アプリケーションのプロジェク ```sh pnpm add -D needmark # npm install -D needmark / yarn add -D needmark -pnpm exec needmark init --language=ja +pnpm exec needmark init +pnpm dev # いつもの開発コマンド ``` -例はpnpmで記載しています。npmでは`npx needmark`、Yarnでは`yarn needmark`でCLIを実行します。初期設定の案内とAIへの指示には、プロジェクトのロックファイルに合ったコマンドが入ります。 +いつもどおりアプリを開くと、ページ下部にNeedMarkのツールバーが表示されます。例はpnpmで記載しています。npmでは`npx needmark`、Yarnでは`yarn needmark`でCLIを実行します。 -初期設定で`needmark.config.ts`と用途に応じたGitの除外ルールを作成します。ターミナルではローカル・Git共有と撮影方法を選びます。英語にする場合は`--language=en`を指定します。[非対話実行の全フラグ](docs/getting-started.ja.md#インストールと初期設定)はガイドに記載しています。 +`init`は推奨設定を示し、同意するとNeedMarkを開発サーバーにつなぎます。アプリのソースと本番ビルドには触れません。 -### Vite +| プロジェクト | `init`が変更するもの | +| --- | --- | +| Vite(React、Vueなど) | `vite.config.*`の`plugins`に`needmark()`を追加 | +| Astro | `astro.config.*`に`vite: { plugins: [needmark()] }`を追加 | +| Next.js | `dev`スクリプトを`needmark dev --`経由にする。いつものポートのままNext.jsをその裏で動かします | -既存のVite設定にプラグインを明示的に追加します。 - -```ts -import { defineConfig } from 'vite'; -import { needmark } from 'needmark'; - -export default defineConfig({ - plugins: [needmark()], -}); -``` - -プラグインは開発時だけ動作し、本番ビルドと`vite preview`には入りません。引数なしで呼ぶと、Viteを起動したディレクトリの`needmark.config.ts`を読み込みます。`localhost`、`127.0.0.1`、`[::1]`では設定なしで動作します。LANのアドレスやトンネルなど別のホスト名でレビューする場合だけ`origins`を指定します。 - -### Next.jsなどの開発サーバー - -通常の方法でアプリケーションを起動し、同じプロジェクトで次を実行します。 - -```sh -pnpm exec needmark dev --target=http://localhost:3000 -``` - -`http://localhost:4190`を開きます。プロキシはアプリのimportやビルドを変更せずにウィジェットを追加します。対象とレビュー先は異なるループバックHTTPオリジンが必要です。詳しくは[プロキシの設定と制約](docs/getting-started.ja.md#nextjsなどの開発サーバー)を参照してください。 +あわせて`needmark.config.ts`と用途に応じたGitの除外ルールを作ります。`NEEDMARK=off pnpm dev`で自分だけNeedMarkなしで起動でき、`needmark.config.ts`の`enabled: false`で全員分を止められます。`pnpm exec needmark remove`で接続を元に戻せます。`init`が読めない設定やほかの開発サーバーは、[はじめに](docs/getting-started.ja.md#開発サーバーにつなぐ)の手順で手動で設定します。[非対話実行の全フラグ](docs/getting-started.ja.md#インストールと初期設定)も記載しています。 ## コメントして、AIに渡して、確認する diff --git a/README.md b/README.md index 6c422ac..94994cd 100644 --- a/README.md +++ b/README.md @@ -22,37 +22,21 @@ Requires Node.js 22.12 or newer. In your application project: ```sh pnpm add -D needmark # npm install -D needmark / yarn add -D needmark -pnpm exec needmark init --language=en +pnpm exec needmark init +pnpm dev # your usual development command ``` -Examples use pnpm. With npm run the CLI as `npx needmark`, with Yarn as `yarn needmark`. Initialization output and agent prompts use the command that matches your project's lockfile. +Open the app as usual; the NeedMark toolbar appears at the bottom of the page. Examples use pnpm. With npm run the CLI as `npx needmark`, with Yarn as `yarn needmark`. -Initialization creates `needmark.config.ts` and targeted Git ignore rules. In a terminal, choose local/Git sharing and capture method. Use `--language=ja` for Japanese; [all noninteractive flags](docs/getting-started.md#install-and-initialize) are documented in the guide. +`init` shows recommended settings and, with your consent, connects NeedMark to the development server. It never touches application source or production builds: -### Vite +| Project | What `init` changes | +| --- | --- | +| Vite (React, Vue and others) | Adds `needmark()` to `plugins` in `vite.config.*` | +| Astro | Adds `vite: { plugins: [needmark()] }` to `astro.config.*` | +| Next.js | Starts the `dev` script through `needmark dev --`, which keeps your usual port and runs Next.js behind it | -Add the plugin explicitly to your existing Vite configuration: - -```ts -import { defineConfig } from 'vite'; -import { needmark } from 'needmark'; - -export default defineConfig({ - plugins: [needmark()], -}); -``` - -The plugin runs during development only and is absent from production builds and `vite preview`. Called without arguments, it loads `needmark.config.ts` from the directory where Vite runs. It works on `localhost`, `127.0.0.1` and `[::1]` without configuration; set `origins` only to review through another hostname, such as a LAN address or a tunnel. - -### Next.js and other development servers - -Start your application normally, then run this in the same project: - -```sh -pnpm exec needmark dev --target=http://localhost:3000 -``` - -Open `http://localhost:4190`. The proxy adds the widget without changing application imports or builds. Target and review origin must be different loopback HTTP origins; [proxy setup and limits](docs/getting-started.md#nextjs-and-other-development-servers) explain the details. +It also writes `needmark.config.ts` and targeted Git ignore rules. `NEEDMARK=off pnpm dev` starts without NeedMark for you; `enabled: false` in `needmark.config.ts` turns it off for everyone; `pnpm exec needmark remove` undoes the connection. Other servers and configs `init` cannot read are set up by hand as described in [Getting started](docs/getting-started.md#connect-the-development-server); [all noninteractive flags](docs/getting-started.md#install-and-initialize) are documented there too. ## Review, hand off, verify diff --git a/docs/README.md b/docs/README.md index 5ca0d0e..0af938c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ Start with the overview in your preferred language, then use the getting-started | Implementation and operating limits / 実装と動作上の制約 | [Architecture](architecture.md) | [英語リファレンス](architecture.md) | | Benchmark and product evaluation / ベンチマークと評価 | [Japanese only](agentation-comparison.ja.md) | [Agentationとの比較](agentation-comparison.ja.md) | | Contributor and agent rules / 開発・AIエージェントの共通ルール | [AGENTS.md](../AGENTS.md) | [AGENTS.md](../AGENTS.md) | -| Testing and manual checks / テストと手動確認 | [Testing](testing.md) | [Testing(英語)](testing.md) | +| Test design, running tests, manual checks and framework examples / テスト設計・実行・手動確認・フレームワーク別の例 | [Testing](testing.md) | [Testing(英語)](testing.md) | | Releasing to npm / npmへのリリース | [Releasing](releasing.md) | [Releasing(英語)](releasing.md) | Detailed reference sections / 詳細リファレンス: diff --git a/docs/agentation-comparison.ja.md b/docs/agentation-comparison.ja.md index 8291895..6571203 100644 --- a/docs/agentation-comparison.ja.md +++ b/docs/agentation-comparison.ja.md @@ -48,7 +48,7 @@ NeedMark のベンチマークは [Agentation](https://www.agentation.com/)(v3 | ハンズフリー運用 | `needmark_watch` と手順書([はじめに](getting-started.ja.md#aiエージェントと連携する)) | ○ | | | 起動ショートカット | Ctrl/Cmd+Shift+F、Shift+C/P/H | ○ | Agentation の単キー操作は、ページ側の入力と衝突しやすいため Shift つきにしている | | 連続したコメント | 「保存」(Ctrl/Cmd+Enter)で一覧を開かずに次の選択へ戻る | ○ | | -| 初回すぐに開始 | 初回の設定画面に「既定のまま始める」 | ○ | | +| 初回すぐに開始 | 初回も設定画面を開かず、すぐに選択を始める | ○ | | | ツールバーの見た目 | 画面下部中央の、たためるアイコンのツールボックス。ツールチップに名前・目的・ショートカット。配色は NeedBack のブランドトークン | ◎ | | | 入力中の対象表示 | コメント入力中も対象の枠を表示 | ○ | | | Webhook | Node のコールバック(`onFeedbackChange` / `onSubmit`)で代替 | △ | URL を指定するだけの Webhook はない。数行のコールバックで送れる | @@ -70,7 +70,7 @@ NeedMark のベンチマークは [Agentation](https://www.agentation.com/)(v3 ### 弱み・リスク - **見た目が古く、ブランドからも外れていた。** 文字と記号(◉・Ⅱ)が混在したツールバーと、緑・朱色の独自配色でした。今回、アイコンとツールチップのツールボックスに作り直し、NeedBack のトークンに合わせました。 -- **最初の一歩が重い。** 初回は 7 項目の設定画面が先に出ます。今回「既定のまま始める」を追加しました。 +- **最初の一歩が重い。** 以前は初回に 7 項目の設定画面が先に出ていました。現在は設定画面を出さずにすぐ選択を始めます。 - **保存のたびに一覧が開く。** 連続してコメントしたいときに手数が増えます。保存後は一覧を開かずに次の選択へ戻るようにしました。 - **ドキュメントが仕様の網羅に寄っている。** アーキテクチャ文書は正確ですが長く、入門者には重い。利用者向けの説明は「はじめに」に寄せています。 @@ -91,7 +91,7 @@ NeedMarkの中心は「場所を指して、意図を残し、AIに渡す」で | 以前 | 課題 | 現在 | | --- | --- | --- | | 「選択方法」メニューで要素・テキスト・複数・配置案・移動案を切り替える | 何が選べるのか分かりにくい。要素モードでもドラッグやShiftで範囲・複数を選べ、モードの違いが曖昧 | 「コメント」(+アイコン)1つの道具に統合。クリック=要素、ドラッグ=範囲、Shift=複数、Ctrl/Cmd=奥の要素、Alt/Option+ドラッグ=文字。選択中は操作の一覧を表示する | -| テキストモード | 文字以外も選べ、使い分ける場面が想像できない | Alt/Option+ドラッグに限定。ページで文字を選んでから始めた場合は「選択中の文字にコメント」で、選んだ文字にそのままコメントできる | +| テキストモード | 文字以外も選べ、使い分ける場面が想像できない | Alt/Option+ドラッグに限定 | | 複数モード | Shiftとの関係が分かりにくい | Shift+クリック・ドラッグで開始し、以降はShiftなしで追加・解除 | | 配置案・移動案が選択方法の中にある | コメントとは性質が違い、文章では伝えにくい配置の提案のための道具 | レイアウト案は機能から外した | | 一時停止のボタン | 何が止まるのか、止まっているのかが分からない | ツールチップで目的を説明し、停止中はボタンが再生アイコンに変わる。たたんだツールバーでも停止中は表示を残す | @@ -102,7 +102,7 @@ NeedMarkの中心は「場所を指して、意図を残し、AIに渡す」で | 課題 | 対応 | | --- | --- | | `docs/architecture.md` に「Agentation MCP プロトコル互換ではない」とだけあり、MCP 連携がない | `needmark mcp` を追加し、文書を更新(AFS とのフィールド対応も記載) | -| 初回の「コメントする」で設定画面が必ず開き、すぐに選べない | 「既定のまま始める」を追加 | +| 初回の「コメントする」で設定画面が必ず開き、すぐに選べない | 設定画面を出さずにすぐ選択を始める | | 保存後に毎回一覧が開く | 「保存して次へ」を追加 | | ツールバーの記号ボタンの意味とショートカットが画面で分からず、配色も NeedBack ブランドから外れている | アイコンとツールチップのツールボックスに作り直し、NeedBack のデザイントークンに配色を統一 | | コメント入力中に対象の枠が消え、どこへのコメントか見失いやすい | 入力中も対象の枠を表示 | diff --git a/docs/architecture.md b/docs/architecture.md index 30a85d4..b59864d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ Quoted control captions below use the Japanese default; the English setting prov ## Activation and boundaries -`needmark({ origins, outputDir, capture, language })` registers a Vite plugin only for development serve, excluding preview; every option is optional. Without an argument, the plugin loads the project configuration from the current working directory in its `config` hook, with the same loader and legacy fallback as the CLI, so builds and preview never execute it; an explicit argument, even `{}`, skips the file. Each load imports the file again, so a Vite restart in the same process sees edits. Configured origins include scheme and port and match exactly. Without them, any HTTP(S) origin whose hostname is `localhost`, `127.0.0.1` or `[::1]` is allowed regardless of port; the server API, HTML injection, JSX source annotations and the widget all apply the same rule, and the script omits `data-needmark-origins`. AsyncLocalStorage associates each HTML request with its origin. Production build and preview do not register the plugin, inject scripts or copy feedback assets. The storage directory must be a dedicated folder outside publicDir and is denied direct Vite static-file access. The client mounts only when its window is the top document. HTML injection into a child response does not create another widget; selecting an accessible same-origin child uses the parent widget. A child URL opened as a top page can mount its own widget. +`needmark({ origins, outputDir, capture, language })` registers a Vite plugin only for development serve, excluding preview; every option is optional. NeedMark is switched off when `NEEDMARK` is `0`, `off`, `false` or `no` (case-insensitive, trimmed), or when the config sets `enabled: false` and `NEEDMARK` is not `1`, `on`, `true` or `yes`; a non-boolean `enabled` is rejected. A switched-off plugin stays registered, because Vite chooses plugins before the config is loaded, but adds no server config, API, source annotations or scripts. Without an argument, the plugin loads the project configuration from the current working directory in its `config` hook, with the same loader and legacy fallback as the CLI, so builds and preview never execute it; an explicit argument, even `{}`, skips the file. Each load imports the file again, so a Vite restart in the same process sees edits. Configured origins include scheme and port and match exactly. Without them, any HTTP(S) origin whose hostname is `localhost`, `127.0.0.1` or `[::1]` is allowed regardless of port; the server API, HTML injection, JSX source annotations and the widget all apply the same rule, and the script omits `data-needmark-origins`. AsyncLocalStorage associates each HTML request with its origin. Vite's `transformIndexHtml` adds the script to entry HTML; for a GET from an allowed origin whose HTML a framework renders in its own middleware without that hook (Astro), the plugin appends the same script after the last chunk of a `text/html` 200 or error response with identity encoding, removes its `Content-Length` and does not buffer or parse the stream. A page already handled by `transformIndexHtml` is not appended twice; responses whose headers arrive as raw arrays are left unchanged. Production build and preview do not register the plugin, inject scripts or copy feedback assets. The storage directory must be a dedicated folder outside publicDir and is denied direct Vite static-file access. The client mounts only when its window is the top document. HTML injection into a child response does not create another widget; selecting an accessible same-origin child uses the parent widget. A child URL opened as a top page can mount its own widget. The shared review API is under Vite's base followed by `/__needmark`, or `/__needmark` on the review proxy. `createReviewApi` validates origins/capture/language preferences, creates one session token and storage instance, supplies script attributes, and handles only its own endpoint prefix; other requests remain with the adapter. Feedback reads and writes require a per-server random token, an allowed Host-derived origin, matching Origin when present, and a fetch site other than cross-site. No CORS endpoints are exposed. PNG payloads are limited to 5 MB; complete requests to 8 MB. UUID identifiers and regular-file checks reject traversal and symlinked item/context/image files. @@ -14,6 +14,8 @@ Users of a trusted shared development server see the same files. Author names ar ## Local review proxy +`needmark dev [--port=<port>] [--capture=…] [--directory=…] -- <command>` wraps a development command instead. It takes the review port from `--port`, else from `-p <port>`, `--port <port>` or `--port=<port>` in the command (removed before starting it), else `PORT`, else 3000, listens on `http://localhost:<port>` and starts the command with inherited stdio and `PORT` set to a free loopback port, through a shell only on Windows. It waits up to two minutes for that port to accept a TCP connection (trying IPv4 and IPv6), then prints the review URL; a command that exits first or never listens stops the proxy with an error that points to `--target`. SIGINT and SIGTERM are forwarded to the command, the proxy closes when it exits, and its exit code becomes the CLI's. A busy review port fails before the command starts. When NeedMark is switched off ([activation](#activation-and-boundaries)), the command runs unchanged with its own port and no proxy. + `pnpm exec needmark dev --target=http://localhost:3000 --origin=http://localhost:4190` starts a separate proxy around an already running development server. The command loads the current project’s `needmark.config.ts` and adapts its shared language, capture, outputDir, editor, contextAttributes, components, onFeedbackChange and onSubmit settings to the proxy. Explicit capture/directory flags override those values. The application is started independently, and config.origins remains the Vite allowlist rather than a proxy listener setting. The default review origin is `http://localhost:4190`, while target is required. Both URLs must be exact loopback HTTP origins (`localhost`, `127.0.0.1` or `[::1]`), must differ, and the review origin must include a port. `needmark/proxy` also exports `startReviewProxy` with a project-root option and a close method. The proxy forwards ordinary requests and WebSocket upgrades for development/HMR. It rewrites proxy-origin Origin/Referer headers for the upstream, local redirects back to the review origin, and matching cookie domains. It forwards HTML incrementally and appends the review script to HTTP 200 and error HTML responses, decoding gzip/deflate/Brotli when necessary and removing stale length/ETag headers. It reuses an existing script/CSP nonce when found, without relaxing the application's CSP. Application absolute URLs, HTTPS-only cookies or framework-specific allowed-origin checks can still require application development settings. @@ -22,9 +24,13 @@ Review Host/Origin validation and protected API routes run before forwarding. Fe The proxy provides DOM/CSS/component context and screenshots without transforming application sources. It enables optional React development source lookup through the protected API. Existing explicit annotations remain usable. Vite's compile-time source mapping and its activation boundaries remain specific to the Vite adapter. +## Connecting the development server + +`planConnection` looks for `astro.config.{ts,mts,js,mjs}`, then `vite.config.{ts,mts,js,mjs}`, then a `package.json` `dev` script running `next dev`. A config that already imports `needmark` is reported as connected. Otherwise Babel parses it as a TypeScript/JSX module and MagicString edits it in place: the exported object is found through `export default`, an exported or local constant, a single call argument such as `defineConfig({...})`, or an arrow/function expression whose body or single `return` is an object, unwrapping `satisfies`, `as` and non-null assertions. Vite gets `needmark()` first in an existing `plugins` array, or a new `plugins: [needmark()]` property before the first property; Astro gets the same inside an object `vite` option, or a new `vite: { plugins: [needmark()] }` property. The import is added after the last import, or first. A non-array `plugins`, a non-object `vite`, any other export shape or a parse failure leaves the file untouched and reports manual setup. For Next.js the `dev` script gets the `needmark dev -- ` prefix by replacing its JSON-encoded value in the file text, preserving formatting; an already prefixed script is connected. The plan lists the changed lines, which `init` prints after writing. In a terminal the recommendation includes connecting when an edit is possible, and declining asks for it separately with the effect on the team and how to undo it; without a terminal, only `--connect=yes` writes. `needmark remove` reverses the same patterns: it removes `needmark` import declarations and `needmark()` array elements, removes a `plugins` property that becomes empty (an emptied sole property becomes `{}`), removes an Astro `vite` option whose only property was that `plugins`, and strips the script prefix. Lines that held only removed code are deleted whole. `needmark.config.ts` and feedback are kept, and the CLI prints the package manager's uninstall command. + ## Initial setup and preferences -`pnpm exec needmark init` creates `needmark.config.ts` exclusively, refusing to overwrite an existing config. A terminal first asks for `en` or `ja`, then asks in that language for local/Git sharing and DOM/tab/no capture. `--language=en|ja` and the other flags supply choices noninteractively. Defaults are `ja`, local and DOM. The generated config has no `origins`, so the loopback default applies. Invalid language values fail before initialization writes a config. The generated config uses `satisfies NeedMarkOptions`. The Vite plugin must be added explicitly; initializer output suggests `needmark()` and also provides the separate proxy command for the project's [package manager](#package-managers). The CLI loads project configuration from its current working directory, without searching parent directories. An absent file preserves existing defaults. Jiti loads the trusted TypeScript module on the supported Node versions; the default export must be a non-null settings object. Syntax/import failures and invalid exports fail explicitly. Feedback content is never imported. Configuration changes take effect on the next CLI invocation or proxy restart. +`pnpm exec needmark init` creates `needmark.config.ts` exclusively, refusing to overwrite an existing config. When `needmark.config.ts` already exists, the CLI skips writing settings and Git rules, asks only the connection question (or follows `--connect`) and reports that it kept the file. Its terminal dialogue, output and flag errors are always English. A terminal first shows the recommended settings (review language from the terminal locale, local sharing, DOM capture) with any flag values applied and asks `Use the recommended settings? [Y/n]`; declining asks each remaining choice as a numbered list with a one-line explanation, accepting the number or the flag value and asking again after an unknown answer. `--language=en|ja` and the other flags supply choices noninteractively. Without a terminal the defaults are `ja`, local and DOM. The generated config explains the language and capture values in comments. The generated config has no `origins`, so the loopback default applies. Invalid language values fail before initialization writes a config. The generated config uses `satisfies NeedMarkOptions`. Connecting the development server is a separate, consented step described in [connecting the development server](#connecting-the-development-server); when it is declined or impossible, initializer output lists the manual steps with the project's [package manager](#package-managers) command. The CLI loads project configuration from its current working directory, without searching parent directories. An absent file preserves existing defaults. Jiti loads the trusted TypeScript module on the supported Node versions; the default export must be a non-null settings object. Syntax/import failures and invalid exports fail explicitly. Feedback content is never imported. Configuration changes take effect on the next CLI invocation or proxy restart. Storage commands use config.outputDir unless a directory is supplied, then fall back to the default feedback folder (`.needmark/feedback` for a new project). Explicit storage directories skip config loading. List, watch, export, resolve, reopen, reply, edit and delete therefore share the server’s configured feedback folder. Export passes the selected directory to the prompt formatter. Help, unknown commands and init do not execute configuration. API callbacks configured in the file run only through the development adapter; direct CLI/file changes retain the existing watch behavior. @@ -32,7 +38,7 @@ Local sharing appends a targeted `/.needmark/` Git ignore rule. Git sharing appe `language` accepts only `en` or `ja` and defaults to `ja` for the widget. The script carries `data-needmark-language`. A valid `needmark-language` browser preference overrides configuration; invalid or unavailable preferences fall back to valid configuration, then `ja`. Capture/author settings include a language selector. Saving it changes the toolbar, forms, list, viewer and known capture-error guidance, and persists the choice for that review origin. Application content, comments, replies, authors and source identifiers are never translated. Unknown server or third-party error text is preserved. -`capture` sets the initial browser method. A saved browser preference takes precedence. On first use, the widget offers its settings panel (heading **レビューを始める**; later **設定**). Capture method and output detail have an information button next to their label whose tooltip, shown on hover or focus and announced through `aria-describedby`, holds the longer explanation; the other settings show only their label; **撮影を停止** appears only while a tab capture stream is active; **このまま始める** at the top of that first-run panel saves the displayed values and starts selection immediately, exactly like **設定を保存**. The first-run panel omits **投稿者名**, which is set later in **設定**; saving the first-run panel keeps any stored author. Browser preferences retain capture method, author, output detail, theme and global keyboard-shortcut enablement across reload; unavailable browser storage does not prevent server saving. OS/light/dark themes, draggable toolbar, pin visibility and animation pause are available. Toolbar dragging starts anywhere on the toolbar, including its buttons, accepts only the primary left pointer and starts moving after three CSS pixels; a drag that starts on a button does not run that button's action, while a press that moves less still clicks it. A completed drag stores finite `{ x, y }` coordinates in `needmark-toolbar-position`. Reload restores them; viewport resize, toolbar resize and reappearance clamp the visible position to a four-pixel margin, without overwriting the stored or chosen position, so the toolbar returns to it once it fits again. Hidden zero-size toolbars defer this correction. Oversized controls keep the handle at a visible origin. Without a saved position, the toolbar stays centred at the bottom; a saved position replaces the centring with exact coordinates. Escape, pointer cancellation, lost capture or viewport resize cancels an active drag, restores its starting styles and releases capture; Escape does not simultaneously close another surface. Clicks and cancelled gestures do not write the preference. Inaccessible browser storage leaves movement usable. Shift+C toggles selection, Shift+P toggles pause, and Shift+H hides/shows controls. Ctrl/Cmd+Shift+F is an alias for Shift+C. The toolbar is a compact icon toolbox centred at the bottom of the viewport: drag handle, then the **コメント** tool (a plus icon), view controls (pin visibility and animation pause), **コメント一覧** with its open count, **設定**, and **ツールバーをたたむ**. Buttons show icons only and keep their localized accessible names; all widget icons (toolbar, panel back/close, image viewer close/zoom, hint help/end) are filled 40×40 glyphs inlined from the NeedBack console icon set, drawn at 16 pixels everywhere inside fixed square buttons (32 pixels in the toolbar, 28 in the hint and panel headers, 38 for image zoom, 20 for setting explanations) (the comment list uses `nav-open`), with no runtime dependency on NeedBack; the select button is pressed while selection is active, pins/pause expose `aria-pressed`. While the page is paused, the pause button shows a play icon without a background; its accessible name changes to **アニメーションを再開**. Toolbar buttons are 32-pixel square icon buttons; an active tool uses the same light background as hover. During page operation the select button additionally shows the visible text **選択を再開 · N件** and **選択を破棄** appears as an icon button. **ツールバーをたたむ** collapses the toolbar to the single **ツールバーを広げる** button; the other tools, including an active pause, are hidden until it is expanded, while global shortcuts keep working; the choice persists in `needmark-toolbar-collapsed`. Collapsing never ends a selection, panel or pause. Hovering or focusing a toolbar button shows a tooltip from the review host after a short delay with its name, a one-line description of what it does and, while global shortcuts are enabled, its shortcut; the tooltip stays inside the viewport, is hidden while dragging, during capture and when the pointer leaves, and never takes focus. The shortcut also stays available in the button's `data-shortcut` attribute; no native `title` tooltip is used. Global shortcuts are enabled initially and can be disabled in browser settings; this affects Shift+C/P/H and Ctrl/Cmd+Shift+F. Global shortcuts do not fire inside inputs, textareas, selects, inherited editable content or during IME composition. Ctrl/Cmd+Enter in NeedMark submits only its own comment/edit/reply forms; host forms and shadow-root editors are never submitted or intercepted by this handler. Local Escape, viewer navigation and active multiple-selection controls remain available with global shortcuts disabled. Escape closes the current NeedMark surface without closing a host modal simultaneously. +`capture` sets the initial browser method. A saved browser preference takes precedence. Capture method and output detail have an information button next to their label whose tooltip, shown on hover or focus and announced through `aria-describedby`, holds the longer explanation; the other settings show only their label; The settings panel has no header (its title **設定** is visually hidden); its footer has **キャンセル**, which closes it, on the left and **保存** on the right, preceded by **撮影を停止** only while a tab capture stream is active. The first **コメント** starts selection immediately with the configured or saved values; settings open only from **設定**. Browser preferences retain capture method, author, output detail, theme and global keyboard-shortcut enablement across reload; unavailable browser storage does not prevent server saving. OS/light/dark themes, draggable toolbar, pin visibility and animation pause are available. Toolbar dragging starts anywhere on the toolbar, including its buttons, accepts only the primary left pointer and starts moving after three CSS pixels; a drag that starts on a button does not run that button's action, while a press that moves less still clicks it. A completed drag stores finite `{ x, y }` coordinates in `needmark-toolbar-position`. Reload restores them; viewport resize, toolbar resize and reappearance clamp the visible position to a four-pixel margin, without overwriting the stored or chosen position, so the toolbar returns to it once it fits again. Hidden zero-size toolbars defer this correction. Oversized controls keep the handle at a visible origin. Without a saved position, the toolbar stays centred at the bottom; a saved position replaces the centring with exact coordinates. Escape, pointer cancellation, lost capture or viewport resize cancels an active drag, restores its starting styles and releases capture; Escape does not simultaneously close another surface. Clicks and cancelled gestures do not write the preference. Inaccessible browser storage leaves movement usable. Shift+C toggles selection, Shift+P toggles pause, and Shift+H hides/shows controls. Ctrl/Cmd+Shift+F is an alias for Shift+C. The toolbar is a compact icon toolbox centred at the bottom of the viewport: drag handle, then the **コメント** tool (a plus icon), view controls (pin visibility and animation pause), **コメント一覧** with its open count in a 14-pixel badge overlapping the lower right of its icon, **設定**, and **ツールバーをたたむ**. Buttons show icons only and keep their localized accessible names; all widget icons (toolbar, panel back/close, image viewer close/zoom, hint help/end) are filled 40×40 glyphs inlined from the NeedBack console icon set, drawn at 16 pixels everywhere inside fixed square buttons (32 pixels in the toolbar, 28 in the hint and panel headers, 38 for image zoom, 20 for setting explanations) (the comment list uses `nav-open`), with no runtime dependency on NeedBack; the select button is pressed while selection is active, pins/pause expose `aria-pressed`. While the page is paused, the pause button shows a play icon without a background; its accessible name changes to **アニメーションを再開**. Toolbar buttons are 32-pixel square icon buttons; an active tool uses the same light background as hover. **ツールバーをたたむ** collapses the toolbar to the single **ツールバーを広げる** button; the other tools, including an active pause, are hidden until it is expanded, while global shortcuts keep working; the choice persists in `needmark-toolbar-collapsed`. Collapsing never ends a selection, panel or pause. Hovering or focusing a toolbar button shows a tooltip from the review host after a short delay with its name, a one-line description of what it does and, while global shortcuts are enabled, its shortcut; the tooltip stays inside the viewport, is hidden while dragging, during capture and when the pointer leaves, and never takes focus. The shortcut also stays available in the button's `data-shortcut` attribute; no native `title` tooltip is used. Global shortcuts are enabled initially and can be disabled in browser settings; this affects Shift+C/P/H and Ctrl/Cmd+Shift+F. Global shortcuts do not fire inside inputs, textareas, selects, inherited editable content or during IME composition. Ctrl/Cmd+Enter in NeedMark submits only its own comment/edit/reply forms; host forms and shadow-root editors are never submitted or intercepted by this handler. Local Escape, viewer navigation and active multiple-selection controls remain available with global shortcuts disabled. Escape closes the current NeedMark surface without closing a host modal simultaneously. The review UI follows the NeedBack design tokens: Helvetica/system UI typography, white/gray surfaces with gray borders, NeedBack primary-interactive blue (`#0f4cc0` in light theme, `#9acdff` in dark theme, with white or dark navy `#0a2063` text) for filled primary actions, outline primary actions (interactive border and text, light primary background on hover), the chosen category (interactive border and text on the light primary background), focus rings (shown only after keyboard input, so clicks and the focus the UI moves after them stay unringed; text fields always show their focused border), selection outlines and selected tools; filled hover and pressed states mix 6% and 10% black in light theme and white in dark theme like NeedBack's filled buttons, and NeedBack status colours for errors. Open pins use the NeedBack unread pin colour (`#1965e3`). Panels use 12-pixel corners with the NeedBack panel shadow; the dark theme uses the NeedBack dark tokens. Unless the reviewer prefers reduced motion, panels scale in from the toolbar button or target they open from and fade back toward it; a panel that stays open glides to its next position and height while the next screen slides in from the side it was reached from (a comment from the right, the list from the left), and a capture that arrives later grows the panel instead of making it jump. Search opens out of its header icon, menus grow from their button (upward when the panel has no room below), pins pop in and shrink away when toggled, and Shift+H fades the toolbar. A drag of the toolbar or a panel ends where it was last shown even when the page swallows the pointer release; only Escape puts it back. The review UI lives in an open shadow root. Its host explicitly resets inherited font, letter/word spacing, text transformation, indentation, alignment, direction and whitespace, including host-level application `!important` rules. These resets also apply after moving into a native modal dialog. Its host remains pointer-transparent, while the toolbar, forms and armed selection cover enable their own pointer handling. Intentional control, brand and code typography remains local to the UI; authored content and the application’s styles remain unchanged. Capture-time visibility uses the existing host visibility property; external positioning rules still follow the normal CSS cascade. @@ -46,19 +52,17 @@ While the selection cover is active and no gesture or panel is open, pointer mov The tooltip is clamped to the viewport and belongs to the existing review host, excluded by DOM capture and hidden with the host during tab capture. Starting a gesture, leaving selection or the cover, window blur and observed page mutations/scroll/resize clear it. A hovered target also follows rendered ancestry (`assignedSlot`, then `parentElement`, then a shadow host or frame owner), bounded to 200 nodes and eight unique document/open-shadow roots, to watch mutations, scrolling, document resize and frame load. This includes shadow-local changes surrounding a slotted leaf. These events clear the preview rather than retain stale target geometry or names. A subsequent pointer movement recreates it. This observation does not guarantee detection of every visual change, such as animation without an observed event. -**コメント** is one tool whose gesture chooses the target: a click selects an element, a drag selects a free area, Shift adds elements to a multiple selection, Ctrl/Cmd+click picks a covered element, and Alt/Option+drag records a partial text range, including inline text and reverse drags within one DOM root. When the page already has a non-empty text selection outside NeedMark as selection starts, the hint offers **選択中の文字にコメント**, which comments on that range without dragging; the selection is never used implicitly. The hint takes the place of the toolbar's buttons while selecting: the toolbar keeps its position, handle and height, eases to the hint's width on a single row (a narrow viewport truncates the hint text before the buttons) and fades the hint in, then returns to its buttons when selection ends; reduced motion switches without animation. The hint is one line (**クリック・ドラッグで選択**, or **N件を選択** in multiple selection) followed by contextual buttons, a **使い方** icon link that opens the getting-started guide's comment section for the current language in a new tab, and an icon-only **終了** button. These two icons show toolbar tips on hover only (the end button's tip includes Esc), because selection start moves focus to the end button; starting selection also closes any tip that was open or pending for the hidden buttons. Multiple selection is entered only by a Shift gesture; the gesture list lives in the guide so the toolbar fits narrow viewports. Different iframe/shadow roots cannot form one text range. A drag records its starting point in page coordinates, so scrolling during the drag (wheel, keyboard or automatic) extends the rectangle beyond the viewport while its start stays on the same page content. While a free-area or multiple drag holds the pointer within 40 CSS pixels of a viewport edge, the page scrolls toward that edge each frame, faster nearer the edge; Alt/Option text drags do not scroll automatically. During an Alt/Option text drag, the preview highlights the line boxes of the text range that releasing at the current pointer would record, instead of a rectangle; when no text lies under the pointer or the range would cross DOM roots, no highlight is shown. The cover shows a text cursor while Alt/Option is held outside multiple selection and throughout a text drag. The free-area rectangle is saved in viewport coordinates of the release position, so its parts outside the viewport have negative or beyond-viewport coordinates. Multiple selection toggles individual elements on click and adds the meaningful elements whose visible boxes intersect a dragged rectangle with positive area. Boundary-only contact and empty rectangles add nothing and have no center-point fallback. Button/media inline descendants belong to their semantic owner; paragraphs and headings retain inline emphasis. Within each new batch, matched ancestors of other matched semantic elements are removed, and every remaining target is retained. Existing explicit selections are retained, and repeated drags do not toggle them off. The bounded traversal visits at most 5,000 nodes across open shadow roots and accessible same-origin frames, with at most eight root crossings per branch, excludes the NeedMark UI, and reports incomplete search when its node or root budget is reached. This flag remains with the selection through additions, undo and page operation; completion saves it as `target.code.truncated`, and the composer and exports warn that discovery is incomplete. Eligibility uses axis-aligned viewport/frame content and applicable overflow clipping with hidden/opacity checks; retained context and capture geometry remain the original full element boxes. Numbered outlines follow the selected originals. Enter or its completion button finishes; Backspace removes the last selected element. Shift+click or Shift+drag enters multiple selection; later plain clicks keep toggling targets until selection completes. Ctrl/Cmd+click also enters multiple selection and opts into deeper hit testing. Alt/Option is ignored during multiple selection. The picker skips the frontmost hit and its ancestor/descendant branch, preferring an independent lower hit inside the frontmost open shadow root or accessible same-origin frame before an outer-root alternative. It scans through additional empty container covers and accessible child roots, rejects invisible or fully clipped candidates, and prefers content-bearing targets. Empty visual candidates fall back to the smallest positive visible area. Content classification uses controls, non-container tags or direct non-whitespace text. The generic containers are `div`, `span`, `section`, `article`, `main`, `aside`, `header`, `footer`, `nav` and `slot`; an ID alone does not make one content-bearing. Deep branch ancestry follows assigned slots with a 200-node bound, without changing selector/source ownership. Lower roots retain meaningful frontmost content, so an inner labelled overlay remains selectable after an outer branch was skipped. This is a hit-stack heuristic rather than certainty about the reviewer’s intent. Root traversal retains the padded/scaled frame transform and eight-level bound. If no lower branch exists, the normal frontmost target remains selected. It never hides or changes application overlays; saved screenshots retain the actual visible screen. Ordinary clicks and Alt/Option text drags keep their frontmost behavior. Ctrl-click suppresses the native context menu only on the active selection cover. Mode, Shift and Ctrl/Cmd are recorded at primary pointer-down, so releasing or pressing modifiers during a gesture does not change its selection type or target depth. Releasing Ctrl/Cmd does not finish selection; Enter or the completion button does. Additional pointers cannot replace or commit that gesture. Pointer cancellation or lost capture clears only that gesture and releases capture; the first Escape during a drag does the same, preserving prior selections, while the next Escape ends selection. Enter and Backspace are ignored during an active multiple gesture, including the first Shift gesture. Explicit multiple selections have no fixed target-count cap. Each selected original keeps its individual context through capture, local save/read and all copy/export paths. HTTP feedback writes retain their existing eight-megabyte body and five-megabyte decoded-PNG limits; a rejected write retains the typed comment for retry. - -Before opening the composer, multiple selection offers **ページを操作**. It hides the selection cover, outlines and pins so application buttons, scrolling and forms work. The toolbar shows **選択を再開 · N件** and **選択を破棄**. Resuming restores the remaining selection; completing it obtains fresh selectors, source context and geometry before the existing capture guard starts. Enter and Backspace only control multiple selection while its cover is visible. During page operation without another NeedMark surface, Escape and application input remain application events. Enabled Shift+C resumes selection; disabled global shortcuts and IME composition retain their existing boundaries. +**コメント** is one tool whose gesture chooses the target: a click selects an element, a drag selects a free area, Shift adds elements to a multiple selection, Ctrl/Cmd+click picks a covered element, and Alt/Option+drag records a partial text range, including inline text and reverse drags within one DOM root. The hint takes the place of the toolbar's buttons while selecting: the toolbar keeps its position, handle and height, eases to the hint's width on a single row (a narrow viewport truncates the hint text before the buttons) and fades the hint in, then returns to its buttons when selection ends; reduced motion switches without animation. The hint is one line (**クリック・ドラッグで選択**, or **N件を選択** in multiple selection) followed by contextual buttons, a **使い方** icon link that opens the getting-started guide's comment section for the current language in a new tab (toolbar dragging captures the pointer on the pressed link, like on a button, so the link keeps its click), and an icon-only **終了** button. These two icons show toolbar tips on hover only (the end button's tip includes Esc), because selection start moves focus to the end button; starting selection also closes any tip that was open or pending for the hidden buttons. Multiple selection is entered only by a Shift gesture; the gesture list lives in the guide so the toolbar fits narrow viewports. Different iframe/shadow roots cannot form one text range. A drag records its starting point in page coordinates, so scrolling during the drag (wheel, keyboard or automatic) extends the rectangle beyond the viewport while its start stays on the same page content. While a free-area or multiple drag holds the pointer within 40 CSS pixels of a viewport edge, the page scrolls toward that edge each frame, faster nearer the edge; Alt/Option text drags do not scroll automatically. During an Alt/Option text drag, the preview highlights the line boxes of the text range that releasing at the current pointer would record, instead of a rectangle; when no text lies under the pointer or the range would cross DOM roots, no highlight is shown. The cover shows a text cursor while Alt/Option is held outside multiple selection and throughout a text drag. The free-area rectangle is saved in viewport coordinates of the release position, so its parts outside the viewport have negative or beyond-viewport coordinates. Multiple selection toggles individual elements on click and adds the meaningful elements whose visible boxes intersect a dragged rectangle with positive area. Boundary-only contact and empty rectangles add nothing and have no center-point fallback. Button/media inline descendants belong to their semantic owner; paragraphs and headings retain inline emphasis. Within each new batch, matched ancestors of other matched semantic elements are removed, and every remaining target is retained. Existing explicit selections are retained, and repeated drags do not toggle them off. The bounded traversal visits at most 5,000 nodes across open shadow roots and accessible same-origin frames, with at most eight root crossings per branch, excludes the NeedMark UI, and reports incomplete search when its node or root budget is reached. This flag remains with the selection through additions and undo; completion saves it as `target.code.truncated`, and the composer and exports warn that discovery is incomplete. Eligibility uses axis-aligned viewport/frame content and applicable overflow clipping with hidden/opacity checks; retained context and capture geometry remain the original full element boxes. Numbered outlines follow the selected originals. Enter or its completion button finishes; Backspace removes the last selected element. Shift+click or Shift+drag enters multiple selection; later plain clicks keep toggling targets until selection completes. Ctrl/Cmd+click also enters multiple selection and opts into deeper hit testing. Alt/Option is ignored during multiple selection. The picker skips the frontmost hit and its ancestor/descendant branch, preferring an independent lower hit inside the frontmost open shadow root or accessible same-origin frame before an outer-root alternative. It scans through additional empty container covers and accessible child roots, rejects invisible or fully clipped candidates, and prefers content-bearing targets. Empty visual candidates fall back to the smallest positive visible area. Content classification uses controls, non-container tags or direct non-whitespace text. The generic containers are `div`, `span`, `section`, `article`, `main`, `aside`, `header`, `footer`, `nav` and `slot`; an ID alone does not make one content-bearing. Deep branch ancestry follows assigned slots with a 200-node bound, without changing selector/source ownership. Lower roots retain meaningful frontmost content, so an inner labelled overlay remains selectable after an outer branch was skipped. This is a hit-stack heuristic rather than certainty about the reviewer’s intent. Root traversal retains the padded/scaled frame transform and eight-level bound. If no lower branch exists, the normal frontmost target remains selected. It never hides or changes application overlays; saved screenshots retain the actual visible screen. Ordinary clicks and Alt/Option text drags keep their frontmost behavior. Ctrl-click suppresses the native context menu only on the active selection cover. Mode, Shift and Ctrl/Cmd are recorded at primary pointer-down, so releasing or pressing modifiers during a gesture does not change its selection type or target depth. Releasing Ctrl/Cmd does not finish selection; Enter or the completion button does. Additional pointers cannot replace or commit that gesture. Pointer cancellation or lost capture clears only that gesture and releases capture; the first Escape during a drag does the same, preserving prior selections, while the next Escape ends selection. Enter and Backspace are ignored during an active multiple gesture, including the first Shift gesture. Explicit multiple selections have no fixed target-count cap. Each selected original keeps its individual context through capture, local save/read and all copy/export paths. HTTP feedback writes retain their existing eight-megabyte body and five-megabyte decoded-PNG limits; a rejected write retains the typed comment for retry. Selected elements retain their original Node, root, document and bounded element evidence when each selection is added. Validation checks their current document/frame connection, recorded child page URLs, identifiers, text/accessibility name, source evidence and closest known component. When frame URL evidence is present, every recorded iframe must retain its exact URL, including query and fragment; a route change invalidates even a still-connected node with identical text and source. Older records without this optional evidence retain their previous identity checks. It does not resolve a replacement through the old selector. Disconnected, replaced or changed targets are removed with a notice; if no targets remain, selection can restart empty. Source line/column movement, class/style changes and geometry changes alone do not remove a target. Validation remains bounded by captured text/name/attribute evidence: a virtual item reusing the same observed information is indistinguishable. Stable row identifiers can be included through `contextAttributes`. Hidden and offscreen original elements stay selected; image acquisition still has the existing visibility and viewport checks. -An exact URL change ends the retained selection, including query/hash and history changes. Explicit discard, settings, list or details, or hiding controls also ends it. Switching into page operation does not automatically change animation pause or stop an active native capture stream. If the stream has ended, resuming can request sharing again. Cancelling or rejecting that request returns to page operation with the remaining selection; discarding or leaving it while permission is pending invalidates the request and stops a late stream. Page operation does not create feedback or persist a selection. The composer already permits interaction outside its panel; it is not rebuilt by this control. +An exact URL change ends the selection, including query/hash and history changes. Ending selection, settings, list or details, or hiding controls also ends it. Completing a selection obtains fresh selectors, source context and geometry before the existing capture guard starts. The composer already permits interaction outside its panel. -A composer titled **コメント** appears next to the target: the capture (its size in the image tooltip), a status line that appears only while capture is pending, after it fails or after choosing to continue without an image, the **カテゴリ** radio group and the focused comment field, whose accessible name is **コメント本文** and which has no visible label. Target details, source/CSS evidence and copy actions are not shown; the source lookup status is announced to assistive technology only, and saving does not wait for it. While a new composer is open for an element, text, area or multiple selection, a dashed draft outline marks each selected target's recorded rectangle (every original of a multiple selection, otherwise the target rectangle). It belongs to the review host, so DOM capture excludes it and tab capture hides it. Page scroll or viewport resize removes the outline rather than leaving it at stale coordinates; closing, saving or replacing the composer also removes it. The composer and edit form offer **カテゴリ** above the comment as three separated pill radio buttons (the selected one has a primary-interactive border and text): 修正 (`fix`, the default for new comments and for edited items without a category), 相談 (`discuss`) and 質問 (`question`). Every save writes the chosen category. Enter on a category never submits the form. A new composer has a single **保存** button (also Ctrl/Cmd+Enter inside the comment field). It waits for the capture or an explicit image-free choice; after a successful save it closes the composer, shows a toast centred 20 pixels above the bottom of the viewport (drawn above the toolbar and any panel) and restarts selection instead of opening the list. Every new selection starts with plain clicks selecting single elements; a previous multiple selection does not carry over. A failed save keeps the composer and typed text. List, settings, composer, detail and edit panels share viewport placement. The list is centred horizontally over the **コメント一覧** button and settings over the **設定** button, opening above the toolbar with a twelve-pixel gap; when less than 120 pixels remain above and more space is below, they open below instead. Details open beside their pin after the target has been scrolled into view, twelve pixels to its right (or to its left when the right does not fit) with their top at the pin's top, moved up only as far as needed to stay inside the viewport; without a visible pin (resolved items, hidden pins) they open beside the target's outline, or beside the recorded rectangle shifted by the scroll since capture. Pressing **コメント一覧** while the list is open, or **設定** while settings are open, closes that panel like its close button. Panels fade and scale in over 180 ms and fade out over 120 ms; with reduced motion they appear and disappear instantly. A closing panel is already hidden for keyboard, assistive technology and pointer input while it fades, and opening another panel ends the fade at once instead of showing both. Other panels without a target keep the default placement. When a panel horizontally overlaps the toolbar, it uses the larger space above or below the toolbar with a twelve-pixel gap and a ten-pixel viewport margin. Its maximum height is capped to that space with internal scrolling. If both spaces are under 120 pixels high, the toolbar uses temporary visibility hiding while the panel uses the full viewport interior. Closing the panel or beginning selection restores toolbar visibility before returning focus; the user’s hidden state and saved coordinates remain intact. Every panel's header (title in 13-pixel semibold, like the image viewer's) stays pinned to the top of the panel while its body scrolls, separated from the body by a bottom border, and is the panel's drag grip: a primary press on the header outside its buttons (which keep their clicks) moves the panel after three pixels, Escape during a drag puts it back without closing it, and pointer capture is held by the panel so a list refresh mid-drag does not end it. The dropped point is kept across list and details until the panel closes, is not saved, and is only clamped inside the ten-pixel viewport margin, so growing content or a smaller window never hides it. Panel/toolbar size changes, toolbar movement and cancellation, and viewport resize recalculate styles without rebuilding forms, moving application targets, changing focus or replacing copy selections. Source/image content changes use the same size observer. Native capture’s host visibility hiding does not change these measurements. Page exit disposes observers, resize listeners and pending animation frames. DOM capture runs while text is entered; native capture briefly hides controls to obtain clean pixels. Saving waits for an image or explicit image-free choice after failure. Switching panels and selection/hide shortcuts preserve unsaved comment/edit/reply text by requiring save or cancellation. Failed writes retain input for retry. Successful writes update the UI from the returned feedback, so a subsequent list read failure does not turn a saved reply or reason into a retryable submission. Async completions do not reopen a closed panel or replace a newer surface. +A composer with no header (its title **コメント** is visually hidden, and **キャンセル** closes it, so it has no close button or drag grip) appears next to the target: the capture (its size in the image tooltip), a status line that appears only while capture is pending or after it fails (**画像を撮影できなかったため、画像なしで保存します。**, with the reason in its tooltip), the **カテゴリ** radio group and the focused comment field, whose accessible name is **コメント本文** and which has no visible label. Target details, source/CSS evidence and copy actions are not shown; the source lookup status is announced to assistive technology only, and saving does not wait for it. While a new composer is open for an element, text, area or multiple selection, a dashed draft outline marks each selected target's recorded rectangle (every original of a multiple selection, otherwise the target rectangle). It belongs to the review host, so DOM capture excludes it and tab capture hides it. Page scroll or viewport resize removes the outline rather than leaving it at stale coordinates; closing, saving or replacing the composer also removes it. The composer and edit form offer **カテゴリ** above the comment as three separated pill radio buttons (the selected one has a primary-interactive border and text): 修正 (`fix`, the default for new comments and for edited items without a category), 相談 (`discuss`) and 質問 (`question`). Every save writes the chosen category. Enter on a category never submits the form. A new composer has a single **保存** button (also Ctrl/Cmd+Enter inside the comment field). It waits for the capture and for the composer to finish growing to fit the preview, so the button does not move under the pointer; a failed capture switches the draft to an image-free save and enables the button without another step. After a successful save it closes the composer, shows a toast centred 20 pixels above the bottom of the viewport (drawn above the toolbar and any panel) and restarts selection instead of opening the list. Every new selection starts with plain clicks selecting single elements; a previous multiple selection does not carry over. A failed save keeps the composer and typed text. List, settings, composer, detail and edit panels share viewport placement. The list is centred horizontally over the **コメント一覧** button and settings over the **設定** button, opening above the toolbar with a twelve-pixel gap; when less than 120 pixels remain above and more space is below, they open below instead. Details open beside their pin after the target has been scrolled into view, twelve pixels to its right (or to its left when the right does not fit) with their top at the pin's top, moved up only as far as needed to stay inside the viewport; without a visible pin (resolved items, hidden pins) they open beside the target's outline, or beside the recorded rectangle shifted by the scroll since capture. Pressing **コメント一覧** while the list is open, or **設定** while settings are open, closes that panel like its close button. Panels fade and scale in over 180 ms and fade out over 120 ms; with reduced motion they appear and disappear instantly. A closing panel is already hidden for keyboard, assistive technology and pointer input while it fades, and opening another panel ends the fade at once instead of showing both. Other panels without a target keep the default placement. When a panel horizontally overlaps the toolbar, it uses the larger space above or below the toolbar with a twelve-pixel gap and a ten-pixel viewport margin. Its maximum height is capped to that space with internal scrolling. If both spaces are under 120 pixels high, the toolbar uses temporary visibility hiding while the panel uses the full viewport interior. Closing the panel or beginning selection restores toolbar visibility before returning focus; the user’s hidden state and saved coordinates remain intact. Every panel's header except those of the new-comment composer and settings (title in 12-pixel semibold) stays pinned to the top of the panel while its body scrolls, separated from the body by a bottom border, and is the panel's drag grip: a primary press on the header outside its buttons (which keep their clicks) moves the panel after three pixels, Escape during a drag puts it back without closing it, and pointer capture is held by the panel so a list refresh mid-drag does not end it. The dropped point is kept across list and details until the panel closes, is not saved, and is only clamped inside the ten-pixel viewport margin, so growing content or a smaller window never hides it. Panel/toolbar size changes, toolbar movement and cancellation, and viewport resize recalculate styles without rebuilding forms, moving application targets, changing focus or replacing copy selections. Source/image content changes use the same size observer. Native capture’s host visibility hiding does not change these measurements. Page exit disposes observers, resize listeners and pending animation frames. DOM capture runs while text is entered; native capture briefly hides controls to obtain clean pixels. Saving waits for an image, or saves without one after capture fails. Switching panels and selection/hide shortcuts preserve unsaved comment/edit/reply text by requiring save or cancellation. Failed writes retain input for retry. Successful writes update the UI from the returned feedback, so a subsequent list read failure does not turn a saved reply or reason into a retryable submission. Async completions do not reopen a closed panel or replace a newer surface. The existing UI host uses a transparent manual popover where supported and moves inside an active document-level native modal dialog. This keeps controls above the dialog and within its focus boundary while preserving the same shadow UI and drafts. Pins outside the active native modal, or attached to currently hidden elements such as closed dialogs, are hidden. Nested document dialogs and returning to the page are supported; dialogs inside shadow roots and custom focus-trap behavior are not covered by this adapter. -Pins show unresolved feedback for the exact pathname, query and hash. Pins whose current element target can be identified use the positive axis-aligned intersection of the current full element box with the page viewport, accessible same-origin frame viewports and applicable ancestor overflow/paint clips. Fully clipped, known hidden or unavailable bounded paths hide that pin. Partial targets place the pin within the remaining visible rectangle, including after collision offsets and viewport placement; the marker itself can extend beyond a narrow visible portion. This calculation follows at most 200 composed ancestors and eight non-page document/open-shadow roots, including assigned slots where accessible, without scanning unrelated page nodes. Absolute and fixed containing-block rules follow the existing selection eligibility rules. It is a clipping check, not an occlusion, clip-path/mask or rotated-frame guarantee. Saved full rectangles and source/identity evidence remain unchanged. An unresolved element identity keeps its unconfirmed recorded-position fallback unless its unique same-tag selector candidate has no visible bounds on the recorded child routes. This suppresses hidden or clipped candidates without accepting them as confirmed targets; changed child URLs, missing or ambiguous candidates retain the fallback. Older records without child URLs use their recorded root path. Geometric area/multiple pins retain their recorded positions. Pin numbers follow creation order across all saved items, resolved ones included, so the newest comment has the highest number and a comment keeps its number until an earlier item is deleted. Pins sharing a location are offset where the visible portion permits; a narrow portion can leave overlapping markers, and all comments remain accessible from the list. Hover/focus previews the comment; click opens its details with edit/delete/resolve/replies. The list contains compact comments rather than all screenshots. Its header holds the **コメント一覧** title, a **プロンプトをコピー** icon button (the `ai` sparkle in the AI gradient described below; its tooltip gives the count and a successful copy shows a toast), a `search` **キーワード検索** button, a `filter` **絞り込み** button, a **一覧の操作** kebab and the close button. Search replaces the whole header with a **キーワード検索** field, the copy icon (so the searched scope can still be copied) and a **検索を閉じる** button; closing it, or Escape in the field, clears the query and returns focus to the search button. The filter button opens a menu of two `menuitemcheckbox` items, **このページのみ** (the same pathname, query and hash as pins, on any origin) and **解決済みも表示**, each label followed by a check on the right that shows only when chosen; choosing one re-renders the list and reopens the menu on that item so both can be toggled in a row; the button keeps its colour. Filters are not persisted; resolved items are hidden by default. The kebab holds **すべて解決済みにする** (disabled without displayed open items), which shows every displayed open item as resolved at once, sends the updates in parallel, reverts any that fail and reports the count or failures in a toast, and **すべて削除** (disabled for an empty list), which asks **表示中の{count}件を画像・返信と一緒に削除します。元に戻せません。** and then deletes each displayed item individually, reporting partial failures as before. Polling does not re-render the list while one of its menus is open. The cards start right below the header; when submission is configured an **AIへ送信** row comes first. The copy icon uses the displayed open repair requests. Cards are separated by borders spanning the full panel width, with none after the last card. Each card shows the author (**ゲスト** when blank) on one line, truncated with an ellipsis, with the relative time beside it, then the category tag on its own line (as in details, including the default 修正), then the comment clamped to two lines, then the commented page's path with its query and hash (without the origin, in quiet 12-pixel text on one line, truncated with an ellipsis, with the full path in the tooltip); replies and reply counts are not shown. A `check-circle` **解決にする** / `check-circle-filled` **未解決に戻す** button beside the author toggles the status and re-renders the list at the same scroll position, keeping focus on the toggle, so the hand-off buttons reflect the change; an item resolved there stays in the list while it is open and is filtered normally the next time the list opens. Opening a card whose pathname, query or hash differs from the current page navigates to that path on the current origin and then opens its details there: a hash-only difference changes `location.hash` in the same document, and any other difference loads the page with the item id kept in `sessionStorage` (`needmark-open`) until the next mount has loaded the list. Details show the target on the page as soon as they open (the same locate behaviour as a pin's saved group); a an area comment outlines the dragged rectangle at its recorded page position rather than any element sampled inside it, and a single target's outline is re-measured after every scroll or resize so it stays on the element, or on the recorded page position when the element cannot be found, then a byline with the author (**ゲスト** when blank), a relative time (`Intl.RelativeTimeFormat`, **たった今** under a minute, with the exact time in the tooltip) and a **コメントの操作** kebab menu holding **編集** (repairs only) and **削除**, then the capture, the category tag (including the default 修正; it changes only in the edit form), the comment, a row with **{editor}で開く** text links for the first element with a source location or file hint (an ancestor source and a file hint on that element each get their own link) and **プロンプトをコピー** (an AI button with the ordinary button corners and size and semibold 12-pixel text, a 16-pixel `ai` sparkle icon filled with the `--ai-gradient` (blue `#0f4cc0` through `#5d59d9` and `#c31177` to amber `#ca8a04`, left to right) and a one-pixel border in the same gradient, defined once in the shadow root so hidden copies never break it; available for every status, including resolved items, which copies that one item explicitly; it shows **コピーしました** with a check icon for two seconds after a successful copy instead of a status line), and the replies, and a full-width reply composer with a tinted background and a top border. The composer is a single-line field (accessible name **返信**, no visible label) that grows with its text up to 160 pixels and then scrolls inside, with a round 28×28 `send` icon button (**送信**, also Ctrl/Cmd+Enter) that is disabled while the text is blank. While empty the button sits inline on the single line; once there is text it moves below the text, keeping the same right and bottom offsets so it never shifts sideways, and the text uses the full width with equal left and right padding. **編集** in a kebab menu turns the comment (category radios and text) or the reply text into an inline form in place, with **キャンセル** and **保存** (32 pixels high like **プロンプトをコピー**); while the comment is being edited the editor link and prompt copy row is hidden; saving or cancelling swaps only that part back, so the capture and the rest of the details do not move or reload. Comment and reply text in details is rendered from a built-in Markdown subset (paragraphs and line breaks, `#` headings, bold, italic, strikethrough, inline and fenced code, `>` quotes, `-`/`1.` lists and links): every character is escaped first and only those generated tags are emitted, and only `http`, `https` and `mailto` targets (Markdown links or bare ASCII URLs, without trailing punctuation) become anchors that open in a new tab with `rel="noopener noreferrer"`; other targets stay as text. Saved files, lists, pins and AI prompts keep the raw Markdown text. Bylines show the name in 13-pixel semibold primary text (`--ink`) with the relative time in quiet 12-pixel text; comment and reply text use 13 pixels with a 1.7 line height in a softer body tone (`--body`: `#3f4348` light, `#dfe1e4` dark) so names stand out, while bold text and headings inside it return to `--ink` and quotes use the quiet tone, the comment and each following reply are separated by a full-content-width border with 16 pixels above and below it. Names in bylines stay on one line and are truncated with an ellipsis (the full name is in the tooltip). Each reply shows its author with the relative time beside it, then its text; every reply has a **返信の操作** kebab with **編集**/**削除** (accessible names **返信を編集**/**返信を削除**); anyone can edit or delete any comment or reply, and editing a reply keeps its original author. Kebab menus close on Escape (returning focus to the kebab), on another click or on a pointer press outside them. The header holds the back button, a visually hidden title (**コメント**, or **対象情報の詳細** for references), a `check-circle` **解決にする** button that patches the status to resolved and swaps only itself in place (the rest of the details is not re-rendered, so the image and outline do not flicker), which then shows as a success-coloured, pressed `check-circle-filled` **未解決に戻す** button that patches it back to open (neither change shows a notice because the icon itself reflects the status), and the close button. There are no selector, style or attribute copy actions in details, the composer or the list. Copying preserves typed forms. Clipboard failure shows an error toast and appends readonly output inside the current panel; focus remains in typed input, and a focused manual-copy field is not replaced by polling. Repair Copy and optional submission use currently displayed open ordinary feedback; target references are excluded. The page/resolved/search filters identify that scope, and the copy button's tooltip gives its count. Reference cards and pins show their target summary with no change requested, and references retain their Locate/resolve/reply actions without a comment editor. Search also uses saved target identifiers, source/component evidence and text. Detail actions select the current item only. The image viewer follows the NeedBack widget: a modal titled **画像プレビュー** over a dimmed backdrop, with a close button in its header, the image fitted to a muted stage and zoom controls (**縮小**, the current percentage, which resets the view, and **拡大**) at its bottom right. Zoom ranges from 50% to 300% in 25% steps from the buttons and the + and − keys; Cmd+wheel or Ctrl+wheel over the stage, including a trackpad pinch, zooms in proportion to the wheel distance, at most one step per event, instead of zooming the page; other wheel input is left alone. Dragging the image or pressing an arrow key (40 pixels) pans it, keeping each image edge within 20% of the stage's size from the stage edge; zooming to 100% or less recentres it. Escape, the close button or a click on the backdrop closes the viewer and returns focus. Deletion explicitly confirms the selected item and removes its local image and replies. The confirmation for **削除** and **すべて削除** replaces the content of the open panel without moving it; **戻る** returns to the details or the list. +Pins show unresolved feedback for the exact pathname, query and hash. Pins whose current element target can be identified use the positive axis-aligned intersection of the current full element box with the page viewport, accessible same-origin frame viewports and applicable ancestor overflow/paint clips. Fully clipped, known hidden or unavailable bounded paths hide that pin. Partial targets place the pin within the remaining visible rectangle, including after collision offsets and viewport placement; the marker itself can extend beyond a narrow visible portion. This calculation follows at most 200 composed ancestors and eight non-page document/open-shadow roots, including assigned slots where accessible, without scanning unrelated page nodes. Absolute and fixed containing-block rules follow the existing selection eligibility rules. It is a clipping check, not an occlusion, clip-path/mask or rotated-frame guarantee. Saved full rectangles and source/identity evidence remain unchanged. An unresolved element identity keeps its unconfirmed recorded-position fallback unless its unique same-tag selector candidate has no visible bounds on the recorded child routes. This suppresses hidden or clipped candidates without accepting them as confirmed targets; changed child URLs, missing or ambiguous candidates retain the fallback. Older records without child URLs use their recorded root path. Geometric area/multiple pins retain their recorded positions. Pin numbers follow creation order across all saved items, resolved ones included, so the newest comment has the highest number and a comment keeps its number until an earlier item is deleted. Pins sharing a location are offset where the visible portion permits; a narrow portion can leave overlapping markers, and all comments remain accessible from the list. While the pointer is on a pin, page DOM changes do not move or re-create pins (scrolling and resizing still do), so a page that reacts to the pointer leaving it, such as a chart hiding its tooltip, cannot pull a pin away; they catch up when the pointer leaves the pin. Hover/focus previews the comment; click opens its details with edit/delete/resolve/replies. The list contains compact comments rather than all screenshots. Its header holds the **コメント一覧** title, a `copy` **プロンプトをコピー** icon button (its tooltip gives the count; a successful copy turns it into a success-coloured `check-circle` named **コピーしました** for two seconds), a `search` **キーワード検索** button, a `filter` **絞り込み** button, a **一覧の操作** kebab and the close button. Search replaces the whole header with a **キーワード検索** field, the copy icon (so the searched scope can still be copied) and a **検索を閉じる** button; closing it, or Escape in the field, clears the query and returns focus to the search button. The filter button opens a menu of two `menuitemcheckbox` items, **このページのみ** (the same pathname, query and hash as pins, on any origin) and **解決済みも表示**, each label followed by a check on the right that shows only when chosen; choosing one re-renders the list and reopens the menu on that item so both can be toggled in a row; the button keeps its colour. Filters are not persisted; resolved items are hidden by default. The kebab holds **すべて解決済みにする** (disabled without displayed open items), which shows every displayed open item as resolved at once, sends the updates in parallel, reverts any that fail and reports the count or failures in a toast, and **すべて削除** (disabled for an empty list), which asks **表示中の{count}件を画像・返信と一緒に削除します。元に戻せません。** and then deletes each displayed item individually, reporting partial failures as before. Polling does not re-render the list while one of its menus is open. The cards start right below the header; when submission is configured an **AIへ送信** row comes first. The copy icon uses the displayed open repair requests. Cards are separated by borders spanning the full panel width, with none after the last card. Each card shows the author (**ゲスト** when blank) on one line, truncated with an ellipsis, with the relative time beside it, then the category tag on its own line (as in details, including the default 修正), then the comment clamped to two lines, then the commented page's path with its query and hash (without the origin, in quiet 12-pixel text on one line, truncated with an ellipsis, with the full path in the tooltip); replies and reply counts are not shown. A `check-circle` **解決にする** / `check-circle-filled` **未解決に戻す** button beside the author toggles the status and re-renders the list at the same scroll position, keeping focus on the toggle, so the hand-off buttons reflect the change; an item resolved there stays in the list while it is open and is filtered normally the next time the list opens. Opening a card whose pathname, query or hash differs from the current page navigates to that path on the current origin and then opens its details there: a hash-only difference changes `location.hash` in the same document, and any other difference loads the page with the item id kept in `sessionStorage` (`needmark-open`) until the next mount has loaded the list. Details show the target on the page as soon as they open (the same locate behaviour as a pin's saved group) and hide it again when the reviewer leaves them (back to the list, close, delete or any other screen); a an area comment outlines the dragged rectangle at its recorded page position rather than any element sampled inside it, and a single target's outline is re-measured after every scroll or resize so it stays on the element, or on the recorded page position when the element cannot be found, then a byline with the author (**ゲスト** when blank), a relative time (`Intl.RelativeTimeFormat`, **たった今** under a minute, with the exact time in the tooltip) and a **コメントの操作** kebab menu holding **編集** (repairs only) and **削除**, then the capture, the category tag (including the default 修正; it changes only in the edit form), the comment, an **AIへ送信** row for open repairs when submission is configured, and the replies, and a full-width reply composer with a tinted background and a top border. The composer is a single-line field (accessible name **返信**, no visible label) that grows with its text up to 160 pixels and then scrolls inside, with a round 28×28 `send` icon button (**送信**, also Ctrl/Cmd+Enter) that is disabled while the text is blank. While empty the button sits inline on the single line; once there is text it moves below the text, keeping the same right and bottom offsets so it never shifts sideways, and the text uses the full width with equal left and right padding. **編集** in a kebab menu turns the comment (category radios and text) or the reply text into an inline form in place, with **キャンセル** and **保存** (32 pixels high); while the comment is being edited the prompt copy icon and the send row are hidden; saving or cancelling swaps only that part back, so the capture and the rest of the details do not move or reload. Comment and reply text in details is rendered from a built-in Markdown subset (paragraphs and line breaks, `#` headings, bold, italic, strikethrough, inline and fenced code, `>` quotes, `-`/`1.` lists and links): every character is escaped first and only those generated tags are emitted, and only `http`, `https` and `mailto` targets (Markdown links or bare ASCII URLs, without trailing punctuation) become anchors that open in a new tab with `rel="noopener noreferrer"`; other targets stay as text. Saved files, lists, pins and AI prompts keep the raw Markdown text. Bylines show the name in 12-pixel semibold primary text (`--ink`) with the relative time in quiet 12-pixel text; comment and reply text use 13 pixels with a 1.7 line height in a softer body tone (`--body`: `#3f4348` light, `#dfe1e4` dark) so names stand out, while bold text and headings inside it return to `--ink` and quotes use the quiet tone, the comment and each following reply are separated by a full-content-width border with 16 pixels above and below it. Names in bylines stay on one line and are truncated with an ellipsis (the full name is in the tooltip). Each reply shows its author with the relative time beside it, then its text; every reply has a **返信の操作** kebab with **編集**/**削除** (accessible names **返信を編集**/**返信を削除**); anyone can edit or delete any comment or reply, and editing a reply keeps its original author. Kebab menus close on Escape (returning focus to the kebab), on another click or on a pointer press outside them. The header holds the back button, a visually hidden title (**コメント**, or **対象情報の詳細** for references), `open-new` **{editor}で開く** icon links for the first element with a source location or file hint (an ancestor source and a file hint on that element each get their own icon, named **親のソースを開く** and **候補ファイルを開く**; the tooltip gives the name and that the local editor opens), a `copy` **プロンプトをコピー** icon for repairs (available for every status, including resolved items, which copies that one item explicitly; a successful copy turns it into a success-coloured `check-circle` named **コピーしました** for two seconds instead of a status line), a `check-circle` **解決にする** button that patches the status to resolved and swaps only itself in place (the rest of the details is not re-rendered, so the image and outline do not flicker), which then shows as a success-coloured, pressed `check-circle-filled` **未解決に戻す** button that patches it back to open (neither change shows a notice because the icon itself reflects the status), and the close button. There are no selector, style or attribute copy actions in details, the composer or the list. Copying preserves typed forms. Clipboard failure shows an error toast and appends readonly output inside the current panel; focus remains in typed input, and a focused manual-copy field is not replaced by polling. Repair Copy and optional submission use currently displayed open ordinary feedback; target references are excluded. The page/resolved/search filters identify that scope, and the copy button's tooltip gives its count. Reference cards and pins show their target summary with no change requested, and references retain their Locate/resolve/reply actions without a comment editor. Search also uses saved target identifiers, source/component evidence and text. Detail actions select the current item only. The image viewer follows the NeedBack widget: a modal titled **画像プレビュー** over a dimmed backdrop, with a close button in its header, the image fitted to a muted stage and zoom controls (**縮小**, the current percentage, which resets the view, and **拡大**) at its bottom right. Zoom ranges from 50% to 300% in 25% steps from the buttons and the + and − keys; Cmd+wheel or Ctrl+wheel over the stage, including a trackpad pinch, zooms in proportion to the wheel distance, at most one step per event, instead of zooming the page; other wheel input is left alone. Dragging the image or pressing an arrow key (40 pixels) pans it, keeping each image edge within 20% of the stage's size from the stage edge; zooming to 100% or less recentres it. Escape, the close button or a click on the backdrop closes the viewer and returns focus. Deletion explicitly confirms the selected item and removes its local image and replies. The confirmation for **削除** and **すべて削除** replaces the content of the open panel without moving it; **戻る** returns to the details or the list. Ordinary multiple comments have a per-member Locate view. Each stored context uses the existing strict selector, tag, identifier, text/name, root, child-URL and source ownership checks. Locate requests scrolling to the first strictly matched member with a measurable box and visible own computed style, then resolves and measures every member again. Numbered outlines use current visible bounds for confirmed targets. Unmatched targets use dashed recorded full rectangles converted by saved minus actual top-page scroll, including when the saved scroll is clamped. A matched member without verified visible bounds has no recorded fallback outline. The group summary distinguishes confirmed, recorded-unconfirmed and members without verified visible bounds. Older multiple records without per-member context use the recorded whole region. An active group follows the existing frame-batched page-change path and includes its stored roots in observation even for resolved feedback; clearing it removes that extra observation scope. Repeating Locate replaces its outlines. Panel replacement/close, opening the image viewer, new selection, hiding controls, exact route change and page exit clear the view. A history update that keeps pathname, query and hash unchanged retains the active group. Rendering does not alter saved context, images or source evidence. @@ -111,7 +115,7 @@ Saved pixels mask password inputs and `[data-needmark-redact]` regions in the do Fresh element and multiple-selection captures require all originally selected nodes, with their original identity and closest component evidence when available. The same original nodes may move before acquisition, including outside the viewport; their target/context rectangles are measured again at the snapshot. Replacement, disappearance or movement during acquisition rejects the image. A text capture retains the original range endpoints, offsets, root and complete selected text, including text beyond the stored display-text limit; its target remains the partial range, while owner context retains full element rectangles. Fixed areas keep their authored geometry and validate the bounded existing-context sample without retargeting to its union. Empty geometric areas remain valid. Pre/post checks do not prove stability between samples or correct every DOM renderer discrepancy. -`none` mode saves comments and target context with capture dimensions 0×0 and no image file. Capture failure, including target/viewport/sensitive-region drift, offers that explicit choice while keeping typed text and withholding image submission until the choice is made. There is no audio/video recording. +`none` mode saves comments and target context with capture dimensions 0×0 and no image file. Capture failure, including target/viewport/sensitive-region drift, switches the draft to that mode while keeping typed text, so the comment saves without an image. There is no audio/video recording. ## Files and mutation @@ -123,7 +127,7 @@ Each new comment is written into a temporary folder and renamed into a UUID dire Ordinary feedback may carry optional `category` (`fix`, `discuss`, `question`); an absent category is treated as `fix`. References reject a category on saving and reading. Files written before this set load with `change` read as `fix`, `approve` read as `discuss` (`change` requested a code change; `approve` requested none) and any `severity` dropped; writes accept only the current values, and a later rewrite stores the normalized record. The category appears in readable Markdown, MCP summaries and every prompt detail level, and as a tag above the comment on list cards and details, including the default `fix`. Statuses are open (未対応) and resolved (解決済み); any other saved status makes the file invalid. Edits retain original creation/capture time, image and target, add updatedAt, and regenerate readable Markdown. Replies have UUID, author, text and timestamps and can be edited/deleted. Old version-1 files remain readable. JSON is the status source of truth. Each context/Markdown replacement is atomic; they are two files, not one filesystem transaction. -Reference saving is an explicit separate action in the composer. It waits for capture readiness, source lookup completion, a blank comment and usable metadata; a failed capture requires explicit image-free continuation. Ordinary commented feedback can still save before lookup finishes. The comment textarea is readonly during a reference POST and restored after failure only for the same connected draft/view. Duplicate saves and late completion do not replace another panel. +Reference saving is an explicit separate action in the composer. It waits for capture readiness, source lookup completion, a blank comment and usable metadata. Ordinary commented feedback can still save before lookup finishes. The comment textarea is readonly during a reference POST and restored after failure only for the same connected draft/view. Duplicate saves and late completion do not replace another panel. Target references use optional `intent: "reference"` with `comment: ""`; absent intent denotes ordinary feedback. The widget no longer creates references; the API still accepts them and existing ones remain readable. Defined unknown intents, nonblank reference comments and targets without usable recorded identity are rejected on saving and reading. A nonblank target/element selector, source/file hint, component or safe nonblank attribute qualifies; coordinates, tag, text and styles alone do not. References retain UUID, capture/source context, optional image, status and replies. Every reference comment patch is rejected after a fresh locked read, and intent cannot be patched. Notes use replies. Readable Markdown marks the item as a target reference with no change requested. Legacy ordinary feedback remains readable; older readers that require nonblank comments reject new reference records and can fail a mixed directory. Historical truncated attributes are not confirmed by a prefix match against a longer current value. @@ -178,9 +182,7 @@ Submission is explicit and does not queue, retry, upload screenshots or launch a ## Verification -`pnpm typecheck`, `pnpm test`, `pnpm build` and `pnpm test:e2e` verify the package. Unit/API tests cover validation, feedback classification, the MCP stdio protocol and tools, old files, lifecycle, reply concurrency, filesystem boundaries, initialization/export, origin/token checks, proxy streaming/compression/CSP/redirects/WebSockets, build/preview absence, bounded runtime source resolution, current JSX identity, file-only hints and exclusion of transient frames from saved metadata. Browser tests cover single top-document widget startup, direct child-page startup, current-state same-origin iframe pixels and nested scroll/redaction through the public proxy, selection/capture/save/reload/CLI, exact HTML/React positions, text/multiple/context/root geometry, edits/replies/failure recovery/viewer, delayed-close behavior, bitmap/stream cleanup/redaction, native modal focus/capture, list search/filters/bulk actions, clipboard races, disarm/rearm, directional overlap selection, staged Escape and capture cancellation, host form shortcut isolation and persisted shortcut disabling. - -E2E runs headed Chromium and headless Playwright WebKit, sequentially against the same isolated development fixture. Install the engines with `pnpm exec playwright install chromium webkit`. The real native tab-picker tests use Chromium-specific automation and are excluded from WebKit; stream/bitmap lifecycle tests use controlled capture devices. Standard/legacy caret APIs, custom identifiers, saved-frame pin clipping and recovery, and ordinary review interactions are exercised in both engines. Review-flow tests cover the first-run start button, the icon toolbar's accessible names and tooltips, category defaults, saving, tags, editing, Enter on the category, the single save returning to selection by button and shortcut, draft outlines and the Ctrl/Cmd+Shift+F shortcut. Playwright WebKit is an engine check, not certification of the installed Safari application. +`pnpm typecheck`, `pnpm test`, `pnpm build` and `pnpm test:e2e` verify the package. Vitest covers logic, storage, the review API, proxy, CLI and MCP; Playwright drives the widget in a real browser. Every browser spec runs in headed Chromium, and tests whose outcome depends on engine behaviour also run in headless Playwright WebKit. [Testing](testing.md) defines the layers, the engine policy and how to run them. ## Animation pause diff --git a/docs/getting-started.ja.md b/docs/getting-started.ja.md index cf5eb95..c85ad3e 100644 --- a/docs/getting-started.ja.md +++ b/docs/getting-started.ja.md @@ -11,17 +11,18 @@ Node.js 22.12以降と、npm・Yarn・pnpmのいずれかを使います。ア ```sh pnpm add -D needmark # npm install -D needmark / yarn add -D needmark pnpm exec needmark init +pnpm dev # いつもの開発コマンド ``` このガイドのCLIコマンドはpnpmで記載しています。npmでは`npx needmark`、Yarn(classic・Plug'n'Play)では`yarn needmark`に読み替えてください(例: `npx needmark init`)。初期設定の案内とAIへの指示には、プロジェクトのロックファイルに合ったコマンドが入ります([パッケージマネージャー](architecture.md#package-managers))。 -ターミナルでは最初に日本語・英語を選び、その言語でローカル・Git共有、DOM・タブ・画像なしを選びます。非対話実行ではすべての選択肢をフラグで指定します。 +ターミナルでの初期設定は英語で表示します。最初に推奨設定(レビュー画面はターミナルの言語、コメントはこのコンピューターだけに保存、スクリーンショットはページを描き直して作成、プロジェクトを認識できた場合は[開発サーバーへの接続](#開発サーバーにつなぐ))を示し、そのまま使うかを尋ねます。`n`と答えると、各項目を説明付きの番号から選べます。フラグで指定した項目は尋ねません。非対話実行ではすべての選択肢をフラグで指定します。ターミナルがない場合、`--connect=yes`を指定しない限りプロジェクトのファイルは変更しません。 ```sh -pnpm exec needmark init --share=local --capture=dom --language=ja +pnpm exec needmark init --share=local --capture=dom --language=ja --connect=yes ``` -初期設定は`needmark.config.ts`と用途に応じたGitの除外ルールを作成し、既存ルールを残します。既存の設定は上書きしません。旧プロジェクトとの互換性は[移行](#移行)を参照してください。インストールだけではアプリやAIは起動しません。 +初期設定は`needmark.config.ts`と用途に応じたGitの除外ルールを作成し、既存ルールを残します。既存の設定は上書きしません。設定があるプロジェクトで再実行すると、`init`はその設定を残し、開発サーバーへの接続だけを提案します。旧プロジェクトとの互換性は[移行](#移行)を参照してください。インストールだけではアプリやAIは起動しません。 ## プロジェクト設定 @@ -39,6 +40,7 @@ export default { | オプション | 動作 | | --- | --- | +| `enabled` | `false`にすると、この設定を使う全員のNeedMarkを止めます。[NeedMarkを止める](#needmarkを止める)を参照してください。 | | `origins` | Viteで許可する完全一致のオリジン配列。任意。スキームとポートを含め、ワイルドカードやパスは使えません。省略すると`localhost`、`127.0.0.1`、`[::1]`をポートを問わず許可するので、Viteが別のポートで起動しても動作します。`--host`で公開したLANのアドレス、トンネル、独自のローカルドメインなど別のホスト名でレビューする場合だけ指定します。指定した配列はループバックの既定値を置き換えます。プロキシの待ち受け先は指定しません。 | | `language` | `'en'`または`'ja'`。UIの初期値は`'ja'`。明示した値はCLIの指示文の言語にも使われます。 | | `capture` | `'dom'`、`'tab'`、`'none'`。初期値は`'dom'`。 | @@ -56,9 +58,25 @@ CLIと引数なしの`needmark()`は現在のプロジェクトディレクト コメント、返信、取得したページのテキスト、既存のコメントは入力どおりに残ります。技術メタデータ、JSONキー、識別子、コマンド名は英語のままです。外部ライブラリーやブラウザのエラーは元の言語で表示される場合があり、すべてのメッセージの翻訳を保証する設定ではありません。 -## Vite +## 開発サーバーにつなぐ -既存のVite設定にプラグインを明示的に追加します。pre順序でJSXをコンパイルするプラグインより前に置いてください。 +同意すると、`init`はいつもの開発コマンドでツールバーが出るようにNeedMarkをつなぎます。変更するのは開発用の設定だけで、アプリのソースと本番ビルドには触れず、変更した行を表示します。 + +| プロジェクト | `init`が変更するもの | +| --- | --- | +| `vite.config.*`(React、Vue、SvelteなどのViteプロジェクト) | `import { needmark } from 'needmark'`を追加し、`plugins`の先頭に`needmark()`を入れる | +| `astro.config.*` | importを追加し、`vite.plugins`に`needmark()`を入れる。必要なら`vite: { plugins: [...] }`を作る | +| `next dev`を実行する`dev`スクリプト | 先頭に`needmark dev --`を付ける。[Next.jsなどの開発サーバー](#nextjsなどの開発サーバー)を参照 | + +変更はプロジェクトと一緒にコミットされるため、開発サーバーを起動する全員にツールバーが表示されます。`init`が読めるのは、`defineConfig({...})`、素のオブジェクト、名前付きの定数、オブジェクトを返す関数で書かれた設定です。それ以外の形には手を付けず、下の手動の手順を表示します。あとから`pnpm exec needmark init --connect=yes`でつなぐこともできます。`pnpm exec needmark remove`は接続で追加したものだけを取り除き、`needmark.config.ts`と保存済みのコメントは残します。 + +### NeedMarkを止める + +`NEEDMARK=off`(`0`、`false`、`no`も可)を付けて開発サーバーを起動すると、自分だけNeedMarkなしで動きます。`NEEDMARK=on`なら、設定で止めていても自分だけ有効にできます。`needmark.config.ts`の`enabled: false`は全員分を止めます。止めている間、Viteプラグインは何も追加せず、`needmark dev -- <コマンド>`はいつものポートでコマンドを実行するだけです。 + +### Vite + +手動でつなぐ場合は、既存のVite設定にプラグインを追加します。pre順序でJSXをコンパイルするプラグインより前に置いてください。 ```ts import { defineConfig } from 'vite'; @@ -71,11 +89,35 @@ export default defineConfig({ 引数なしの場合、開発サーバーの起動時に現在のディレクトリの`needmark.config.ts`を読み込みます。CLIと同じファイルです。`needmark(settings)`や`needmark({ outputDir: 'reviews' })`のように設定を渡すと、その設定だけを使い、ファイルは読みません。 -通常のVite開発コマンドを実行し、`localhost`、`127.0.0.1`、`[::1]`または指定したオリジンで開きます。プラグインはViteのHTML変換を使い、開発serve時だけ動作します。`vite preview`と本番ビルドには入りません。ローカルの`.jsx`・`.tsx`のネイティブタグと入口HTMLにソース情報を付けますが、他のテンプレート、SSR、生成ソースを一律には対応付けません。ウィジェットは最上位の文書だけに表示されます。同一オリジンでアクセスできる子ページは親から選べ、直接開くと専用ツールバーが表示されます。 +通常のVite開発コマンドを実行し、`localhost`、`127.0.0.1`、`[::1]`または指定したオリジンで開きます。プラグインはViteのHTML変換を使い、その変換を通らずにフレームワークが描画したHTMLにはウィジェットを末尾に追記します。開発serve時だけ動作します。`vite preview`と本番ビルドには入りません。ローカルの`.jsx`・`.tsx`のネイティブタグと入口HTMLにソース情報を付けますが、他のテンプレート、SSR、生成ソースを一律には対応付けません。ウィジェットは最上位の文書だけに表示されます。同一オリジンでアクセスできる子ページは親から選べ、直接開くと専用ツールバーが表示されます。 + +### Astro + +AstroはViteで動くため、`vite`オプションに同じプラグインを追加し、いつもどおり`astro dev`を実行します。 + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import { needmark } from 'needmark'; + +export default defineConfig({ + vite: { plugins: [needmark()] }, +}); +``` + +AstroはViteのHTML変換を通さずにページを描画するため、プラグインは各HTMLレスポンスの末尾にウィジェットのscriptを追記します。`.astro`テンプレートにはソース位置を付けず、コメントにはDOMの情報とファイルの手掛かりを残します。 + +### Next.jsなどの開発サーバー + +`package.json`の開発コマンドの前に`needmark dev --`を付けます。`init`がNeedMarkをNext.jsにつなぐときと同じ形です。 + +```json +"scripts": { "dev": "needmark dev -- next dev" } +``` -## Next.jsなどの開発サーバー +これで`pnpm dev`を実行すると、いつも開くポートでツールバー付きのアプリが表示されます。ポートは、コマンドに`-p`・`--port`で指定した値(コマンドからは取り除きます)、なければ`PORT`、なければ3000です。`--`の前に`--port`を付けて指定することもできます。NeedMarkが`http://localhost:<ポート>`で待ち受け、空いているポートを`PORT`に入れてコマンドを起動するため、サーバーは`PORT`を読む必要があります。Next.jsや多くのNodeサーバーは読みます。Ctrl+Cで両方を止め、コマンドの終了コードを引き継ぎます。 -通常のコマンドでアプリを起動し、そのプロジェクトで次を実行します。 +`PORT`を読まないサーバーは、いつもどおり起動してから、隣でプロキシを起動します。 ```sh pnpm exec needmark dev --target=http://localhost:3000 --origin=http://localhost:4190 @@ -111,13 +153,12 @@ pnpm exec needmark dev --target=http://localhost:3000 --origin=http://localhost: | Ctrl/Cmd+クリック | 重なりの下にある要素 | | Alt/Option+ドラッグ | 文字の一部(誤字や文言のコメント) | -複数選択はShift+クリックかShift+ドラッグで始まります。以降はShiftなしのクリックで追加・解除でき、ツールバーには件数と **選択を完了**・**ページを操作** が表示されます。ページで文字を選んでから「コメント」を押すと、ツールバーに **選択中の文字にコメント** が表示され、その文字範囲にそのままコメントできます。 +複数選択はShift+クリックかShift+ドラッグで始まります。以降はShiftなしのクリックで追加・解除でき、ツールバーには件数と **選択を完了** が表示されます。 クリック前にポインターを重ねると、対象の枠・タグ・取得できる名前と、ローカルのメタデータで分かるコンポーネントを確認できます。ポインターを動かさずにCtrl/Cmdを押す・離すだけでも、重なりの下の対象のプレビューに切り替わります。名前は探索範囲を限定したラベル・文字・IDからの手掛かりで、完全なアクセシブルネームの計算ではありません。フォームの値は読み取らず、ソース検索のリクエストも送りません。監視しているページ・ルートの変化、スクロール、サイズ変更、新しい選択操作でプレビューを消し、保存画像にも操作UIと一緒に含めません。[プレビューの動作と制約(英語)](architecture.md#review-interaction)も参照してください。 - Ctrl/Cmd+クリックで、最前面とは独立した下の対象を探します。空のコンテナーや非表示の要素が複数重なっていても、内容や操作部品を優先します。空の視覚要素だけの場合は表示領域が最小の対象を選びます。最前面のopen Shadow DOMやアクセス可能な同一オリジンiframeの内側を優先し、次に外側の対象を探します。下の対象がなければ最前面を選び、アプリのオーバーレイは隠しません。修飾キーを離しても完了せず、Enterか完了ボタンで終了します。 - 複数選択では、選んだ各要素に対象情報と番号付きの枠が付きます。Escapeはまず進行中のドラッグだけを取り消して以前の選択を残し、次に選択を終了します。範囲検索が上限に達した場合は不完全である旨を表示し、クリックや追加ドラッグで対象を増やせます。 -- 複数選択中の **ページを操作** では、選択を保持したままアプリを使い、選択に戻れます。変更・削除された対象は通知付きで除外し、完全なページURLが変わると保持中の選択を終了します。 ### コメントを書く @@ -125,7 +166,7 @@ pnpm exec needmark dev --target=http://localhost:3000 --origin=http://localhost: **保存**(コメント欄でCtrl/Cmd+Enter)を押すと、一覧を開かずにすぐ次の対象を選べる状態に戻ります。やめるときはEscか終了ボタンを押し、一覧はツールバーから開きます。コメントの入力中は、選んだ対象に点線の枠が表示されます。ページをスクロールすると枠は消えます。撮影画像は見出しとコメント欄の間に表示し(クリックで拡大)、AIに渡すソースの場所はフォームの下に折りたたんだ1行で表示します。 -初回の **コメント** では設定画面が開きます。上部の **このまま始める** を押すと、表示中の設定のまますぐに選択を始めます。複数人で同じファイルをレビューする場合は、あとから **設定** で投稿者名を入力してください。 +**コメント** を押すとすぐに選択を始めます。`needmark.config.ts`と **設定** で保存した値を使います。複数人で同じファイルをレビューする場合は、**設定** で投稿者名を入力してください。 ### キーボードショートカット @@ -148,7 +189,7 @@ pnpm exec needmark dev --target=http://localhost:3000 --origin=http://localhost: | --- | --- | | `dom` | 共有許可は不要。同一オリジンでアクセス可能なiframeを含む現在のDOMを描画します。非対応CSS、動画、クロスオリジン内容は実際の表示と異なる場合があります。 | | `tab` | 現在のタブの共有許可が必要。ウィンドウ・画面共有は拒否し、取得したストリームは停止かページ終了まで再利用します。ブラウザが検証できない場合は自分で現在のタブを選びます。 | -| `none` | コメントと対象情報を画像なしで保存。撮影に失敗すると明示的にこの方法を選べ、入力した文章を保持します。 | +| `none` | コメントと対象情報を画像なしで保存。撮影に失敗した場合もこの方法に切り替わり、入力した文章を保ったまま通常どおり保存できます。 | 保存画像ではパスワード欄と`data-needmark-redact`領域をマスクし、旧`data-needback-redact`も認識します。クロスオリジンiframeやclosed Shadow DOMの内部を隠す場合はiframeやホスト自体を指定してください。ブラウザのライブ共有ストリームはマスクしません。撮影中の対象、ルート、ビューポート・iframeの形状、秘匿領域の変化で撮影を拒否する場合があります。採用した画像は取得時の形状情報と一緒に保存します。音声・動画の録画はしません。画像は拡大・移動できるビューアーで確認できます。完全な安定性・描画上の制約は[撮影の詳細(英語)](architecture.md#capture)を参照してください。 @@ -156,7 +197,7 @@ pnpm exec needmark dev --target=http://localhost:3000 --origin=http://localhost: コメントごとのUUIDフォルダーに`comment.md`、`context.json`、任意の`screenshot.png`を保存します。新規プロジェクトは`.needmark/feedback`、デモは`demo/.needmark/feedback`を使います。ステータスの正本はJSONです。対象の参照には`intent: "reference"`と空のコメントを記録します。参照を追加する前に共有先の読み取り側も更新してください。旧版のNeedMarkは空のコメントを拒否し、コメントと参照が混在する一覧を読めない場合があります。操作中でないUIはCLI・Gitの変更を反映し、編集中の内容を捨てません。 -一覧のヘッダーには **プロンプトをコピー**(AIのスパークルのアイコン)・検索・絞り込み・⋮ のアイコンがあります。検索を押すとヘッダー全体が **キーワード検索** の入力欄になり(コピーのアイコンは横に残ります)、Escか閉じるボタンで検索を解除します。絞り込みのメニューでは **このページのみ**(ピンと同じページ。localhostと127.0.0.1のどちらで開いても同じ)と **解決済みも表示** を組み合わせて選べます。既定では解決済みを表示しません。⋮ メニューの **すべて解決済みにする** と **すべて削除** は、一覧に表示中の項目が対象です。削除は同じパネルの中で確認してから実行し、一部の失敗も報告します。**プロンプトをコピー** は表示中の未対応の修正依頼をコピーします(件数はツールチップに表示し、コピーするとアイコンが少しの間チェックに変わります)。詳細からは1件だけコピーできます。カードには投稿者(未設定ならゲスト)を1行で、投稿からの時間、詳細と同じくコメントの上にカテゴリのタグ、コメントの先頭2行と、コメントしたページのパスを表示します。投稿者の横のチェックアイコンで解決済みにしたり未対応に戻したりできます。一覧で解決済みにした項目は、一覧を開き直すまで表示したままにします。カードを押すと詳細が開きます。別のページのカードは、そのページへ移動してから詳細と対象を表示します。compact・standard・detailed・forensicで含める情報量を選び、既定はstandardです。完全な根拠はファイルに残ります。検証済みの位置、親の手掛かり、ファイル候補は区別されるため、現在のコードと画像を照合してください。コピーするとボタンが少しの間 **コピーしました** に変わります。クリップボードが使えない場合はトーストで知らせ、画面内の手動コピー欄を使えます。通知は常に画面下部中央のトーストで表示し、削除や返信の保存などUIで分かる結果は通知しません。 +一覧のヘッダーには **プロンプトをコピー**(コピーのアイコン)・検索・絞り込み・⋮ のアイコンがあります。検索を押すとヘッダー全体が **キーワード検索** の入力欄になり(コピーのアイコンは横に残ります)、Escか閉じるボタンで検索を解除します。絞り込みのメニューでは **このページのみ**(ピンと同じページ。localhostと127.0.0.1のどちらで開いても同じ)と **解決済みも表示** を組み合わせて選べます。既定では解決済みを表示しません。⋮ メニューの **すべて解決済みにする** と **すべて削除** は、一覧に表示中の項目が対象です。削除は同じパネルの中で確認してから実行し、一部の失敗も報告します。**プロンプトをコピー** は表示中の未対応の修正依頼をコピーします(件数はツールチップに表示し、コピーすると少しの間、成功色のチェックに変わります)。詳細からは1件だけコピーできます。カードには投稿者(未設定ならゲスト)を1行で、投稿からの時間、詳細と同じくコメントの上にカテゴリのタグ、コメントの先頭2行と、コメントしたページのパスを表示します。投稿者の横のチェックアイコンで解決済みにしたり未対応に戻したりできます。一覧で解決済みにした項目は、一覧を開き直すまで表示したままにします。カードを押すと詳細が開きます。別のページのカードは、そのページへ移動してから詳細と対象を表示します。compact・standard・detailed・forensicで含める情報量を選び、既定はstandardです。完全な根拠はファイルに残ります。検証済みの位置、親の手掛かり、ファイル候補は区別されるため、現在のコードと画像を照合してください。コピーするとボタンが少しの間、成功色のチェックに変わります。クリップボードが使えない場合はトーストで知らせ、画面内の手動コピー欄を使えます。通知は常に画面下部中央のトーストで表示し、削除や返信の保存などUIで分かる結果は通知しません。 ```sh pnpm exec needmark list @@ -168,7 +209,7 @@ pnpm exec needmark resolve <id> pnpm exec needmark reopen <id> ``` -いつものAIにプロジェクトと保存画像へのアクセスを渡します。画像の理解には画像対応のAIが必要です。コメントとDOMの文章はレビュー資料として扱い、実行しないでください。AIは変更と検証を返信で報告し、人が確認するまで未対応のまま残します。詳細を開くと、ページ上の対象がすぐに枠で示されます。ヘッダーには戻るボタン、**解決にする** のチェックアイコン(解決済みになると成功色の塗りつぶしに変わり、もう一度押すと未解決に戻る)、閉じるボタンだけが並ぶため、ページ上で修正を確かめてそのまま承認できます。撮影画像の上には投稿者名と投稿からの時間(「2日前」「たった今」など)、**編集**・**削除** を開く ⋮ メニューがあります。撮影画像の下にカテゴリのタグ(変更は **編集** から)とコメント、その下にテキストリンクの **VS Codeで開く**(エディターを設定している場合。Cursorも可)と、グラデーションの枠のAIボタン **プロンプトをコピー** があり(解決済みの項目でも使えます)、コピーすると少しの間 **コピーしました** とチェックの表示に変わります。コメントと返信ではMarkdown(太字、斜体、コード、リスト、引用、見出し、リンク)が使え、Webのリンクは新しいタブで開きます。返信は投稿者名と投稿からの時間つきで並びます。**編集** を選ぶとコメントや返信がその場で編集フォームに変わるため、パネルのほかの部分は動きません。下部の返信欄は1行から始まり、入力に合わせて伸び(上限を超えると欄の中でスクロール)、矢印のボタンかCtrl/Cmd+Enterで送信します。返信はどれも ⋮ メニューから編集・削除できます。コメントと返信は誰でも編集・削除でき、返信を編集しても投稿者名は元のまま残ります。**送信** で返信を残せます。参照と`resolved`は修正用の指示と送信から除外します。参照だけを選んだ場合、修正コマンドや根拠ファイルへのパスを含まない中立な文章を出力します。UI・CLIには編集、返信、削除もあります。 +いつものAIにプロジェクトと保存画像へのアクセスを渡します。画像の理解には画像対応のAIが必要です。コメントとDOMの文章はレビュー資料として扱い、実行しないでください。AIは変更と検証を返信で報告し、人が確認するまで未対応のまま残します。詳細を開くと、ページ上の対象がすぐに枠で示されます。ヘッダーには戻るボタン、**VS Codeで開く** のアイコン(エディターを設定している場合。Cursorも可)、**プロンプトをコピー** のコピーアイコン(解決済みの項目でも使えます。コピーすると少しの間、成功色のチェックに変わります)、**解決にする** のチェックアイコン(解決済みになると成功色の塗りつぶしに変わり、もう一度押すと未解決に戻る)、閉じるボタンが並ぶため、ページ上で修正を確かめてそのまま承認できます。撮影画像の上には投稿者名と投稿からの時間(「2日前」「たった今」など)、**編集**・**削除** を開く ⋮ メニューがあります。撮影画像の下にカテゴリのタグ(変更は **編集** から)とコメントがあります。コメントと返信ではMarkdown(太字、斜体、コード、リスト、引用、見出し、リンク)が使え、Webのリンクは新しいタブで開きます。返信は投稿者名と投稿からの時間つきで並びます。**編集** を選ぶとコメントや返信がその場で編集フォームに変わるため、パネルのほかの部分は動きません。下部の返信欄は1行から始まり、入力に合わせて伸び(上限を超えると欄の中でスクロール)、矢印のボタンかCtrl/Cmd+Enterで送信します。返信はどれも ⋮ メニューから編集・削除できます。コメントと返信は誰でも編集・削除でき、返信を編集しても投稿者名は元のまま残ります。**送信** で返信を残せます。参照と`resolved`は修正用の指示と送信から除外します。参照だけを選んだ場合、修正コマンドや根拠ファイルへのパスを含まない中立な文章を出力します。UI・CLIには編集、返信、削除もあります。 CLIの保存操作は設定の`outputDir`を使います。保存先を明示すると設定の読み込みを省略します。`list`・`export`・`watch`の後、ステータス・編集・削除のIDの後、または返信の`--directory`で指定できます。ソースチェックアウトではビルド後に`node dist/cli.js ...`を使います。`watch`はCLI・Git・直接編集の変更を監視します。任意の送信操作は`onSubmit`の完了を待ちますが、AIの起動、画像バイトのアップロード、コメントの消去、解決はしません。受け手にはローカル画像へのアクセスが必要です。失敗、配信、再試行の制約は[コールバックの動作(英語)](architecture.md#optional-node-integration-hooks)を参照してください。 diff --git a/docs/getting-started.md b/docs/getting-started.md index ce116d8..895350b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,17 +11,18 @@ Use Node.js 22.12 or newer with npm, Yarn or pnpm. Install NeedMark in your appl ```sh pnpm add -D needmark # npm install -D needmark / yarn add -D needmark pnpm exec needmark init +pnpm dev # your usual development command ``` This guide writes CLI commands for pnpm. With npm use `npx needmark`, with Yarn (classic or Plug'n'Play) `yarn needmark`, for example `npx needmark init`. Initialization output and agent prompts use the command that matches your project's lockfile ([package managers](architecture.md#package-managers)). -A terminal first asks for English or Japanese, then asks for local/Git sharing and DOM/tab/no image in that language. Supply all choices as flags for a noninteractive invocation: +In a terminal, setup is in English. It first shows recommended settings (the review screen in your terminal's language, feedback kept on this computer, screenshots redrawn from the page and, when it recognizes your project, [connecting the development server](#connect-the-development-server)) and asks whether to use them. Answer `n` to choose each item from a numbered list that explains it. Choices given as flags are not asked again. Supply all choices as flags for a noninteractive invocation; without a terminal `init` changes no file of yours unless `--connect=yes` is given: ```sh -pnpm exec needmark init --share=local --capture=dom --language=en +pnpm exec needmark init --share=local --capture=dom --language=en --connect=yes ``` -Initialization writes `needmark.config.ts` and targeted Git ignore rules, preserving existing rules. It refuses to overwrite an existing config; see [migration](#migration) for compatibility with older projects. Installing does not start the application or an agent. +Initialization writes `needmark.config.ts` and targeted Git ignore rules, preserving existing rules. It never overwrites an existing config: run again in a project that has one, `init` keeps it and only offers to connect the development server; see [migration](#migration) for compatibility with older projects. Installing does not start the application or an agent. ## Project configuration @@ -39,6 +40,7 @@ export default { | Option | Behavior | | --- | --- | +| `enabled` | `false` turns NeedMark off for everyone who uses this config; see [turn NeedMark off](#turn-needmark-off). | | `origins` | Optional Vite allowlist of exact origins, including scheme and port; no wildcards or paths. When omitted, the plugin allows `localhost`, `127.0.0.1` and `[::1]` on any port, so it keeps working when Vite moves to another port. Set it only to review through another hostname, such as a LAN address with `--host`, a tunnel or a custom local domain; the list then replaces the loopback default. It does not choose the proxy listener. | | `language` | `'en'` or `'ja'`; initial UI language defaults to `'ja'`. An explicit value also selects CLI prompt prose. | | `capture` | `'dom'`, `'tab'` or `'none'`; initial capture defaults to `'dom'`. | @@ -56,9 +58,25 @@ A valid browser language preference overrides `language`. Change it in review se Comments, replies, captured page text, and existing feedback remain as written. Technical metadata, JSON keys, identifiers and commands retain their English names. Third-party/browser errors can remain in their original language; the language setting does not guarantee translation of every message. -## Vite +## Connect the development server -Add the plugin explicitly to your existing Vite config, before any plugin whose pre-ordered transform compiles JSX: +With your consent, `init` connects NeedMark so that your usual development command shows the toolbar. It changes only development configuration, never application source or production builds, and lists each line it changed: + +| Project | What `init` changes | +| --- | --- | +| `vite.config.*` (React, Vue, Svelte and other Vite projects) | Adds `import { needmark } from 'needmark'` and `needmark()` first in `plugins` | +| `astro.config.*` | Adds the import and `needmark()` to `vite.plugins`, creating `vite: { plugins: [...] }` when needed | +| A `dev` script running `next dev` | Prefixes it with `needmark dev --`; see [Next.js and other development servers](#nextjs-and-other-development-servers) | + +The change is committed with your project, so everyone who runs the development server gets the toolbar. `init` reads a config written as `defineConfig({...})`, a plain object, a named constant or a function returning the object; it leaves any other shape untouched and prints the manual steps below. `pnpm exec needmark init --connect=yes` connects later, and `pnpm exec needmark remove` removes exactly what connecting added, keeping `needmark.config.ts` and saved feedback. + +### Turn NeedMark off + +Start the development server with `NEEDMARK=off` (also `0`, `false` or `no`) to run it without NeedMark for yourself; `NEEDMARK=on` turns it back on for you even when the config says otherwise. `enabled: false` in `needmark.config.ts` turns it off for everyone. While off, the Vite plugin adds nothing and `needmark dev -- <command>` only runs the command on its usual port. + +### Vite + +To connect by hand, add the plugin to your existing Vite config, before any plugin whose pre-ordered transform compiles JSX: ```ts import { defineConfig } from 'vite'; @@ -71,11 +89,35 @@ export default defineConfig({ Without arguments, the plugin loads `needmark.config.ts` from the current working directory when the development server starts, the same file the CLI reads. Passing settings, as in `needmark(settings)` or `needmark({ outputDir: 'reviews' })`, uses only those settings and skips the file. -Run your usual Vite development command and open it on `localhost`, `127.0.0.1` or `[::1]`, or on a configured origin. The plugin uses Vite's HTML transformation and runs only in development serve, excluding `vite preview` and production builds. It annotates local `.jsx`/`.tsx` native tags and entry HTML; other template/SSR/generated-source mapping is not universal. The widget mounts only in the top document. Accessible same-origin children can be selected from their parent; opening a child directly creates its own toolbar. +Run your usual Vite development command and open it on `localhost`, `127.0.0.1` or `[::1]`, or on a configured origin. The plugin uses Vite's HTML transformation, appends the widget to HTML that a framework renders without it, and runs only in development serve, excluding `vite preview` and production builds. It annotates local `.jsx`/`.tsx` native tags and entry HTML; other template/SSR/generated-source mapping is not universal. The widget mounts only in the top document. Accessible same-origin children can be selected from their parent; opening a child directly creates its own toolbar. + +### Astro + +Astro runs on Vite, so add the same plugin to the `vite` option and run `astro dev` as usual: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import { needmark } from 'needmark'; + +export default defineConfig({ + vite: { plugins: [needmark()] }, +}); +``` + +Astro renders pages without Vite's HTML transformation, so the plugin appends the widget script to the end of each HTML response instead. Source positions are not added to `.astro` templates; feedback keeps the DOM context and file hints. + +### Next.js and other development servers + +Put `needmark dev --` in front of the development command in `package.json`, as `init` does for Next.js: + +```json +"scripts": { "dev": "needmark dev -- next dev" } +``` -## Next.js and other development servers +`pnpm dev` then serves the app with the toolbar on the port you always open: the port given to the command with `-p`/`--port` (removed from the command), otherwise `PORT`, otherwise 3000, or `--port` before `--`. NeedMark listens on `http://localhost:<port>` and starts the command with `PORT` set to a free port, so the server must read `PORT`, as Next.js and most Node servers do. Ctrl+C stops both, and the command's exit code is kept. -Start your application with its usual command. In that project, run: +For a server that does not read `PORT`, start it as usual and run the proxy next to it: ```sh pnpm exec needmark dev --target=http://localhost:3000 --origin=http://localhost:4190 @@ -111,13 +153,12 @@ The toolbar sits at the bottom centre of the page; drag it from anywhere, includ | Ctrl/Cmd+click | The element underneath an overlay | | Alt/Option+drag | Part of the text, for typos and wording | -Multiple selection starts with Shift+click or Shift+drag; after that, plain clicks add or remove targets and the hint shows the count with **Finish selection** and **Interact with page**. If you select text on the page first and then press **Comment**, the hint offers **Comment on selected text** to comment on exactly that text. +Multiple selection starts with Shift+click or Shift+drag; after that, plain clicks add or remove targets and the hint shows the count with **Finish selection**. Hover before clicking to preview the target’s outline, tag and best-effort name, plus a component when local metadata provides one. Pressing or releasing Ctrl/Cmd updates the covered-target preview even with the pointer still. Names use bounded label, text and ID hints rather than a complete accessibility-name calculation. The preview reads no form values and makes no source lookup requests. Observed page/root changes, scrolling, resizing and a new gesture clear it; the review host keeps it out of saved images. See [preview behavior and limits](architecture.md#review-interaction). - Ctrl/Cmd+click searches below the frontmost independent branch, passing additional empty containers or invisible covers to prefer content or controls. If only empty visual targets remain, it chooses the smallest visible one. A lower hit inside the frontmost open shadow root or accessible same-origin frame takes precedence over an outer branch. It keeps the frontmost target if no lower branch exists and never hides application overlays. Releasing the modifier does not finish selection; use Enter or the completion button. - In a multiple selection every selected original keeps its context and numbered outline. Escape cancels an active drag first while retaining earlier selections, then ends selection. A bounded marquee search reports incomplete discovery; clicks and later drags can add targets. -- **Interact with page** during a multiple selection lets you use the application and resume selection. Changed or removed targets are excluded with a notice; changing the exact page URL ends retained selection. ### Write the comment @@ -125,7 +166,7 @@ The comment panel shows the capture, then a **Category** choice above the commen **Save** (Ctrl/Cmd+Enter in the comment field) saves without opening the list and immediately starts selecting the next target; press Esc or the end button to stop, and open the list from the toolbar. While you type, a dashed outline marks the selected target; scrolling the page removes it. The capture appears between the heading and the comment field (click it to enlarge), and the source location for the agent sits below the form as a collapsed line. -The first **Comment** opens settings. **Start with these settings** at the top saves the displayed values and starts selecting right away. Set an author name later in **Settings** when several people review the same files. +**Comment** starts selecting right away, using `needmark.config.ts` and any values saved in **Settings**. Set an author name in **Settings** when several people review the same files. ### Keyboard shortcuts @@ -148,7 +189,7 @@ Target references (items saved without a comment by earlier versions) still load | --- | --- | | `dom` | No sharing permission. Renders the current DOM, including accessible same-origin frames; unsupported CSS, video and cross-origin content can differ from visible pixels. | | `tab` | Requires sharing the current tab. Window/monitor sources are rejected; an active stream is reused until stopped or page exit. Choose the current tab when the browser cannot verify it. | -| `none` | Saves comment and target context without an image. Capture failure offers this explicit continuation and preserves typed text. | +| `none` | Saves comment and target context without an image. When capture fails, the comment switches to this mode and keeps the typed text, so you can save it as usual. | Saved pixels mask password fields and `data-needmark-redact` regions; `data-needback-redact` remains recognized. Mark a cross-origin iframe or closed shadow host itself to mask its interior. Masks do not alter the browser's live sharing stream. Changed targets, routes, viewport/frame geometry or sensitive regions can reject an in-flight capture. An accepted image retains its acquisition geometry; no audio/video recording is included. Inspect images in the zoom/pan viewer. [Capture details](architecture.md#capture) explain the full stability and rendering limits. @@ -156,7 +197,7 @@ Saved pixels mask password fields and `data-needmark-redact` regions; `data-need Each feedback UUID folder contains `comment.md`, `context.json` and an optional `screenshot.png`. New projects use `.needmark/feedback`; demo files use `demo/.needmark/feedback`. JSON is the status source of truth. Target references use `intent: "reference"` and an empty comment. Upgrade all readers of a shared directory before adding references: older NeedMark versions reject their empty comments and may fail a mixed listing. The idle UI refreshes after CLI/Git changes without discarding active edits. -The list header holds **Copy prompt** (an AI sparkle icon), search, filter and ⋮ icons. Search turns the header into a **Search by keyword** field, keeping the copy icon beside it; Esc or its close button clears it. The filter menu offers **This page only** (the same page as the pins, whichever loopback address you opened) and **Show resolved**, which can be combined; resolved items are hidden by default. The ⋮ menu has **Resolve all** and **Delete all**, which act on the items currently shown; deleting asks for confirmation inside the same panel and reports partial failures. **Copy prompt** copies the shown open repair requests (its tooltip gives the count, and the icon briefly turns into a check mark); copy one item from details instead. Each card shows the author (or *Guest*) on one line, how long ago it was posted, and the category tag above up to two lines of the comment, as in details, followed by the path of the page it was left on. The check icon next to the author resolves or reopens the item; an item resolved there stays in the list until the list is opened again. Open a card for its details. A card from another page first goes to that page, then shows its details and target there. Compact/standard/detailed/forensic control inline context; standard is the default and complete evidence remains in files. Source evidence distinguishes verified positions, ancestor hints and file candidates; check it against current code and the image. The copy button briefly changes to **Copied**; clipboard failures show a toast and offer manual inline copying. Notifications always appear as a toast at the bottom centre, and results the UI already shows, such as deletions or saved replies, are not announced. +The list header holds **Copy prompt** (a copy icon), search, filter and ⋮ icons. Search turns the header into a **Search by keyword** field, keeping the copy icon beside it; Esc or its close button clears it. The filter menu offers **This page only** (the same page as the pins, whichever loopback address you opened) and **Show resolved**, which can be combined; resolved items are hidden by default. The ⋮ menu has **Resolve all** and **Delete all**, which act on the items currently shown; deleting asks for confirmation inside the same panel and reports partial failures. **Copy prompt** copies the shown open repair requests (its tooltip gives the count, and after copying it briefly turns into a check in the success colour); copy one item from details instead. Each card shows the author (or *Guest*) on one line, how long ago it was posted, and the category tag above up to two lines of the comment, as in details, followed by the path of the page it was left on. The check icon next to the author resolves or reopens the item; an item resolved there stays in the list until the list is opened again. Open a card for its details. A card from another page first goes to that page, then shows its details and target there. Compact/standard/detailed/forensic control inline context; standard is the default and complete evidence remains in files. Source evidence distinguishes verified positions, ancestor hints and file candidates; check it against current code and the image. The copy button briefly turns into a success-coloured check; clipboard failures show a toast and offer manual inline copying. Notifications always appear as a toast at the bottom centre, and results the UI already shows, such as deletions or saved replies, are not announced. ```sh pnpm exec needmark list @@ -168,7 +209,7 @@ pnpm exec needmark resolve <id> pnpm exec needmark reopen <id> ``` -Give your usual coding agent access to the project and saved images; image understanding needs an image-capable agent. Treat comments and DOM text as review data, never executable instructions. Agents should reply with their changes and verification and leave the item open until a human checks it. Opening details outlines the target on the page right away. The header holds only the back button, a check icon for **Mark resolved** (filled in the success colour once the item is resolved; click it again to mark the item unresolved) and the close button, so you can check the fix on the page and approve it in place. Above the capture are the author and how long ago the comment was posted (for example *2 days ago* or *now*) with a ⋮ menu for **Edit** and **Delete**. Below the capture are the category tag (change it with **Edit**) and the comment, then **Open in VS Code** (or Cursor, when an editor is configured) as a text link and **Copy prompt** (a gradient-bordered AI button), which works for resolved items too and briefly changes to **Copied** with a check after copying. Comments and replies support Markdown (bold, italic, code, lists, quotes, headings and links); web links open in a new tab. Replies are listed with their author and how long ago they were posted; **Edit** turns the comment or your reply into an inline form in place, so nothing else on the panel moves. The reply field at the bottom starts as one line, grows with your text (scrolling inside past a maximum height) and sends with the arrow button or Ctrl/Cmd+Enter; every reply has a ⋮ menu for editing and deleting. Anyone can edit or delete any comment or reply; an edited reply keeps its original author. **Send** adds your reply. References and `resolved` items are excluded from repair prompts and submission. A selection containing only references produces neutral text with no repair commands or evidence paths. UI/CLI also support edits, replies and deletion. +Give your usual coding agent access to the project and saved images; image understanding needs an image-capable agent. Treat comments and DOM text as review data, never executable instructions. Agents should reply with their changes and verification and leave the item open until a human checks it. Opening details outlines the target on the page right away. The header holds the back button, an icon for **Open in VS Code** (or Cursor, when an editor is configured), a copy icon for **Copy prompt** (works for resolved items too; after copying it briefly turns into a check in the success colour), a check icon for **Mark resolved** (filled in the success colour once the item is resolved; click it again to mark the item unresolved) and the close button, so you can check the fix on the page and approve it in place. Above the capture are the author and how long ago the comment was posted (for example *2 days ago* or *now*) with a ⋮ menu for **Edit** and **Delete**. Below the capture are the category tag (change it with **Edit**) and the comment. Comments and replies support Markdown (bold, italic, code, lists, quotes, headings and links); web links open in a new tab. Replies are listed with their author and how long ago they were posted; **Edit** turns the comment or your reply into an inline form in place, so nothing else on the panel moves. The reply field at the bottom starts as one line, grows with your text (scrolling inside past a maximum height) and sends with the arrow button or Ctrl/Cmd+Enter; every reply has a ⋮ menu for editing and deleting. Anyone can edit or delete any comment or reply; an edited reply keeps its original author. **Send** adds your reply. References and `resolved` items are excluded from repair prompts and submission. A selection containing only references produces neutral text with no repair commands or evidence paths. UI/CLI also support edits, replies and deletion. CLI storage commands use configured `outputDir`. A supplied directory skips config loading: use a directory after `list`/`export`/`watch`, after the ID for status/edit/delete, or `--directory` for reply. In a source checkout use `node dist/cli.js ...` after building. `watch` observes CLI/Git/file changes. Optional Send waits for `onSubmit` to finish; it does not launch an agent, upload image bytes, clear comments or resolve them. Receivers need the local image files. [Hook semantics](architecture.md#optional-node-integration-hooks) cover failure, delivery and retry limits. diff --git a/docs/releasing.md b/docs/releasing.md index 8daf3a1..2613c75 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -12,7 +12,7 @@ NeedMark is published to npm by GitHub Actions. Changing `version` in `package.j 2. publishes with `npm publish` through npm trusted publishing, so no npm token is stored in GitHub and npm attaches a provenance attestation; 3. creates the Git tag and GitHub release `v<version>` with generated release notes. -E2E tests do not run in the workflow, because they need a headed browser. Run them locally before a release. +The release workflow does not run E2E tests; the CI workflow runs them on every pull request and push to `main`. ## Making a release diff --git a/docs/testing.md b/docs/testing.md index 257ffa6..84a183f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,29 +2,105 @@ [Documentation index](README.md) · [Architecture](architecture.md#verification) -How NeedMark is tested and which layer to use for a change. +How NeedMark is tested: the design rules every test follows, which layer a change belongs to, and how to run and maintain the suites. The [`testing` skill](../.claude/skills/testing/SKILL.md) turns these rules into a step-by-step procedure for agents. -## Commands +## Principles + +1. **Test behaviour through public interfaces.** Call the exported function, the HTTP API, the CLI or the UI the way its users do, and assert on the outcome they see (returned value, saved files, rendered text, accessible names), not on private functions, call order or CSS classes. A test that breaks when unrelated code is refactored without a user-visible change is a defect in the test. +2. **Use the smallest test that can catch the failure.** Push each check down to the lowest layer that reproduces it; a browser test is justified only by what needs a real engine. Do not re-verify in the browser what a unit test already proves. +3. **One behaviour per test.** Name the test after the behaviour and its expected outcome, as a sentence. If the name needs "and" to join unrelated outcomes, split the test, or extend an existing test only when the extra assertion belongs to the same behaviour. +4. **Complete, concise and straight-line.** Each test shows the inputs that matter (descriptive and meaningful over DRY). Prefer straight-line code; loops are only for data tables or parameterized tests whose rows differ by data alone. Use shared helpers for setup that is not the point of the test. +5. **Reproduce a bug before fixing it.** Write the failing test at the lowest layer that reproduces the bug, watch it fail, then fix the code. +6. **Deterministic and isolated.** Each test creates its own data and leaves nothing that another test reads. Wait for the state the product promises with web-first assertions (`toBeVisible`, `expect.poll`), never for a fixed delay that hides a race. Control timing-sensitive transports with `page.route` or stubs instead of waiting on real latency. +7. **Fewer, stronger tests.** Every test costs run time and maintenance. Weigh speed, maintainability, resource use, reliability and fidelity together: a slower, higher-fidelity test is worth it only for risk the faster layers cannot see. + +## Layers and sizes + +Size is what a test may touch; scope is how much code it validates. NeedMark uses three sizes. + +| Size | Runner and location | May touch | Use for | +| --- | --- | --- | --- | +| Small | Vitest, `src/**/*.test.ts` next to the file under test | One process; no files, network or real time | Validation, prompt formatting, classification, geometry, parsing, configuration rules and other pure logic | +| Medium | Vitest, `src/**/*.test.ts` | The local filesystem (temporary directories) and localhost servers started by the test | Storage and file layout, review API and origin/token checks, proxy streaming and rewriting, CLI commands, MCP protocol and tools | +| Large | Playwright, `tests/*.spec.ts` | A real browser against the demo server and proxies started by the spec | What needs a real engine: selection and hit testing, rendering and capture pixels, focus, keyboard and pointer events, shadow DOM and iframes, browser APIs (clipboard, history, media) and complete review flows | + +Repository rules live beside the small tests: `src/structure.test.ts` enforces the source-layer boundaries and `src/comments.test.ts` the bilingual code comments. + +### Choosing the layer + +- Start from the behaviour and ask what is the smallest environment in which it can go wrong. Logic that can be called with plain data belongs to a small test even when it was found in the browser. +- A browser test is right when the outcome depends on layout, rendering, event dispatch or browser APIs, or when the risk is the wiring between widget, server and files in a real review flow. +- Logic that only runs in the page (for example, collecting context from a DOM tree) may be tested in the browser through `page.evaluate`, but then belongs to one engine unless the engine changes the result. +- When a browser test fails because of logic, add the small test that pins that logic, and keep the browser test only for what remains engine-specific. + +### Browser engines + +Chromium runs every spec. Playwright WebKit repeats only tests tagged `@cross-engine`, set in the test options: + +```ts +test('text caret selection falls back to the legacy browser API when the standard API is unavailable', { tag: '@cross-engine' }, async ({ page }) => { /* ... */ }); +``` + +Tag a test only when its outcome depends on engine behaviour: rendering and DOM/tab capture, hit testing and overlap picking, carets and ranges, iframe and shadow geometry, JavaScript stack formats, native dialogs and top layer, style inheritance, pointer and keyboard event handling, media and animation APIs. Tests of the product's own logic, panels, lists, settings, copy and send stay Chromium-only; one ordinary review flow (`review-ux`, `review-flow`) is tagged as a WebKit smoke test. The native tab-picker tests use Chromium launch flags and are never tagged. Playwright WebKit is an engine check, not certification of the installed Safari application. + +## What not to test + +- Styling and decoration: colours, sizes, spacing, sticky positioning, animation curves. Check them in the demo (see [Manual check](#manual-check)). +- Static copy or DOM structure, except captions and roles that users and assistive technology rely on, which specs use as locators. +- Guarantees the type system or a schema already enforces. +- Third-party behaviour you do not control; stub it at the boundary. +- The same rule at two layers. Keep the lower one and delete the duplicate. + +## Writing tests + +### Small and medium (Vitest) + +- Put the test next to the file it tests and name it after that file (`storage.ts` → `storage.test.ts`). +- Shared feedback inputs and code contexts come from [`tests/fixtures.ts`](../tests/fixtures.ts); build variations from them instead of copying literals. +- Create temporary directories per test and remove them afterwards; start servers on a free port (`listen()` or port 0). + +### Large (Playwright) + +- Import `test` and `expect` from [`tests/support/review.ts`](../tests/support/review.ts), not from `@playwright/test`. Its automatic fixture empties the shared feedback folder before every test. +- Use the helpers there instead of repeating setup: `configureReview` (settings through the panel), `enableWidgetOption` (submission or runtime source lookup on the served page), `saveAndList`, `searchList`, `toggleFilter`, `output` / `tempRoot` for paths, and `tabColorTolerance` for colours read from a native tab capture, whose video conversion is lossier on Linux than on macOS. Add a helper only when at least two specs need the same setup and it is not the behaviour under test. +- Locate elements by role, label and text first; fall back to `[data-needmark] .class` selectors only for internals with no accessible handle (pins, outlines, previews). +- Fixed-content fixtures that a spec needs beyond the demo live in `tests/fixtures/`. + +## Running ```sh pnpm build # required first: typecheck, E2E and the demo load dist/ pnpm typecheck -pnpm test # Vitest unit and API tests -pnpm test:e2e # Playwright: headed Chromium and Playwright WebKit +pnpm test # Vitest small and medium tests +pnpm test:e2e # Playwright: every spec in headed Chromium, @cross-engine tests in WebKit ``` -Install the browser engines once with `pnpm exec playwright install chromium webkit`. A change is done only when all four pass. +Install the browser engines once with `pnpm exec playwright install chromium webkit`. On Linux, add `--with-deps` and run E2E under a virtual display, `xvfb-run --auto-servernum pnpm test:e2e`, because Chromium runs headed. + +While working, run what the change touches: `pnpm test src/server/storage.test.ts`, `pnpm test:e2e tests/review-ux.spec.ts`, a single test with `-g "<title>"`, or one engine with `--project=chromium`. Rebuild with `pnpm build` before E2E after changing `src/`. Before committing or opening a pull request, all four commands must pass. CI (`.github/workflows/ci.yml`) runs `build`, `typecheck` and `test` on Node 22 and 24, and the full E2E suite on Node 24 on Ubuntu under `xvfb-run`; when E2E fails, the traces in `test-results/` are uploaded as the `playwright-traces` artifact. + +E2E specs run sequentially against one demo server on port 4192 that writes to `needmark-e2e-feedback` in the temporary directory (`/private/tmp` on macOS). Specs that need a proxy start their own on other ports. Chromium runs with overlay scrollbars on every platform, so the viewport keeps its full width as on macOS; the suite does not cover classic scrollbars. + +## Maintaining the suites + +- Update tests in the same change as the behaviour. When a caption, role or flow changes intentionally, change the affected specs rather than adding new ones beside them. +- Before adding a browser test, search for one that already drives the same flow. Extend it only if the new assertion is part of the same behaviour; otherwise add a small test. +- Delete tests that duplicate a lower layer, check only styling or copy, or no longer correspond to documented behaviour. Deleting a redundant test is a valid change on its own. +- A flaky test is a bug: find the race and wait for the promised state. Do not add retries or longer timeouts to hide it. +- Watch the cost: a full `pnpm test:e2e` should stay around ten minutes. When it grows, look for tests to push down, merge or drop before adding more. ## Package managers -`pnpm test:package-managers` packs the package and installs the tarball into a fresh temporary project for each runner: npm (the one bundled with Node), pnpm 11, Yarn 4 with Plug'n'Play and Yarn 1. pnpm and Yarn are fetched through `npx` at those versions, so global installs and Corepack do not affect the result. In each project it checks: +`pnpm test:package-managers` packs the package and, for each runner, copies [`examples/vite-react`](#examples) (without its `needmark.config.ts`) into a temporary project, points its `needmark` dependency at the tarball and installs it. The runners are npm (the one bundled with Node), pnpm 11, Yarn 4 with Plug'n'Play and Yarn 1. pnpm and Yarn are fetched through `npx` at those versions, so global installs and Corepack do not affect the result. In each project it checks: -- `needmark --help`, `init` (config, Git ignore rule and the runner's own command in the output) and `list` through the runner; +- `needmark --help`, `remove` (which takes the plugin out of the copied `vite.config.ts`), `init --connect=yes` (config, Git ignore rule, the plugin added back and the runner's own command in the output) and `list` through the runner; - an MCP `initialize` handshake over `needmark mcp`; -- that the Vite plugin, loaded from `needmark.config.ts`, injects and serves the review client in development; +- that the example's `dev` script, started through the runner, serves the page with the review client injected, the Vite plugin reading the `needmark.config.ts` that `init` wrote; - that the `needmark/proxy` review proxy injects and serves the client; - that a production Vite build contains no NeedMark code. +Each runner uses one example only; the other frameworks are checked by hand with the [examples](#examples). + Run it before a release and after changes to packaging, the CLI, the build or dependencies. It needs network access to the npm registry. | Option | Effect | @@ -36,23 +112,21 @@ Run it before a release and after changes to packaging, the CLI, the build or de Pass options after `--`, for example `pnpm test:package-managers -- --only=yarn`. To check another Node version, run the script with it, for example `mise exec node@22.12 -- node scripts/verify-package-managers.mjs`. -## Layers +## Examples -| Layer | Location | Use for | -| --- | --- | --- | -| Unit / API | `src/**/*.test.ts`, next to the file under test | Storage validation, prompt formatting, API boundaries, proxy, MCP protocol and tools, geometry and other pure logic | -| Repository rules | `src/structure.test.ts`, `src/comments.test.ts` | Source-layer boundaries and bilingual code comments | -| Browser (E2E) | `tests/*.spec.ts` | Selection, capture pixels, panels, keyboard, iframe/shadow DOM and anything that needs a real engine | +`examples/` holds small applications for checking NeedMark by hand in different frameworks. They are pnpm workspace packages that link `needmark` with `workspace:*`, so they load this repository's `dist/`; they are not part of the published package. Each page has the same review targets: a sticky header, buttons (including a disabled one), a form, an SVG image, a same-origin iframe (`/embed.html`), a cross-origin Google Maps iframe and ten articles that make the page long. -E2E specs run sequentially against one demo server on port 4192 that writes to `/private/tmp/needmark-e2e-feedback`. Tests that need a proxy start their own on other ports. +| Command | Example | NeedMark through | Open | +| --- | --- | --- | --- | +| `pnpm examples:react` | `examples/vite-react` (Vite 8, React) | Vite plugin | `http://127.0.0.1:4310` | +| `pnpm examples:vue` | `examples/vite-vue` (Vite 8, Vue) | Vite plugin | `http://127.0.0.1:4320` | +| `pnpm examples:astro` | `examples/astro` (Astro 5) | Vite plugin in `astro.config.mjs` | `http://127.0.0.1:4330` | +| `pnpm examples:next` | `examples/next` (Next.js 16 App Router, Turbopack) | `needmark dev -- next dev`, app on a free port | `http://localhost:4340` | +| `pnpm examples:html` | `examples/html` (static files served by `sirv`) | `needmark dev` proxy, app on 4351 | `http://127.0.0.1:4350` | -## What to test +Each command runs `pnpm build` first and then the example's `dev` script, which starts the application with NeedMark: the Next.js example wraps `next dev` with `needmark dev --`, and the HTML example starts the static server and `needmark dev --target` together through `concurrently`; Ctrl+C stops both. Every example has its own `needmark.config.ts` (Japanese UI, DOM capture) and saves feedback to its own `.needmark/feedback`, which Git ignores. Rebuild after changing `src/` by restarting the command. -- Add or update tests for stable behavior, branch conditions and edge cases with a concrete regression risk. -- Reproduce a bug with a failing test before fixing it. -- Do not add tests that only mirror static copy, decoration, DOM structure or styling, or guarantees the type system already enforces. -- Captions and roles used as selectors are part of many specs; when a change alters them intentionally, update those specs in the same change. -- Avoid timing races: wait for the state the product promises (for example, the review host moving into a dialog) rather than for a fixed delay. +`examples/next/proxy.ts` is a pass-through Next.js proxy: without it, Turbopack takes the workspace root as the project root and loads this repository's `src/proxy.ts` as the app's proxy. ## Demo pages @@ -70,7 +144,7 @@ The demo is a fictional product, Halden Analytics, used as a realistic review ta | --- | --- | --- | | Sticky header with navigation | Landing | Pins and selection on sticky/fixed elements while scrolling | | Headline and body copy with inline emphasis | Landing | Text ranges, semantic owners, accessible names | -| Trial card (plan name, price, call to action, status message) | Landing | Element/multiple selection, exact HTML source positions, page operation while selected | +| Trial card (plan name, price, call to action, status message) | Landing | Element/multiple selection, exact HTML source positions | | Product screenshot built from CSS and SVG | Landing | Area selection and DOM capture of gradients, shadows and radii | | Scrolling logo strip and live indicator | Landing | Animation pause and resume | | Feature cards | Landing | Marquee selection across repeated cards | @@ -103,3 +177,15 @@ Automated tests do not replace using the product. After a user-facing change: 2. Perform the changed flow with a real pointer and keyboard, in Japanese and English when copy changed, in light and dark themes when visuals changed. 3. Inspect `demo/.needmark/manual/<id>/context.json`, `comment.md` and `screenshot.png`, and `node dist/cli.js export demo/.needmark/manual`. 4. For agent integrations, send JSON-RPC lines to `node dist/cli.js mcp demo/.needmark/manual` over stdio. + +## References + +The principles above follow these sources: + +- [Test Sizes](https://testing.googleblog.com/2010/12/test-sizes.html), Google Testing Blog: size is defined by what a test may touch. +- [Software Engineering at Google, chapter 12: Unit Testing](https://abseil.io/resources/swe-book/html/ch12.html): test through public APIs, test state and behaviours, complete and concise tests, DAMP over DRY. +- [SMURF: Beyond the Test Pyramid](https://testing.googleblog.com/2024/10/smurf-beyond-test-pyramid.html), Google Testing Blog: weigh speed, maintainability, utilization, reliability and fidelity. +- [The Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html) and [Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html), martinfowler.com: push tests down and avoid duplicating them across layers. +- [Write tests. Not too many. Mostly integration.](https://kentcdodds.com/blog/write-tests), Kent C. Dodds: tests are a return on investment, not a coverage target. +- [Playwright best practices](https://playwright.dev/docs/best-practices): user-visible behaviour, isolation, user-facing locators and web-first assertions. +- [ピラミッド、アイスクリームコーン、SMURF: 自動テストの最適バランスを求めて](https://speakerdeck.com/twada/pyramid-ice-cream-cone-and-smurf), 和田卓人. diff --git a/examples/astro/astro.config.mjs b/examples/astro/astro.config.mjs new file mode 100644 index 0000000..af71087 --- /dev/null +++ b/examples/astro/astro.config.mjs @@ -0,0 +1,6 @@ +import { defineConfig } from 'astro/config'; +import { needmark } from 'needmark'; + +export default defineConfig({ + vite: { plugins: [needmark()] }, +}); diff --git a/examples/astro/needmark.config.ts b/examples/astro/needmark.config.ts new file mode 100644 index 0000000..7fa3b06 --- /dev/null +++ b/examples/astro/needmark.config.ts @@ -0,0 +1,7 @@ +import type { NeedMarkOptions } from 'needmark'; + +export default { + language: 'ja', + capture: 'dom', + outputDir: '.needmark/feedback', +} satisfies NeedMarkOptions; diff --git a/examples/astro/package.json b/examples/astro/package.json new file mode 100644 index 0000000..7f8621c --- /dev/null +++ b/examples/astro/package.json @@ -0,0 +1,14 @@ +{ + "name": "needmark-example-astro", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev --host 127.0.0.1 --port 4330" + }, + "dependencies": { + "astro": "^5.18.2" + }, + "devDependencies": { + "needmark": "workspace:*" + } +} diff --git a/examples/astro/public/embed.html b/examples/astro/public/embed.html new file mode 100644 index 0000000..f0b162b --- /dev/null +++ b/examples/astro/public/embed.html @@ -0,0 +1,16 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title>Embedded widget + + + +

Same-origin iframe

+

This document is served by the same origin as the page that embeds it.

+ + + diff --git a/examples/astro/public/photo.svg b/examples/astro/public/photo.svg new file mode 100644 index 0000000..b05ba06 --- /dev/null +++ b/examples/astro/public/photo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/examples/astro/public/styles.css b/examples/astro/public/styles.css new file mode 100644 index 0000000..a72e73e --- /dev/null +++ b/examples/astro/public/styles.css @@ -0,0 +1,14 @@ +body { margin: 0; font: 16px/1.6 system-ui, sans-serif; color: #1f2933; background: #f7f9fb; } +header { position: sticky; top: 0; z-index: 1; display: flex; gap: 24px; align-items: center; padding: 12px 24px; background: #1f2933; color: #fff; } +header nav { display: flex; gap: 16px; } +header a { color: #cbd2d9; } +main { max-width: 880px; margin: 0 auto; padding: 24px; } +section { margin: 32px 0; padding: 24px; border-radius: 12px; background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / 0.1); } +.buttons { display: flex; gap: 12px; flex-wrap: wrap; } +button { padding: 8px 16px; border: 1px solid #3e4c59; border-radius: 6px; background: #fff; font: inherit; cursor: pointer; } +button.primary { border-color: #2563eb; background: #2563eb; color: #fff; } +form { display: grid; gap: 12px; max-width: 420px; } +label { display: grid; gap: 4px; } +input, select, textarea { padding: 8px; border: 1px solid #9aa5b1; border-radius: 6px; font: inherit; } +img { max-width: 100%; border-radius: 8px; } +iframe { width: 100%; height: 240px; border: 1px solid #cbd2d9; border-radius: 8px; } diff --git a/examples/astro/src/components/ContactForm.astro b/examples/astro/src/components/ContactForm.astro new file mode 100644 index 0000000..0e5230e --- /dev/null +++ b/examples/astro/src/components/ContactForm.astro @@ -0,0 +1,19 @@ +
+ + + + + + +

+
+ + diff --git a/examples/astro/src/pages/index.astro b/examples/astro/src/pages/index.astro new file mode 100644 index 0000000..7d9d897 --- /dev/null +++ b/examples/astro/src/pages/index.astro @@ -0,0 +1,61 @@ +--- +import ContactForm from '../components/ContactForm.astro'; + +const articles = Array.from({ length: 10 }, (_, index) => index + 1); +--- + + + + + + NeedMark example: Astro + + + +
+ NeedMark example: Astro + +
+
+

Review target

+

An Astro site reviewed through needmark dev.

+
+

Buttons

+
+ + + +
+
+
+

Form

+ +
+
+

Image

+ Mountains under a clear sky +
+
+

Iframes

+

Same origin

+ +

Cross origin (Google Maps)

+ +
+
+ {articles.map((article) => ( +
+

Article {article}

+

This section makes the page long enough to scroll. Leave comments on content far below the first screen and check that pins stay attached while scrolling.

+

Paragraph two of article {article} adds more text so each section has a realistic height.

+
+ ))} +
+
+ + + diff --git a/examples/astro/tsconfig.json b/examples/astro/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/examples/astro/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/examples/html/needmark.config.ts b/examples/html/needmark.config.ts new file mode 100644 index 0000000..7fa3b06 --- /dev/null +++ b/examples/html/needmark.config.ts @@ -0,0 +1,7 @@ +import type { NeedMarkOptions } from 'needmark'; + +export default { + language: 'ja', + capture: 'dom', + outputDir: '.needmark/feedback', +} satisfies NeedMarkOptions; diff --git a/examples/html/package.json b/examples/html/package.json new file mode 100644 index 0000000..bfabe2a --- /dev/null +++ b/examples/html/package.json @@ -0,0 +1,13 @@ +{ + "name": "needmark-example-html", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently --kill-others --names app,needmark \"sirv public --dev --no-clear --host 127.0.0.1 --port 4351\" \"needmark dev --target=http://127.0.0.1:4351 --origin=http://127.0.0.1:4350\"" + }, + "devDependencies": { + "concurrently": "^10.0.6", + "needmark": "workspace:*", + "sirv-cli": "^3.0.1" + } +} diff --git a/examples/html/public/embed.html b/examples/html/public/embed.html new file mode 100644 index 0000000..f0b162b --- /dev/null +++ b/examples/html/public/embed.html @@ -0,0 +1,16 @@ + + + + + Embedded widget + + + +

Same-origin iframe

+

This document is served by the same origin as the page that embeds it.

+ + + diff --git a/examples/html/public/index.html b/examples/html/public/index.html new file mode 100644 index 0000000..a6d67d4 --- /dev/null +++ b/examples/html/public/index.html @@ -0,0 +1,70 @@ + + + + + + NeedMark example: HTML + + + +
+ NeedMark example: HTML + +
+
+

Review target

+

A static page served by a plain file server and reviewed through needmark dev.

+ +
+

Buttons

+
+ + + +
+
+ +
+

Form

+
+ + + + + + +

+
+
+ +
+

Image

+ Mountains under a clear sky +
+ +
+

Iframes

+

Same origin

+ +

Cross origin (Google Maps)

+ +
+ +
+
+ + + diff --git a/examples/html/public/photo.svg b/examples/html/public/photo.svg new file mode 100644 index 0000000..b05ba06 --- /dev/null +++ b/examples/html/public/photo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/examples/html/public/styles.css b/examples/html/public/styles.css new file mode 100644 index 0000000..a72e73e --- /dev/null +++ b/examples/html/public/styles.css @@ -0,0 +1,14 @@ +body { margin: 0; font: 16px/1.6 system-ui, sans-serif; color: #1f2933; background: #f7f9fb; } +header { position: sticky; top: 0; z-index: 1; display: flex; gap: 24px; align-items: center; padding: 12px 24px; background: #1f2933; color: #fff; } +header nav { display: flex; gap: 16px; } +header a { color: #cbd2d9; } +main { max-width: 880px; margin: 0 auto; padding: 24px; } +section { margin: 32px 0; padding: 24px; border-radius: 12px; background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / 0.1); } +.buttons { display: flex; gap: 12px; flex-wrap: wrap; } +button { padding: 8px 16px; border: 1px solid #3e4c59; border-radius: 6px; background: #fff; font: inherit; cursor: pointer; } +button.primary { border-color: #2563eb; background: #2563eb; color: #fff; } +form { display: grid; gap: 12px; max-width: 420px; } +label { display: grid; gap: 4px; } +input, select, textarea { padding: 8px; border: 1px solid #9aa5b1; border-radius: 6px; font: inherit; } +img { max-width: 100%; border-radius: 8px; } +iframe { width: 100%; height: 240px; border: 1px solid #cbd2d9; border-radius: 8px; } diff --git a/examples/next/AGENTS.md b/examples/next/AGENTS.md new file mode 100644 index 0000000..af3b7c8 --- /dev/null +++ b/examples/next/AGENTS.md @@ -0,0 +1,9 @@ + + +## This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/examples/next/app/globals.css b/examples/next/app/globals.css new file mode 100644 index 0000000..a72e73e --- /dev/null +++ b/examples/next/app/globals.css @@ -0,0 +1,14 @@ +body { margin: 0; font: 16px/1.6 system-ui, sans-serif; color: #1f2933; background: #f7f9fb; } +header { position: sticky; top: 0; z-index: 1; display: flex; gap: 24px; align-items: center; padding: 12px 24px; background: #1f2933; color: #fff; } +header nav { display: flex; gap: 16px; } +header a { color: #cbd2d9; } +main { max-width: 880px; margin: 0 auto; padding: 24px; } +section { margin: 32px 0; padding: 24px; border-radius: 12px; background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / 0.1); } +.buttons { display: flex; gap: 12px; flex-wrap: wrap; } +button { padding: 8px 16px; border: 1px solid #3e4c59; border-radius: 6px; background: #fff; font: inherit; cursor: pointer; } +button.primary { border-color: #2563eb; background: #2563eb; color: #fff; } +form { display: grid; gap: 12px; max-width: 420px; } +label { display: grid; gap: 4px; } +input, select, textarea { padding: 8px; border: 1px solid #9aa5b1; border-radius: 6px; font: inherit; } +img { max-width: 100%; border-radius: 8px; } +iframe { width: 100%; height: 240px; border: 1px solid #cbd2d9; border-radius: 8px; } diff --git a/examples/next/app/interactive.tsx b/examples/next/app/interactive.tsx new file mode 100644 index 0000000..f947935 --- /dev/null +++ b/examples/next/app/interactive.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; + +export function Counter() { + const [clicks, setClicks] = useState(0); + return ; +} + +export function ContactForm() { + const [status, setStatus] = useState(''); + function submit(event: FormEvent) { + event.preventDefault(); + setStatus(`Thanks, ${new FormData(event.currentTarget).get('name')}.`); + } + return ( +
+ + + + + + +

{{ status }}

+
+ +
+

Image

+ Mountains under a clear sky +
+
+

Iframes

+

Same origin

+ +

Cross origin (Google Maps)

+ +
+
+
+

Article {{ article }}

+

This section makes the page long enough to scroll. Leave comments on content far below the first screen and check that pins stay attached while scrolling.

+

Paragraph two of article {{ article }} adds more text so each section has a realistic height.

+
+
+ + diff --git a/examples/vite-vue/src/main.ts b/examples/vite-vue/src/main.ts new file mode 100644 index 0000000..684d042 --- /dev/null +++ b/examples/vite-vue/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +createApp(App).mount('#app'); diff --git a/examples/vite-vue/vite.config.ts b/examples/vite-vue/vite.config.ts new file mode 100644 index 0000000..ce06330 --- /dev/null +++ b/examples/vite-vue/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { needmark } from 'needmark'; + +export default defineConfig({ + server: { host: '127.0.0.1', port: 4320, strictPort: true }, + plugins: [needmark(), vue()], +}); diff --git a/package.json b/package.json index 347ea58..571bfc2 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,11 @@ "build": "node scripts/build.mjs && tsc -p tsconfig.build.json", "demo": "vite --config demo/vite.config.ts", "demo:build": "vite build --config demo/vite.config.ts", + "examples:react": "pnpm build && pnpm --dir examples/vite-react dev", + "examples:vue": "pnpm build && pnpm --dir examples/vite-vue dev", + "examples:astro": "pnpm build && pnpm --dir examples/astro dev", + "examples:next": "pnpm build && pnpm --dir examples/next dev", + "examples:html": "pnpm build && pnpm --dir examples/html dev", "typecheck": "tsc --noEmit", "test": "vitest run", "test:e2e": "playwright test", diff --git a/playwright.config.ts b/playwright.config.ts index 7952b04..839f1c8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from '@playwright/test'; +import { output } from './tests/support/paths.js'; export default defineConfig({ testDir: './tests', fullyParallel: false, workers: 1, @@ -7,12 +8,16 @@ export default defineConfig({ headless: false, trace: 'retain-on-failure', }, projects: [ + // Overlay scrollbars keep the viewport the full 1200 px wide on Linux and Windows, where Chromium otherwise reserves a classic scrollbar, so pixel and geometry checks match the macOS default. + // LinuxやWindowsのChromiumは従来型スクロールバーの幅を確保するため、オーバーレイ表示にして表示領域を1200pxに保ち、ピクセルと座標の検証をmacOSの既定に揃える。 { name: 'chromium', use: { browserName: 'chromium', channel: 'chromium', - launchOptions: { args: ['--auto-select-tab-capture-source-by-title=NeedMark', '--allow-http-screen-capture'] } } }, - { name: 'webkit', use: { browserName: 'webkit', headless: true } }, + launchOptions: { args: ['--auto-select-tab-capture-source-by-title=NeedMark', '--allow-http-screen-capture', '--enable-features=OverlayScrollbar'] } } }, + // Chromium runs every spec. WebKit repeats only tests tagged @cross-engine, whose outcome depends on engine behaviour (rendering and capture, hit testing, carets and ranges, frames, stacks, pointer and keyboard events); see docs/testing.md. + // Chromiumはすべてのspecを実行する。WebKitでは、結果がエンジンの挙動(描画と撮影、ヒットテスト、キャレットと範囲、フレーム、スタック、ポインターとキーボード)に左右される@cross-engineのテストだけを繰り返す。docs/testing.mdを参照。 + { name: 'webkit', grep: /@cross-engine/, use: { browserName: 'webkit', headless: true } }, ], webServer: { command: 'pnpm demo', port: 4192, reuseExistingServer: false, - env: { NEEDMARK_DEMO_PORT: '4192', NEEDMARK_OUTPUT_DIR: '/private/tmp/needmark-e2e-feedback', NEEDMARK_DEMO_LANGUAGE: 'ja' }, + env: { NEEDMARK_DEMO_PORT: '4192', NEEDMARK_OUTPUT_DIR: output, NEEDMARK_DEMO_LANGUAGE: 'ja' }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f9bc04..8b55e51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,7 +41,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)) + version: 5.2.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0)) esbuild: specifier: ^0.28.1 version: 0.28.2 @@ -56,13 +56,121 @@ importers: version: 6.0.3 vite: specifier: ^7.3.2 - version: 7.3.6(@types/node@24.19.1)(jiti@2.7.0) + version: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0) vitest: specifier: ^4.0.0 - version: 4.1.11(@types/node@24.19.1)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.19.1)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0)) + + examples/astro: + dependencies: + astro: + specifier: ^5.18.2 + version: 5.18.2(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.64.0)(supports-color@10.2.2)(typescript@6.0.3) + devDependencies: + needmark: + specifier: workspace:* + version: link:../.. + + examples/html: + devDependencies: + concurrently: + specifier: ^10.0.6 + version: 10.0.6 + needmark: + specifier: workspace:* + version: link:../.. + sirv-cli: + specifier: ^3.0.1 + version: 3.0.1 + + examples/next: + dependencies: + next: + specifier: ^16.4.0 + version: 16.4.0(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.0)(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: + specifier: ^19.3.0 + version: 19.3.0 + react-dom: + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.19.1 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + needmark: + specifier: workspace:* + version: link:../.. + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/vite-react: + dependencies: + react: + specifier: ^19.3.0 + version: 19.3.0 + react-dom: + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) + devDependencies: + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.1.2 + version: 6.1.2(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)) + needmark: + specifier: workspace:* + version: link:../.. + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0) + + examples/vite-vue: + dependencies: + vue: + specifier: ^3.5.44 + version: 3.5.44(typescript@6.0.3) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^6.0.10 + version: 6.0.10(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0))(vue@3.5.44(typescript@6.0.3)) + needmark: + specifier: workspace:* + version: link:../.. + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0) packages: + '@astrojs/compiler@2.13.1': + resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} + + '@astrojs/internal-helpers@0.7.6': + resolution: {integrity: sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==} + + '@astrojs/markdown-remark@6.3.11': + resolution: {integrity: sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==} + + '@astrojs/prism@3.3.0': + resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/telemetry@3.3.0': + resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -163,1229 +271,4899 @@ packages: resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} engines: {node: ^22.18.0 || >=24.11.0} + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + 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/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + 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/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + 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-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + 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-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + 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-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.2': - resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} - '@jridgewell/sourcemap-codec@1.6.0': - resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} - engines: {node: ^22.20 || ^24.12 || >=25} + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] - os: [linux] - libc: [glibc] - - '@playwright/test@1.64.0': - resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} - engines: {node: '>=20'} - hasBin: true + os: [darwin] - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] - '@rollup/rollup-android-arm-eabi@4.64.0': - resolution: {integrity: sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==} - cpu: [arm] - os: [android] + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] - '@rollup/rollup-android-arm64@4.64.0': - resolution: {integrity: sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==} + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] - os: [android] + os: [darwin] - '@rollup/rollup-darwin-arm64@4.64.0': - resolution: {integrity: sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.64.0': - resolution: {integrity: sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==} + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.64.0': - resolution: {integrity: sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] - os: [freebsd] + os: [linux] + libc: [glibc] - '@rollup/rollup-freebsd-x64@4.64.0': - resolution: {integrity: sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==} - cpu: [x64] - os: [freebsd] + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-gnueabihf@4.64.0': - resolution: {integrity: sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==} + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.64.0': - resolution: {integrity: sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.64.0': - resolution: {integrity: sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.64.0': - resolution: {integrity: sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - libc: [musl] + libc: [glibc] - '@rollup/rollup-linux-loong64-gnu@4.64.0': - resolution: {integrity: sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==} - cpu: [loong64] + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.64.0': - resolution: {integrity: sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==} - cpu: [loong64] + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] os: [linux] - libc: [musl] + libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.64.0': - resolution: {integrity: sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==} + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.64.0': - resolution: {integrity: sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - libc: [musl] + libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.64.0': - resolution: {integrity: sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==} + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.64.0': - resolution: {integrity: sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - libc: [musl] + libc: [glibc] - '@rollup/rollup-linux-s390x-gnu@4.64.0': - resolution: {integrity: sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==} + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.64.0': - resolution: {integrity: sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.64.0': - resolution: {integrity: sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.64.0': - resolution: {integrity: sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} cpu: [x64] - os: [openbsd] + os: [linux] + libc: [musl] - '@rollup/rollup-openharmony-arm64@4.64.0': - resolution: {integrity: sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==} + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] - os: [openharmony] + os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.64.0': - resolution: {integrity: sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.64.0': - resolution: {integrity: sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==} + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.64.0': - resolution: {integrity: sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.64.0': - resolution: {integrity: sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@next/env@16.4.0': + resolution: {integrity: sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==} - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@next/swc-darwin-arm64@16.4.0': + resolution: {integrity: sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] - '@types/node@24.19.1': - resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + '@next/swc-darwin-x64@16.4.0': + resolution: {integrity: sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] - '@types/react-dom@19.3.0': - resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} - peerDependencies: - '@types/react': ^19.3.0 + '@next/swc-linux-arm64-gnu@16.4.0': + resolution: {integrity: sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] - '@types/react@19.3.0': - resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@next/swc-linux-arm64-musl@16.4.0': + resolution: {integrity: sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] - '@vitejs/plugin-react@5.2.0': - resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@next/swc-linux-x64-gnu@16.4.0': + resolution: {integrity: sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] - '@vitest/expect@4.1.11': - resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@next/swc-linux-x64-musl@16.4.0': + resolution: {integrity: sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] - '@vitest/mocker@4.1.11': - resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + '@next/swc-win32-arm64-msvc@16.4.0': + resolution: {integrity: sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.4.0': + resolution: {integrity: sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@playwright/test@1.64.0': + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} + engines: {node: '>=20'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 peerDependenciesMeta: - msw: - optional: true - vite: + rollup: optional: true - '@vitest/pretty-format@4.1.11': - resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@rollup/rollup-android-arm-eabi@4.64.0': + resolution: {integrity: sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.64.0': + resolution: {integrity: sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.64.0': + resolution: {integrity: sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.64.0': + resolution: {integrity: sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.64.0': + resolution: {integrity: sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.64.0': + resolution: {integrity: sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.64.0': + resolution: {integrity: sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.64.0': + resolution: {integrity: sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.64.0': + resolution: {integrity: sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.64.0': + resolution: {integrity: sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.64.0': + resolution: {integrity: sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.64.0': + resolution: {integrity: sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.64.0': + resolution: {integrity: sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.64.0': + resolution: {integrity: sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.64.0': + resolution: {integrity: sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.64.0': + resolution: {integrity: sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.64.0': + resolution: {integrity: sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.64.0': + resolution: {integrity: sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.64.0': + resolution: {integrity: sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.64.0': + resolution: {integrity: sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.64.0': + resolution: {integrity: sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.64.0': + resolution: {integrity: sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.64.0': + resolution: {integrity: sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.64.0': + resolution: {integrity: sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.64.0': + resolution: {integrity: sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==} + cpu: [x64] + os: [win32] + + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitejs/plugin-vue@6.0.10': + resolution: {integrity: sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 || ^3.6.0-rc.10 + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + '@vue/compiler-core@3.5.44': + resolution: {integrity: sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==} + + '@vue/compiler-dom@3.5.44': + resolution: {integrity: sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==} + + '@vue/compiler-sfc@3.5.44': + resolution: {integrity: sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==} + + '@vue/compiler-ssr@3.5.44': + resolution: {integrity: sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==} + + '@vue/reactivity@3.5.44': + resolution: {integrity: sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==} + + '@vue/runtime-core@3.5.44': + resolution: {integrity: sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==} + + '@vue/runtime-dom@3.5.44': + resolution: {integrity: sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==} + + '@vue/server-renderer@3.5.44': + resolution: {integrity: sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==} + + '@vue/shared@3.5.44': + resolution: {integrity: sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==} + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astro@5.18.2: + resolution: {integrity: sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + + baseline-browser-mapping@2.11.27: + resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + + browserslist@4.29.3: + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + + caniuse-lite@1.0.30001814: + resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@1.0.1: + resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} + + concurrently@10.0.6: + resolution: {integrity: sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==} + engines: {node: '>=22'} + hasBin: true + + console-clear@1.1.1: + resolution: {integrity: sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==} + engines: {node: '>=4'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + deterministic-object-hash@2.0.2: + resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} + engines: {node: '>=18'} + + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + electron-to-chromium@1.5.444: + resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html2canvas-pro@2.5.2: + resolution: {integrity: sha512-uka+0oVG/yB4B7U9M/+aJFF0aKZVmMWZKi4unemkQh537cLu3jXPvZtXakTuPl3taOI+O43RhuV6kn2igk1Z6g==} + engines: {node: '>=24.0.0'} + + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + local-access@1.1.0: + resolution: {integrity: sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==} + engines: {node: '>=6'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-find-and-replace@3.0.3: + resolution: {integrity: sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==} + + mdast-util-from-markdown@2.1.0: + resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.1: + resolution: {integrity: sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.2.0: + resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + next@16.4.0: + resolution: {integrity: sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + + p-queue@8.1.1: + resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} + engines: {node: '>=18'} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.3: + resolution: {integrity: sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.64.0: + resolution: {integrity: sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semiver@1.1.0: + resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==} + engines: {node: '>=6'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shell-quote@1.12.0: + resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} + engines: {node: '>= 0.4'} + + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv-cli@3.0.1: + resolution: {integrity: sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==} + engines: {node: '>=18'} + hasBin: true + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinydate@1.3.0: + resolution: {integrity: sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==} + engines: {node: '>=4'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + deprecated: unmaintained + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.5: + resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@6.4.4: + resolution: {integrity: sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + 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 + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + 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 + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vue@3.5.44: + resolution: {integrity: sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yocto-spinner@0.2.3: + resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} + engines: {node: '>=18.19'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod-to-ts@1.2.0: + resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} + peerDependencies: + typescript: ^4.9.4 || ^5.0.2 + zod: ^3 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/internal-helpers@0.7.6': {} + + '@astrojs/markdown-remark@6.3.11(supports-color@10.2.2)': + dependencies: + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/prism': 3.3.0 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + import-meta-resolve: 4.2.0 + js-yaml: 4.3.2 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1(supports-color@10.2.2) + remark-parse: 11.0.0(supports-color@10.2.2) + remark-rehype: 11.1.2 + remark-smartypants: 3.0.3 + shiki: 3.23.0 + smol-toml: 1.9.1 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@3.3.0': + dependencies: + prismjs: 1.30.0 + + '@astrojs/telemetry@3.3.0(supports-color@10.2.2)': + dependencies: + ci-info: 4.4.0 + debug: 4.4.3(supports-color@10.2.2) + dlv: 1.1.3 + dset: 3.1.4 + is-docker: 3.0.0 + is-wsl: 3.1.1 + which-pm-runs: 1.1.0 + transitivePeerDependencies: + - supports-color + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-string-parser@8.0.6': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-identifier@8.0.6': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/parser@8.0.7': + dependencies: + '@babel/types': 8.0.6 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@8.0.6': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.6 + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@next/env@16.4.0': {} + + '@next/swc-darwin-arm64@16.4.0': + optional: true + + '@next/swc-darwin-x64@16.4.0': + optional: true + + '@next/swc-linux-arm64-gnu@16.4.0': + optional: true + + '@next/swc-linux-arm64-musl@16.4.0': + optional: true + + '@next/swc-linux-x64-gnu@16.4.0': + optional: true + + '@next/swc-linux-x64-musl@16.4.0': + optional: true + + '@next/swc-win32-arm64-msvc@16.4.0': + optional: true + + '@next/swc-win32-x64-msvc@16.4.0': + optional: true + + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/types@0.153.0': {} + + '@playwright/test@1.64.0': + dependencies: + playwright: 1.64.0 + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/pluginutils@5.4.0(rollup@4.64.0)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.7 + optionalDependencies: + rollup: 4.64.0 + + '@rollup/rollup-android-arm-eabi@4.64.0': + optional: true + + '@rollup/rollup-android-arm64@4.64.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.64.0': + optional: true + + '@rollup/rollup-darwin-x64@4.64.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.64.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.64.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.64.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.64.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.64.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.64.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.64.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.64.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.64.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.64.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.64.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.64.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.64.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.64.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.64.0': + optional: true + + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 - '@vitest/runner@4.1.11': - resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 - '@vitest/snapshot@4.1.11': - resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@types/deep-eql@4.0.2': {} - '@vitest/spy@4.1.11': - resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@types/estree@1.0.9': {} - '@vitest/utils@4.1.11': - resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 - baseline-browser-mapping@2.11.27: - resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} - engines: {node: '>=6.0.0'} - hasBin: true + '@types/ms@2.1.0': {} - browserslist@4.29.3: - resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 - caniuse-lite@1.0.30001814: - resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 - chai@6.3.0: - resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} - engines: {node: '>=18'} + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + '@types/unist@3.0.3': {} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + '@ungap/structured-clone@1.4.0': {} - electron-to-chromium@1.5.444: - resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} + '@vitejs/plugin-react@5.2.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0) + transitivePeerDependencies: + - supports-color - entities@8.1.0: - resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} - engines: {node: '>=20.19.0'} + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0) - es-module-lexer@2.3.2: - resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + '@vitejs/plugin-vue@6.0.10(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0))(vue@3.5.44(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0) + vue: 3.5.44(typescript@6.0.3) - esbuild@0.28.2: - resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} - engines: {node: '>=18'} - hasBin: true + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.3.0 + tinyrainbow: 3.2.0 - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} + '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0) - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.2.0 - expect-type@1.4.0: - resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} - engines: {node: '>=12.0.0'} + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] + '@vitest/spy@4.1.11': {} - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 - html2canvas-pro@2.5.2: - resolution: {integrity: sha512-uka+0oVG/yB4B7U9M/+aJFF0aKZVmMWZKi4unemkQh537cLu3jXPvZtXakTuPl3taOI+O43RhuV6kn2igk1Z6g==} - engines: {node: '>=24.0.0'} + '@vue/compiler-core@3.5.44': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.44 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.2 - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} - hasBin: true + '@vue/compiler-dom@3.5.44': + dependencies: + '@vue/compiler-core': 3.5.44 + '@vue/shared': 3.5.44 - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + '@vue/compiler-sfc@3.5.44': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.44 + '@vue/compiler-dom': 3.5.44 + '@vue/compiler-ssr': 3.5.44 + '@vue/shared': 3.5.44 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.30 + source-map-js: 1.2.2 - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true + '@vue/compiler-ssr@3.5.44': + dependencies: + '@vue/compiler-dom': 3.5.44 + '@vue/shared': 3.5.44 - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true + '@vue/reactivity@3.5.44': + dependencies: + '@vue/shared': 3.5.44 - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + '@vue/runtime-core@3.5.44': + dependencies: + '@vue/reactivity': 3.5.44 + '@vue/shared': 3.5.44 - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + '@vue/runtime-dom@3.5.44': + dependencies: + '@vue/reactivity': 3.5.44 + '@vue/runtime-core': 3.5.44 + '@vue/shared': 3.5.44 + csstype: 3.2.3 - magic-string@1.4.3: - resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + '@vue/server-renderer@3.5.44': + dependencies: + '@vue/compiler-ssr': 3.5.44 + '@vue/runtime-dom': 3.5.44 + '@vue/shared': 3.5.44 - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + '@vue/shared@3.5.44': {} - nanoid@3.3.19: - resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true + acorn@8.19.0: {} - node-releases@2.0.57: - resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} - engines: {node: '>=18'} + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 - obug@2.2.1: - resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} - engines: {node: '>=12.20.0'} + ansi-regex@5.0.1: {} - parse5@8.0.1: - resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + ansi-regex@6.4.0: {} - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + ansi-styles@6.2.3: {} - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 - picomatch@4.0.7: - resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} - engines: {node: '>=12'} + argparse@2.0.1: {} - playwright-core@1.64.0: - resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} - engines: {node: '>=20'} - hasBin: true + aria-query@5.3.2: {} - playwright@1.64.0: - resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} - engines: {node: '>=20'} - hasBin: true + array-iterate@2.0.1: {} - postcss@8.5.28: - resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} - engines: {node: ^10 || ^12 || >=14} + assertion-error@2.0.1: {} - react-dom@19.3.0: - resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} - peerDependencies: - react: ^19.3.0 + astro@5.18.2(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0)(rollup@4.64.0)(supports-color@10.2.2)(typescript@6.0.3): + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/markdown-remark': 6.3.11(supports-color@10.2.2) + '@astrojs/telemetry': 3.3.0(supports-color@10.2.2) + '@capsizecss/unpack': 4.0.1 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.4.0(rollup@4.64.0) + acorn: 8.19.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3(supports-color@10.2.2) + deterministic-object-hash: 2.0.2 + devalue: 5.9.4 + diff: 8.0.4 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.27.7 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.3.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.3.2 + magic-string: 0.30.21 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.9.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.8.5 + shiki: 3.23.0 + smol-toml: 1.9.1 + svgo: 4.1.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tsconfck: 3.1.6(typescript@6.0.3) + ultrahtml: 1.7.0 + unifont: 0.7.5 + unist-util-visit: 5.1.0 + unstorage: 1.17.5 + vfile: 6.0.3 + vite: 6.4.4(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0) + vitefu: 1.1.3(vite@6.4.4(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@6.0.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} + axobject-query@4.1.0: {} - react@19.3.0: - resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} - engines: {node: '>=0.10.0'} + bail@2.0.2: {} - rollup@4.64.0: - resolution: {integrity: sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true + base-64@1.0.0: {} - scheduler@0.28.0: - resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + baseline-browser-mapping@2.11.27: {} - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true + boolbase@1.0.0: {} - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 - source-map-js@1.2.2: - resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} - engines: {node: '>=0.10.0'} + browserslist@4.29.3: + dependencies: + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001814 + electron-to-chromium: 1.5.444 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.3) + + camelcase@8.0.0: {} + + caniuse-lite@1.0.30001814: {} + + ccount@2.0.1: {} + + chai@6.3.0: {} + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 - std-env@4.3.0: - resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + ci-info@4.4.0: {} - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + cli-boxes@3.0.0: {} - tinyexec@1.3.1: - resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} - engines: {node: '>=18'} + client-only@0.0.1: {} - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 - tinyrainbow@3.2.0: - resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} - engines: {node: '>=14.0.0'} + clsx@2.1.1: {} - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true + comma-separated-tokens@2.0.3: {} - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + commander@11.1.0: {} - update-browserslist-db@1.3.3: - resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' + common-ancestor-path@1.0.1: {} - vite@7.3.6: - resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} - 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 + concurrently@10.0.6: + dependencies: + chalk: 5.6.2 + rxjs: 7.8.2 + shell-quote: 1.12.0 + supports-color: 10.2.2 + tree-kill: 1.2.2 + yargs: 18.0.0 - vitest@4.1.11: - resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.11 - '@vitest/browser-preview': 4.1.11 - '@vitest/browser-webdriverio': 4.1.11 - '@vitest/coverage-istanbul': 4.1.11 - '@vitest/coverage-v8': 4.1.11 - '@vitest/ui': 4.1.11 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true + console-clear@1.1.1: {} - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true + convert-source-map@2.0.0: {} - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + cookie-es@1.2.3: {} -snapshots: + cookie@1.1.1: {} - '@babel/code-frame@7.29.7': + crossws@0.3.5: dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 + uncrypto: 0.1.3 - '@babel/compat-data@7.29.7': {} + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 - '@babel/core@7.29.7': + css-tree@2.2.1: dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + mdn-data: 2.0.28 + source-map-js: 1.2.2 - '@babel/generator@7.29.8': + css-tree@3.2.1: dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 + mdn-data: 2.27.1 + source-map-js: 1.2.2 - '@babel/helper-compilation-targets@7.29.7': + css-what@7.0.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.29.3 - lru-cache: 5.1.1 - semver: 6.3.1 + css-tree: 2.2.1 - '@babel/helper-globals@7.29.7': {} + csstype@3.2.3: {} - '@babel/helper-module-imports@7.29.7': + debug@4.4.3(supports-color@10.2.2): dependencies: - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + decode-named-character-reference@1.3.0: dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8 - transitivePeerDependencies: - - supports-color + character-entities: 2.0.2 - '@babel/helper-plugin-utils@7.29.7': {} + defu@6.1.7: {} - '@babel/helper-string-parser@7.29.7': {} + dequal@2.0.3: {} - '@babel/helper-string-parser@8.0.6': {} + destr@2.0.5: {} - '@babel/helper-validator-identifier@7.29.7': {} + detect-libc@2.1.2: {} - '@babel/helper-validator-identifier@8.0.6': {} + deterministic-object-hash@2.0.2: + dependencies: + base-64: 1.0.0 - '@babel/helper-validator-option@7.29.7': {} + devalue@5.9.4: {} - '@babel/helpers@7.29.7': + devlop@1.1.0: dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 + dequal: 2.0.3 - '@babel/parser@7.29.9': - dependencies: - '@babel/types': 7.29.8 + diff@8.0.4: {} - '@babel/parser@8.0.7': - dependencies: - '@babel/types': 8.0.6 + dlv@1.1.3: {} - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dom-serializer@2.0.0: dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 + domelementtype@2.3.0: {} - '@babel/template@7.29.7': + domhandler@5.0.3: dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 + domelementtype: 2.3.0 - '@babel/traverse@7.29.8': + domutils@3.2.2: dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 - '@babel/types@7.29.8': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 + dset@3.1.4: {} - '@babel/types@8.0.6': - dependencies: - '@babel/helper-string-parser': 8.0.6 - '@babel/helper-validator-identifier': 8.0.6 + electron-to-chromium@1.5.444: {} - '@esbuild/aix-ppc64@0.28.2': - optional: true + emoji-regex@10.6.0: {} - '@esbuild/android-arm64@0.28.2': - optional: true + emoji-regex@8.0.0: {} - '@esbuild/android-arm@0.28.2': - optional: true + entities@4.5.0: {} - '@esbuild/android-x64@0.28.2': - optional: true + entities@6.0.1: {} - '@esbuild/darwin-arm64@0.28.2': - optional: true + entities@7.0.1: {} - '@esbuild/darwin-x64@0.28.2': - optional: true + entities@8.1.0: {} - '@esbuild/freebsd-arm64@0.28.2': - optional: true + es-module-lexer@1.7.0: {} - '@esbuild/freebsd-x64@0.28.2': - optional: true + es-module-lexer@2.3.2: {} - '@esbuild/linux-arm64@0.28.2': - optional: true + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + 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 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 - '@esbuild/linux-arm@0.28.2': - optional: true + escalade@3.2.0: {} - '@esbuild/linux-ia32@0.28.2': - optional: true + escape-string-regexp@5.0.0: {} - '@esbuild/linux-loong64@0.28.2': - optional: true + estree-walker@2.0.2: {} - '@esbuild/linux-mips64el@0.28.2': - optional: true + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 - '@esbuild/linux-ppc64@0.28.2': - optional: true + eventemitter3@5.0.4: {} - '@esbuild/linux-riscv64@0.28.2': - optional: true + expect-type@1.4.0: {} - '@esbuild/linux-s390x@0.28.2': - optional: true + extend@3.0.2: {} - '@esbuild/linux-x64@0.28.2': - optional: true + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 - '@esbuild/netbsd-arm64@0.28.2': - optional: true + flattie@1.1.1: {} - '@esbuild/netbsd-x64@0.28.2': - optional: true + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 - '@esbuild/openbsd-arm64@0.28.2': - optional: true + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 - '@esbuild/openbsd-x64@0.28.2': + fsevents@2.3.3: optional: true - '@esbuild/openharmony-arm64@0.28.2': - optional: true + gensync@1.0.0-beta.2: {} - '@esbuild/sunos-x64@0.28.2': - optional: true + get-caller-file@2.0.5: {} - '@esbuild/win32-arm64@0.28.2': - optional: true + get-east-asian-width@1.7.0: {} - '@esbuild/win32-ia32@0.28.2': - optional: true + get-port@5.1.1: {} - '@esbuild/win32-x64@0.28.2': - optional: true + github-slugger@2.0.0: {} - '@jridgewell/gen-mapping@0.3.13': + h3@1.15.11: dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - '@jridgewell/trace-mapping': 0.3.31 + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 - '@jridgewell/remapping@2.3.5': + hast-util-parse-selector@4.0.0: dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@types/hast': 3.0.5 - '@jridgewell/resolve-uri@3.1.2': {} + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.4.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 - '@jridgewell/sourcemap-codec@1.6.0': {} + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 - '@jridgewell/trace-mapping@0.3.31': + hastscript@9.0.1: dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.6.0 + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - optional: true + html-escaper@3.0.3: {} - '@playwright/test@1.64.0': - dependencies: - playwright: 1.64.0 + html-void-elements@3.0.0: {} - '@rolldown/pluginutils@1.0.0-rc.3': {} + html2canvas-pro@2.5.2: {} - '@rollup/rollup-android-arm-eabi@4.64.0': - optional: true + http-cache-semantics@4.3.0: {} - '@rollup/rollup-android-arm64@4.64.0': - optional: true + import-meta-resolve@4.2.0: {} - '@rollup/rollup-darwin-arm64@4.64.0': - optional: true + iron-webcrypto@1.2.1: {} - '@rollup/rollup-darwin-x64@4.64.0': - optional: true + is-docker@3.0.0: {} - '@rollup/rollup-freebsd-arm64@4.64.0': - optional: true + is-fullwidth-code-point@3.0.0: {} - '@rollup/rollup-freebsd-x64@4.64.0': - optional: true + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 - '@rollup/rollup-linux-arm-gnueabihf@4.64.0': - optional: true + is-plain-obj@4.1.0: {} - '@rollup/rollup-linux-arm-musleabihf@4.64.0': - optional: true + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 - '@rollup/rollup-linux-arm64-gnu@4.64.0': - optional: true + jiti@2.7.0: {} - '@rollup/rollup-linux-arm64-musl@4.64.0': - optional: true + js-tokens@4.0.0: {} - '@rollup/rollup-linux-loong64-gnu@4.64.0': - optional: true + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 - '@rollup/rollup-linux-loong64-musl@4.64.0': - optional: true + jsesc@3.1.0: {} - '@rollup/rollup-linux-ppc64-gnu@4.64.0': - optional: true + json5@2.2.3: {} - '@rollup/rollup-linux-ppc64-musl@4.64.0': - optional: true + kleur@3.0.3: {} - '@rollup/rollup-linux-riscv64-gnu@4.64.0': - optional: true + kleur@4.1.5: {} - '@rollup/rollup-linux-riscv64-musl@4.64.0': + lightningcss-android-arm64@1.33.0: optional: true - '@rollup/rollup-linux-s390x-gnu@4.64.0': + lightningcss-darwin-arm64@1.33.0: optional: true - '@rollup/rollup-linux-x64-gnu@4.64.0': + lightningcss-darwin-x64@1.33.0: optional: true - '@rollup/rollup-linux-x64-musl@4.64.0': + lightningcss-freebsd-x64@1.33.0: optional: true - '@rollup/rollup-openbsd-x64@4.64.0': + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - '@rollup/rollup-openharmony-arm64@4.64.0': + lightningcss-linux-arm64-gnu@1.33.0: optional: true - '@rollup/rollup-win32-arm64-msvc@4.64.0': + lightningcss-linux-arm64-musl@1.33.0: optional: true - '@rollup/rollup-win32-ia32-msvc@4.64.0': + lightningcss-linux-x64-gnu@1.33.0: optional: true - '@rollup/rollup-win32-x64-gnu@4.64.0': + lightningcss-linux-x64-musl@1.33.0: optional: true - '@rollup/rollup-win32-x64-msvc@4.64.0': + lightningcss-win32-arm64-msvc@1.33.0: optional: true - '@standard-schema/spec@1.1.0': {} + lightningcss-win32-x64-msvc@1.33.0: + optional: true - '@types/babel__core@7.20.5': + lightningcss@1.33.0: dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 - '@types/babel__generator@7.27.0': + local-access@1.1.0: {} + + longest-streak@3.1.0: {} + + lru-cache@11.5.3: {} + + lru-cache@5.1.1: dependencies: - '@babel/types': 7.29.8 + yallist: 3.1.1 - '@types/babel__template@7.4.4': + magic-string@0.30.21: dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 + '@jridgewell/sourcemap-codec': 1.6.0 - '@types/babel__traverse@7.28.0': + magic-string@1.4.3: dependencies: - '@babel/types': 7.29.8 + '@jridgewell/sourcemap-codec': 1.6.0 - '@types/chai@5.2.3': + magicast@0.5.5: dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 - '@types/deep-eql@4.0.2': {} + markdown-table@3.0.4: {} - '@types/estree@1.0.9': {} + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 - '@types/node@24.19.1': + mdast-util-find-and-replace@3.0.3: dependencies: - undici-types: 7.24.6 + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 - '@types/react-dom@19.3.0(@types/react@19.3.0)': + mdast-util-from-markdown@2.1.0(supports-color@10.2.2): dependencies: - '@types/react': 19.3.0 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3(supports-color@10.2.2) + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color - '@types/react@19.3.0': + mdast-util-gfm-autolink-literal@2.0.1: dependencies: - csstype: 3.2.3 + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.3 + micromark-util-character: 2.1.1 - '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0))': + mdast-util-gfm-footnote@2.1.0(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0) + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - '@vitest/expect@4.1.11': + mdast-util-gfm-strikethrough@2.0.1(supports-color@10.2.2): dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - chai: 6.3.0 - tinyrainbow: 3.2.0 + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color - '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0))': + mdast-util-gfm-table@2.0.0(supports-color@10.2.2): dependencies: - '@vitest/spy': 4.1.11 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0) + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color - '@vitest/pretty-format@4.1.11': + mdast-util-gfm-task-list-item@2.0.0(supports-color@10.2.2): dependencies: - tinyrainbow: 3.2.0 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color - '@vitest/runner@4.1.11': + mdast-util-gfm@3.1.0(supports-color@10.2.2): dependencies: - '@vitest/utils': 4.1.11 - pathe: 2.0.3 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0(supports-color@10.2.2) + mdast-util-gfm-strikethrough: 2.0.1(supports-color@10.2.2) + mdast-util-gfm-table: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color - '@vitest/snapshot@4.1.11': + mdast-util-phrasing@4.1.0: dependencies: - '@vitest/pretty-format': 4.1.11 - '@vitest/utils': 4.1.11 - magic-string: 0.30.21 - pathe: 2.0.3 + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 - '@vitest/spy@4.1.11': {} - - '@vitest/utils@4.1.11': + mdast-util-to-hast@13.2.1: dependencies: - '@vitest/pretty-format': 4.1.11 - convert-source-map: 2.0.0 - tinyrainbow: 3.2.0 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.2.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 - assertion-error@2.0.1: {} + mdn-data@2.0.28: {} - baseline-browser-mapping@2.11.27: {} + mdn-data@2.27.1: {} - browserslist@4.29.3: + micromark-core-commonmark@2.0.4: dependencies: - baseline-browser-mapping: 2.11.27 - caniuse-lite: 1.0.30001814 - electron-to-chromium: 1.5.444 - node-releases: 2.0.57 - update-browserslist-db: 1.3.3(browserslist@4.29.3) - - caniuse-lite@1.0.30001814: {} + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - chai@6.3.0: {} + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-table@2.1.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - convert-source-map@2.0.0: {} + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.3 - csstype@3.2.3: {} + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - debug@4.4.3: + micromark-extension-gfm@3.0.0: dependencies: - ms: 2.1.3 + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.2 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - electron-to-chromium@1.5.444: {} + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - entities@8.1.0: {} + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 - es-module-lexer@2.3.2: {} + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - esbuild@0.28.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.2 - '@esbuild/android-arm': 0.28.2 - '@esbuild/android-arm64': 0.28.2 - '@esbuild/android-x64': 0.28.2 - '@esbuild/darwin-arm64': 0.28.2 - '@esbuild/darwin-x64': 0.28.2 - '@esbuild/freebsd-arm64': 0.28.2 - '@esbuild/freebsd-x64': 0.28.2 - '@esbuild/linux-arm': 0.28.2 - '@esbuild/linux-arm64': 0.28.2 - '@esbuild/linux-ia32': 0.28.2 - '@esbuild/linux-loong64': 0.28.2 - '@esbuild/linux-mips64el': 0.28.2 - '@esbuild/linux-ppc64': 0.28.2 - '@esbuild/linux-riscv64': 0.28.2 - '@esbuild/linux-s390x': 0.28.2 - '@esbuild/linux-x64': 0.28.2 - '@esbuild/netbsd-arm64': 0.28.2 - '@esbuild/netbsd-x64': 0.28.2 - '@esbuild/openbsd-arm64': 0.28.2 - '@esbuild/openbsd-x64': 0.28.2 - '@esbuild/openharmony-arm64': 0.28.2 - '@esbuild/sunos-x64': 0.28.2 - '@esbuild/win32-arm64': 0.28.2 - '@esbuild/win32-ia32': 0.28.2 - '@esbuild/win32-x64': 0.28.2 + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - escalade@3.2.0: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - estree-walker@3.0.3: + micromark-util-chunked@2.0.1: dependencies: - '@types/estree': 1.0.9 + micromark-util-symbol: 2.0.1 - expect-type@1.4.0: {} + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - fdir@6.5.0(picomatch@4.0.7): - optionalDependencies: - picomatch: 4.0.7 + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 - fsevents@2.3.3: - optional: true + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 - gensync@1.0.0-beta.2: {} + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 - html2canvas-pro@2.5.2: {} + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 - jiti@2.7.0: {} + micromark-util-encode@2.0.1: {} - js-tokens@4.0.0: {} + micromark-util-html-tag-name@2.0.1: {} - jsesc@3.1.0: {} + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 - json5@2.2.3: {} + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 - lru-cache@5.1.1: + micromark-util-sanitize-uri@2.0.1: dependencies: - yallist: 3.1.1 + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 - magic-string@0.30.21: + micromark-util-subtokenize@2.1.0: dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 - magic-string@1.4.3: + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3(supports-color@10.2.2): dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@10.2.2) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + mri@1.2.0: {} + + mrmime@2.0.1: {} ms@2.1.3: {} nanoid@3.3.19: {} + nanoid@3.3.20: {} + + neotraverse@0.6.18: {} + + next@16.4.0(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.64.0)(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + '@next/env': 16.4.0 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001814 + postcss: 8.5.23 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.3.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.4.0 + '@next/swc-darwin-x64': 16.4.0 + '@next/swc-linux-arm64-gnu': 16.4.0 + '@next/swc-linux-arm64-musl': 16.4.0 + '@next/swc-linux-x64-gnu': 16.4.0 + '@next/swc-linux-x64-musl': 16.4.0 + '@next/swc-win32-arm64-msvc': 16.4.0 + '@next/swc-win32-x64-msvc': 16.4.0 + '@playwright/test': 1.64.0 + sharp: 0.35.5(@types/node@24.19.1) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.5: {} + node-releases@2.0.57: {} + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + obug@2.2.1: {} + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.12: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.2 + + p-queue@8.1.1: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 6.1.4 + + p-timeout@6.1.4: {} + + package-manager-detector@1.9.0: {} + + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.1.0 pathe@2.0.3: {} + piccolore@0.1.3: {} + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.7: {} playwright-core@1.64.0: {} @@ -1394,12 +5172,35 @@ snapshots: dependencies: playwright-core: 1.64.0 + postcss@8.5.23: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.2 + postcss@8.5.28: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.2 + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prismjs@1.30.0: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + property-information@7.2.0: {} + + radix3@1.1.2: {} + react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 @@ -1409,6 +5210,130 @@ snapshots: react@19.3.0: {} + readdirp@5.1.1: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.5 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + + remark-gfm@4.0.1(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0(supports-color@10.2.2) + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0(supports-color@10.2.2) + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + micromark-util-types: 2.0.3 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.3: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.2.0 + unified: 11.0.5 + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + rollup@4.64.0: dependencies: '@types/estree': 1.0.9 @@ -1441,20 +5366,184 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.64.0 fsevents: 2.3.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + sax@1.6.1: {} + scheduler@0.28.0: {} + semiver@1.1.0: {} + semver@6.3.1: {} + semver@7.8.5: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + sharp@0.35.5(@types/node@24.19.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 24.19.1 + optional: true + + shell-quote@1.12.0: {} + + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + siginfo@2.0.0: {} + sirv-cli@3.0.1: + dependencies: + console-clear: 1.1.1 + get-port: 5.1.1 + kleur: 4.1.5 + local-access: 1.1.0 + sade: 1.8.1 + semiver: 1.1.0 + sirv: 3.0.2 + tinydate: 1.3.0 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + smol-toml@1.9.1: {} + source-map-js@1.2.2: {} + space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} std-env@4.3.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.3.0): + dependencies: + client-only: 0.0.1 + react: 19.3.0 + optionalDependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + + supports-color@10.2.2: {} + + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + + tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} + tinydate@1.3.0: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: @@ -1464,17 +5553,139 @@ snapshots: tinyrainbow@3.2.0: {} + totalist@3.0.1: {} + + tree-kill@1.2.2: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tsconfck@3.1.6(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + tslib@2.8.1: {} + + type-fest@4.41.0: {} + typescript@6.0.3: {} + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + undici-types@7.24.6: {} + undici@8.11.2: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.5: + dependencies: + css-tree: 3.2.1 + ohash: 2.0.12 + undici: 8.11.2 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.3 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + update-browserslist-db@1.3.3(browserslist@4.29.3): dependencies: browserslist: 4.29.3 escalade: 3.2.0 picocolors: 1.1.1 - vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0): + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@6.4.4(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.64.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.1 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.33.0 + + vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.7) @@ -1486,11 +5697,29 @@ snapshots: '@types/node': 24.19.1 fsevents: 2.3.3 jiti: 2.7.0 + lightningcss: 1.33.0 + + vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.30 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.1 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 - vitest@4.1.11(@types/node@24.19.1)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)): + vitefu@1.1.3(vite@6.4.4(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0)): + optionalDependencies: + vite: 6.4.4(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0) + + vitest@4.1.11(@types/node@24.19.1)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1507,16 +5736,78 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0) + vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.33.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.1 transitivePeerDependencies: - msw + vue@3.5.44(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.44 + '@vue/compiler-sfc': 3.5.44 + '@vue/runtime-dom': 3.5.44 + '@vue/server-renderer': 3.5.44 + '@vue/shared': 3.5.44 + optionalDependencies: + typescript: 6.0.3 + + web-namespaces@2.0.1: {} + + which-pm-runs@1.1.0: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@1.2.2: {} + + yocto-spinner@0.2.3: + dependencies: + yoctocolors: 2.2.0 + + yoctocolors@2.2.0: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-to-ts@1.2.0(typescript@6.0.3)(zod@3.25.76): + dependencies: + typescript: 6.0.3 + zod: 3.25.76 + + zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed0b5a..c35c7fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ +packages: + - examples/* allowBuilds: esbuild: true + sharp: false diff --git a/scripts/verify-package-managers.mjs b/scripts/verify-package-managers.mjs index 27014b4..68389f7 100644 --- a/scripts/verify-package-managers.mjs +++ b/scripts/verify-package-managers.mjs @@ -1,7 +1,7 @@ -// Installs the packed package (or a published spec) into a fresh project per package manager and checks -// the CLI, MCP, Vite plugin and review proxy there. Usage: docs/testing.md#package-managers. +// Installs the packed package (or a published spec) into a copy of examples/vite-react per package manager and checks +// the CLI, MCP, the example's dev server, the review proxy and a production build there. Usage: docs/testing.md#package-managers. import { spawn } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { cp, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -10,12 +10,12 @@ const repo = resolve(import.meta.dirname, '..'); // Each runner except npm is fetched at a pinned major through npx, so the result does not depend on global installs or Corepack. const pnpm = ['npx', '-y', `pnpm@${args.pnpm ?? 11}`], yarn = ['npx', '-y', `@yarnpkg/cli-dist@${args.yarn ?? 4}`], yarn1 = ['npx', '-y', 'yarn@1']; const runners = { - npm: { bin: ['npm'], install: (spec) => ['npm', 'install', '--save-dev', '--no-audit', '--no-fund', spec, 'vite@7'], exec: ['npx', 'needmark'], node: ['node'], hint: 'npx needmark' }, - pnpm: { bin: pnpm, install: (spec) => [...pnpm, 'add', '--save-dev', spec, 'vite@7'], exec: [...pnpm, 'exec', 'needmark'], node: ['node'], hint: 'pnpm exec needmark', + npm: { bin: ['npm'], install: ['npm', 'install', '--no-audit', '--no-fund'], dev: (port) => ['npm', 'run', 'dev', '--', '--port', port], exec: ['npx', 'needmark'], node: ['node'], hint: 'npx needmark' }, + pnpm: { bin: pnpm, install: [...pnpm, 'install'], dev: (port) => [...pnpm, 'run', 'dev', '--port', port], exec: [...pnpm, 'exec', 'needmark'], node: ['node'], hint: 'pnpm exec needmark', files: { 'pnpm-workspace.yaml': 'allowBuilds:\n esbuild: true\n' } }, - yarn: { bin: yarn, install: (spec) => [...yarn, 'add', '--dev', spec.endsWith('.tgz') ? `needmark@file:${spec}` : spec, 'vite@7'], exec: [...yarn, 'needmark'], node: [...yarn, 'node'], hint: 'yarn needmark', + yarn: { bin: yarn, install: [...yarn, 'install'], dev: (port) => [...yarn, 'run', 'dev', '--port', port], exec: [...yarn, 'needmark'], node: [...yarn, 'node'], hint: 'yarn needmark', files: { 'yarn.lock': '' } }, - yarn1: { bin: yarn1, install: (spec) => [...yarn1, 'add', '--dev', spec, 'vite@7'], exec: [...yarn1, '--silent', 'needmark'], node: ['node'], hint: 'yarn needmark', + yarn1: { bin: yarn1, install: [...yarn1, 'install'], dev: (port) => [...yarn1, 'run', 'dev', '--port', port], exec: [...yarn1, '--silent', 'needmark'], node: ['node'], hint: 'yarn needmark', files: { 'yarn.lock': '' } }, }; // Yarn 4 quarantines recently published versions, which would block checking a release right after publishing it. @@ -38,7 +38,7 @@ function run(command, options = {}) { const check = ` import { createServer as createHttpServer } from 'node:http'; import { readdir, readFile } from 'node:fs/promises'; -import { createServer, build } from 'vite'; +import { build } from 'vite'; import { startReviewProxy } from 'needmark/proxy'; const port = Number(process.argv[2]); const scriptOf = (html) => html.match(/]*src="([^"]*needmark[^"]*)"/i)?.[1] ?? html.match(/]*data-needmark[^>]*src="([^"]+)"/i)?.[1]; @@ -50,16 +50,16 @@ async function clientServed(origin, html, label) { if (!response.ok || body.length < 10000) throw new Error(label + ': client script ' + src + ' returned ' + response.status); console.log('ok ' + label + ' injects and serves the review client'); } -const server = await createServer({ server: { port, strictPort: true, host: '127.0.0.1' }, logLevel: 'silent' }); -await server.listen(); -try { await clientServed('http://127.0.0.1:' + port, await (await fetch('http://127.0.0.1:' + port + '/')).text(), 'vite dev'); } finally { await server.close(); } +await clientServed('http://127.0.0.1:' + port, await (await fetch('http://127.0.0.1:' + port + '/')).text(), 'example dev script'); const target = createHttpServer((request, response) => { response.setHeader('content-type', 'text/html'); response.end('

target

'); }); await new Promise((done) => target.listen(port + 1, '127.0.0.1', done)); const proxy = await startReviewProxy({ target: 'http://127.0.0.1:' + (port + 1), origin: 'http://127.0.0.1:' + (port + 2) }); try { await clientServed(proxy.origin, await (await fetch(proxy.origin + '/')).text(), 'review proxy'); } finally { await proxy.close(); target.close(); } await build({ logLevel: 'silent' }); +// The client, its injected tag and source annotations all carry these markers; the example's page title names NeedMark. +// クライアント、注入タグ、ソース注釈はどれもこの目印を含む。exampleのページタイトルにはNeedMarkの名前が入っている。 for (const file of await readdir('dist', { recursive: true, withFileTypes: true })) - if (file.isFile() && /needmark/i.test(await readFile(file.parentPath + '/' + file.name, 'utf8'))) throw new Error('production build contains NeedMark: ' + file.name); + if (file.isFile() && /data-needmark|__needmark/.test(await readFile(file.parentPath + '/' + file.name, 'utf8'))) throw new Error('production build contains NeedMark: ' + file.name); console.log('ok production build excludes NeedMark'); `; @@ -70,29 +70,48 @@ async function mcpHandshake(runner, cwd) { if (!reply.result?.serverInfo) throw new Error(`MCP initialize returned ${stdout}`); } +// Runs the example's own dev script through the runner while `action` checks it. The runner and the dev server share a +// new process group, so stopping the group also stops a server that npx or Yarn started as a grandchild. +async function whileDevRuns(runner, cwd, port, action) { + const command = runner.dev(String(port)); + const child = spawn(command[0], command.slice(1), { cwd, env: process.env, stdio: 'ignore', detached: true }); + try { + for (const deadline = Date.now() + 60000; !(await fetch(`http://127.0.0.1:${port}/`).then((response) => response.ok, () => false)); await new Promise((wait) => setTimeout(wait, 500))) { + if (child.exitCode !== null) throw new Error(`dev script exited with ${child.exitCode}`); + if (Date.now() > deadline) throw new Error(`dev script did not serve port ${port} within 60 s`); + } + return await action(); + } finally { try { process.kill(-child.pid, 'SIGTERM'); } catch { /* The dev script already stopped. */ } } +} + async function verify(name, runner, spec, port) { const cwd = join(root, name); - await mkdir(cwd); - const files = { 'package.json': JSON.stringify({ name: `verify-${name}`, private: true, type: 'module' }), ...runner.files, - 'index.html': '

verify

', 'main.js': 'document.title = "verify";', - 'vite.config.ts': "import { defineConfig } from 'vite';\nimport { needmark } from 'needmark';\nexport default defineConfig({ plugins: [needmark()] });\n", 'check.mjs': check }; + // The example's config is left out so that `init` below writes it as it would in a user's project. + await cp(example, cwd, { recursive: true, filter: (source) => !/[\\/](node_modules|\.needmark|dist|needmark\.config\.ts)$/.test(source) }); + const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')); + manifest.devDependencies.needmark = spec.endsWith('.tgz') ? `file:${spec}` : spec.replace(/^needmark@/, ''); + const files = { 'package.json': JSON.stringify(manifest, null, 2), ...runner.files, 'check.mjs': check }; for (const [file, content] of Object.entries(files)) await writeFile(join(cwd, file), content); await run(['git', 'init', '-q'], { cwd }); const step = async (label, action) => { await action(); console.log(` ok ${label}`); }; - await step('install', () => run(runner.install(spec), { cwd })); + await step('install', () => run(runner.install, { cwd })); await step('--help', async () => { if (!(await run([...runner.exec, '--help'], { cwd })).stdout.includes('needmark init')) throw new Error('help text missing'); }); + // The example already loads the plugin, so `remove` takes it out first and `init --connect=yes` has to add it back for the dev check below. + await step('remove', async () => { await run([...runner.exec, 'remove'], { cwd }); if ((await readFile(join(cwd, 'vite.config.ts'), 'utf8')).includes('needmark')) throw new Error('vite.config.ts still loads NeedMark'); }); await step('init', async () => { - const { stdout } = await run([...runner.exec, 'init', '--share=local', '--capture=dom', '--language=en'], { cwd }); + const { stdout } = await run([...runner.exec, 'init', '--share=local', '--capture=dom', '--language=en', '--connect=yes'], { cwd }); + if (!(await readFile(join(cwd, 'vite.config.ts'), 'utf8')).includes('needmark()')) throw new Error('vite.config.ts not connected'); if (!(await readFile(join(cwd, 'needmark.config.ts'), 'utf8')).includes('outputDir')) throw new Error('needmark.config.ts not written'); if (!(await readFile(join(cwd, '.gitignore'), 'utf8')).includes('/.needmark/')) throw new Error('.gitignore rule not written'); - if (!stdout.includes(`${runner.hint} dev`)) throw new Error(`init guidance does not use "${runner.hint}":\n${stdout}`); + if (!stdout.includes(`${runner.hint} remove`)) throw new Error(`init guidance does not use "${runner.hint}":\n${stdout}`); }); await step('list', async () => { if (!Array.isArray(JSON.parse((await run([...runner.exec, 'list'], { cwd })).stdout))) throw new Error('list did not print an array'); }); await step('mcp initialize', () => mcpHandshake(runner, cwd)); - const { stdout } = await run([...runner.node, 'check.mjs', String(port)], { cwd }); + const { stdout } = await whileDevRuns(runner, cwd, port, () => run([...runner.node, 'check.mjs', String(port)], { cwd })); for (const line of stdout.trim().split('\n')) console.log(` ${line}`); } +const example = join(repo, 'examples', 'vite-react'); const root = await mkdtemp(join(tmpdir(), 'needmark-pm-')); let spec = args.spec; if (!spec) { diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..0a10f52 --- /dev/null +++ b/site/index.html @@ -0,0 +1,174 @@ + + + + + + NeedMark + + + + + + +
+ NeedMark + +
+ +
+
+

NeedMark

+

Point at your UI, leave a comment, and hand it to any coding agent.

+
+ + GitHub +
+

MIT · Node.js 22.12+ · No account, no AI lock-in · Development only

+
+ + + +
+

Set up with your agent

+

Paste this prompt into the coding agent that works in your project, such as Claude Code, Codex or Cursor. It installs NeedMark, creates needmark.config.ts, connects your development server and tells you how to start reviewing.

+
+
+ Setup prompt + +
+
Set up NeedMark (https://github.com/mssh21/needmark) in this project. NeedMark is a development-only tool: I point at the UI in the browser and leave comments, which are saved as local files for a coding agent.
+
+1. Run `node -v`. NeedMark needs Node.js 22.12 or newer; if it is older, stop and tell me.
+2. Work in the app that has the development server (in a monorepo, that package's folder). Pick the package manager from the nearest lockfile: pnpm-lock.yaml → pnpm, yarn.lock → Yarn, package-lock.json or none → npm. The NeedMark command is then `pnpm exec needmark`, `yarn needmark` or `npx needmark`; below it is written as <needmark>.
+3. Install it as a dev dependency: `pnpm add -D needmark`, `yarn add -D needmark` or `npm install -D needmark`.
+4. If needmark.config.ts does not exist yet, initialize without questions and connect the development server:
+   <needmark> init --language=en --share=local --capture=dom --connect=yes
+   Use --share=git instead only if I asked to share feedback through Git.
+5. Read the output of init. It adds needmark() to vite.config.* or astro.config.*, or runs a Next.js dev script through `needmark dev --`, and prints each changed line; keep those changes. If it says the development server was not detected, connect it by hand as described in https://github.com/mssh21/needmark/blob/main/docs/getting-started.md#connect-the-development-server. Make no other changes. NeedMark never enters production builds.
+6. Run `<needmark> list` and check that it prints `[]`.
+7. If you can use MCP servers, ask me whether to register NeedMark so you can read feedback and its screenshots directly. Only if I agree, register it from this folder, for example `claude mcp add needmark -- <needmark> mcp` or `codex mcp add needmark -- <needmark> mcp`.
+8. Finish by telling me to start the development server with its usual command and open it as usual: the NeedMark toolbar appears at the bottom of the page. Mention that NEEDMARK=off starts it without NeedMark and `<needmark> remove` undoes the connection.
+
+
+ +
+

Install

+

Requires Node.js 22.12 or newer. In your application project:

+
+
+ + + +
+
pnpm add -D needmark
+pnpm exec needmark init
+pnpm dev
+ + + +
+

Open your app as usual; the NeedMark toolbar appears at the bottom of the page.

+

init first offers recommended settings: the review screen in your terminal's language, feedback kept on this computer, screenshots redrawn from the page and connecting your development server. Press Enter to accept them, or answer n to choose each one from a list that explains it. It lists every line it changes, and never touches your application source or production builds:

+
    +
  • Vite (React, Vue and others): adds needmark() to plugins in vite.config.*.
  • +
  • Astro: adds needmark() to vite.plugins in astro.config.*.
  • +
  • Next.js: starts the dev script through needmark dev --, so the usual port shows the toolbar.
  • +
+

NEEDMARK=off starts the development server without NeedMark for you, enabled: false in needmark.config.ts turns it off for everyone, and needmark remove undoes the connection. For other servers, see Connect the development server.

+
+ +
+

How you use it

+
    +
  1. Open your app in the development server and press Comment (+) in the toolbar, or Shift+C.
  2. +
  3. Click an element or drag an area. Shift+click selects several elements; Alt/Option+drag selects part of the text.
  4. +
  5. Write the comment, choose Fix, Discuss or Question, and save it with a screenshot or without one.
  6. +
  7. Open Comments, press Copy prompt and paste it into your agent.
  8. +
  9. The agent fixes the code and replies with how it checked the change. Check it yourself, then mark the comment resolved.
  10. +
+
+ +
+

What gets saved

+

Every comment becomes a folder in your project, so any agent with access to the files can read it, and you can keep it local or share it through Git.

+
+
.needmark/feedback/<id>/
+  comment.md       the comment, page and target
+  context.json     selector, component and source location
+  screenshot.png   optional, with passwords masked
+
+
+ +
+

Let your agent read it directly

+

Agents that support MCP, such as Claude Code, Codex and Gemini CLI, can list feedback, see its screenshot, reply, resolve and wait for new comments through needmark mcp. It needs no extra server or account and opens no network port.

+
+
+ + + +
+
# Claude Code
+claude mcp add needmark -- pnpm exec needmark mcp
+# Codex
+codex mcp add needmark -- pnpm exec needmark mcp
+ + + +
+

Without MCP, the CLI does the same: needmark export, watch, reply and resolve.

+
+ +
+

Documentation

+ +
+
+ + + + + diff --git a/site/ja/index.html b/site/ja/index.html new file mode 100644 index 0000000..9d3ae1e --- /dev/null +++ b/site/ja/index.html @@ -0,0 +1,174 @@ + + + + + + NeedMark + + + + + + +
+ NeedMark + +
+ +
+
+

NeedMark

+

UIを指してコメントを書き、いつものコーディングAIへ渡す。

+
+ + GitHub +
+

MIT · Node.js 22.12以降 · アカウント不要、AIを選ばない · 開発時のみ動作

+
+ + + +
+

AIに導入してもらう

+

このプロンプトを、プロジェクトで作業しているコーディングAI(Claude Code、Codex、Cursorなど)に貼り付けてください。NeedMarkのインストール、needmark.config.tsの作成、開発サーバーへの組み込みまで行い、レビューの始め方を案内します。

+
+
+ 導入プロンプト + +
+
このプロジェクトにNeedMark(https://github.com/mssh21/needmark)を導入してください。NeedMarkは開発時だけ動くツールで、私がブラウザでUIを指してコメントを書くと、コーディングAI向けにローカルファイルとして保存されます。
+
+1. `node -v`を実行してください。Node.js 22.12以降が必要です。古い場合は作業を止めて私に伝えてください。
+2. 開発サーバーを持つアプリのフォルダで作業してください(モノレポならそのパッケージのフォルダ)。一番近いロックファイルでパッケージマネージャーを決めます。pnpm-lock.yamlならpnpm、yarn.lockならYarn、package-lock.jsonか、ロックファイルがなければnpmです。NeedMarkのコマンドはそれぞれ`pnpm exec needmark`、`yarn needmark`、`npx needmark`になります。以下では<needmark>と書きます。
+3. 開発用の依存として入れてください。`pnpm add -D needmark`、`yarn add -D needmark`、`npm install -D needmark`のいずれかです。
+4. needmark.config.tsがまだなければ、質問なしで初期設定し、開発サーバーにつないでください。
+   <needmark> init --language=ja --share=local --capture=dom --connect=yes
+   コメントをGitで共有するよう私が頼んだ場合だけ、--share=gitにしてください。
+5. initの出力を読んでください。initはvite.config.*かastro.config.*にneedmark()を追加するか、Next.jsのdevスクリプトを`needmark dev --`経由にし、変更した行を表示します。その変更はそのまま残してください。開発サーバーを検出できなかったと表示された場合は、https://github.com/mssh21/needmark/blob/main/docs/getting-started.ja.md#開発サーバーにつなぐ の手順で手動でつないでください。ほかの変更はしないでください。NeedMarkは本番ビルドには入りません。
+6. `<needmark> list`を実行し、`[]`と表示されることを確認してください。
+7. あなたがMCPサーバーを使える場合は、NeedMarkを登録するか私に確認してください。登録するとコメントとスクリーンショットを直接読めます。私が同意した場合だけ、このフォルダで登録してください。例:`claude mcp add needmark -- <needmark> mcp`、`codex mcp add needmark -- <needmark> mcp`
+8. 最後に、いつもの開発コマンドで開発サーバーを起動し、いつもどおり開けばページ下部にNeedMarkのツールバーが出ると伝えてください。NEEDMARK=offでNeedMarkなしで起動できること、`<needmark> remove`で接続を元に戻せることも伝えてください。
+
+
+ +
+

導入

+

Node.js 22.12以降が必要です。アプリのプロジェクトで実行します。

+
+
+ + + +
+
pnpm add -D needmark
+pnpm exec needmark init
+pnpm dev
+ + + +
+

いつもどおりアプリを開くと、ページ下部にNeedMarkのツールバーが表示されます。

+

initは最初に推奨設定(レビュー画面はターミナルの言語、コメントはこのコンピューターだけに保存、スクリーンショットはページを描き直して作成、開発サーバーへの接続)を示します。Enterでそのまま使い、nと答えると説明付きの一覧から項目ごとに選べます。質問と表示は英語です。変更した行はすべて表示し、アプリのソースと本番ビルドには触れません。

+
    +
  • Vite(React、Vueなど):vite.config.*のpluginsにneedmark()を追加します。
  • +
  • Astro:astro.config.*のvite.pluginsにneedmark()を追加します。
  • +
  • Next.js:devスクリプトをneedmark dev --経由にし、いつものポートでツールバーが出るようにします。
  • +
+

NEEDMARK=offで自分だけNeedMarkなしで起動でき、needmark.config.tsのenabled: falseで全員分を止められます。needmark removeで接続を元に戻せます。ほかの開発サーバーは開発サーバーにつなぐを参照してください。

+
+ +
+

使い方

+
    +
  1. 開発サーバーでアプリを開き、ツールバーのコメント(+)を押します。Shift+Cでも始められます。
  2. +
  3. 要素をクリックするか、範囲をドラッグします。Shift+クリックで複数の要素、Alt/Option+ドラッグで文章の一部を選べます。
  4. +
  5. コメントを書き、修正・相談・質問から種類を選んで保存します。スクリーンショットは付けても付けなくても構いません。
  6. +
  7. コメント一覧を開いてプロンプトをコピーを押し、AIに貼り付けます。
  8. +
  9. AIがコードを直し、どう確認したかを返信します。結果を自分で確かめてから、コメントを解決済みにします。
  10. +
+
+ +
+

保存されるもの

+

コメントは1件ずつプロジェクト内のフォルダになります。ファイルを読めるAIならどれでも扱え、ローカルに置いたままにもGitで共有もできます。

+
+
.needmark/feedback/<id>/
+  comment.md       コメント、ページ、対象
+  context.json     セレクタ、コンポーネント、ソースの位置
+  screenshot.png   任意。パスワード欄はマスク
+
+
+ +
+

AIに直接読ませる

+

Claude Code、Codex、Gemini CLIなどMCPに対応したAIは、needmark mcpを通してコメントの一覧とスクリーンショットを読み、返信や解決、新しいコメントの待ち受けができます。追加のサーバーやアカウントは不要で、ネットワークのポートも開きません。

+
+
+ + + +
+
# Claude Code
+claude mcp add needmark -- pnpm exec needmark mcp
+# Codex
+codex mcp add needmark -- pnpm exec needmark mcp
+ + + +
+

MCPを使わない場合も、CLIのneedmark export、watch、reply、resolveで同じことができます。

+
+ +
+

ドキュメント

+ +
+
+ +
MITライセンス · 作者 mssh21
+ + + diff --git a/site/site.js b/site/site.js new file mode 100644 index 0000000..28e7f0e --- /dev/null +++ b/site/site.js @@ -0,0 +1,33 @@ +// Copy buttons read their text from the element named in data-copy, or from the pre in the same code block. +for (const button of document.querySelectorAll('[data-copy], .code .copy')) { + button.addEventListener('click', async () => { + const source = button.dataset.copy + ? document.getElementById(button.dataset.copy) + : [...button.closest('.code').querySelectorAll('pre')].find((pre) => !pre.closest('[hidden]')); + try { + await navigator.clipboard.writeText(source.textContent.trim() + '\n'); + } catch { + // Clipboard access can be refused; selecting the text lets the reader copy it by hand. + getSelection().selectAllChildren(source); + return; + } + const label = button.querySelector('.label'); + const original = label?.textContent; + button.classList.add('copied'); + if (label) label.textContent = button.dataset.copied; + setTimeout(() => { + button.classList.remove('copied'); + if (label) label.textContent = original; + }, 1600); + }); +} + +// Package manager tabs switch every code block on the page together, so the reader picks once. +const tabButtons = document.querySelectorAll('.tabs button'); +for (const button of tabButtons) { + button.addEventListener('click', () => { + const manager = button.dataset.tab; + for (const other of tabButtons) other.setAttribute('aria-selected', String(other.dataset.tab === manager)); + for (const panel of document.querySelectorAll('[data-panel]')) panel.hidden = panel.dataset.panel !== manager; + }); +} diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..312a2dd --- /dev/null +++ b/site/style.css @@ -0,0 +1,426 @@ +:root { + --bg: #ffffff; + --ink: #18181b; + --body: #3f3f46; + --quiet: #71717a; + --edge: #e4e4e7; + --area: #f6f7f8; + --code: #fafafa; + --accent: #1965e3; + --accent-soft: #e4f3ff; + --button: #18181b; + --on-button: #ffffff; + --toolbar: #27292c; + --mock-block: #eceef0; + --mock-line: #e1e4e7; + --sans: system-ui, -apple-system, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #111113; + --ink: #f4f4f5; + --body: #d4d4d8; + --quiet: #a1a1aa; + --edge: #2e2e33; + --area: #1b1b1f; + --code: #18181b; + --accent: #69abff; + --accent-soft: #0a2063; + --button: #f4f4f5; + --on-button: #18181b; + --toolbar: #2e3134; + --mock-block: #26262b; + --mock-line: #2f2f35; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--body); + font: 15px/1.7 var(--sans); +} + +a { color: inherit; } + +main, .top { + max-width: 672px; + margin: 0 auto; + padding: 0 16px; +} + +.top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: 56px; + font-size: 14px; +} + +.top nav { display: flex; gap: 18px; } + +.top a { color: var(--quiet); text-decoration: none; } +.top a:hover { color: var(--ink); } + +.wordmark { color: var(--ink) !important; font-weight: 650; } + +.hero { + padding: 56px 0 40px; + text-align: center; +} + +h1 { + margin: 0; + color: var(--ink); + font-size: 48px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.1; +} + +.lead { + max-width: 520px; + margin: 14px auto 0; + font-size: 17px; +} + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + margin-top: 28px; +} + +.button { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border: 1px solid var(--edge); + border-radius: 8px; + background: var(--bg); + color: var(--ink); + font: 500 14px var(--sans); + text-decoration: none; + cursor: pointer; +} + +.button.primary { + border-color: var(--button); + background: var(--button); + color: var(--on-button); +} + +.facts { + margin: 18px 0 0; + color: var(--quiet); + font-size: 13px; +} + +/* Product illustration */ +.mock { + margin: 0 0 56px; + border: 1px solid var(--edge); + border-radius: 12px; + overflow: hidden; + background: var(--bg); +} + +.mock-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid var(--edge); + background: var(--area); +} + +.mock-bar i { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--mock-line); +} + +.mock-bar span { + margin-left: 10px; + padding: 1px 10px; + border-radius: 5px; + background: var(--bg); + color: var(--quiet); + font: 11px var(--mono); +} + +.mock-page { + position: relative; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + padding: 18px 18px 72px; +} + +.mock-card { + position: relative; + height: 70px; + padding: 12px; + border: 1px solid var(--edge); + border-radius: 8px; +} + +.mock-card::before, .mock-card::after { + content: ""; + display: block; + height: 7px; + border-radius: 4px; + background: var(--mock-line); +} + +.mock-card::before { width: 55%; } +.mock-card::after { width: 35%; height: 14px; margin-top: 10px; background: var(--mock-block); } + +.mock-card.target { outline: 2px solid var(--accent); outline-offset: 2px; } + +.mock-wide { + grid-column: 1 / -1; + height: 92px; + border: 1px solid var(--edge); + border-radius: 8px; + background: repeating-linear-gradient(to bottom, transparent 0 22px, var(--mock-line) 22px 23px); +} + +.pin { + position: absolute; + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: 2px solid #ffffff; + border-radius: 50% 50% 50% 0; + background: #1965e3; + color: #ffffff; + font: 600 11px var(--sans); + box-shadow: 0 1px 4px rgb(0 0 0 / 0.25); +} + +.note { + position: absolute; + top: 54px; + left: calc(33% + 20px); + width: 232px; + padding: 12px; + border: 1px solid var(--edge); + border-radius: 10px; + background: var(--bg); + box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); + font-size: 12px; + line-height: 1.5; + text-align: left; +} + +.note b { + display: inline-block; + margin-bottom: 6px; + padding: 0 7px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 11px; + font-weight: 600; +} + +.note p { margin: 0; color: var(--ink); } + +.toolbar { + position: absolute; + bottom: 16px; + left: 50%; + display: flex; + align-items: center; + gap: 14px; + padding: 8px 14px; + border-radius: 10px; + background: var(--toolbar); + color: #ffffff; + transform: translateX(-50%); +} + +.toolbar svg { display: block; width: 16px; height: 16px; } + +.toolbar .count { + display: grid; + place-items: center; + width: 16px; + height: 16px; + margin-left: -10px; + border-radius: 50%; + background: #1965e3; + font: 600 10px var(--sans); +} + +/* Sections */ +section { margin: 0 0 56px; } + +h2 { + margin: 0 0 8px; + color: var(--ink); + font-size: 15px; + font-weight: 600; +} + +h3 { + margin: 24px 0 6px; + color: var(--ink); + font-size: 14px; + font-weight: 600; +} + +p { margin: 0 0 12px; } + +code { + font: 0.92em var(--mono); +} + +:not(pre) > code { + padding: 1px 5px; + border-radius: 4px; + background: var(--area); +} + +.code { + position: relative; + margin: 12px 0; + border: 1px solid var(--edge); + border-radius: 8px; + background: var(--code); +} + +.code pre { + margin: 0; + padding: 12px 48px 12px 14px; + overflow-x: auto; + color: var(--ink); + font: 13px/1.6 var(--mono); +} + +.copy { + position: absolute; + top: 6px; + right: 6px; + display: grid; + place-items: center; + width: 30px; + height: 30px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--quiet); + cursor: pointer; +} + +.copy:hover { background: var(--area); color: var(--ink); } +.copy svg { width: 15px; height: 15px; } +.copy .done, .copy.copied .idle { display: none; } +.copy.copied .done { display: block; color: var(--accent); } + +.prompt pre { + max-height: 320px; + white-space: pre-wrap; + font-size: 12.5px; +} + +.prompt-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px 10px 14px; + border-bottom: 1px solid var(--edge); + color: var(--quiet); + font-size: 13px; +} + +.prompt-head .button { height: 32px; padding: 0 12px; font-size: 13px; } + +.tabs { + display: flex; + gap: 4px; + padding: 6px 6px 0; + border-bottom: 1px solid var(--edge); +} + +.tabs button { + padding: 4px 10px 6px; + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--quiet); + font: 500 13px var(--sans); + cursor: pointer; +} + +.tabs button[aria-selected="true"] { border-bottom-color: var(--ink); color: var(--ink); } + +[data-panel][hidden] { display: none; } + +ol.steps { + margin: 0; + padding: 0; + list-style: none; + counter-reset: step; +} + +ol.steps li { + position: relative; + padding: 6px 0 6px 32px; + counter-increment: step; +} + +ol.steps li::before { + content: counter(step); + position: absolute; + top: 8px; + left: 0; + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--area); + color: var(--ink); + font-size: 12px; + font-weight: 600; +} + +ul.plain { margin: 0 0 12px; padding-left: 20px; } + +.links { display: flex; flex-wrap: wrap; gap: 8px 20px; } + +footer { + max-width: 672px; + margin: 0 auto; + padding: 24px 16px 48px; + border-top: 1px solid var(--edge); + color: var(--quiet); + font-size: 13px; +} + +@media (max-width: 560px) { + h1 { font-size: 38px; } + .mock-page { grid-template-columns: 1fr 1fr; } + .mock-card:nth-child(3) { display: none; } + .note { left: 24px; width: auto; right: 24px; } + .pin.extra { display: none; } + .top nav a.optional { display: none; } +} diff --git a/src/cli.test.ts b/src/cli.test.ts index 39437a4..685c4e4 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -34,13 +34,15 @@ const run = (directory: string, args: string[], answers?: string[]) => execute(p [...(answers ? ['--import', terminal] : []), cli, ...args], { cwd: directory, timeout: 10_000, env: { ...process.env, ...(answers ? { NEEDMARK_TEST_ANSWERS: JSON.stringify(answers) } : {}) } }); -describe('CLI language configuration', () => { - it.each([undefined, 'en', 'ja'] as const)('persists the %s initialization choice without a terminal', async (language) => { +describe('CLI initialization', () => { + it.each([undefined, 'en', 'ja'] as const)('persists the %s initialization choice without a terminal and reports it in English', async (language) => { const directory = await temporary(); const result = await run(directory, ['init', ...(language ? [`--language=${language}`] : [])]); expect(await loadProjectConfig(directory)).toEqual({ language: language ?? 'ja', capture: 'dom', outputDir: '.needmark/feedback' }); expect(result.stderr).toBe(''); - expect(result.stdout).toContain(language === 'en' ? 'Created needmark.config.ts' : 'needmark.config.tsを作成しました'); + expect(result.stdout).toContain('Created needmark.config.ts'); + expect(result.stdout).toContain('Redraw the page'); + expect(result.stdout).not.toMatch(/[\u3040-\u30ff]/); }); it.each(['fr', 'EN', 'en-US', ' ja'])('rejects language flag %j before writing any settings', async (language) => { @@ -51,28 +53,61 @@ describe('CLI language configuration', () => { expect(await readFile(join(directory, '.gitignore'), 'utf8')).toBe('preserved/\n'); }); - it.each(['en', 'ja'] as const)('selects %s before the other terminal questions and persists the answer', async (language) => { + it('accepts the recommended settings with one answer in a terminal', async () => { const directory = await temporary(); - await run(directory, ['init'], [language, 'git', 'none']); - expect(await loadProjectConfig(directory)).toEqual({ language, capture: 'none', outputDir: '.needmark/feedback' }); + await run(directory, ['init'], ['']); + expect(await loadProjectConfig(directory)).toMatchObject({ capture: 'dom', outputDir: '.needmark/feedback' }); + expect(await readFile(join(directory, '.gitignore'), 'utf8')).toContain('/.needmark/'); const questions = JSON.parse(await readFile(join(directory, 'questions.json'), 'utf8')) as string[]; - expect(questions).toHaveLength(3); - expect(questions[0]).toContain('Language / 言語'); - expect(questions[1]).toContain(language === 'en' ? 'Feedback storage' : 'コメントの保存先'); + expect(questions).toHaveLength(1); + expect(questions[0]).toContain('Use the recommended settings?'); }); - it('uses the language flag for terminal questions without asking for it again', async () => { - const directory = await temporary(); - await run(directory, ['init', '--language=en'], ['', '']); - expect(await loadProjectConfig(directory)).toMatchObject({ language: 'en', capture: 'dom' }); - expect(JSON.parse(await readFile(join(directory, 'questions.json'), 'utf8'))).toHaveLength(2); + it('connects a Vite project only with consent and remove undoes it while keeping the settings', async () => { + const directory = await temporary(), original = "import { defineConfig } from 'vite';\n\nexport default defineConfig({});\n"; + await writeFile(join(directory, 'vite.config.ts'), original); + await writeFile(join(directory, 'pnpm-lock.yaml'), ''); + const declined = await run(directory, ['init']); + expect(await readFile(join(directory, 'vite.config.ts'), 'utf8')).toBe(original); + expect(declined.stdout).toContain('init --connect=yes'); + const config = await readFile(join(directory, 'needmark.config.ts'), 'utf8'); + const connected = await run(directory, ['init', '--connect=yes']); + expect(connected.stdout).toContain('Kept the existing needmark.config.ts'); + expect(await readFile(join(directory, 'needmark.config.ts'), 'utf8')).toBe(config); + expect(await readFile(join(directory, 'vite.config.ts'), 'utf8')).toContain('plugins: [needmark()]'); + expect(connected.stdout).toContain('Changed vite.config.ts'); + expect(connected.stdout).toContain('start your development server as usual (pnpm dev)'); + const removed = await run(directory, ['remove']); + expect(await readFile(join(directory, 'vite.config.ts'), 'utf8')).toBe(original); + expect(removed.stdout).toContain('pnpm remove needmark'); + expect(await readFile(join(directory, 'needmark.config.ts'), 'utf8')).toContain('capture'); + }); + + it('serves a wrapped development command on its usual port and passes it through when switched off', async () => { + const directory = await temporary(), port = 4000 + Math.floor(Math.random() * 900); + await writeFile(join(directory, 'app.mjs'), "import { createServer } from 'node:http';\ncreateServer((q, r) => { r.setHeader('content-type', 'text/html'); r.end('app ' + process.env.PORT + ''); }).listen(Number(process.env.PORT));\n"); + const child = execFile(process.execPath, [cli, 'dev', '--', process.execPath, 'app.mjs', '--port', String(port)], { cwd: directory }); + try { + let html = ''; + for (let attempt = 0; attempt < 100 && !html; attempt++) { + const response = await fetch(`http://localhost:${port}/`).catch(() => undefined); + if (response?.ok) html = await response.text(); else await new Promise((wait) => setTimeout(wait, 100)); + } + expect(html).toContain('/__needmark/client.js'); + expect(html).not.toContain(`app ${port}`); + } finally { child.kill('SIGINT'); } + const plain = await execute(process.execPath, [cli, 'dev', '--', process.execPath, '-e', 'console.log("plain " + (process.env.PORT ?? "none"))'], { cwd: directory, env: { ...process.env, NEEDMARK: 'off' } }); + expect(plain.stdout).toContain('plain none'); }); - it('rejects an unsupported terminal language before asking about or writing project settings', async () => { + it('asks each remaining choice by number after the recommendation is declined', async () => { const directory = await temporary(); - await expect(run(directory, ['init'], ['fr'])).rejects.toMatchObject({ code: 1 }); - expect(JSON.parse(await readFile(join(directory, 'questions.json'), 'utf8'))).toHaveLength(1); - await expect(readFile(join(directory, 'needmark.config.ts'))).rejects.toMatchObject({ code: 'ENOENT' }); + await run(directory, ['init', '--language=en'], ['n', '2', '3']); + expect(await loadProjectConfig(directory)).toEqual({ language: 'en', capture: 'none', outputDir: '.needmark/feedback' }); + expect(await readFile(join(directory, '.gitignore'), 'utf8')).toContain('!/.needmark/feedback/**'); + const questions = JSON.parse(await readFile(join(directory, 'questions.json'), 'utf8')) as string[]; + expect(questions).toHaveLength(3); + expect(questions[2]).toContain('Record this browser tab'); }); it.each([{ args: [] }, { args: ['help'] }, { args: ['--help'] }, { args: ['unknown'] }])('does not import trusted settings for help invocation $args', async ({ args }) => { diff --git a/src/cli.ts b/src/cli.ts index 189f10a..4dd09bb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,22 +1,30 @@ #!/usr/bin/env node import { resolve } from 'node:path'; +import { spawn } from 'node:child_process'; import { createInterface } from 'node:readline/promises'; import { setTimeout } from 'node:timers/promises'; -import { initialize, type InitOptions } from './server/init.js'; +import { initialize } from './server/init.js'; +import { askConnection, askInitChoices, describeConnection, describeInit, type InitChoices } from './server/init-questions.js'; +import { planConnection, planDisconnection } from './server/connect.js'; import { loadProjectConfig } from './server/config.js'; import { startReviewProxy } from './proxy.js'; +import { needmarkEnabled } from './server/enabled.js'; +import { freePort, takePort, waitForPort } from './server/app-command.js'; import { defaultFeedbackDirectory, FeedbackStore } from './server/storage.js'; import { formatFeedbackPrompt, type PromptDetail } from './shared/prompt.js'; import type { CaptureMethod, FeedbackStatus } from './shared/types.js'; import { isNeedMarkLanguage, type NeedMarkLanguage } from './shared/language.js'; import { NeedMarkMcp, serveMcp } from './server/mcp.js'; -import { needmarkCommand } from './server/package-manager.js'; -import { readFileSync } from 'node:fs'; +import { devCommand, needmarkCommand, removeCommand } from './server/package-manager.js'; +import { existsSync, readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; const [command, ...args] = process.argv.slice(2); const help = `NeedMark commands / NeedMarkのコマンド +needmark dev [--port=3000] [--capture=dom|tab|none] [--directory=.needmark/feedback] -- needmark dev --target=http://localhost:3000 [--origin=http://localhost:4190] [--capture=dom|tab|none] [--directory=.needmark/feedback] -needmark init [--language=en|ja] [--share=local|git] [--capture=dom|tab|none] +needmark init [--language=en|ja] [--share=local|git] [--capture=dom|tab|none] [--connect=yes|no] +needmark remove needmark list [directory] needmark export [directory] [--detail compact|standard|detailed|forensic] needmark watch [directory] @@ -68,8 +76,61 @@ async function watch(store: FeedbackStore) { finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); } } +/** See docs/architecture.md#local-review-proxy. / 仕様は docs/architecture.md#local-review-proxy を参照。 */ +async function runWrapped(options: string[], given: string[]) { + const values: { port?: string; capture?: CaptureMethod; directory?: string } = {}; + for (let index = 0; index < options.length; index++) { + const match = options[index]!.match(/^--(port|capture|directory)(?:=(.*))?$/); + if (!match) throw new Error(message(`Unknown argument: ${options[index]}`, `不明な引数: ${options[index]}`)); + const key = match[1] as keyof typeof values; + const value = match[2] ?? options[++index]; + if (!value || values[key] !== undefined) throw new Error(message(`Missing or duplicate ${key} option`, `${key}の値がないか、重複しています。`)); + Object.assign(values, { [key]: value }); + } + if (!given.length) throw new Error(message('Give the development command after --, for example: needmark dev -- next dev', '--の後に開発コマンドを指定してください。例: needmark dev -- next dev')); + const settings = await projectSettings(); + const start = (argv: string[], env: NodeJS.ProcessEnv) => spawn(argv[0]!, argv.slice(1), { stdio: 'inherit', env, + // Package managers install command shims as .cmd files on Windows, which only a shell can start. + // Windowsではパッケージマネージャーがコマンドを.cmdとして入れるため、シェル経由でないと起動できない。 + shell: process.platform === 'win32' }); + if (!needmarkEnabled(settings)) { + console.log(message('NeedMark is off (NEEDMARK or enabled: false); starting the development server without it.', 'NeedMarkはオフです(NEEDMARKまたはenabled: false)。NeedMarkなしで開発サーバーを起動します。')); + const child = start(given, process.env); + const forward = (signal: NodeJS.Signals) => child.kill(signal); + process.on('SIGINT', forward); process.on('SIGTERM', forward); + process.exitCode = await new Promise((done) => child.once('exit', (code) => done(code ?? 1))); + return; + } + const { command: app, port: requested } = takePort(given); + const port = Number(values.port ?? requested ?? process.env.PORT ?? 3000), internal = await freePort(); + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(message(`Invalid port: ${values.port ?? process.env.PORT}`, `ポートが不正です: ${values.port ?? process.env.PORT}`)); + // The reviewer keeps opening the port they always use, so NeedMark takes that port and moves the app to a free one through PORT, + // which Next.js and most Node servers read. + // レビューする人がいつものポートを開けばよいように、NeedMarkがそのポートで待ち受け、アプリはPORTで空いているポートへ移す。 + // Next.jsや多くのNodeサーバーはPORTを読む。 + const proxy = await startReviewProxy({ target: `http://localhost:${internal}`, origin: `http://localhost:${port}`, + capture: values.capture ?? settings.capture, outputDir: values.directory ?? settings.outputDir, language: settings.language, + editor: settings.editor, contextAttributes: settings.contextAttributes, onFeedbackChange: settings.onFeedbackChange, onSubmit: settings.onSubmit, + }).catch((error: NodeJS.ErrnoException) => { + throw error.code === 'EADDRINUSE' ? new Error(message(`Port ${port} is already in use. Stop the other server or pass --port.`, `ポート${port}は使用中です。ほかのサーバーを止めるか、--portを指定してください。`)) : error; + }); + const child = start(app, { ...process.env, PORT: String(internal) }); + let exited = false; + const stop = (signal: NodeJS.Signals) => { if (!exited) child.kill(signal); }; + process.on('SIGINT', stop); process.on('SIGTERM', stop); + const exit = new Promise((done) => child.once('exit', (code) => { exited = true; done(code ?? 1); })); + try { + await Promise.race([waitForPort(internal, () => exited), exit]); + if (!exited) console.log(`\nNeedMark: ${proxy.origin}\n${message(`Open this URL as usual; the app itself runs on port ${internal}. Set NEEDMARK=off to start it without NeedMark.`, `いつもどおりこのURLを開いてください。アプリ本体はポート${internal}で動いています。NEEDMARK=offでNeedMarkなしで起動できます。`)}\n`); + process.exitCode = await exit; + } catch (error) { stop('SIGTERM'); throw error; } + finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); await proxy.close(); } +} + try { - if (command === 'dev') { + if (command === 'dev' && args.includes('--')) { + await runWrapped(args.slice(0, args.indexOf('--')), args.slice(args.indexOf('--') + 1)); + } else if (command === 'dev') { const values: { target?: string; origin?: string; capture?: CaptureMethod; directory?: string } = {}; for (let index = 0; index < args.length; index++) { const match = args[index]!.match(/^--(target|origin|capture|directory)(?:=(.*))?$/); @@ -92,36 +153,64 @@ try { try { await new Promise((done) => proxy.server.once('close', () => done())); } finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); } } else if (command === 'init') { - const choices: Partial = {}; + // Setup runs before the project has a language, and its output is often pasted into issues or chats, so it is always English. + // 初期設定はプロジェクトの言語が決まる前に動き、出力をissueやチャットに貼ることも多いため、常に英語で表示する。 + language = 'en'; + let choices: Partial = {}; for (let index = 0; index < args.length; index++) { - const match = args[index]!.match(/^--(share|capture|language)(?:=(.*))?$/); - if (!match) throw new Error(message(`Unknown argument: ${args[index]}`, `不明な引数: ${args[index]}`)); - const key = match[1] as keyof InitOptions; + const match = args[index]!.match(/^--(share|capture|language|connect)(?:=(.*))?$/); + if (!match) throw new Error(`Unknown argument: ${args[index]}`); + const key = match[1] as keyof InitChoices; const value = match[2] ?? args[++index]; - if (!value || choices[key] !== undefined) throw new Error(message(`Missing or duplicate ${key} option`, `${key}の値がないか、重複しています。`)); - if (key === 'language') { - if (!isNeedMarkLanguage(value)) throw new Error(invalidLanguage); - language = value; - } + if (!value || choices[key] !== undefined) throw new Error(`Missing or duplicate ${key} option`); + if (key === 'language' && !isNeedMarkLanguage(value)) throw new Error('Invalid NeedMark language. Use en or ja.'); + if (key === 'connect' && !['yes', 'no'].includes(value)) throw new Error('Invalid connect option. Use yes or no.'); Object.assign(choices, { [key]: value }); } - language = choices.language ?? 'ja'; + const directory = process.cwd(), connection = await planConnection(directory); + // A project set up before connecting existed already has its config, so running init again only offers the connection and keeps that file. + // 接続機能より前に設定したプロジェクトには設定ファイルがあるため、initを再実行したときは接続だけを提案し、そのファイルは残す。 + const existing = existsSync(resolve(directory, 'needmark.config.ts')); if (process.stdin.isTTY && process.stdout.isTTY) { const terminal = createInterface({ input: process.stdin, output: process.stdout }); + const ask = (text: string) => terminal.question(text); try { - const selected = choices.language ?? ((await terminal.question('Language / 言語: en (English) or ja (日本語) [ja]: ')).trim() || 'ja'); - if (!isNeedMarkLanguage(selected)) throw new Error(invalidLanguage); - language = choices.language = selected; - choices.share ??= ((await terminal.question(message('Feedback storage: local or git [local]: ', 'コメントの保存先: local(ローカル)またはgit(共有) [local]: '))).trim() || 'local') as InitOptions['share']; - choices.capture ??= ((await terminal.question(message('Screenshots: dom, tab or none [dom]: ', 'スクリーンショット: dom、tab、none [dom]: '))).trim() || 'dom') as InitOptions['capture']; + if (!existing) choices = await askInitChoices(ask, choices, connection); + else if (connection.kind === 'edit' && !choices.connect) choices.connect = await askConnection(ask, connection); } finally { terminal.close(); } } - const options: InitOptions = { share: 'local', capture: 'dom', language, ...choices }; - await initialize(process.cwd(), options); - console.log(message(`Created needmark.config.ts; language: ${language}; feedback storage: ${options.share}; capture: ${options.capture}.`, `needmark.config.tsを作成しました。言語: ${language}、コメントの保存先: ${options.share}、キャプチャ: ${options.capture}。`)); - console.log(message("In vite.config.ts, import { needmark } from 'needmark' and add needmark() to plugins; it loads needmark.config.ts.", "vite.config.tsでimport { needmark } from 'needmark'を追加し、pluginsにneedmark()を設定してください。needmark.config.tsを読み込みます。")); - const command = needmarkCommand(process.cwd()); - console.log(message(`For a development server at http://localhost:3000, run ${command} dev --target=http://localhost:3000; open http://localhost:4190 for review. Shared settings are loaded from needmark.config.ts.`, `開発サーバーがhttp://localhost:3000の場合、${command} dev --target=http://localhost:3000を実行し、http://localhost:4190を開いてレビューします。共通設定はneedmark.config.tsから読み込みます。`)); + // Without a terminal nobody can be asked, so files other than NeedMark's own are changed only when --connect=yes says so. + // ターミナルがなければ確認できないため、NeedMark自身のもの以外のファイルは--connect=yesの指定があるときだけ変更する。 + const options: InitChoices = { share: 'local', capture: 'dom', language: 'ja', connect: 'no', ...choices }; + if (!existing) await initialize(directory, options); + const connected = connection.kind === 'edit' && options.connect === 'yes'; + if (connected) await writeFile(resolve(directory, connection.file), connection.content); + const command = needmarkCommand(directory), run = devCommand(command); + if (existing) console.log(`\nKept the existing needmark.config.ts.\n Development server ${connected || connection.kind !== 'edit' ? describeConnection(connection) : 'Not connected'}`); + else console.log(`\nCreated needmark.config.ts\n${describeInit(options, connection)}`); + if (connected) console.log(`\nChanged ${connection.file}:\n${connection.lines.map((line) => ` + ${line.trim()}`).join('\n')}`); + if (connected || connection.kind === 'connected') { + console.log(`\nNext step: start your development server as usual (${run}) and open it. The NeedMark toolbar appears at the bottom of the page. +Turn it off with NEEDMARK=off, or for everyone with enabled: false in needmark.config.ts. Undo the change with ${command} remove.`); + } else { + console.log(`\nNext step: connect NeedMark to your development server + Vite Add needmark() to plugins in vite.config.ts (import { needmark } from 'needmark'). + Astro Add vite: { plugins: [needmark()] } to astro.config.mjs. + Others Start the dev command through NeedMark in package.json, for example + "dev": "needmark dev -- next dev" + ${run} then serves the app with the toolbar on its usual port. +${command} init --connect=yes does this for Vite, Astro and Next.js when it can read the config.`); + } + } else if (command === 'remove') { + language = 'en'; + if (args.length) throw new Error('remove takes no arguments.'); + const directory = process.cwd(), changes = await planDisconnection(directory); + for (const change of changes) { + await writeFile(resolve(directory, change.file), change.content); + console.log(`Changed ${change.file}:\n${change.lines.map((line) => ` - ${line.trim()}`).join('\n')}`); + } + console.log(`${changes.length ? '\n' : ''}${changes.length ? 'NeedMark is no longer connected to the development server.' : 'NeedMark was not connected to the development server; nothing changed.'} +needmark.config.ts and saved feedback were kept. To uninstall the package: ${removeCommand(needmarkCommand(directory))}`); } else if (command === 'list' || command === 'watch') { if (args.length > 1) throw new Error(message('Expected an optional feedback directory', '引数はコメントの保存先のみ指定できます。')); const store = storeAt(await feedbackDirectory(args[0])); @@ -176,10 +265,6 @@ try { } } catch (error) { const text = (error as Error).message; - const initErrors: Record = { - 'Invalid initialization choice.': '初期化の選択が不正です。shareにはlocalまたはgit、captureにはdom、tab、noneを指定してください。', - 'Existing needback.config.ts is still supported. Rename it to needmark.config.ts before initializing again.': '既存のneedback.config.tsは引き続き利用できます。再初期化する前にneedmark.config.tsへ名前を変更してください。', - }; - console.error(language === 'ja' ? initErrors[text] ?? text : text); + console.error(text); process.exitCode = 1; } diff --git a/src/client/capture/frames.ts b/src/client/capture/frames.ts index efc9fc4..c143fac 100644 --- a/src/client/capture/frames.ts +++ b/src/client/capture/frames.ts @@ -18,6 +18,12 @@ export interface FrameCapturePlan { const changed = () => new Error('撮影中に埋め込み画面が変わりました。画像なしで保存するか、場所を選び直してください。'); const unavailable = () => new Error('埋め込み画面を再現できませんでした。画像なしで保存するか、高精度キャプチャを選んでください。'); const ignored = (element: Element) => !!element.closest('[data-needmark],[data-html2canvas-ignore]') || element.localName === 'script'; +// html2canvas scrolls its clone before onclone, and a page with smooth scrolling is still moving there, so frames are compared in document coordinates. +// html2canvasはoncloneの前に複製をスクロールし、なめらかなスクロールのページではまだ移動中のため、iframeは文書座標で比較する。 +const documentRect = (element: Element) => { + const rect = element.getBoundingClientRect(), view = element.ownerDocument.defaultView; + return { x: rect.x + (view?.scrollX ?? 0), y: rect.y + (view?.scrollY ?? 0), width: rect.width, height: rect.height }; +}; const styleEntries = (element: Element): [string, string][] => { const style = element.ownerDocument.defaultView!.getComputedStyle(element); return Array.from(style).map((name): [string, string] => [name, style.getPropertyValue(name)]); @@ -144,7 +150,7 @@ export async function captureFramePlan(document: Document, scale: number, const styles = styleEntries(frame); if (child && styles.some(([name, value]) => name === 'background-blend-mode' && value.split(',').some((mode) => mode.trim() !== 'normal'))) throw unavailable(); const parent = frame.parentElement ?? (frame.getRootNode() as ShadowRoot).host; - records.push({ frame, location: locationOf(frame, document), rect: viewportRectOf(frame), localRect: frame.getBoundingClientRect(), parentRect: parent.getBoundingClientRect(), + records.push({ frame, location: locationOf(frame, document), rect: viewportRectOf(frame), localRect: documentRect(frame), parentRect: documentRect(parent), attributes: Array.from(frame.attributes, ({ name, value }): [string, string] => [name, value]), styles, document: child, ...(child ? { url: child.URL } : {}), width: view?.innerWidth ?? 0, height: view?.innerHeight ?? 0, scrollX: view?.scrollX ?? 0, scrollY: view?.scrollY ?? 0 }); @@ -200,8 +206,8 @@ export async function captureFramePlan(document: Document, scale: number, for (let index = 0; index < frames.length; index++) { const parent = locations[index]!.parent; const box = parent.nodeType === 11 ? (parent as ShadowRoot).host : parent as Element; - if (!rectanglesMatch(frames[index]!.getBoundingClientRect(), records[index]!.localRect) - || !rectanglesMatch(box.getBoundingClientRect(), records[index]!.parentRect)) throw unavailable(); + if (!rectanglesMatch(documentRect(frames[index]!), records[index]!.localRect) + || !rectanglesMatch(documentRect(box), records[index]!.parentRect)) throw unavailable(); } } catch (error) { frames.forEach((frame) => frame.remove()); throw error; } finally { detach.forEach((clean) => clean()); } diff --git a/src/client/index.ts b/src/client/index.ts index 6f6ae5d..58844ea 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -31,15 +31,11 @@ const ICONS = { more: '', back: '', send: '', + copy: '', + openNew: '', check: '', checkCircle: '', checkCircleFilled: '', - // The sparkle is copied from the design file's AI button; its gradient lives once in the shadow root because a gradient defined inside an icon stops painting wherever the first copy of that ID is hidden. - // スパークルはデザインファイルのAIボタンから写した。グラデーションをアイコン内に定義すると、同じIDの最初の要素が非表示のときに塗られなくなるため、シャドウルートに1つだけ置く。 - ai: '', - // The copied check reuses the sparkle's gradient, so its path is drawn at half scale in the same 20-unit space the gradient is defined in. - // コピー後のチェックはスパークルと同じグラデーションを使うため、グラデーションを定義した20単位の座標に合わせて半分の大きさでパスを描く。 - aiCheck: '', search: '', filter: '', } as const; @@ -72,23 +68,22 @@ function mount(endpoint: string, token: string) { shadow.innerHTML = `
- -
${icon('grip')}
${icon('help')}
+
${icon('grip')}
${icon('help')}
`; const detachReviewLayer = attachReviewLayer(host, () => queueMicrotask(renderPins)); const cover = shadow.querySelector('.cover')!, outline = shadow.querySelector('.outline')!, textPreview = shadow.querySelector('.text-preview')!, panel = shadow.querySelector('.panel')!, dock = shadow.querySelector('.dock')!, pins = shadow.querySelector('.pins')!, viewer = shadow.querySelector('.viewer')!; @@ -128,7 +123,7 @@ function mount(endpoint: string, token: string) { let edgeScroll = 0; let multi: Element[] = [], multiSearchIncomplete = false, items: Feedback[] = [], directory = '.needmark/feedback', command: string | undefined, imageUrls: string[] = []; let syncPageChanges: (paths: readonly (readonly string[])[]) => void = () => {}; - let operatingPage = false, selectionUrl = '', resumingCapture = false; + let selectionUrl = ''; const selectionIdentity = new Map(); let pageOnly = false, showResolved = false, searching = false, query = '', returnFocus: HTMLElement | null = null, viewerReturnFocus: HTMLElement | null = null, refreshVersion = 0, offline = false, zoom = 1, pan = { x: 0, y: 0 }; let route = location.pathname + location.search + location.hash, hiddenPins = false, locatedId: string | null = null; @@ -180,6 +175,14 @@ function mount(endpoint: string, token: string) { return { left: Number.parseFloat(panel.style.left) + x, top: Number.parseFloat(panel.style.top) + y, height: panel.offsetHeight }; } function settleMorph() { const running = morphing; if (!running.length) return; morphing = []; running.forEach((animation) => animation.cancel()); panel.classList.remove('morphing'); panelPosition.hold(false); } + // A button enabled mid-morph is still sliding, so a click aimed at it lands where it was a frame earlier. Callers wait here first; the finished promise can resolve before the finish event, so the morph is settled here as well. + // 変形中に押せるようにしたボタンはまだ動いているため、狙ったクリックが1フレーム前の位置に当たる。呼び出し側はここで待つ。finishedはfinishイベントより先に解決することがあるため、ここでも変形を確定させる。 + async function morphEnded() { + for (let running = morphing; running.length; running = morphing) { + await Promise.all(running.map((animation) => animation.finished.catch(() => {}))); + if (morphing === running) settleMorph(); + } + } // A panel that stays open glides from its old box to the new one instead of jumping, and a new screen slides in from the side it was reached from (a comment from the right, the list back from the left). Callers place the panel after show() (details beside their pin), so the morph runs in a microtask, still before the next frame is painted. // 開いたままのパネルは、跳ばずに前の位置と大きさから新しいものへ移り、新しい画面は来た方向から入る(コメントは右から、一覧へ戻るときは左から)。呼び出し側はshow()の後でパネルを配置する(詳細はピンの横)ため、変形は次のフレームを描く前のマイクロタスクで行う。 function morph(from: PanelBox, direction?: number) { @@ -222,7 +225,7 @@ function mount(endpoint: string, token: string) { // 通知は常にトーストにしてパネルには内容だけを置く。UIで分かる結果(削除した項目、保存した返信、押したボタンの状態)は通知しない。 function notify(text: string, error = false) { const toast = shadow.querySelector('.toast')!; toast.textContent = text; toast.classList.toggle('error', error); toast.getAnimations().forEach((animation) => animation.cancel()); toast.hidden = false; - nativeClearTimeout(toastTimer); toastTimer = nativeSetTimeout(() => { if (!motion()) { toast.hidden = true; return; } toast.animate({ opacity: [1, 0], translate: ['0px 0px', '0px 6px'] }, { duration: 160, easing: easeIn, fill: 'forwards' }).finished.then(() => { toast.hidden = true; toast.getAnimations().forEach((animation) => animation.cancel()); }, () => {}); }, error ? 5000 : 3000); + nativeClearTimeout(toastTimer); toastTimer = nativeSetTimeout(() => { if (!motion()) { toast.hidden = true; return; } toast.animate({ opacity: [1, 0], translate: ['0px 0px', '0px 6px'] }, { duration: 160, easing: easeIn, fill: 'forwards' }).finished.then(() => { toast.hidden = true; toast.getAnimations().forEach((animation) => animation.cancel()); }, () => {}); }, error ? 5000 : 1500); } // The reply composer runs to the panel's bottom edge, so a manual copy field is placed above the replies instead of after it. // 返信の入力エリアはパネルの下端まで広がるため、手動コピー欄はその後ろではなく返信の上に置く。 @@ -234,22 +237,24 @@ function mount(endpoint: string, token: string) { } function retainSaved(item: Feedback) { items = [item, ...items.filter((existing) => existing.id !== item.id)].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); renderPins(); } async function refresh() { const version = ++refreshVersion; let result: { feedback: Feedback[]; directory: string; command: string }; try { result = await (await request('/feedback')).json() as typeof result; } catch (error) { if (version === refreshVersion) offline = true; throw error; } if (version !== refreshVersion) return; offline = false; const changed = JSON.stringify(items) !== JSON.stringify(result.feedback); items = result.feedback; directory = result.directory; command = result.command; if (changed) { renderPins(); if (locatedId) renderSelections(); } } - function settings(first = false) { + function settings() { const field = (id: string, name: ClientMessage, info: ClientMessage | null, control: string) => `
${info ? `${t(info)}` : ''}
${control}`; - show(`${heading(first ? t('レビューを始める') : t('設定'))}${first ? `` : ''} + // Settings drop the header like the composer: キャンセル in the footer already closes the panel. The title stays for assistive technology so the panel is still named. + // 作成パネルと同じく設定もヘッダーを置かない。フッターのキャンセルで閉じられるため。パネルの名前が伝わるよう、見出しは支援技術向けにだけ残す。 + show(`

${t('設定')}

${field('nm-language', '表示言語', null, '')} - ${first ? '' : field('nm-author', '投稿者名', null, ``)} + ${field('nm-author', '投稿者名', null, ``)} ${field('nm-method', 'キャプチャ方式', '簡易撮影は共有の許可が不要ですが、画像・iframe・一部のCSSが再現されない場合があります。高精度撮影は、初回にこのタブの共有を許可する必要があります。共有中は続けて撮影できます。', ``)} ${field('nm-detail', 'AIへの情報量', '簡潔では、コメントと修正場所・画像の保存先をまとめます。詳しい対象情報は保存ファイルに残ります。', ``)} ${field('nm-shortcuts', 'キーボードショートカット', null, ``)} ${field('nm-theme', '表示テーマ', null, ``)} ${field('nm-directory', '保存先', null, `${escape(directory)}`)} -
${capture.active ? `` : ''}
`, 'settings'); +
${capture.active ? `` : ''}
`, 'settings'); (panel.querySelector('#nm-language') as HTMLSelectElement).value = language; (panel.querySelector('#nm-method') as HTMLSelectElement).value = method; (panel.querySelector('#nm-detail') as HTMLSelectElement).value = detail; (panel.querySelector('#nm-shortcuts') as HTMLSelectElement).value = String(keyboardShortcuts); (panel.querySelector('#nm-theme') as HTMLSelectElement).value = readPreference('theme', 'auto'); } const shortcuts: Record = { select: 'Shift+C', pause: 'Shift+P' }; const purposes: Record = { select: '画面の場所を選んでコメントを残す', pins: '保存したコメントのピンを表示・非表示にする', - pause: 'アニメーション・動画・タイマーを止めて、動く画面の一瞬にコメントしやすくする', list: 'コメントの確認・返信と、AIへの受け渡し', settings: '言語・撮影方式・投稿者など', collapse: 'ツールを隠してツールバーを小さくする', 'discard-selection': '保持中の複数選択を破棄する' }; + pause: 'アニメーション・動画・タイマーを止めて、動く画面の一瞬にコメントしやすくする', list: 'コメントの確認・返信と、AIへの受け渡し', settings: '言語・撮影方式・投稿者など', collapse: 'ツールを隠してツールバーを小さくする' }; let collapsed = readPreference('toolbar-collapsed', 'false') === 'true'; let collapseAnimation: Animation | undefined; // The tools stay visible while the width animates and `collapsed` is applied only afterwards, so they fade out instead of vanishing at once. Items are right-aligned while animating so the collapse button is the last thing clipped. @@ -352,18 +357,12 @@ function mount(endpoint: string, token: string) { label('.handle', 'ツールバーを移動'); label('[data-action="pins"]', 'ピンの表示切り替え'); label('[data-action="pause"]', paused ? 'アニメーションを再開' : 'アニメーションを停止'); label('[data-action="list"]', 'コメント一覧'); label('[data-action="settings"]', '設定'); - text('[data-action="multi-done"]', '選択を完了'); text('[data-action="page-operation"]', 'ページを操作'); label('[data-action="cancel-selection"]', '終了'); label('[data-guide]', '使い方'); shadow.querySelector('[data-guide]')!.href = guideUrl(language); - label('[data-action="discard-selection"]', '選択を破棄'); selectionControls(); applyCollapsed(); text('[data-action="comment-selection"]', '選択中の文字にコメント'); + text('[data-action="multi-done"]', '選択を完了'); label('[data-action="cancel-selection"]', '終了'); label('[data-guide]', '使い方'); shadow.querySelector('[data-guide]')!.href = guideUrl(language); + label('[data-action="select"]', 'コメント'); applyCollapsed(); label('.viewer .modal', '画像プレビュー'); text('.viewer h2', '画像プレビュー'); label('[data-action="viewer-close"]', '閉じる'); label('[data-action="zoom-out"]', '縮小'); label('[data-action="zoom-in"]', '拡大'); label('[data-action="zoom-reset"]', '表示をリセット'); label('.target-preview', '選択候補'); viewer.querySelector('img')!.alt = t('コメントした場所のキャプチャ拡大'); - selectionControls(); setHint(); renderPins(); - } - function selectionControls() { - const button = dock.querySelector('[data-action="select"]')!; - button.querySelector('.label')!.textContent = operatingPage ? t('選択を再開 · {count}件', { count: multi.length }) : ''; - button.setAttribute('aria-label', operatingPage ? t('選択を再開') : t('コメント')); - dock.querySelector('[data-action="discard-selection"]')!.hidden = !operatingPage; + setHint(); renderPins(); } function clearSelectionGesture() { clearHover(); @@ -373,16 +372,14 @@ function mount(endpoint: string, token: string) { } function cancelSelection() { clearLocated(); - if (starting) cancelStart(false); + if (starting) cancelStart(); shadow.querySelector('.tip')!.hidden = true; pins.hidden = false; setCover(true); outline.hidden = true; - clearSelectionGesture(); multi = []; multiSearchIncomplete = false; selectionIdentity.clear(); operatingPage = false; selectionUrl = ''; - shadow.querySelector('.selections')!.replaceChildren(); window.getSelection()?.removeAllRanges(); selectionControls(); + clearSelectionGesture(); multi = []; multiSearchIncomplete = false; selectionIdentity.clear(); selectionUrl = ''; + shadow.querySelector('.selections')!.replaceChildren(); window.getSelection()?.removeAllRanges(); } - function cancelStart(announce = true) { - const resuming = resumingCapture; - startVersion++; starting = false; resumingCapture = false; capture.stop(); dock.querySelector('[data-action="select"]')!.disabled = false; - if (resuming) { panel.hidden = true; panelPosition.close(); selectionControls(); if (announce) notify(t('撮影の再開をキャンセルしました。選択は保持しています。')); } - else { selectionUrl = ''; multi = []; multiSearchIncomplete = false; selectionIdentity.clear(); selectionControls(); if (screen === 'other') closePanel(); } + function cancelStart() { + startVersion++; starting = false; capture.stop(); dock.querySelector('[data-action="select"]')!.disabled = false; + selectionUrl = ''; multi = []; multiSearchIncomplete = false; selectionIdentity.clear(); if (screen === 'other') closePanel(); } function selectionPageMatches() { if (!selectionUrl || selectionUrl === location.href) return true; @@ -411,57 +408,29 @@ function mount(endpoint: string, token: string) { } catch { return false; } }); for (const element of selectionIdentity.keys()) if (!multi.includes(element)) selectionIdentity.delete(element); - selectionControls(); if (before !== multi.length) notify(t('{count}件の選択対象が消えたか変わったため、選択から外しました。{retry}', { count: before - multi.length, retry: !multi.length ? t('もう一度場所を選んでください。') : '' })); } - function operatePage() { - if (cover.hidden || mode !== 'multi' || starting || busy || draft || hasUnsavedInput()) return; - if (!selectionPageMatches()) return; - pruneSelection(); selectionUrl = location.href; operatingPage = true; - clearSelectionGesture(); setCover(true); outline.hidden = true; pins.hidden = true; shadow.querySelector('.tip')!.hidden = true; - shadow.querySelector('.selections')!.replaceChildren(); selectionControls(); dock.querySelector('[data-action="select"]')!.focus(); - } function setHint() { const hint = shadow.querySelector('[data-hint]')!; hint.textContent = mode === 'multi' ? t('{count}件を選択', { count: multi.length }) : t('クリック・ドラッグで選択'); - shadow.querySelector('[data-action="comment-selection"]')!.hidden = mode === 'multi' || !pendingText; - shadow.querySelector('[data-action="multi-done"]')!.hidden = mode !== 'multi'; shadow.querySelector('[data-action="page-operation"]')!.hidden = mode !== 'multi'; - } - // A text selection made before the tool started is only offered, never used implicitly, because a leftover selection would otherwise turn a click into a text comment. - // ツール開始前の文字選択は提示するだけで暗黙には使わない。残っていた選択によってクリックが文字のコメントに変わるのを防ぐため。 - let pendingText: Range | null = null, pointerText: Range | null = null; - function pageTextSelection(): Range | null { - const selection = document.getSelection(); - if (!selection || selection.isCollapsed || !selection.rangeCount) return null; - const range = selection.getRangeAt(0), owner = range.commonAncestorContainer instanceof Element ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement; - return range.toString().trim() && !owner?.closest('input,textarea,[contenteditable]') && !host.contains(owner) ? range.cloneRange() : null; + shadow.querySelector('[data-action="multi-done"]')!.hidden = mode !== 'multi'; } - // Pressing a toolbar button can collapse the page selection before click fires, so it is read on pointerdown first. - // ツールバーのボタンを押すとclickより前にページの選択が解除されることがあるため、pointerdownの時点で先に読み取る。 - dock.addEventListener('pointerdown', () => { pointerText = pageTextSelection(); }, true); function hasUnsavedInput() { return !!(editing || replying || !panel.hidden && (panel.querySelector('#nm-comment')?.value.trim() || panel.querySelector('#nm-reply')?.value.trim() || panel.querySelector('#nm-reason')?.value.trim())); } async function select() { if (busy || starting) return; if (hasUnsavedInput()) { notify(t('入力中のコメントを保存またはキャンセルしてください。')); return; } - if (!readPreference('configured', '')) { settings(true); return; } - const resuming = operatingPage; - if (resuming && !selectionPageMatches()) return; - if (resuming) pruneSelection(); else selectionUrl = location.href; + selectionUrl = location.href; clearLocated(); - starting = true; resumingCapture = resuming; const version = ++startVersion; dock.querySelector('[data-action="select"]')!.disabled = true; + starting = true; const version = ++startVersion; dock.querySelector('[data-action="select"]')!.disabled = true; try { if (method === 'tab' && !capture.active) { show(`${heading(t('このタブの撮影を開始'))}

${t('ブラウザの共有画面で、このタブを選択してください。')}

${t('共有画面が表示されない場合は簡易撮影を選べます。')}

`); await capture.start(); } if (version !== startVersion || !selectionPageMatches()) return; - panel.hidden = true; panelPosition.close(); outline.hidden = true; pins.hidden = true; operatingPage = false; - if (!resuming) { multi = []; multiSearchIncomplete = false; selectionIdentity.clear(); mode = 'element'; pendingText = pageTextSelection() ?? pointerText; } - pointerText = null; - setHint(); setCover(false); selectionControls(); renderSelections(); dock.querySelector(mode === 'multi' ? '[data-action="multi-done"]' : '[data-action="cancel-selection"]')!.focus(); + panel.hidden = true; panelPosition.close(); outline.hidden = true; pins.hidden = true; + multi = []; multiSearchIncomplete = false; selectionIdentity.clear(); mode = 'element'; + setHint(); setCover(false); renderSelections(); dock.querySelector('[data-action="cancel-selection"]')!.focus(); } catch (error) { - if (version === startVersion) { - if (resuming) { panel.hidden = true; panelPosition.close(); selectionControls(); notify(t('{error} 選択は保持しています。', { error: errorText(error) }), true); } - else { selectionUrl = ''; settings(); notify(errorText(error), true); } - } - } finally { if (version === startVersion) { starting = false; resumingCapture = false; dock.querySelector('[data-action="select"]')!.disabled = false; } } + if (version === startVersion) { selectionUrl = ''; settings(); notify(errorText(error), true); } + } finally { if (version === startVersion) { starting = false; dock.querySelector('[data-action="select"]')!.disabled = false; } } } function placeOutline(rect: Rectangle) { Object.assign(outline.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px` }); } function highlight(rect: Rectangle) { clearLocated(); locatedTarget = null; outline.hidden = false; placeOutline(rect); } @@ -514,7 +483,7 @@ function mount(endpoint: string, token: string) { }; } function previewAt(x: number, y: number, deep: boolean) { - if (cover.hidden || !panel.hidden || pointerStart || busy || starting || operatingPage || !selectionPageMatches()) { clearHover(); return; } + if (cover.hidden || !panel.hidden || pointerStart || busy || starting || !selectionPageMatches()) { clearHover(); return; } const element = elementAtPoint(x, y, host, deep); if (!element) { clearHover(); return; } if (element !== hoverTarget) { @@ -531,7 +500,10 @@ function mount(endpoint: string, token: string) { targetPreview.style.left = `${Math.max(margin, Math.min(x + 12, innerWidth - width - margin))}px`; targetPreview.style.top = `${Math.max(margin, Math.min(top, innerHeight - height - margin))}px`; } + // Leaving details must also hide a single target's outline: it shares the selection outline element, so only locatedTarget tells a located outline apart from a drag preview that must stay. + // 詳細を離れるときは単一対象の枠も消す。選択の枠と同じ要素を使うため、残すべきドラッグ中のプレビューと区別できるのはlocatedTargetだけになる。 function clearLocated() { + if (locatedTarget) { locatedTarget = null; outline.hidden = true; } if (!locatedId) return; locatedId = null; locateNotice = ''; shadow.querySelectorAll('.located-outline').forEach((node) => node.remove()); renderPins(); @@ -578,7 +550,7 @@ function mount(endpoint: string, token: string) { multi.forEach((element, index) => { const rect = viewportRectOf(element), node = document.createElement('div'); node.className = 'outline selected-outline'; Object.assign(node.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px` }); const badge = document.createElement('span'); badge.textContent = String(index + 1); node.append(badge); selected.append(node); }); setHint(); } - function completeMulti() { if (cover.hidden || operatingPage || pointerStart) return; const count = multi.length; pruneSelection(); if (count !== multi.length) { renderSelections(); return; } if (!cover.hidden && multi.length) { const elements = [...multi]; void compose({ kind: 'multi', text: elements.map((element) => contextForElement(element).elements[0]?.text ?? '').join(' / ').slice(0, 1000), selector: '', rect: union(elements.map(viewportRectOf)), code: contextForElements(elements, multiSearchIncomplete) }, { elements }); } } + function completeMulti() { if (cover.hidden || pointerStart) return; const count = multi.length; pruneSelection(); if (count !== multi.length) { renderSelections(); return; } if (!cover.hidden && multi.length) { const elements = [...multi]; void compose({ kind: 'multi', text: elements.map((element) => contextForElement(element).elements[0]?.text ?? '').join(' / ').slice(0, 1000), selector: '', rect: union(elements.map(viewportRectOf)), code: contextForElements(elements, multiSearchIncomplete) }, { elements }); } } function union(rects: Rectangle[]): Rectangle { const x = Math.min(...rects.map((r) => r.x)), y = Math.min(...rects.map((r) => r.y)); return { x, y, width: Math.max(1, Math.max(...rects.map((r) => r.x + r.width)) - x), height: Math.max(1, Math.max(...rects.map((r) => r.y + r.height)) - y) }; } function targetFor(element: Element): FeedbackInput['target'] { const code = contextForElement(element); return { kind: 'element', text: code.elements[0]?.text ?? '', selector: selectorOf(element), rect: viewportRectOf(element), code }; } function positionNear(rect: Rectangle) { panelPosition.place(rect); } @@ -605,7 +577,7 @@ function mount(endpoint: string, token: string) { function previewHtml(image: string) { return ``; } function editorLink(source: SourceLocation, label = t('{editor}で開く', { editor: editor === 'cursor' ? 'Cursor' : 'VS Code' })): string { if (!['vscode', 'cursor'].includes(editor ?? '')) return ''; - return ``; + return ``; } async function prepareEditorLink(button: HTMLButtonElement): Promise { if (!button.isConnected || !panel.contains(button)) return; @@ -613,7 +585,7 @@ function mount(endpoint: string, token: string) { let source: SourceLocation; try { source = JSON.parse(button.dataset.editorLocation!); } catch { return; } const version = panelVersion; - button.disabled = true; button.textContent = t('リンクを確認中…'); + button.disabled = true; button.setAttribute('aria-label', t('リンクを確認中…')); const current = () => version === panelVersion && button.isConnected && panel.contains(button); try { const result = await (await request('/editor', { method: 'POST', body: JSON.stringify({ file: source.file, line: source.line, column: source.column }) })).json() as { url?: unknown }; @@ -622,13 +594,13 @@ function mount(endpoint: string, token: string) { const url = new URL(result.url); if (url.protocol !== `${editor}:` || url.hostname !== 'file' || url.search || url.hash) throw new Error(t('エディタリンクを取得できませんでした。')); const link = document.createElement('a'); link.href = url.href; - link.className = 'text-link'; link.textContent = button.dataset.editorLabel!; - link.title = t('設定したローカルエディタを開きます。アプリの起動確認が表示される場合があります。'); + link.className = 'icon-button'; link.innerHTML = icon('openNew'); link.setAttribute('aria-label', button.dataset.editorLabel!); + link.title = `${button.dataset.editorLabel!}\n${t('設定したローカルエディタを開きます。アプリの起動確認が表示される場合があります。')}`; const focused = shadow.activeElement === button; button.replaceWith(link); if (focused) link.focus(); } catch (error) { if (!current()) return; - button.disabled = false; button.textContent = t('エディタリンクを再取得'); + button.disabled = false; button.setAttribute('aria-label', t('エディタリンクを再取得')); button.title = errorText(error); } } @@ -686,7 +658,9 @@ function mount(endpoint: string, token: string) { draft = { target, url: location.href, title: document.title, capturedAt: new Date().toISOString(), viewport: { width: innerWidth, height: innerHeight, devicePixelRatio, scrollX, scrollY }, capture: { method: 'none', width: 0, height: 0 } }; // The source lookup status stays for assistive technology only: saving does not wait for it and the reviewer has nothing to act on. // ソース照合の状態は支援技術向けにだけ残す。保存はそれを待たず、レビュアーが対応することもないため。 - show(`${heading(t('コメント'))}${previewHtml(``)}

${method === 'none' ? '' : t('キャプチャを作成中… コメントを入力できます。')}

${categoryHtml()}
`, 'draft'); + // The composer has no header because キャンセル already closes it; the title stays for assistive technology so the panel is still named. + // キャンセルで閉じられるため作成中はヘッダーを置かない。パネルの名前が伝わるよう、見出しは支援技術向けにだけ残す。 + show(`

${t('コメント')}

${previewHtml(``)}

${method === 'none' ? '' : t('キャプチャを作成中… コメントを入力できます。')}

${categoryHtml()}
`, 'draft'); positionNear(target.rect); showDraftOutline(target); panel.querySelector('textarea')!.focus(); void enrichSource(target, panelVersion); if (method === 'none') return; @@ -705,10 +679,14 @@ function mount(endpoint: string, token: string) { panel.querySelector('.preview')!.title = t('{width} × {height} · クリックで拡大', { width: result.capture.width, height: result.capture.height }); } catch (error) { if (version !== captureVersion || composerVersion !== panelVersion) return; - panel.querySelector('[data-capture-state]')!.textContent = errorText(error); - const skip = document.createElement('button'); skip.textContent = t('画像なしで保存する'); skip.dataset.action = 'skip-capture'; panel.querySelector('[data-capture-state]')!.after(skip); + // A missing image should not block the comment the reviewer is typing, so a failed capture falls back to an image-free save and keeps the reason in the tooltip. + // 画像がないことで入力中のコメントを止めないよう、撮影に失敗したら画像なしの保存へ切り替え、理由はツールチップに残す。 + const state = panel.querySelector('[data-capture-state]')!; + state.textContent = t('画像を撮影できなかったため、画像なしで保存します。'); state.title = errorText(error); enableSubmit(); } finally { if (hideForTab) { nativeCaptures--; if (!nativeCaptures) host.style.visibility = ''; } } - if (shot && await revealImage(panel.querySelector('.preview img')!, shot, () => { panel.querySelector('[data-capture-state]')!.hidden = true; }) && version === captureVersion && composerVersion === panelVersion) enableSubmit(); + if (shot && await revealImage(panel.querySelector('.preview img')!, shot, () => { panel.querySelector('[data-capture-state]')!.hidden = true; }) && version === captureVersion && composerVersion === panelVersion) { + await morphEnded(); if (version === captureVersion && composerVersion === panelVersion) enableSubmit(); + } } function enableSubmit() { panel.querySelectorAll('button[type="submit"]').forEach((button) => { button.disabled = false; }); } function itemText(item: Feedback) { return item.intent === 'reference' ? item.target.text || item.target.code?.elements[0]?.accessibleName || item.target.selector || t('選択した範囲') : item.comment; } @@ -810,6 +788,9 @@ function mount(endpoint: string, token: string) { cover.addEventListener('pointercancel', cancelPointer); cover.addEventListener('lostpointercapture', cancelPointer); cover.addEventListener('pointerleave', clearHover); window.addEventListener('blur', clearHover); dock.addEventListener('pointerenter', clearHover); + // Pins wait for the pointer to leave before following a DOM change: hovering a pin leaves the page under it, and a page that reacts (a chart hiding its tooltip) could move the pin or swap its element mid-click, so it could never be pressed. + // ピンに乗っている間はDOMの変化に追従せず、離れてから位置を直す。ピンに乗るとその下のページから外れ、それに反応するページ(ツールチップを消すグラフなど)でピンが動いたりクリック中に要素が置き換わったりして押せなくなるため。 + let pinsDeferred = false; function renderPins() { syncPageChanges(items.filter((item) => item.id === locatedId || item.status === 'open' && new URL(item.url).pathname + new URL(item.url).search + new URL(item.url).hash === route).flatMap((item) => { const contexts = [...(item.target.code?.elements ?? [])]; @@ -847,7 +828,7 @@ function mount(endpoint: string, token: string) { const tip = shadow.querySelector('.tip')!; const preview = () => { if (!viewer.hidden || screen === 'draft') { tip.hidden = true; return; } tip.textContent = `${item.author || t('ゲスト')}${stale ? t(' · 対象未確認(記録位置)') : ''}\n${item.intent === 'reference' ? `${t('対象情報のみ')} · ` : ''}${itemText(item).slice(0, 200)}`; tip.style.left = `${Math.min(innerWidth - 270, Math.max(10, x + 16))}px`; tip.style.top = `${Math.min(innerHeight - 100, Math.max(10, y + 14))}px`; tip.hidden = false; }; pin.addEventListener('pointerenter', preview); - pin.addEventListener('focus', () => { if (!restoringPinFocus) preview(); }); pin.addEventListener('pointerleave', () => { tip.hidden = true; }); pin.addEventListener('blur', () => { tip.hidden = true; }); pins.append(pin); + pin.addEventListener('focus', () => { if (!restoringPinFocus) preview(); }); pin.addEventListener('pointerleave', () => { tip.hidden = true; if (pinsDeferred) { pinsDeferred = false; renderPins(); } }); pin.addEventListener('blur', () => { tip.hidden = true; }); pins.append(pin); }); // Restoring focus after a re-render refreshes a preview that was already showing, but must not reopen one that opening the details closed for a clicked pin. // 再描画後にフォーカスを戻すとき、表示中のプレビューは更新するが、クリックしたピンで詳細を開いて閉じたプレビューは再表示しない。 @@ -877,7 +858,7 @@ function mount(endpoint: string, token: string) { const listMenu = menuHtml('一覧の操作', ``); // The copy icon stays in the search header too, because closing the search clears the query that scopes the copy. // 検索を閉じるとコピーの範囲を決めるキーワードも消えるため、検索中のヘッダーにもコピーのアイコンを残す。 - const copyButton = ``; + const copyButton = ``; const header = searching ? `
${copyButton}
` : heading(t('コメント一覧'), false, `${copyButton}${filterMenu}${listMenu}`); const cards = filtered.map((item) => `
${resolveButton(item)}
`).join(''); @@ -888,12 +869,13 @@ function mount(endpoint: string, token: string) { async function openItem(id: string) { const item = items.find((item) => item.id === id); if (!item) return; editing = null; replying = null; draft = null; shadow.querySelector('.tip')!.hidden = true; - // The byline and capture identify the item, the category and comment say what is asked, the editor link and prompt copy hand it on, and the reply area holds the conversation; editing and deleting live in kebab menus. - // 投稿者と画像で項目を見分け、カテゴリとコメントで依頼内容を示し、エディタのリンクとプロンプトのコピーで引き渡し、返信エリアにやり取りをまとめる。編集と削除はケバブメニューに置く。 - const repair = item.intent !== 'reference', editorLinks = editorLinksHtml(item.target); + // The byline and capture identify the item, the category and comment say what is asked, the header icons for the editor and prompt copy hand it on next to the status toggle like the list header does, and the reply area holds the conversation; editing and deleting live in kebab menus. + // 投稿者と画像で項目を見分け、カテゴリとコメントで依頼内容を示し、一覧と同じくヘッダーのアイコン(エディタで開く・プロンプトのコピー)で解決の切り替えと並べて引き渡し、返信エリアにやり取りをまとめる。編集と削除はケバブメニューに置く。 + const repair = item.intent !== 'reference', send = repair && item.status === 'open' ? sendButton('send-one', isRepairFeedback(item) ? 1 : 0, id) : ''; + const copyButton = repair ? `` : ''; const itemMenu = menuHtml('コメントの操作', `${repair ? `` : ''}`); const replies = (item.replies ?? []).map((reply) => `
${bylineHtml(reply.author || t('ゲスト'), reply.createdAt, menuHtml('返信の操作', ``))}${replyTextHtml(reply.comment)}
`).join(''); - show(`${heading(t(item.intent === 'reference' ? '対象情報の詳細' : 'コメント'), true, resolveButton(item), false)}${bylineHtml(item.author || t('ゲスト'), item.createdAt, itemMenu)}${item.image ? previewHtml(``) : ''}
${commentBodyHtml(item)}
${repair || editorLinks ? `
${editorLinks}${repair ? `${item.status === 'open' ? sendButton('send-one', isRepairFeedback(item) ? 1 : 0, id) : ''}` : ''}
` : ''}
${replies}
`, 'detail'); + show(`${heading(t(item.intent === 'reference' ? '対象情報の詳細' : 'コメント'), true, `${editorLinksHtml(item.target)}${copyButton}${resolveButton(item)}`, false)}${bylineHtml(item.author || t('ゲスト'), item.createdAt, itemMenu)}${item.image ? previewHtml(``) : ''}
${commentBodyHtml(item)}
${send ? `
${send}
` : ''}
${replies}
`, 'detail'); positionNear(locateWithAnchor(item)); panel.querySelectorAll('[data-action="editor-link"]').forEach((button) => { void prepareEditorLink(button); }); await loadImage(item).catch((error) => notify(errorText(error), true)); } @@ -955,9 +937,9 @@ function mount(endpoint: string, token: string) { const item = items.find((item) => item.id === id), body = panel.querySelector('[data-comment-body]'); if (!item || item.intent === 'reference' || !body) return; editing = id; replying = null; body.innerHTML = `
${categoryHtml(item)}
`; - // The handoff row acts on the saved comment, so it is hidden while that comment is being rewritten. - // 引き渡しの行は保存済みのコメントに対する操作なので、書き換えている間は隠す。 - panel.querySelector('.detail-actions')?.toggleAttribute('hidden', true); + // The prompt copy and send act on the saved comment, so they are hidden while that comment is being rewritten. + // プロンプトのコピーと送信は保存済みのコメントに対する操作なので、書き換えている間は隠す。 + panel.querySelectorAll('.detail-actions, [data-action="copy-one"]').forEach((element) => element.toggleAttribute('hidden', true)); body.querySelector('textarea')!.focus(); } function editReply(id: string, replyId: string) { @@ -970,7 +952,7 @@ function mount(endpoint: string, token: string) { function closeInlineEdit(id: string, replyId?: string) { const item = items.find((item) => item.id === id); if (!item) return false; if (replyId) { const form = panel.querySelector(`[data-reply-id="${CSS.escape(replyId)}"] form`), reply = item.replies?.find((reply) => reply.id === replyId); if (!form || !reply) return false; replying = null; form.outerHTML = replyTextHtml(reply.comment); return true; } - const body = panel.querySelector('[data-comment-body]'); if (!body?.querySelector('form')) return false; editing = null; body.innerHTML = commentBodyHtml(item); panel.querySelector('.detail-actions')?.toggleAttribute('hidden', false); return true; + const body = panel.querySelector('[data-comment-body]'); if (!body?.querySelector('form')) return false; editing = null; body.innerHTML = commentBodyHtml(item); panel.querySelectorAll('.detail-actions, [data-action="copy-one"]').forEach((element) => element.toggleAttribute('hidden', false)); return true; } async function copyText(value: string, label: string, announce = true): Promise { const version = panelVersion; @@ -990,9 +972,9 @@ function mount(endpoint: string, token: string) { async function copy(selected?: Feedback[], button?: HTMLButtonElement) { const single = selected?.length === 1, scope = selected ?? filteredItems(), repairs = scope.filter((item) => single ? item.intent !== 'reference' : isRepairFeedback(item)); if (!repairs.length && scope.some((item) => item.intent === 'reference')) return; const copied = await copyText(formatFeedbackPrompt(repairs, directory, detail, language, single, command), t('AIへの指示'), !button); - // Copy buttons confirm the copy on themselves and returns after a moment, so the reviewer sees the result where they clicked without a lingering status line; an icon button shows only the check so the header keeps its width. - // コピーのボタンはその場でコピー完了を示して少し後に戻る。押した場所で結果が分かり、状態の行が残らないようにするため。アイコンのボタンはヘッダーの幅を変えないようチェックだけを表示する。 - if (copied && button?.isConnected && !button.dataset.copied) { const original = button.innerHTML, label = button.getAttribute('aria-label'); button.dataset.copied = ''; button.innerHTML = button.classList.contains('icon-button') ? icon('aiCheck') : `${icon('aiCheck')}${t('コピーしました')}`; if (label) button.setAttribute('aria-label', t('コピーしました')); nativeSetTimeout(() => { if (button.isConnected) { button.innerHTML = original; if (label) button.setAttribute('aria-label', label); delete button.dataset.copied; } }, 2000); } + // Copy buttons confirm the copy on themselves and return after a moment, so the reviewer sees the result where they clicked without a lingering status line; the check is drawn in the success colour so the icon change stands out without text that would widen the header. + // コピーのボタンはその場でコピー完了を示して少し後に戻る。押した場所で結果が分かり、状態の行が残らないようにするため。ヘッダーの幅を変える文字を足さずに気付けるよう、チェックは成功色で描く。 + if (copied && button?.isConnected && !button.dataset.copied) { const original = button.innerHTML, label = button.getAttribute('aria-label'); button.dataset.copied = ''; button.innerHTML = icon('checkCircle'); if (label) button.setAttribute('aria-label', t('コピーしました')); nativeSetTimeout(() => { if (button.isConnected) { button.innerHTML = original; if (label) button.setAttribute('aria-label', label); delete button.dataset.copied; } }, 2000); } } async function send(selected: Feedback[]) { selected = selected.filter(isRepairFeedback); @@ -1117,22 +1099,19 @@ function mount(endpoint: string, token: string) { if (action === 'cancel' || action === 'close') { if (starting) cancelStart(); closePanel(); return; } if (action === 'editor-link') { await prepareEditorLink(button); return; } if (busy) return; - if (hasUnsavedInput() && !['image', 'skip-capture', 'pause', 'pins', 'collapse', 'edit-cancel', 'reply-cancel'].includes(action ?? '')) { notify(t('入力中のコメントを保存またはキャンセルしてください。')); return; } - if (selectionUrl && !['select', 'page-operation', 'discard-selection', 'multi-done', 'pause', 'pins', 'collapse'].includes(action ?? '')) cancelSelection(); - if (draft && !['image', 'skip-capture', 'pause', 'pins', 'collapse'].includes(action ?? '')) { draft = null; captureVersion++; } + if (hasUnsavedInput() && !['image', 'pause', 'pins', 'collapse', 'edit-cancel', 'reply-cancel'].includes(action ?? '')) { notify(t('入力中のコメントを保存またはキャンセルしてください。')); return; } + if (selectionUrl && !['select', 'multi-done', 'pause', 'pins', 'collapse'].includes(action ?? '')) cancelSelection(); + if (draft && !['image', 'pause', 'pins', 'collapse'].includes(action ?? '')) { draft = null; captureVersion++; } returnFocus = button; if (action === 'select') { if (!cover.hidden) cancelSelection(); else void select(); } - else if (action === 'cancel-selection' || action === 'discard-selection') { cancelSelection(); dock.querySelector('[data-action="select"]')!.focus(); } - else if (action === 'page-operation') operatePage(); + else if (action === 'cancel-selection') { cancelSelection(); dock.querySelector('[data-action="select"]')!.focus(); } else if (action === 'collapse') { collapsed = !collapsed; persist('toolbar-collapsed', String(collapsed)); applyCollapsed(true); hideToolbarTip(); } - else if (action === 'comment-selection') { const range = pendingText; pendingText = null; if (range) void compose({ kind: 'text', text: range.toString().trim().slice(0, 1000), selector: '', rect: viewportRectForRange(range), code: contextForRange(range) }, { range }); } else if (action === 'multi-done') completeMulti(); else if (action === 'settings') { if (!panel.hidden && screen === 'settings') closePanel(); else settings(); } else if (action === 'pause') pause(); else if (action === 'pins') { hiddenPins = !hiddenPins; button.setAttribute('aria-pressed', String(!hiddenPins)); button.innerHTML = icon(hiddenPins ? 'eyeOff' : 'eye'); togglePins(); } else if (action === 'stop') { capture.stop(); notify(t('タブ撮影を停止しました。')); } - else if (action === 'settings-save' || action === 'settings-start') { const selectedLanguage = panel.querySelector('#nm-language')!.value; if (isNeedMarkLanguage(selectedLanguage)) { language = selectedLanguage; persist('language', language); applyLanguage(); } author = panel.querySelector('#nm-author')?.value.trim() ?? author; method = panel.querySelector('#nm-method')!.value as CaptureMethod; detail = panel.querySelector('#nm-detail')!.value as typeof detail; persist('author', author); persist('method', method); persist('detail', detail); keyboardShortcuts = panel.querySelector('#nm-shortcuts')!.value === 'true'; persist('shortcuts', String(keyboardShortcuts)); persist('theme', panel.querySelector('#nm-theme')!.value); persist('configured', 'true'); applyTheme(); applyTitles(); if (method !== 'tab') capture.stop(); const first = button.dataset.first === 'true'; closePanel(); if (first) void select(); } - else if (action === 'skip-capture') { if (draft) { draft.capture = { method: 'none', width: 0, height: 0 }; delete draft.screenshot; } button.remove(); enableSubmit(); const state = panel.querySelector('[data-capture-state]')!; state.hidden = false; state.textContent = t('画像なしで保存します。'); } + else if (action === 'settings-save') { const selectedLanguage = panel.querySelector('#nm-language')!.value; if (isNeedMarkLanguage(selectedLanguage)) { language = selectedLanguage; persist('language', language); applyLanguage(); } author = panel.querySelector('#nm-author')!.value.trim(); method = panel.querySelector('#nm-method')!.value as CaptureMethod; detail = panel.querySelector('#nm-detail')!.value as typeof detail; persist('author', author); persist('method', method); persist('detail', detail); keyboardShortcuts = panel.querySelector('#nm-shortcuts')!.value === 'true'; persist('shortcuts', String(keyboardShortcuts)); persist('theme', panel.querySelector('#nm-theme')!.value); applyTheme(); applyTitles(); if (method !== 'tab') capture.stop(); closePanel(); } else if (action === 'list') { if (!panel.hidden && screen === 'list') closePanel(); else void list(); } else if (action === 'search' || action === 'search-close') toggleSearch(action === 'search'); // Choosing a filter re-renders the list, so the menu is reopened on the same option to let several be checked in a row. @@ -1210,7 +1189,7 @@ function mount(endpoint: string, token: string) { }, true); for (const type of ['keydown', 'keyup'] as const) document.addEventListener(type, (event) => { if (['Alt', 'Shift'].includes(event.key)) syncTextCursor(event); }, true); document.addEventListener('keyup', (event) => { if (!event.isComposing && !pointerStart && hoverPoint && ['Control', 'Meta'].includes(event.key)) previewAt(hoverPoint.x, hoverPoint.y, event.ctrlKey || event.metaKey); }, true); - let pinFrame = 0; const refreshPins = () => { clearHover(); nativeCancelAnimationFrame(pinFrame); pinFrame = nativeRequestAnimationFrame(() => { renderPins(); renderSelections(); if (!cover.hidden) { clearHover(); outline.hidden = true; } }); }; + let pinFrame = 0, layoutChanged = false; const refreshPins = (contentOnly: boolean) => { if (!contentOnly) layoutChanged = true; clearHover(); nativeCancelAnimationFrame(pinFrame); pinFrame = nativeRequestAnimationFrame(() => { pinsDeferred = !layoutChanged && !!pins.querySelector('.pin:hover'); layoutChanged = false; if (!pinsDeferred) renderPins(); renderSelections(); if (!cover.hidden) { clearHover(); outline.hidden = true; } }); }; const pageChanges = installPageChanges(host, refreshPins); syncPageChanges = pageChanges.sync; syncPageChanges([]); // The first load can race the dev server's startup; polling retries every two seconds and the list shows the failure. diff --git a/src/client/messages.ts b/src/client/messages.ts index 7d95723..bcb8ed7 100644 --- a/src/client/messages.ts +++ b/src/client/messages.ts @@ -11,7 +11,6 @@ const english = { "保存先": "Storage folder", "ツールバーをたたむ": "Collapse toolbar", "ツールバーを広げる": "Expand toolbar", - "選択中の文字にコメント": "Comment on selected text", "画面の場所を選んでコメントを残す": "Pick a spot on the page and leave a comment", "保存したコメントのピンを表示・非表示にする": "Show or hide pins of saved feedback", "アニメーション・動画・タイマーを止めて、動く画面の一瞬にコメントしやすくする": "Freeze animations, video and timers to comment on one moment", @@ -19,21 +18,17 @@ const english = { "言語・撮影方式・投稿者など": "Language, capture, author and more", "ツールを隠してツールバーを小さくする": "Hide the tools to keep the toolbar small", "すべてのツールを表示する": "Show all tools", - "保持中の複数選択を破棄する": "Discard the retained multiple selection", - "このまま始める": "Start with these settings", "対象情報のみ": "Target reference", "対象情報の詳細": "Target reference details", "対象情報: {target}": "Target reference: {target}", "選択候補": "Selection preview", "選択を完了": "Finish selection", - "ページを操作": "Interact with page", "終了": "Exit", "使い方": "How to select", "クリック・ドラッグで選択": "Click or drag to select", "{count}件を選択": "{count} selected", "ツールバーを移動": "Move toolbar", "コメント": "Comment", - "選択を破棄": "Discard selection", "ピンの表示切り替え": "Toggle pins", "アニメーションを停止": "Pause animations", "アニメーションを再開": "Resume animations", @@ -47,7 +42,6 @@ const english = { "コメントした場所のキャプチャ拡大": "Enlarged capture of the feedback location", "パネルを閉じる": "Close panel", "保存に失敗しました": "Unable to save feedback", - "レビューを始める": "Start review", "投稿者名": "Author name", "名前(省略可)": "Name (optional)", "キャプチャ方式": "Capture method", @@ -69,9 +63,6 @@ const english = { "ライト": "Light", "ダーク": "Dark", "撮影を停止": "Stop capture", - "設定を保存": "Save settings", - "選択を再開": "Resume selection", - "撮影の再開をキャンセルしました。選択は保持しています。": "Capture restart canceled. Your selection is preserved.", "ページのURLが変わったため、保持していた選択を終了しました。": "The page URL changed, so the retained selection was cleared.", "入力中のコメントを保存またはキャンセルしてください。": "Save or cancel the feedback you are editing.", "このタブの撮影を開始": "Start capturing this tab", @@ -93,10 +84,9 @@ const english = { "ファイルの候補を保存します。": "A candidate file will be saved.", "セレクタとコンポーネントを手掛かりとして保存します。": "The selector and component will be saved as context.", "コメントした場所のキャプチャ": "Capture of the feedback location", - "画像なしで保存します。": "Saving without an image.", + "画像を撮影できなかったため、画像なしで保存します。": "The image could not be captured, so the comment will be saved without one.", "キャプチャを作成中… コメントを入力できます。": "Creating capture… You can enter your comment.", "例:ボタンの上下の余白を20pxにしてほしい": "Example: Set the button’s top and bottom spacing to 20px", - "画像なしで保存する": "Save without an image", "文字の上をドラッグしてください。": "Drag over text.", "同じ画面・文字領域の中で選択してください。": "Select within the same screen and text area.", "探索の上限に達しました。見つかった対象を選択しています。不足している対象はクリックで追加してください。": "The search limit was reached. Found targets are selected. Click to add any missing targets.", @@ -135,9 +125,7 @@ const english = { "削除を確認": "Confirm deletion", "削除する": "Delete", "ドラッグをキャンセルしました。選択済みの対象は保持しています。": "Drag canceled. Your selected targets are preserved.", - "選択を再開 · {count}件": "Resume selection · {count} items", "{count}件の選択対象が消えたか変わったため、選択から外しました。{retry}": "Removed {count} selected targets that disappeared or changed. {retry}", - "{error} 選択は保持しています。": "{error} Your selection is preserved.", "{index} · 記録": "{index} · Recorded", "{confirmed}件を確認、{recorded}件は対象未確認(記録位置)です。": "Confirmed {confirmed} targets; {recorded} are unconfirmed (recorded locations).", "{count}件の選択対象を確認しました。": "Confirmed {count} selected targets.", diff --git a/src/client/page-changes.ts b/src/client/page-changes.ts index f95673a..685ccde 100644 --- a/src/client/page-changes.ts +++ b/src/client/page-changes.ts @@ -2,12 +2,12 @@ import { resolveElement } from './context/code-context.js'; // Observing only the roots that saved pins live in keeps MutationObserver cost proportional to the feedback, not to the size of the app. // 保存済みピンがあるルートだけを監視し、MutationObserverの負荷をアプリの大きさではなくコメントの数に比例させる。 -export function installPageChanges(host: HTMLElement, onChange: () => void): { sync(paths: readonly (readonly string[])[]): void; dispose(): void } { +export function installPageChanges(host: HTMLElement, onChange: (contentOnly: boolean) => void): { sync(paths: readonly (readonly string[])[]): void; dispose(): void } { type Root = Document | ShadowRoot; const observed = new Map void>(), frames = new Map void>(); let paths: readonly (readonly string[])[] = [], disposed = false; const isWidget = (node: Node) => !!(node.nodeType === 1 ? node as Element : node.parentElement)?.closest('[data-needmark]'); - const changed = () => { if (!disposed) onChange(); }; + const changed = () => { if (!disposed) onChange(false); }; function observeHistory(view: Window): () => void { let active = true; @@ -62,7 +62,7 @@ export function installPageChanges(host: HTMLElement, onChange: () => void): { s } for (const [root, detach] of observed) if (!wantedRoots.has(root)) { detach(); observed.delete(root); } for (const root of wantedRoots) if (!observed.has(root)) { - const observer = new MutationObserver((records) => { if (records.some((record) => !isWidget(record.target))) changed(); }); + const observer = new MutationObserver((records) => { if (!disposed && records.some((record) => !isWidget(record.target))) onChange(true); }); observer.observe(root, { subtree: true, childList: true, characterData: true, attributes: true }); root.addEventListener('scroll', changed, true); const view = root.nodeType === 9 ? (root as Document).defaultView : null; diff --git a/src/client/toolbar-position.ts b/src/client/toolbar-position.ts index ee9e620..57d97c2 100644 --- a/src/client/toolbar-position.ts +++ b/src/client/toolbar-position.ts @@ -49,11 +49,11 @@ export function installToolbarPosition(dock: HTMLElement, storage: Pick { if (!visible()) { cancel(); return; } if (!drag && preferred) place(clamp(preferred)); }; const down = (event: PointerEvent): void => { if (drag || !event.isPrimary || event.button !== 0 || !visible()) return; - // The whole toolbar is a grip, not only the narrow handle. Capturing on the pressed button keeps its click when the pointer + // The whole toolbar is a grip, not only the narrow handle. Capturing on the pressed button or link keeps its click when the pointer // does not travel; elsewhere the default is prevented so the press does not start a page text selection. - // 細い持ち手だけでなくツールバー全体を掴めるようにする。押したボタン自身でキャプチャすれば動かさなかったときのクリックが残り、 + // 細い持ち手だけでなくツールバー全体を掴めるようにする。押したボタンやリンク自身でキャプチャすれば動かさなかったときのクリックが残り、 // それ以外の場所では既定動作を止めてページの文字選択が始まらないようにする。 - const button = (event.target as Element).closest('button'), target = button ?? dock, rect = dock.getBoundingClientRect(); + const button = (event.target as Element).closest('button, a'), target = button ?? dock, rect = dock.getBoundingClientRect(); drag = { pointer: event.pointerId, x: event.clientX, y: event.clientY, origin: { x: rect.left, y: rect.top }, target, viewport: `${view.innerWidth}x${view.innerHeight}`, styles: ['left', 'top', 'right', 'bottom', 'transform', '--nm-shift'].map((property) => [property, dock.style.getPropertyValue(property), dock.style.getPropertyPriority(property)]) }; try { target.setPointerCapture(event.pointerId); } catch { drag = undefined; return; } diff --git a/src/index.test.ts b/src/index.test.ts index 65c3092..9f0aff1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -138,6 +138,32 @@ describe('Vite integration boundaries', () => { expect(await direct.text()).not.toContain('private feedback'); }); + it('appends the client once to HTML that a framework renders without transformIndexHtml, as Astro does', async () => { + const directory = await root(); + // Astro renders pages in its own middleware, streams them with writeHead headers and never calls transformIndexHtml. + const framework = { name: 'framework-pages', configureServer(server: ViteDevServer) { + server.middlewares.use((request, response, next) => { + if (request.url === '/page') { + const head = '

Rendered

', tail = ''; + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': String(head.length + tail.length) }); + response.write(head); response.end(tail); return; + } + if (request.url === '/data') { response.setHeader('Content-Type', 'application/json'); response.end('{"ok":true}'); return; } + next(); + }); + } }; + const server = await createServer({ configFile: false, root: directory, plugins: [needmark(), framework], + server: { host: '127.0.0.1', port: 0 }, logLevel: 'silent' }); + servers.push(server); await server.listen(); + const origin = `http://127.0.0.1:${(server.httpServer!.address() as AddressInfo).port}`; + const page = await (await fetch(`${origin}/page`)).text(); + expect(page.startsWith('

Rendered

')).toBe(true); + expect(page.match(/\/__needmark\/client\.js/g)).toHaveLength(1); + expect(await (await fetch(`${origin}/data`)).text()).toBe('{"ok":true}'); + expect((await (await fetch(origin)).text()).match(/\/__needmark\/client\.js/g)).toHaveLength(1); + expect(await (await fetch(`${origin}/page`, { headers: { origin: 'http://evil.example' } })).text()).not.toContain('needmark'); + }); + it('allows only loopback hostnames on whatever port Vite chose when origins are omitted', async () => { const directory = await root(); const server = await createServer({ configFile: false, root: directory, plugins: [needmark()], @@ -175,6 +201,18 @@ describe('Vite integration boundaries', () => { } finally { process.chdir(previous); } }); + it.each([[{ enabled: false }, {}], [{}, { NEEDMARK: 'off' }]])('injects nothing when switched off by %j or %j', async (options, env) => { + const directory = await root(), previous = process.env.NEEDMARK; + Object.assign(process.env, env); + try { + const server = await createServer({ configFile: false, root: directory, plugins: [needmark(options)], server: { host: '127.0.0.1', port: 0 }, logLevel: 'silent' }); + servers.push(server); await server.listen(); + const origin = `http://127.0.0.1:${(server.httpServer!.address() as AddressInfo).port}`; + expect(await (await fetch(origin)).text()).not.toContain('needmark'); + expect((await fetch(`${origin}/__needmark/feedback`)).status).not.toBe(403); + } finally { if (previous === undefined) delete process.env.NEEDMARK; else process.env.NEEDMARK = previous; } + }); + it('does not include scripts, endpoints or feedback files in production or preview', async () => { const directory = await root(); await mkdir(join(directory, '.needmark/feedback'), { recursive: true }); diff --git a/src/index.ts b/src/index.ts index ebad24a..203af59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ import { defaultFeedbackDirectory } from './server/storage.js'; import type { NeedMarkOptions } from './shared/types.js'; import { instrumentHtml, instrumentJsx } from './server/code-source.js'; import { isAllowedOrigin } from './shared/origin.js'; +import { appendReviewScript } from './server/review-script.js'; +import { needmarkEnabled } from './server/enabled.js'; export { formatFeedbackPrompt } from './shared/prompt.js'; export type { PromptDetail } from './shared/prompt.js'; @@ -15,12 +17,17 @@ export type { NeedMarkLanguage, NeedMarkOptions, Feedback, FeedbackInput, Feedba export function needmark(options?: NeedMarkOptions): Plugin { let settings = options ?? {}; let { origins } = validateReviewOptions(settings); - // transformIndexHtml receives no request, so the origin checked in the middleware travels with the request's async context. - // transformIndexHtmlはリクエストを受け取らないため、ミドルウェアで確認したオリジンを非同期コンテキストで受け渡す。 - const requestOrigin = new AsyncLocalStorage(); + // transformIndexHtml receives no request, so the origin checked in the middleware travels with the request's async context, + // and the hook marks the page so the appended fallback script is not added twice. + // transformIndexHtmlはリクエストを受け取らないため、ミドルウェアで確認したオリジンを非同期コンテキストで受け渡し、 + // 追記用の代替scriptを二重に入れないようフック側で印を付ける。 + const requestPage = new AsyncLocalStorage<{ origin: string; injected: boolean }>(); let endpoint = '/__needmark'; let api: ReturnType; let root = ''; + // Vite has already decided which plugins apply before the project config is loaded, so a switched-off plugin stays registered and every hook returns early instead. + // プロジェクトの設定を読む前にViteが適用するプラグインを決めてしまうため、止めたプラグインも登録したまま、各フックで何もせずに戻る。 + let active = true; return { name: 'needmark', @@ -34,6 +41,8 @@ export function needmark(options?: NeedMarkOptions): Plugin { // TypeScriptの設定の読み込みは非同期で、ファクトリーはvite.config内で同期的に呼ばれるため、このフックまで待つ。 // ビルド時に設定を実行しないことにもなる。 if (!options) ({ origins } = validateReviewOptions(settings = await loadProjectConfig())); + active = needmarkEnabled(settings); + if (!active) return; const output = resolve(config.root ?? process.cwd(), settings.outputDir ?? defaultFeedbackDirectory(config.root ?? process.cwd())); const pattern = `${output.replaceAll('\\', '/')}/**`; const legacyPattern = `${resolve(config.root ?? process.cwd(), '.needback/feedback').replaceAll('\\', '/')}/**`; @@ -46,6 +55,7 @@ export function needmark(options?: NeedMarkOptions): Plugin { } }; }, configResolved(config) { + if (!active) return; root = config.root; endpoint = `${config.base.replace(/\/$/, '')}/__needmark`; const output = resolve(config.root, settings.outputDir ?? defaultFeedbackDirectory(config.root)); @@ -56,16 +66,20 @@ export function needmark(options?: NeedMarkOptions): Plugin { api = createReviewApi({ ...settings, root: config.root, sourceRoot: process.cwd(), endpoint, secure: !!config.server.https, logger: config.logger }); }, configureServer(server) { + if (!active) return; server.middlewares.use((request, response, next) => { if (api.handle(request, response)) return; const origin = `${server.config.server.https ? 'https' : 'http'}://${request.headers.host ?? ''}`; const sameOrigin = !request.headers.origin || request.headers.origin === origin; - requestOrigin.run(sameOrigin ? origin : '', next); + const page = { origin: sameOrigin ? origin : '', injected: false }; + if (request.method === 'GET' && isAllowedOrigin(page.origin, origins)) appendReviewScript(response, api.scriptAttrs, () => page.injected); + requestPage.run(page, next); }); }, transform: { order: 'pre', handler(code, id, transformOptions) { + if (!active) return; const file = id.split('?')[0]!; const local = relative(root, file); if (transformOptions?.ssr || !/\.[jt]sx$/.test(file) || local.startsWith('..') || local.includes('node_modules')) return; @@ -77,7 +91,9 @@ export function needmark(options?: NeedMarkOptions): Plugin { transformIndexHtml: { order: 'pre', handler(html, context) { - if (!isAllowedOrigin(requestOrigin.getStore() ?? '', origins)) return; + const page = requestPage.getStore(); + if (!active || !page || !isAllowedOrigin(page.origin, origins)) return; + page.injected = true; return { html: instrumentHtml(html, relative(process.cwd(), context.filename).replaceAll('\\', '/')), tags: [{ tag: 'script', attrs: api.scriptAttrs, injectTo: 'body' }] }; }, }, diff --git a/src/proxy.ts b/src/proxy.ts index c6c96ba..1746c53 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,6 +5,7 @@ import { createGunzip, createInflate, createBrotliDecompress } from 'node:zlib'; import type { Socket } from 'node:net'; import { createReviewApi } from './server/review-api.js'; import { createRuntimeSourceResolver } from './server/runtime-source.js'; +import { reviewScriptTag } from './server/review-script.js'; import { defaultFeedbackDirectory } from './server/storage.js'; import type { CaptureMethod, NeedMarkOptions } from './shared/types.js'; @@ -18,7 +19,6 @@ export interface ReviewProxyOptions extends Pick } const loopback = (url: URL) => url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); -const escapeAttribute = (value: string) => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); // Appending after the last chunk streams HTML without buffering or parsing it; browsers still run a script after . // The 2 KB tail finds a CSP nonce split across chunk boundaries without keeping the whole document. @@ -32,8 +32,7 @@ function injectReview(attrs: Record, policy?: string): callback(null, chunk); }, flush(callback) { - const attributes: Record = { ...attrs, ...(nonce ? { nonce } : {}) }; - this.push(`\n\n`); callback(); + this.push(reviewScriptTag({ ...attrs, ...(nonce ? { nonce } : {}) })); callback(); }, }); } diff --git a/src/server/app-command.ts b/src/server/app-command.ts new file mode 100644 index 0000000..d971550 --- /dev/null +++ b/src/server/app-command.ts @@ -0,0 +1,40 @@ +import { createServer, connect } from 'node:net'; + +const portFlags = ['-p', '--port']; + +/** See docs/architecture.md#local-review-proxy. / 仕様は docs/architecture.md#local-review-proxy を参照。 */ +export function takePort(command: readonly string[]): { command: string[]; port?: number } { + const rest: string[] = []; + let port: number | undefined; + for (let index = 0; index < command.length; index++) { + const argument = command[index]!, inline = argument.match(/^--port=(.+)$/); + const value = inline ? inline[1] : portFlags.includes(argument) ? command[++index] : undefined; + if (value === undefined) { rest.push(argument); continue; } + port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`Invalid port in the development command: ${value}`); + } + return { command: rest, ...(port === undefined ? {} : { port }) }; +} + +export async function freePort(): Promise { + const server = createServer(); + await new Promise((ready, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', ready); }); + const { port } = server.address() as { port: number }; + await new Promise((done) => server.close(() => done())); + return port; +} + +export async function waitForPort(port: number, stopped: () => boolean, timeout = 120_000): Promise { + for (const deadline = Date.now() + timeout; ;) { + // A plain TCP connection is enough, and autoSelectFamily tries both IPv4 and IPv6 because dev servers differ in which one "localhost" binds. + // TCP接続だけで十分で、autoSelectFamilyならIPv4とIPv6の両方を試せる。「localhost」でどちらに待ち受けるかは開発サーバーごとに違うため。 + const open = await new Promise((done) => { + const socket = connect({ port, host: 'localhost', autoSelectFamily: true }); + socket.once('connect', () => { socket.destroy(); done(true); }).once('error', () => done(false)); + }); + if (open) return; + if (stopped()) throw new Error('The development server stopped before it started listening.'); + if (Date.now() > deadline) throw new Error(`The development server did not listen on port ${port}. If it does not read the PORT environment variable, run it with \`needmark dev --target=http://localhost:\` instead.`); + await new Promise((wait) => setTimeout(wait, 250)); + } +} diff --git a/src/server/connect.test.ts b/src/server/connect.test.ts new file mode 100644 index 0000000..feed4de --- /dev/null +++ b/src/server/connect.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { planConnection, planDisconnection } from './connect.js'; + +const folders: string[] = []; +afterEach(async () => { await Promise.all(folders.splice(0).map((folder) => rm(folder, { recursive: true, force: true }))); }); +async function project(files: Record) { + const directory = await mkdtemp(join(tmpdir(), 'needmark-connect-')); folders.push(directory); + for (const [name, content] of Object.entries(files)) await writeFile(join(directory, name), content); + return directory; +} +async function roundTrip(files: Record, file: string) { + const directory = await project(files), plan = await planConnection(directory); + if (plan.kind !== 'edit') throw new Error(`Expected an edit, got ${plan.kind}`); + await writeFile(join(directory, file), plan.content); + const [removal] = await planDisconnection(directory); + return { connected: plan.content, lines: plan.lines, disconnected: removal?.content }; +} + +describe('connecting the development server', () => { + it('adds the plugin first in an existing Vite plugins array and removes it again', async () => { + const original = "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n"; + const result = await roundTrip({ 'vite.config.ts': original }, 'vite.config.ts'); + expect(result.connected).toBe("import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nimport { needmark } from 'needmark';\n\nexport default defineConfig({\n plugins: [needmark(), react()],\n});\n"); + expect(result.lines).toEqual(["import { needmark } from 'needmark';", ' plugins: [needmark(), react()],']); + expect(result.disconnected).toBe(original); + }); + + it.each([ + ['a plain object without plugins', "export default {\n server: { port: 3000 },\n};\n", "import { needmark } from 'needmark';\nexport default {\n plugins: [needmark()],\n server: { port: 3000 },\n};\n"], + ['a named constant with satisfies', "import type { UserConfig } from 'vite';\nconst config = { plugins: [] } satisfies UserConfig;\nexport default config;\n", "import type { UserConfig } from 'vite';\nimport { needmark } from 'needmark';\nconst config = { plugins: [needmark()] } satisfies UserConfig;\nexport default config;\n"], + ['an arrow function', "import { defineConfig } from 'vite';\nexport default defineConfig(() => ({}));\n", "import { defineConfig } from 'vite';\nimport { needmark } from 'needmark';\nexport default defineConfig(() => ({ plugins: [needmark()] }));\n"], + ])('connects %s', async (_name, original, expected) => { + const result = await roundTrip({ 'vite.config.ts': original }, 'vite.config.ts'); + expect(result.connected).toBe(expected); + }); + + it('adds a vite option to an Astro config and removes all of it again', async () => { + const original = "import { defineConfig } from 'astro/config';\n\nexport default defineConfig({\n site: 'https://example.com',\n});\n"; + const result = await roundTrip({ 'astro.config.mjs': original, 'package.json': '{"scripts":{"dev":"astro dev"}}' }, 'astro.config.mjs'); + expect(result.connected).toContain(" vite: { plugins: [needmark()] },\n site: 'https://example.com',"); + expect(result.disconnected).toBe(original); + }); + + it('wraps a Next.js dev script without reformatting package.json and unwraps it again', async () => { + const original = '{\n "name": "app",\n "scripts": { "dev": "next dev --turbopack", "build": "next build" }\n}\n'; + const result = await roundTrip({ 'package.json': original }, 'package.json'); + expect(result.connected).toBe(original.replace('"next dev --turbopack"', '"needmark dev -- next dev --turbopack"')); + expect(result.disconnected).toBe(original); + }); + + it('reports a connected project and leaves unknown shapes to the person', async () => { + expect(await planConnection(await project({ 'vite.config.ts': "import { needmark } from 'needmark';\nexport default { plugins: [needmark()] };\n" }))).toMatchObject({ kind: 'connected', framework: 'Vite' }); + expect(await planConnection(await project({ 'vite.config.ts': "import base from './base';\nexport default { ...base, plugins: base.plugins };\n" }))).toEqual({ kind: 'manual', file: 'vite.config.ts' }); + expect(await planConnection(await project({ 'package.json': '{"scripts":{"dev":"webpack serve"}}' }))).toEqual({ kind: 'manual' }); + }); +}); diff --git a/src/server/connect.ts b/src/server/connect.ts new file mode 100644 index 0000000..70633f5 --- /dev/null +++ b/src/server/connect.ts @@ -0,0 +1,192 @@ +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse } from '@babel/parser'; +import type { ArrayExpression, Node, ObjectExpression, ObjectProperty } from '@babel/types'; +import MagicString from 'magic-string'; + +/** See docs/architecture.md#connecting-the-development-server. / 仕様は docs/architecture.md#connecting-the-development-server を参照。 */ +export type Connection = + | { kind: 'edit'; framework: 'Vite' | 'Astro' | 'Next.js'; file: string; content: string; lines: string[] } + | { kind: 'connected'; framework: 'Vite' | 'Astro' | 'Next.js'; file: string } + | { kind: 'manual'; file?: string }; +export interface Change { file: string; content: string; lines: string[] } + +const importLine = "import { needmark } from 'needmark';"; +const wrapper = 'needmark dev -- '; +const configFile = (directory: string, base: string) => ['ts', 'mts', 'js', 'mjs'].map((extension) => `${base}.config.${extension}`).find((name) => existsSync(join(directory, name))); + +// Lines are compared as a multiset rather than with a full diff, because an edit here only adds or removes a few whole lines or one fragment of a line. +// ここでの編集は数行の追加・削除か1行の一部だけなので、本格的な差分ではなく行の多重集合で比べる。 +function changedLines(before: string, after: string): string[] { + const remaining = new Map(); + for (const line of before.split('\n')) remaining.set(line, (remaining.get(line) ?? 0) + 1); + return after.split('\n').filter((line) => { const count = remaining.get(line) ?? 0; remaining.set(line, count - 1); return count <= 0; }); +} + +const parseModule = (code: string) => parse(code, { sourceType: 'module', plugins: ['typescript', 'jsx'] }).program; +function unwrap(node: Node | null | undefined): Node | undefined { + while (node && (node.type === 'TSSatisfiesExpression' || node.type === 'TSAsExpression' || node.type === 'TSNonNullExpression')) node = node.expression; + return node ?? undefined; +} + +// Configs are written as `defineConfig({...})`, a plain object, a named constant or an arrow function returning the object; anything else is left for the person to edit, because guessing would risk breaking their config. +// 設定は`defineConfig({...})`、素のオブジェクト、名前付きの定数、オブジェクトを返すアロー関数のいずれかで書かれる。それ以外は推測で壊さないよう、本人に編集を任せる。 +function configObject(code: string): ObjectExpression | undefined { + const program = parseModule(code); + const exported = program.body.find((node) => node.type === 'ExportDefaultDeclaration'); + let node = exported ? unwrap(exported.declaration) : undefined; + if (node?.type === 'Identifier') { + const name = node.name; + for (const statement of program.body) { + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + if (declaration?.type !== 'VariableDeclaration') continue; + const found = declaration.declarations.find((item) => item.id.type === 'Identifier' && item.id.name === name); + if (found) node = unwrap(found.init); + } + } + if (node?.type === 'CallExpression' && node.arguments.length === 1) node = unwrap(node.arguments[0]); + if (node?.type === 'ArrowFunctionExpression' || node?.type === 'FunctionExpression') { + const body = node.body; + if (body.type === 'BlockStatement') { + const returned = body.body.filter((statement) => statement.type === 'ReturnStatement'); + node = returned.length === 1 ? unwrap(returned[0]!.argument) : undefined; + } else node = unwrap(body); + } + return node?.type === 'ObjectExpression' ? node : undefined; +} +function property(object: ObjectExpression, name: string): ObjectProperty | undefined { + return object.properties.find((item): item is ObjectProperty => item.type === 'ObjectProperty' && !item.computed + && ((item.key.type === 'Identifier' && item.key.name === name) || (item.key.type === 'StringLiteral' && item.key.value === name))); +} +function addProperty(output: MagicString, code: string, object: ObjectExpression, text: string) { + const first = object.properties[0]; + if (!first) { output.overwrite(object.start!, object.end!, `{ ${text} }`); return; } + const gap = code.slice(object.start! + 1, first.start!); + output.prependLeft(first.start!, gap.includes('\n') ? `${text},\n${gap.slice(gap.lastIndexOf('\n') + 1)}` : `${text}, `); +} +function addPlugin(output: MagicString, array: ArrayExpression) { + const first = array.elements[0]; + if (first) output.prependLeft(first.start!, 'needmark(), '); + else output.overwrite(array.start!, array.end!, '[needmark()]'); +} + +function connectConfig(code: string, framework: 'Vite' | 'Astro'): string | undefined { + const object = configObject(code); + if (!object) return; + const output = new MagicString(code); + let target = object; + if (framework === 'Astro') { + const vite = property(object, 'vite'); + if (!vite) addProperty(output, code, object, 'vite: { plugins: [needmark()] }'); + else if (vite.value.type === 'ObjectExpression') target = vite.value; + else return; + } + if (framework === 'Vite' || target !== object) { + const plugins = property(target, 'plugins'); + if (!plugins) addProperty(output, code, target, 'plugins: [needmark()]'); + else if (plugins.value.type === 'ArrayExpression') addPlugin(output, plugins.value); + else return; + } + const imports = parseModule(code).body.filter((node) => node.type === 'ImportDeclaration'); + if (imports.length) output.appendRight(imports.at(-1)!.end!, `\n${importLine}`); + else output.prepend(`${importLine}\n`); + return output.toString(); +} +const importsNeedMark = (code: string) => parseModule(code).body.some((node) => node.type === 'ImportDeclaration' && node.source.value === 'needmark'); + +async function packageScript(directory: string): Promise<{ raw: string; dev: string; pattern: RegExp } | undefined> { + const path = join(directory, 'package.json'); + if (!existsSync(path)) return; + const raw = await readFile(path, 'utf8'); + const dev = (JSON.parse(raw) as { scripts?: Record }).scripts?.dev; + if (typeof dev !== 'string') return; + // Replacing the encoded string in the file keeps the person's formatting, which re-serializing package.json would not. + // ファイル中のエンコード済み文字列を置き換えれば、package.jsonを再シリアライズする場合と違い本人の書式を保てる。 + return { raw, dev, pattern: new RegExp(`("dev"\\s*:\\s*)${JSON.stringify(dev).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) }; +} + +export async function planConnection(directory: string): Promise { + for (const [base, framework] of [['astro', 'Astro'], ['vite', 'Vite']] as const) { + const file = configFile(directory, base); + if (!file) continue; + const code = await readFile(join(directory, file), 'utf8'); + try { + if (importsNeedMark(code)) return { kind: 'connected', framework, file }; + const content = connectConfig(code, framework); + return content ? { kind: 'edit', framework, file, content, lines: changedLines(code, content) } : { kind: 'manual', file }; + } catch { return { kind: 'manual', file }; } /* A config the parser cannot read is left untouched. / パーサーが読めない設定には手を付けない。 */ + } + const script = await packageScript(directory); + if (!script || !/(^|[\s;&|])next\s+dev\b/.test(script.dev)) return { kind: 'manual' }; + if (script.dev.startsWith(wrapper)) return { kind: 'connected', framework: 'Next.js', file: 'package.json' }; + const content = script.raw.replace(script.pattern, (_match, key: string) => key + JSON.stringify(wrapper + script.dev)); + return content === script.raw ? { kind: 'manual', file: 'package.json' } : { kind: 'edit', framework: 'Next.js', file: 'package.json', content, lines: changedLines(script.raw, content) }; +} + +// Removal widens a range to whole lines only when the node had its line to itself, so code sharing that line is never deleted. +// 削除範囲を行全体へ広げるのはノードが単独で行を占めていた場合だけにし、同じ行のほかのコードを消さないようにする。 +function wholeLines(code: string, start: number, end: number): [number, number] { + const lineStart = code.lastIndexOf('\n', start - 1) + 1, lineEnd = code.indexOf('\n', end); + const after = lineEnd === -1 ? code.length : lineEnd; + return code.slice(lineStart, start).trim() === '' && code.slice(end, after).trim() === '' ? [lineStart, lineEnd === -1 ? after : lineEnd + 1] : [start, end]; +} +function removeProperty(output: MagicString, code: string, node: ObjectProperty) { + const comma = code.slice(node.end!).match(/^\s*,/); + output.remove(...wholeLines(code, node.start!, node.end! + (comma ? comma[0].length : 0))); +} + +function disconnectConfig(code: string): string { + const program = parseModule(code), output = new MagicString(code); + for (const node of program.body) if (node.type === 'ImportDeclaration' && node.source.value === 'needmark') output.remove(...wholeLines(code, node.start!, node.end!)); + const isCall = (node: Node | null) => node?.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'needmark'; + const visit = (node: Node, parents: Node[]) => { + if (node.type === 'ArrayExpression' && node.elements.some(isCall)) { + const kept = node.elements.filter((element) => !isCall(element)); + const owner = parents.at(-1), object = parents.at(-2), outer = parents.at(-3); + if (!kept.length && owner?.type === 'ObjectProperty' && object?.type === 'ObjectExpression') { + // An emptied `vite: { plugins: [] }` is what connecting added to an Astro config without a vite option, so it goes as a whole. + // 空になった`vite: { plugins: [] }`は、viteオプションのないAstroの設定に接続時に足したものなので、まとめて消す。 + if (object.properties.length === 1 && outer?.type === 'ObjectProperty') removeProperty(output, code, outer); + else if (object.properties.length === 1) output.overwrite(object.start!, object.end!, '{}'); + else removeProperty(output, code, owner); + return; + } + node.elements.forEach((element, index) => { + if (!isCall(element)) return; + const next = node.elements[index + 1], previous = node.elements[index - 1]; + if (next) output.remove(element!.start!, next.start!); + else if (previous) output.remove(previous.end!, element!.end!); + else output.remove(element!.start!, element!.end!); + }); + return; + } + for (const value of Object.values(node)) { + const children = Array.isArray(value) ? value : [value]; + for (const child of children) if (child && typeof child === 'object' && typeof (child as Node).type === 'string') visit(child as Node, [...parents, node]); + } + }; + visit(program, []); + return output.toString(); +} + +export async function planDisconnection(directory: string): Promise { + const changes: Change[] = []; + for (const base of ['astro', 'vite']) { + const file = configFile(directory, base); + if (!file) continue; + const code = await readFile(join(directory, file), 'utf8'); + try { + if (!importsNeedMark(code)) continue; + const content = disconnectConfig(code); + if (content !== code) changes.push({ file, content, lines: changedLines(content, code) }); + } catch { /* A config the parser cannot read is left untouched. / パーサーが読めない設定には手を付けない。 */ } + } + const script = await packageScript(directory); + if (script?.dev.startsWith(wrapper)) { + const content = script.raw.replace(script.pattern, (_match, key: string) => key + JSON.stringify(script.dev.slice(wrapper.length))); + if (content !== script.raw) changes.push({ file: 'package.json', content, lines: changedLines(content, script.raw) }); + } + return changes; +} + diff --git a/src/server/enabled.ts b/src/server/enabled.ts new file mode 100644 index 0000000..3605b23 --- /dev/null +++ b/src/server/enabled.ts @@ -0,0 +1,13 @@ +import type { NeedMarkOptions } from '../shared/types.js'; + +const off = ['0', 'off', 'false', 'no'], on = ['1', 'on', 'true', 'yes']; + +/** See docs/getting-started.md#turn-needmark-off. / 仕様は docs/getting-started.ja.md#needmarkを止める を参照。 */ +export function needmarkEnabled(settings: Pick, env: NodeJS.ProcessEnv = process.env): boolean { + // The variable wins over the shared config so one person can opt out, or back in, without editing a committed file. + // 共有の設定より環境変数を優先し、コミット済みのファイルを書き換えずに一人だけ止めたり戻したりできるようにする。 + const value = env.NEEDMARK?.trim().toLowerCase(); + if (value && off.includes(value)) return false; + if (value && on.includes(value)) return true; + return settings.enabled !== false; +} diff --git a/src/server/init-questions.test.ts b/src/server/init-questions.test.ts new file mode 100644 index 0000000..f426dff --- /dev/null +++ b/src/server/init-questions.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { askInitChoices } from './init-questions.js'; +import type { Connection } from './connect.js'; + +function terminal(answers: string[]) { + const questions: string[] = []; + return { questions, ask: async (text: string) => { questions.push(text); return answers.shift() ?? ''; } }; +} +const vite: Connection = { kind: 'edit', framework: 'Vite', file: 'vite.config.ts', content: '', lines: [] }; +const manual: Connection = { kind: 'manual' }; + +describe('init questions', () => { + it('offers the recommendation first, including connecting the development server', async () => { + const { questions, ask } = terminal(['y']); + expect(await askInitChoices(ask, {}, vite, 'ja')).toEqual({ language: 'ja', share: 'local', capture: 'dom', connect: 'yes' }); + expect(questions).toHaveLength(1); + expect(questions[0]).toContain('Japanese (日本語)'); + expect(questions[0]).toContain('Add needmark() to vite.config.ts'); + }); + + it('asks nothing when every choice came from flags', async () => { + const { questions, ask } = terminal([]); + expect(await askInitChoices(ask, { language: 'en', share: 'git', capture: 'tab', connect: 'no' }, vite, 'ja')).toEqual({ language: 'en', share: 'git', capture: 'tab', connect: 'no' }); + expect(questions).toHaveLength(0); + }); + + it('does not ask about connecting when there is nothing it can change', async () => { + const { questions, ask } = terminal(['n', '', '', '']); + expect(await askInitChoices(ask, {}, manual, 'en')).toEqual({ language: 'en', share: 'local', capture: 'dom', connect: 'no' }); + expect(questions).toHaveLength(4); + expect(questions[0]).toContain('Not detected'); + }); + + it('accepts numbers or flag values and asks again after an unknown answer', async () => { + const { questions, ask } = terminal(['maybe', 'no', 'fr', '1', 'git', '', '2']); + expect(await askInitChoices(ask, {}, vite, 'ja')).toEqual({ language: 'en', share: 'git', capture: 'dom', connect: 'no' }); + expect(questions[1]).toContain('Enter y or n.'); + expect(questions[3]).toContain('Enter a number from 1-2.'); + expect(questions[6]).toContain('Undo with `needmark remove`'); + expect(questions).toHaveLength(7); + }); +}); diff --git a/src/server/init-questions.ts b/src/server/init-questions.ts new file mode 100644 index 0000000..f032044 --- /dev/null +++ b/src/server/init-questions.ts @@ -0,0 +1,88 @@ +import type { InitOptions } from './init.js'; +import type { Connection } from './connect.js'; +import type { NeedMarkLanguage } from '../shared/language.js'; + +export type InitChoices = InitOptions & { connect: 'yes' | 'no' }; +type Choice = keyof InitChoices; +interface Option { value: string; label: string; detail?: string } +type Question = { title: string; options: Option[] }; + +// Each choice is described by what the reviewer will notice, because names such as "dom" or "tab" only make sense after reading the architecture notes. +// 「dom」「tab」のような名前は仕組みを知らないと伝わらないため、各選択肢はレビューする人に見える違いで説明する。 +const questions: Record, Question> = { + language: { title: 'Review screen language', options: [{ value: 'en', label: 'English' }, { value: 'ja', label: 'Japanese (日本語)' }] }, + share: { title: 'Where to keep feedback', options: [ + { value: 'local', label: 'This computer only', detail: 'Git ignores the feedback files.' }, + { value: 'git', label: 'Share through Git', detail: 'Feedback files can be committed so teammates see them.' }, + ] }, + capture: { title: 'How to take screenshots', options: [ + { value: 'dom', label: 'Redraw the page', detail: 'Works right away with no permission prompt. Videos, embedded sites and some effects may look different.' }, + { value: 'tab', label: 'Record this browser tab', detail: 'Exact pixels. The browser asks once for permission to share the tab.' }, + { value: 'none', label: 'No screenshots', detail: 'Saves the comment and target details only.' }, + ] }, +}; + +export function describeConnection(connection: Connection): string { + if (connection.kind === 'connected') return `Already connected (${connection.file})`; + if (connection.kind === 'manual') return 'Not detected; see the steps after setup'; + return connection.framework === 'Next.js' ? 'Start `next dev` through NeedMark (package.json)' : `Add needmark() to ${connection.file}`; +} +// Consent is asked only when there is an edit to make; the question names the file and the effect on the team because the change is committed with the project. +// 書き換えるものがあるときだけ同意を求める。変更はプロジェクトと一緒にコミットされるため、質問ではファイルとチームへの影響を示す。 +function connectQuestion(connection: Connection): Question { + const detail = connection.kind === 'edit' && connection.framework === 'Next.js' + ? 'Your usual dev command and port keep working: NeedMark takes the port and runs Next.js behind it. Everyone who runs dev gets the toolbar; NEEDMARK=off skips it. Undo with `needmark remove`.' + : 'Your usual dev command then shows the toolbar. It never enters production builds. Everyone who runs dev gets it; NEEDMARK=off skips it. Undo with `needmark remove`.'; + return { title: 'Connect NeedMark to the development server', options: [ + { value: 'yes', label: describeConnection(connection), detail }, + { value: 'no', label: 'Not now', detail: 'Change no files; the steps to do it by hand are shown after setup.' }, + ] }; +} +const questionFor = (choice: Choice, connection: Connection) => choice === 'connect' ? connectQuestion(connection) : questions[choice]; + +// The terminal's locale is the best guess at the reviewer's language before any project setting exists. +// プロジェクトの設定がまだない段階では、ターミナルのロケールがレビューする人の言語の最もよい推測になるため。 +export function systemLanguage(): NeedMarkLanguage { + return new Intl.Locale(Intl.DateTimeFormat().resolvedOptions().locale).language === 'ja' ? 'ja' : 'en'; +} + +export function describeInit(options: InitChoices, connection: Connection): string { + const rows: [string, string][] = (['language', 'share', 'capture'] as const).map((choice) => [questions[choice].title, + questions[choice].options.find((option) => option.value === options[choice])?.label ?? String(options[choice])]); + rows.push(['Development server', connection.kind === 'edit' && options.connect === 'no' ? 'Not connected' : describeConnection(connection)]); + const width = Math.max(...rows.map(([title]) => title.length)); + return rows.map(([title, value]) => ` ${title.padEnd(width)} ${value}`).join('\n'); +} + +async function choose(ask: (text: string) => Promise, { title, options }: Question, fallback: string): Promise { + const range = `1-${options.length}`; + const menu = `\n${title}\n${options.map((option, index) => ` ${index + 1}) ${option.label}${option.detail ? `\n ${option.detail}` : ''}`).join('\n')}\n`; + let retry = ''; + for (;;) { + const answer = (await ask(`${menu}${retry}Choose ${range} [${options.findIndex((option) => option.value === fallback) + 1}]: `)).trim().toLowerCase(); + if (!answer) return fallback; + // Numbers are what the menu shows; the flag values are still accepted for people who know them. + // 画面に出すのは番号だが、フラグの値を知っている人のためにその値も受け付ける。 + const option = /^\d+$/.test(answer) ? options[Number(answer) - 1] : options.find((item) => item.value === answer); + if (option) return option.value; + retry = `Enter a number from ${range}.\n`; + } +} + +export const askConnection = (ask: (text: string) => Promise, connection: Connection) => choose(ask, connectQuestion(connection), 'yes') as Promise<'yes' | 'no'>; + +export async function askInitChoices(ask: (text: string) => Promise, given: Partial, connection: Connection, language = systemLanguage()): Promise { + const recommended: InitChoices = { language, share: 'local', capture: 'dom', connect: connection.kind === 'edit' ? 'yes' : 'no', ...given }; + const order: Choice[] = ['language', 'share', 'capture', ...(connection.kind === 'edit' ? ['connect' as const] : [])]; + if (order.every((choice) => given[choice] !== undefined)) return recommended; + let retry = ''; + for (;;) { + const answer = (await ask(`${retry ? '' : `NeedMark setup\n\nRecommended settings:\n${describeInit(recommended, connection)}\n\n`}${retry}Use the recommended settings? [Y/n]: `)).trim().toLowerCase(); + if (['', 'y', 'yes'].includes(answer)) return recommended; + if (['n', 'no'].includes(answer)) break; + retry = 'Enter y or n.\n'; + } + const chosen: Partial = { ...given }; + for (const choice of order) if (chosen[choice] === undefined) Object.assign(chosen, { [choice]: await choose(ask, questionFor(choice, connection), recommended[choice]!) }); + return { ...recommended, ...chosen }; +} diff --git a/src/server/init.ts b/src/server/init.ts index 5deb356..895d751 100644 --- a/src/server/init.ts +++ b/src/server/init.ts @@ -11,7 +11,19 @@ export async function initialize(directory: string, options: InitOptions): Promi if (options.language !== undefined && !isNeedMarkLanguage(options.language)) throw new Error('Invalid NeedMark language. Use en or ja.'); if (!['dom', 'tab', 'none'].includes(options.capture) || !['local', 'git'].includes(options.share)) throw new Error('Invalid initialization choice.'); const outputDir = defaultFeedbackDirectory(directory), storage = outputDir.split('/')[0]!; - const config = `import type { NeedMarkOptions } from 'needmark';\n\nexport default {\n language: ${JSON.stringify(options.language ?? 'ja')},\n capture: ${JSON.stringify(options.capture)},\n outputDir: '${outputDir}',\n} satisfies NeedMarkOptions;\n`; + // The comments name what each value does, because the file is where people come back to change a choice made during setup. + // 初期設定で選んだ値を後から変えるときに見るのはこのファイルのため、各値の意味をコメントで残す。 + const config = `import type { NeedMarkOptions } from 'needmark'; + +export default { + // Set enabled: false to turn NeedMark off for everyone; NEEDMARK=off does it for one person or run. + // Review screen language: 'en' or 'ja'. + language: ${JSON.stringify(options.language ?? 'ja')}, + // Screenshots: 'dom' redraws the page (no permission prompt), 'tab' records this browser tab (asks once to share it), 'none' takes no image. + capture: ${JSON.stringify(options.capture)}, + outputDir: '${outputDir}', +} satisfies NeedMarkOptions; +`; const configPath = join(directory, 'needmark.config.ts'); if (!existsSync(configPath) && existsSync(join(directory, 'needback.config.ts'))) throw new Error('Existing needback.config.ts is still supported. Rename it to needmark.config.ts before initializing again.'); // The exclusive `wx` flag refuses an existing file atomically, unlike an exists check followed by a write. diff --git a/src/server/package-manager.ts b/src/server/package-manager.ts index 9691f34..5fb6246 100644 --- a/src/server/package-manager.ts +++ b/src/server/package-manager.ts @@ -13,3 +13,9 @@ export function needmarkCommand(from: string): string { if (dirname(directory) === directory) return 'npx needmark'; } } + +const scripts: Record = { + 'pnpm exec needmark': ['pnpm dev', 'pnpm remove needmark'], 'yarn needmark': ['yarn dev', 'yarn remove needmark'], 'npx needmark': ['npm run dev', 'npm uninstall needmark'], +}; +export const devCommand = (needmark: string) => scripts[needmark]?.[0] ?? 'npm run dev'; +export const removeCommand = (needmark: string) => scripts[needmark]?.[1] ?? 'npm uninstall needmark'; diff --git a/src/server/review-api.ts b/src/server/review-api.ts index b56d0c7..66aef46 100644 --- a/src/server/review-api.ts +++ b/src/server/review-api.ts @@ -11,7 +11,8 @@ import { normalizeContextAttributes } from '../shared/context-attributes.js'; import { isNeedMarkLanguage, type NeedMarkLanguage } from '../shared/language.js'; import { isAllowedOrigin } from '../shared/origin.js'; -export function validateReviewOptions(options: Pick): { origins?: string[]; capture: CaptureMethod; language: NeedMarkLanguage } { +export function validateReviewOptions(options: Pick): { origins?: string[]; capture: CaptureMethod; language: NeedMarkLanguage } { + if (options.enabled !== undefined && typeof options.enabled !== 'boolean') throw new Error('NeedMark enabled must be true or false.'); if (options.language !== undefined && !isNeedMarkLanguage(options.language)) throw new Error('Invalid NeedMark language. Use en or ja.'); if (options.editor !== undefined && !['vscode', 'cursor'].includes(options.editor)) throw new Error('Invalid NeedMark editor.'); normalizeContextAttributes(options.contextAttributes); diff --git a/src/server/review-script.ts b/src/server/review-script.ts new file mode 100644 index 0000000..4d75ef8 --- /dev/null +++ b/src/server/review-script.ts @@ -0,0 +1,44 @@ +import type { OutgoingHttpHeaders, ServerResponse } from 'node:http'; + +const escapeAttribute = (value: string) => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); + +export function reviewScriptTag(attrs: Record): string { + return `\n\n`; +} + +// Frameworks such as Astro render pages in their own middleware and never call transformIndexHtml, so the script is appended after the +// last chunk of their HTML response instead; browsers still run a script after , and the stream is neither buffered nor parsed. +// AstroなどはページをVite独自のミドルウェアで描画しtransformIndexHtmlを呼ばないため、HTMLレスポンスの最後のチャンクの後に追記する。 +// の後のscriptもブラウザは実行し、ストリームをバッファも解析もしない。 +export function appendReviewScript(response: ServerResponse, attrs: Record, skip: () => boolean): void { + let html: boolean | undefined; + const decide = () => { + if (html !== undefined) return html; + const type = String(response.getHeader('content-type') ?? ''), encoding = String(response.getHeader('content-encoding') ?? 'identity'); + html = (response.statusCode === 200 || response.statusCode >= 400) && /\btext\/html\b/i.test(type) && encoding === 'identity' && !skip(); + // The appended script changes the body, so a declared length would cut it off. + // 追記したscriptで本文が変わるため、宣言済みの長さのままだと途中で切れてしまう。 + if (html) response.removeHeader('content-length'); + return html; + }; + const writeHead = response.writeHead.bind(response) as (...args: unknown[]) => ServerResponse; + // Headers passed to writeHead skip getHeader, so they are stored first; raw header arrays are left alone and never get the script. + // writeHeadに渡したヘッダーはgetHeaderで読めないため先に保存する。生のヘッダー配列はそのまま渡し、scriptも追加しない。 + response.writeHead = ((status: number, ...rest: unknown[]) => { + const reason = typeof rest[0] === 'string' ? rest.shift() as string : undefined, headers = rest[0]; + if (Array.isArray(headers)) { html = false; return writeHead(status, ...(reason === undefined ? [] : [reason]), headers); } + for (const [name, value] of Object.entries((headers ?? {}) as OutgoingHttpHeaders)) if (value !== undefined) response.setHeader(name, value); + response.statusCode = status; decide(); + return reason === undefined ? writeHead(status) : writeHead(status, reason); + }) as typeof response.writeHead; + const write = response.write.bind(response) as (...args: unknown[]) => boolean; + response.write = ((...args: unknown[]) => { if (!response.headersSent) decide(); return write(...args); }) as typeof response.write; + const end = response.end.bind(response) as (...args: unknown[]) => ServerResponse; + response.end = ((...args: unknown[]) => { + if (!response.headersSent) decide(); + if (!html || typeof args[0] === 'function') return html ? end(reviewScriptTag(attrs), args[0]) : end(...args); + const callback = args.find((value) => typeof value === 'function'); + if (args[0] !== undefined && args[0] !== null) write(...args.filter((value) => typeof value !== 'function')); + return callback ? end(reviewScriptTag(attrs), callback) : end(reviewScriptTag(attrs)); + }) as typeof response.end; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 6f07d70..b2e8318 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -123,6 +123,7 @@ export interface FeedbackSubmission { /** Options are specified in docs/getting-started.md#project-configuration. / 各オプションの仕様は docs/getting-started.ja.md#プロジェクト設定 を参照。 */ export interface NeedMarkOptions { + enabled?: boolean; origins?: string[]; language?: NeedMarkLanguage; capture?: CaptureMethod; diff --git a/tests/animation-freeze.spec.ts b/tests/animation-freeze.spec.ts index 7d5b399..9efa35a 100644 --- a/tests/animation-freeze.spec.ts +++ b/tests/animation-freeze.spec.ts @@ -1,12 +1,11 @@ -import { test, expect, type Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, output, configureReview } from './support/review.js'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; type Fixture = { counts: { timeout: number; interval: number; frame: number; kept: number; canceled: number }; appAnimation: Animation; priorPaused: Animation; widgetAnimation: Animation; @@ -21,13 +20,10 @@ async function state(page: Page) { }); } -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); -test('pause freezes CSS, page loops and WAAPI, keeps the widget/capture/poll live, and resumes cancelable work', async ({ page }) => { +test('pause freezes CSS, page loops and WAAPI, keeps the widget/capture/poll live, and resumes cancelable work', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('dom'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await configureReview(page, { method: 'dom' }); await page.evaluate(() => { const style = document.createElement('style'); style.textContent = '@keyframes pause-fixture-spin{from{opacity:.2}to{opacity:1}}.pause-fixture-spin{animation:pause-fixture-spin .3s linear infinite}.pause-fixture-prior{animation-play-state:paused}.pause-fixture-transition{transition:opacity 2s linear}'; @@ -96,7 +92,7 @@ test('pause freezes CSS, page loops and WAAPI, keeps the widget/capture/poll liv expect(await page.locator('[data-needmark-freeze]').count()).toBe(0); }); -test('pagehide releases page animations and queued loops without resuming a video or animation that was already paused', async ({ page }) => { +test('pagehide releases page animations and queued loops without resuming a video or animation that was already paused', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.evaluate(() => { const video = document.createElement('video'); video.id = 'pause-empty-video'; document.body.append(video); diff --git a/tests/capture-clip-path.spec.ts b/tests/capture-clip-path.spec.ts index 0646fc5..5c04498 100644 --- a/tests/capture-clip-path.spec.ts +++ b/tests/capture-clip-path.spec.ts @@ -1,10 +1,10 @@ -import { test, expect, type Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; +import { test, expect, output, configureReview } from './support/review.js'; import { resolve } from 'node:path'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; import type { CaptureSnapshot } from '../src/client/capture/identity.js'; -const output = '/private/tmp/needmark-e2e-feedback'; const urls = { capture: `/@fs/${resolve('src/client/capture/screenshot.ts')}`, identity: `/@fs/${resolve('src/client/capture/identity.ts')}`, context: `/@fs/${resolve('src/client/context/code-context.ts')}` }; const background = [217, 223, 199, 255], shade = [250, 244, 221, 255], mask = [46, 49, 52, 255]; const shapes = [ @@ -60,9 +60,8 @@ async function domCapture(page: Page) { } finally { capture.stop(); host.remove(); } }, urls); } -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); -test('full viewport DOM PNG preserves the consumer trapezoid, inset, circle, ellipse and clipped descendants outside the selected target', async ({ page }) => { +test('full viewport DOM PNG preserves the consumer trapezoid, inset, circle, ellipse and clipped descendants outside the selected target', { tag: '@cross-engine' }, async ({ page }) => { await fixture(page); const native = await page.screenshot(); expect(await pixels(page, native.toString('base64'), shapes)).toEqual(expected(shapes)); const captured = await domCapture(page); @@ -74,7 +73,7 @@ test('full viewport DOM PNG preserves the consumer trapezoid, inset, circle, ell expect(await pixels(page, captured.png, [{ name: 'excluded-kit', x: 1090, y: 40 }])).toEqual([{ name: 'excluded-kit', color: background }]); }); -test('DOM shape rendering handles pseudo-elements, open shadow and same-origin frame content while keeping all privacy masks', async ({ page }) => { +test('DOM shape rendering handles pseudo-elements, open shadow and same-origin frame content while keeping all privacy masks', { tag: '@cross-engine' }, async ({ page }) => { await fixture(page); expect(await pixels(page, (await page.screenshot()).toString('base64'), nested)).toEqual(expected(nested)); const before = await page.evaluate(() => ({ password: (document.querySelector('#private-password') as HTMLInputElement).value, @@ -91,15 +90,14 @@ test('DOM shape rendering handles pseudo-elements, open shadow and same-origin f expect(before).toEqual({ password: 'private-password', shadow: 'shadow-private', frame: 'frame-private' }); }); -test('the real widget saves a full viewport shape-correct PNG and selected code geometry without requesting tab permission', async ({ page }) => { +test('the real widget saves a full viewport shape-correct PNG and selected code geometry without requesting tab permission', { tag: '@cross-engine' }, async ({ page }) => { let sharingRequests = 0; await page.exposeFunction('recordUnexpectedSharing', () => { sharingRequests++; }); await page.addInitScript(() => { if (navigator.mediaDevices) Object.defineProperty(navigator.mediaDevices, 'getDisplayMedia', { configurable: true, value: () => { void (window as unknown as { recordUnexpectedSharing(): Promise }).recordUnexpectedSharing(); return Promise.reject(new Error('DOM capture must not request tab permission')); } }); }); - await fixture(page, true); await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('dom'); await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await fixture(page, true); await configureReview(page, { method: 'dom' }); await page.getByRole('button', { name: 'コメント', exact: true }).click(); const bounds = (await page.locator('#review-target').boundingBox())!; await page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await page.getByLabel('コメント本文').fill('台形と丸の形を維持して余白を調整する'); diff --git a/tests/capture-consistency.spec.ts b/tests/capture-consistency.spec.ts index 8189515..b6b6928 100644 --- a/tests/capture-consistency.spec.ts +++ b/tests/capture-consistency.spec.ts @@ -1,11 +1,11 @@ -import { test, expect, type Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; +import { test, expect, output, configureReview } from './support/review.js'; import { resolve } from 'node:path'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import type { CaptureGuard } from '../src/client/capture/identity.js'; import type { ScreenshotCapture } from '../src/client/capture/screenshot.js'; import type { Feedback, FeedbackInput, Rectangle } from '../src/shared/types.js'; -const output = '/private/tmp/needmark-e2e-feedback'; const urls = { capture: `/@fs/${resolve('src/client/capture/screenshot.ts')}`, identity: `/@fs/${resolve('src/client/capture/identity.ts')}`, context: `/@fs/${resolve('src/client/context/code-context.ts')}` }; interface NativeHarness { capture: ScreenshotCapture; canvas: HTMLCanvasElement; host: HTMLElement; requested: Promise; closed: number; @@ -50,9 +50,8 @@ async function nativeFixture(page: Page) { }, urls.capture); } -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); -test('DOM pixels, selection outline and committed code geometry agree after the same original element moves before prepare', async ({ page }) => { +test('DOM pixels, selection outline and committed code geometry agree after the same original element moves before prepare', { tag: '@cross-engine' }, async ({ page }) => { await modulePage(page); const result = await page.evaluate(async (urls) => { const { ScreenshotCapture } = await import(urls.capture), { createCaptureGuard, applyCaptureSnapshot } = await import(urls.identity); @@ -80,7 +79,7 @@ test('DOM pixels, selection outline and committed code geometry agree after the expect(result.capture).toEqual({ method: 'dom', ...result.image }); }); -test('native frames prepare after the hide delay, rebase before acquisition and reject later original movement without committing', async ({ page }) => { +test('native frames prepare after the hide delay, rebase before acquisition and reject later original movement without committing', { tag: '@cross-engine' }, async ({ page }) => { await nativeFixture(page); const result = await page.evaluate(async (urls) => { const fixture = (window as unknown as { nativeHarness: NativeHarness }).nativeHarness; @@ -108,7 +107,7 @@ test('native frames prepare after the hide delay, rebase before acquisition and expect(result.capture).toEqual({ method: 'tab', width: 1200, height: 850 }); }); -test('native pixel acquisition rejects privacy identity, geometry, URL and DPR drift and masks tolerated subpixel movement at the original edges', async ({ page }) => { +test('native pixel acquisition rejects privacy identity, geometry, URL and DPR drift and masks tolerated subpixel movement at the original edges', { tag: '@cross-engine' }, async ({ page }) => { await nativeFixture(page); const result = await page.evaluate(async () => { const fixture = (window as unknown as { nativeHarness: NativeHarness }).nativeHarness; @@ -145,9 +144,8 @@ test('native pixel acquisition rejects privacy identity, geometry, URL and DPR d }); for (const change of ['move', 'replace'] as const) { - test(`widget preserves typed feedback after original ${change} during DOM rendering and requires explicit image-free saving`, async ({ page }) => { - await page.goto('/'); await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('dom'); await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + test(`widget preserves typed feedback after original ${change} during DOM rendering and falls back to image-free saving`, { tag: '@cross-engine' }, async ({ page }) => { + await page.goto('/'); await configureReview(page, { method: 'dom' }); await page.evaluate(() => { const state = window as unknown as { captureGateReached: boolean; releaseCaptureGate: () => void; captureGateObserver: MutationObserver }; state.captureGateReached = false; const pending = new Promise((resolve) => { state.releaseCaptureGate = resolve; }); @@ -169,11 +167,10 @@ for (const change of ['move', 'replace'] as const) { if (change === 'move') original.style.transform = 'translateX(30px)'; else original.replaceWith(original.cloneNode(true)); (window as unknown as { releaseCaptureGate: () => void }).releaseCaptureGate(); }, change); - await expect(page.getByRole('button', { name: '画像なしで保存する', exact: true })).toBeVisible(); + await expect(page.getByText('画像を撮影できなかったため、画像なしで保存します。', { exact: true })).toBeVisible(); await expect(page.getByLabel('コメント本文')).toHaveValue(`撮影待ちに入力した ${change} のコメント`); - await expect(page.getByRole('button', { name: '保存', exact: true })).toBeDisabled(); await expect(page.getByRole('img', { name: 'コメントした場所のキャプチャ', exact: true })).toBeHidden(); - await page.getByRole('button', { name: '画像なしで保存する', exact: true }).click(); await page.getByRole('button', { name: '保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); await expect(page.getByText('コメントを保存しました。', { exact: true })).toBeVisible(); const ids = (await readdir(output)).filter((id) => !id.startsWith('.')); expect(ids).toHaveLength(1); const saved = JSON.parse(await readFile(`${output}/${ids[0]}/context.json`, 'utf8')) as Feedback; diff --git a/tests/capture-identity.spec.ts b/tests/capture-identity.spec.ts index ffd0313..7a97201 100644 --- a/tests/capture-identity.spec.ts +++ b/tests/capture-identity.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/review.js'; import { resolve } from 'node:path'; import type { FeedbackInput, Rectangle } from '../src/shared/types.js'; @@ -56,7 +56,7 @@ test('identical clones remain valid saved targets but cannot replace original ca expect(result).toEqual({ savedAcceptsClone: true, beforeRejects: true, duringRejects: true, missingRejects: true }); }); -test('reversed text selections retain partial geometry and reject endpoint or full selected text drift beyond saved excerpts', async ({ page }) => { +test('reversed text selections retain partial geometry and reject endpoint or full selected text drift beyond saved excerpts', { tag: '@cross-engine' }, async ({ page }) => { const result = await page.evaluate(async (urls) => { const { createCaptureGuard, applyCaptureSnapshot } = await import(urls.identity); const { contextForRange, rangeBetween, viewportRectForRange, viewportRectOf, resolveContextElement } = await import(urls.context); diff --git a/tests/capture.spec.ts b/tests/capture.spec.ts index 088d3af..248b4b7 100644 --- a/tests/capture.spec.ts +++ b/tests/capture.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/review.js'; import { resolve } from 'node:path'; const moduleUrl = `/@fs/${resolve('src/client/capture/screenshot.ts')}`; @@ -121,7 +121,7 @@ test('native bitmap frames are closed after success and after stop during a pend dimensions: { method: 'tab', width: 64, height: 48 }, png: true, trackState: 'ended' }); }); -test('DOM capture masks passwords and explicit redaction including open shadows and same-origin frames without altering layout', async ({ page }) => { +test('DOM capture masks passwords and explicit redaction including open shadows and same-origin frames without altering layout', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); const result = await page.evaluate(async (moduleUrl) => { const { ScreenshotCapture } = await import(moduleUrl); @@ -174,7 +174,7 @@ test('DOM capture masks passwords and explicit redaction including open shadows }); -test.describe('scrolled high-DPI capture geometry', () => { +test.describe('scrolled high-DPI capture geometry', { tag: '@cross-engine' }, () => { test.use({ deviceScaleFactor: 2 }); test('saved selection outline and redaction use viewport pixels after DOM renderer transforms', async ({ page }) => { await page.goto('/'); diff --git a/tests/cli-onboarding.spec.ts b/tests/cli-onboarding.spec.ts index 98522f4..d61c721 100644 --- a/tests/cli-onboarding.spec.ts +++ b/tests/cli-onboarding.spec.ts @@ -1,11 +1,10 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, saveAndList, output, tempRoot } from './support/review.js'; import { execFile, spawn, type ChildProcess } from 'node:child_process'; import { promisify } from 'node:util'; import { createServer, type Server } from 'node:http'; import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; const cli = resolve('dist/cli.js'), execute = promisify(execFile); test.setTimeout(45_000); @@ -25,7 +24,7 @@ async function exists(path: string) { // Intentional onboarding contract: init's shared settings also govern CLI proxy and local-agent commands. test('fresh CLI init settings govern proxy capture and one shared review directory through UI and agent commands', async ({ page }, info) => { - const root = await mkdtemp('/private/tmp/needmark-cli-onboarding-'); + const root = await mkdtemp(join(tempRoot, 'needmark-cli-onboarding-')); let upstream: Server | undefined, dev: ChildProcess | undefined, exited: Promise | undefined; let stdout = '', stderr = ''; const commands: { args: string[]; stdout: string; stderr: string }[] = []; @@ -74,7 +73,7 @@ test('fresh CLI init settings govern proxy capture and one shared review directo // C23 flags-only behavior ignores this generated config and initializes the fresh browser to DOM. await expect(page.getByLabel('キャプチャ方式')).toHaveValue('none'); expect(setup.script.capture).toBe('none'); expect(setup.feedback).toEqual({ directory: 'reviews', command: 'npx needmark', feedback: [] }); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); const targetButton = page.locator('#onboarding-target'); await targetButton.click(); await expect(page.getByLabel('Native click count')).toHaveText('1'); const original = await targetButton.elementHandle(), originalDocument = await page.evaluateHandle(() => document); if (!original) throw new Error('The original reviewed control is missing'); @@ -132,7 +131,7 @@ test('fresh CLI init settings govern proxy capture and one shared review directo type CliReview = { root: string; origin: string; run: (...args: string[]) => Promise<{ stdout: string; stderr: string }> }; async function withConfiguredCli(page: import('@playwright/test').Page, info: import('@playwright/test').TestInfo, options: { flags?: string[]; serverSettings?: string }, work: (review: CliReview) => Promise) { - const root = await mkdtemp('/private/tmp/needmark-configured-cli-'); + const root = await mkdtemp(join(tempRoot, 'needmark-configured-cli-')); let upstream: Server | undefined, dev: ChildProcess | undefined, exited: Promise | undefined; let stdout = '', stderr = ''; const commands: { args: string[]; stdout: string; stderr: string }[] = []; @@ -200,11 +199,11 @@ test('explicit CLI flags override shared defaults while a saved browser capture const initial = await configuredEvidence(page); expect(initial).toMatchObject({ capture: 'dom', preference: null, status: 200, feedback: { directory: 'overrides', feedback: [] } }); await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('キャプチャ方式')).toHaveValue('dom'); - await page.getByLabel('キャプチャ方式').selectOption('none'); await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByLabel('キャプチャ方式').selectOption('none'); await page.getByRole('button', { name: '保存', exact: true }).click(); await page.reload(); const afterReload = await configuredEvidence(page); expect(afterReload).toMatchObject({ capture: 'dom', preference: 'none', feedback: { directory: 'overrides', feedback: [] } }); await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('キャプチャ方式')).toHaveValue('none'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); const saved = await saveConfiguredTarget(page, origin, 'CLIの明示した保存先と、ブラウザで選んだ画像なし設定を優先する'); const directory = root + '/overrides/' + saved.id; expect(JSON.parse(await readFile(directory + '/context.json', 'utf8'))).toEqual(saved); @@ -224,7 +223,7 @@ test('shared CLI configuration enables custom target evidence, a validated local const initial = await configuredEvidence(page); expect(initial).toMatchObject({ capture: 'none', editor: 'cursor', submit: 'true', contextAttributes: ['data-review-key'], status: 200, feedback: { directory: 'reviews', feedback: [] } }); await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('キャプチャ方式')).toHaveValue('none'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); const original = await page.locator('#configured-target').elementHandle(); if (!original) throw new Error('The original configured control is missing'); try { const saved = await saveConfiguredTarget(page, origin, '開発者が指定した識別子を保存し、設定したローカル処理へコメントを渡す'); diff --git a/tests/code-context.spec.ts b/tests/code-context.spec.ts index c1d9f71..90ed95d 100644 --- a/tests/code-context.spec.ts +++ b/tests/code-context.spec.ts @@ -1,9 +1,9 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/review.js'; import { resolve } from 'node:path'; const moduleUrl = `/@fs/${resolve('src/client/context/code-context.ts')}`; -test('area context keeps text blocks with inline children, controls and image names without duplicate spans', async ({ page }) => { +test('area context keeps text blocks with inline children, controls and image names without duplicate spans', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(`

Title with emphasis

@@ -41,7 +41,7 @@ test('selectors escape unusual identifiers and remain unique with duplicate ids expect(contexts.same).toEqual([true, true, true, true]); }); -test('text ranges identify semantic owners and accessibility names without reading field values', async ({ page }) => { +test('text ranges identify semantic owners and accessibility names without reading field values', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('

Selected heading

Description selected text
second line.

Confirm changes'); const evidence = await page.evaluate(async (url) => { @@ -58,7 +58,7 @@ test('text ranges identify semantic owners and accessibility names without readi expect(JSON.stringify(evidence)).not.toContain('private-password'); }); -test('open shadow roots and same-origin frames retain source evidence, resolvable paths and top viewport bounds', async ({ page }) => { +test('open shadow roots and same-origin frames retain source evidence, resolvable paths and top viewport bounds', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('
'); const evidence = await page.evaluate(async (url) => { @@ -92,7 +92,7 @@ test('open shadow roots and same-origin frames retain source evidence, resolvabl expect(evidence.area.elements.map((element: { text: string }) => element.text)).toEqual(['Shadow buy', 'Frame buy']); }); -test('text caret selection works in open roots and scaled frames, accepts reversed drags and rejects root crossings', async ({ page }) => { +test('text caret selection works in open roots and scaled frames, accepts reversed drags and rejects root crossings', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('

Top words

'); const evidence = await page.evaluate(async (url) => { @@ -133,7 +133,7 @@ test('text caret selection works in open roots and scaled frames, accepts revers expect(evidence.privateCaret).toBe(true); }); -test('text caret selection falls back to the legacy browser API when the standard API is unavailable', async ({ page }) => { +test('text caret selection falls back to the legacy browser API when the standard API is unavailable', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('

Legacy text

'); const evidence = await page.evaluate(async (url) => { @@ -269,7 +269,7 @@ test('explicit multiple context keeps twenty owners while area and text collecti } }); -test('area sampling requires half of the original box to be visibly covered and preserves native center fallback', async ({ page }) => { +test('area sampling requires half of the original box to be visibly covered and preserves native center fallback', { tag: '@cross-engine' }, async ({ page }) => { await page.setViewportSize({ width: 800, height: 600 }); await page.goto('/'); await page.setContent('
'); @@ -300,7 +300,7 @@ test('area sampling requires half of the original box to be visibly covered and expect(evidence.after).toMatchObject(evidence.bounds); }); -test('area sampling keeps painted absolute and fixed escapes but rejects their containing block clips', async ({ page }) => { +test('area sampling keeps painted absolute and fixed escapes but rejects their containing block clips', { tag: '@cross-engine' }, async ({ page }) => { await page.setViewportSize({ width: 800, height: 600 }); await page.goto('/'); await page.setContent('
'); @@ -333,7 +333,7 @@ test('area sampling keeps painted absolute and fixed escapes but rejects their c } }); -test('area sampling rejects ancestor opacity zero while retaining an explicitly visible child of a hidden ancestor', async ({ page }) => { +test('area sampling rejects ancestor opacity zero while retaining an explicitly visible child of a hidden ancestor', { tag: '@cross-engine' }, async ({ page }) => { await page.setViewportSize({ width: 800, height: 600 }); await page.goto('/'); await page.setContent('
'); diff --git a/tests/compact-handoff.spec.ts b/tests/compact-handoff.spec.ts index 58e90bc..e03e335 100644 --- a/tests/compact-handoff.spec.ts +++ b/tests/compact-handoff.spec.ts @@ -1,11 +1,9 @@ -import { test, expect, type Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; +import { test, expect, searchList, toggleFilter, output, enableWidgetOption } from './support/review.js'; import { randomUUID } from 'node:crypto'; -import { rm } from 'node:fs/promises'; import type { ElementContext, Feedback, FeedbackInput, FeedbackStatus } from '../src/shared/types.js'; import { input } from './fixtures.js'; -import { searchList, toggleFilter } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; type ClipboardState = { compactCopied?: string; compactCopyPending?: boolean; rejectCompactCopy?: (error: Error) => void }; const copied = (page: Page) => page.evaluate(() => (window as unknown as ClipboardState).compactCopied ?? ''); function element(selector = '#compact-target', file = 'src/CompactButton.tsx'): ElementContext { @@ -25,15 +23,12 @@ async function setup(page: Page, submit = false, compact = true) { } } }); }); // Control the optional UI transport; the actual Node callback is covered by review-api tests. - if (submit) await page.route('http://127.0.0.1:4192/', async (route) => { - const response = await route.fetch(); - await route.fulfill({ response, body: (await response.text()).replace('data-needmark-capture=', 'data-needmark-submit="true" data-needmark-capture=') }); - }); + if (submit) await enableWidgetOption(page, 'data-needmark-submit'); await page.goto('/'); await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('AIへの情報量')).toHaveValue('standard'); await page.getByLabel('キャプチャ方式').selectOption('none'); if (compact) await page.getByLabel('AIへの情報量').selectOption({ label: '簡潔:コメントと場所・画像パス' }); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); } async function headers(page: Page) { return { 'x-needmark-token': (await page.locator('script[data-needmark-token]').getAttribute('data-needmark-token'))! }; } async function seed(page: Page, comment: string, selected = target(), status: FeedbackStatus = 'open', url = page.url(), image = false): Promise { @@ -53,25 +48,24 @@ async function list(page: Page) { } async function copyList(page: Page) { await page.getByRole('button', { name: 'プロンプトをコピー', exact: true }).click(); return copied(page); } -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); test('compact preference survives saving and reload, changes actual output, and invalid saved values fall back to standard', async ({ page }) => { await setup(page); const item = await seed(page, '保存した簡潔設定でこのボタンを修正する'); expect(await page.evaluate(() => localStorage.getItem('needmark-detail'))).toBe('compact'); await page.reload(); await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('AIへの情報量')).toHaveValue('compact'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); await list(page); + await page.getByRole('button', { name: '保存', exact: true }).click(); await list(page); const compact = await copyList(page); for (const value of [item.id, item.comment, item.url, `${item.id}/context.json`, 'src/CompactButton.tsx:17:3']) expect(compact).toContain(value); await page.getByRole('button', { name: '設定', exact: true }).click(); await page.getByLabel('AIへの情報量').selectOption('standard'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); await list(page); + await page.getByRole('button', { name: '保存', exact: true }).click(); await list(page); const standard = await copyList(page); expect(standard).not.toEqual(compact); expect(standard).toContain(item.comment); await page.evaluate(() => localStorage.setItem('needmark-detail', 'unsupported-format')); await page.reload(); await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('AIへの情報量')).toHaveValue('standard'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); expect(await page.evaluate(() => localStorage.getItem('needmark-detail'))).toBe('standard'); }); diff --git a/tests/context-attributes.spec.ts b/tests/context-attributes.spec.ts index 920c71d..952c5da 100644 --- a/tests/context-attributes.spec.ts +++ b/tests/context-attributes.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/review.js'; import { resolve } from 'node:path'; const moduleUrl = `/@fs/${resolve('src/client/context/code-context.ts')}`; diff --git a/tests/copy-scope.spec.ts b/tests/copy-scope.spec.ts index 7cbba5f..b1eb5f7 100644 --- a/tests/copy-scope.spec.ts +++ b/tests/copy-scope.spec.ts @@ -1,10 +1,8 @@ -import { test, expect, type Page } from '@playwright/test'; -import { rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect, searchList, toggleFilter, configureReview, enableWidgetOption } from './support/review.js'; import type { Feedback, FeedbackInput, FeedbackStatus } from '../src/shared/types.js'; import { input } from './fixtures.js'; -import { searchList, toggleFilter } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; const copied = (page: Page) => page.evaluate(() => (window as unknown as { scopeCopied: string }).scopeCopied); function target(selector: string): FeedbackInput['target'] { return { kind: 'element', text: 'Review target', selector, rect: { x: 100, y: 100, width: 120, height: 40 } }; @@ -18,12 +16,8 @@ async function setup(page: Page, send = false, denied = false) { if (denied) throw new Error('Clipboard denied'); } } }); }, { denied }); - if (send) await page.route('http://127.0.0.1:4192/', async (route) => { - const response = await route.fetch(); - await route.fulfill({ response, body: (await response.text()).replace('data-needmark-capture=', 'data-needmark-submit="true" data-needmark-capture=') }); - }); - await page.goto('/'); await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('none'); await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + if (send) await enableWidgetOption(page, 'data-needmark-submit'); + await page.goto('/'); await configureReview(page, { method: 'none' }); } async function headers(page: Page) { return { 'x-needmark-token': (await page.locator('script[data-needmark-token]').getAttribute('data-needmark-token'))! }; } async function seed(page: Page, comment: string, selected: FeedbackInput['target'], status: FeedbackStatus = 'open', url = page.url()): Promise { @@ -40,7 +34,6 @@ async function list(page: Page) { await page.getByRole('button', { name: 'コメ const copyPrompt = (page: Page) => page.getByRole('button', { name: 'プロンプトをコピー', exact: true }); async function promptIds(page: Page, seeded: Feedback[]) { await copyPrompt(page).click(); const prompt = await copied(page); return seeded.filter((item) => prompt.includes(item.id)).map((item) => item.id).sort(); } -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); test('prompt copies and send share the searched, resolved-filtered and exact-page open scope', async ({ page }) => { await setup(page, true); diff --git a/tests/deep-pick-context.spec.ts b/tests/deep-pick-context.spec.ts index 6891fd5..b14ab47 100644 --- a/tests/deep-pick-context.spec.ts +++ b/tests/deep-pick-context.spec.ts @@ -1,9 +1,9 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/review.js'; import { resolve } from 'node:path'; const moduleUrl = `/@fs/${resolve('src/client/context/code-context.ts')}`; -test('deep picking skips an overlay label and its ancestors while excluding the widget', async ({ page }) => { +test('deep picking skips an overlay label and its ancestors while excluding the widget', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(`
@@ -20,7 +20,7 @@ test('deep picking skips an overlay label and its ancestors while excluding the expect(hits).toEqual({ normal: 'overlay-label', deep: 'underlay', styleUnchanged: true }); }); -test('skipping an outer overlay retains meaningful front content in the lower open shadow host', async ({ page }) => { +test('skipping an outer overlay retains meaningful front content in the lower open shadow host', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(`
Outer overlay
`); @@ -35,7 +35,7 @@ test('skipping an outer overlay retains meaningful front content in the lower op expect(hits).toEqual({ normal: 'outer-overlay', deep: 'shadow-front', rootPath: ['shadow: #shadow-host'] }); }); -test('deep picking prefers a lower branch inside the frontmost open shadow root', async ({ page }) => { +test('deep picking prefers a lower branch inside the frontmost open shadow root', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(`
`); @@ -49,7 +49,7 @@ test('deep picking prefers a lower branch inside the frontmost open shadow root' expect(hits).toEqual({ normal: 'shadow-overlay-label', deep: 'shadow-underlay' }); }); -test('skipping an outer overlay preserves slotted light DOM targets within the lower shadow host', async ({ page }) => { +test('skipping an outer overlay preserves slotted light DOM targets within the lower shadow host', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('
Outer overlay
'); const hits = await page.evaluate(async (url) => { @@ -60,7 +60,7 @@ test('skipping an outer overlay preserves slotted light DOM targets within the l expect(hits).toEqual({ normal: 'outer-overlay', deep: 'slotted' }); }); -test('deep frame picking preserves padded scaled coordinates and retains a meaningful inner overlay', async ({ page }) => { +test('deep frame picking preserves padded scaled coordinates and retains a meaningful inner overlay', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(''); const hits = await page.evaluate(async (url) => { @@ -86,7 +86,7 @@ test('deep frame picking preserves padded scaled coordinates and retains a meani expect(hits.outer).toEqual({ normal: 'outer-overlay', deep: 'frame-overlay-label' }); }); -test('deep picking retains the normal hit when only its ancestors lie underneath', async ({ page }) => { +test('deep picking retains the normal hit when only its ancestors lie underneath', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('
'); const hits = await page.evaluate(async (url) => { @@ -96,7 +96,7 @@ test('deep picking retains the normal hit when only its ancestors lie underneath expect(hits).toEqual({ normal: 'only-hit', deep: 'only-hit' }); }); -test('deep picking reaches meaningful content beneath two independent empty overlays', async ({ page }) => { +test('deep picking reaches meaningful content beneath two independent empty overlays', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(`
@@ -108,7 +108,7 @@ test('deep picking reaches meaningful content beneath two independent empty over expect(hits).toEqual({ normal: 'upper-cover', deep: 'underlay' }); }); -test('deep picking searches through empty outer and inner shadow covers', async ({ page }) => { +test('deep picking searches through empty outer and inner shadow covers', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(`
@@ -123,7 +123,7 @@ test('deep picking searches through empty outer and inner shadow covers', async expect(hits).toEqual({ normal: 'outer-cover', deep: 'shadow-underlay', rootPath: ['shadow: #shadow-host'] }); }); -test('deep picking searches through empty outer and padded scaled frame covers', async ({ page }) => { +test('deep picking searches through empty outer and padded scaled frame covers', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(''); const hits = await page.evaluate(async (url) => { @@ -144,7 +144,7 @@ test('deep picking searches through empty outer and padded scaled frame covers', context: { rootPath: ['iframe: #embed'], rect: { x: 335, y: 131.25, width: 100, height: 50 } } }); }); -test('deep picking ignores an opacity-zero intermediate hit and retains normal fallback when all lower hits are invisible', async ({ page }) => { +test('deep picking ignores an opacity-zero intermediate hit and retains normal fallback when all lower hits are invisible', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(` @@ -158,7 +158,7 @@ test('deep picking ignores an opacity-zero intermediate hit and retains normal f expect(hits).toEqual({ normal: 'cover', deep: 'visible', fallback: 'cover' }); }); -test('deep picking uses the smallest visible rectangle when lower branches contain no meaningful content', async ({ page }) => { +test('deep picking uses the smallest visible rectangle when lower branches contain no meaningful content', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(`
@@ -170,7 +170,7 @@ test('deep picking uses the smallest visible rectangle when lower branches conta expect(hits).toEqual({ normal: 'cover', deep: 'swatch' }); }); -test('deep picking ranks an empty accessible control above a smaller decorative visual', async ({ page }) => { +test('deep picking ranks an empty accessible control above a smaller decorative visual', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent(`
@@ -182,7 +182,7 @@ test('deep picking ranks an empty accessible control above a smaller decorative expect(hits).toEqual({ normal: 'cover', deep: 'accessible-control' }); }); -test('a frontmost slotted button retains normal fallback rather than selecting its rendered slot ancestor', async ({ page }) => { +test('a frontmost slotted button retains normal fallback rather than selecting its rendered slot ancestor', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('
'); const hits = await page.evaluate(async (url) => { @@ -193,7 +193,7 @@ test('a frontmost slotted button retains normal fallback rather than selecting i expect(hits).toEqual({ normal: 'slotted', deep: 'slotted' }); }); -test('deep root search keeps the existing eight-level descent bound', async ({ page }) => { +test('deep root search keeps the existing eight-level descent bound', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('
'); const hits = await page.evaluate(async (url) => { @@ -211,7 +211,7 @@ test('deep root search keeps the existing eight-level descent bound', async ({ p expect(hits).toEqual({ normal: 'cover', deep: 'root-7', roots: 7 }); }); -test('closed shadow roots and inaccessible frames remain opaque targets in deep mode', async ({ page }) => { +test('closed shadow roots and inaccessible frames remain opaque targets in deep mode', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('
'); const hits = await page.evaluate(async (url) => { @@ -228,7 +228,7 @@ test('closed shadow roots and inaccessible frames remain opaque targets in deep expect(hits).toEqual({ closedNormal: 'closed-host', closedDeep: 'closed-host', frameNormal: 'opaque-frame', frameDeep: 'opaque-frame', inaccessible: true }); }); -test('a hidden lower shadow host can still supply an explicitly visible child', async ({ page }) => { +test('a hidden lower shadow host can still supply an explicitly visible child', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await page.setContent('
'); const hits = await page.evaluate(async (url) => { diff --git a/tests/deep-selection-layered.spec.ts b/tests/deep-selection-layered.spec.ts index 143cc05..b6b20d3 100644 --- a/tests/deep-selection-layered.spec.ts +++ b/tests/deep-selection-layered.spec.ts @@ -1,11 +1,9 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, saveAndList, output } from './support/review.js'; import { readFile, readdir, rm } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test('a target beneath multiple empty covers keeps its identifiers and visible pixels through save and sharing', async ({ page }, info) => { +test('a target beneath multiple empty covers keeps its identifiers and visible pixels through save and sharing', { tag: '@cross-engine' }, async ({ page }, info) => { await rm(output, { recursive: true, force: true }); await page.addInitScript(() => { localStorage.setItem('needmark-configured', 'true'); diff --git a/tests/deep-selection.spec.ts b/tests/deep-selection.spec.ts index b9c31cb..9babc7f 100644 --- a/tests/deep-selection.spec.ts +++ b/tests/deep-selection.spec.ts @@ -1,9 +1,8 @@ -import { test, expect, type Page } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, output } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; const cover = (page: Page) => page.locator('[data-needmark] .cover'); const field = (page: Page) => page.getByLabel('コメント本文'); @@ -31,9 +30,8 @@ async function saved(): Promise { return JSON.parse(await readFile(`${output}/${ids[0]}/context.json`, 'utf8')) as Feedback; } -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); -test('ordinary clicks still select the frontmost overlay and do not activate the application', async ({ page }) => { +test('ordinary clicks still select the frontmost overlay and do not activate the application', { tag: '@cross-engine' }, async ({ page }) => { await fixture(page); await page.mouse.click(200, 210); await field(page).fill('Review the overlay'); await saveAndList(page); await expect(page.getByText('コメントを保存しました。', { exact: true })).toBeVisible(); @@ -43,7 +41,7 @@ test('ordinary clicks still select the frontmost overlay and do not activate the }); for (const modifier of ['Control', 'Meta'] as const) { - test(`${modifier} at gesture onset selects the covered target, saves pixels and shares its actual context`, async ({ page }, info) => { + test(`${modifier} at gesture onset selects the covered target, saves pixels and shares its actual context`, { tag: '@cross-engine' }, async ({ page }, info) => { // caret: 'hide' writes and removes inline styles on the page's inputs, and NeedMark clears the hover a frame after any page change. await fixture(page, 'dom'); const reference = await page.screenshot({ caret: 'initial' }); await page.keyboard.down(modifier); await page.mouse.move(200, 210); @@ -88,7 +86,7 @@ for (const modifier of ['Control', 'Meta'] as const) { }); } -test('a modifier pressed only after pointerdown cannot retarget the gesture', async ({ page }) => { +test('a modifier pressed only after pointerdown cannot retarget the gesture', { tag: '@cross-engine' }, async ({ page }) => { await fixture(page); await page.mouse.move(200, 210); await page.mouse.down(); await page.keyboard.down('Meta'); await page.mouse.up(); await page.keyboard.up('Meta'); await field(page).fill('Keep the initially selected overlay'); diff --git a/tests/editor-links.spec.ts b/tests/editor-links.spec.ts index 6b69ac0..033fc67 100644 --- a/tests/editor-links.spec.ts +++ b/tests/editor-links.spec.ts @@ -1,16 +1,12 @@ -import { test, expect, type Page } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, output, configureReview } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); async function setup(page: Page) { await page.goto('/'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('none'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await configureReview(page, { method: 'none' }); } async function select(page: Page) { await page.getByRole('button', { name: 'コメント', exact: true }).click(); diff --git a/tests/feedback.spec.ts b/tests/feedback.spec.ts index ca6c24d..6932351 100644 --- a/tests/feedback.spec.ts +++ b/tests/feedback.spec.ts @@ -1,13 +1,11 @@ -import { test, expect, type Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, output, configureReview, tabColorTolerance } from './support/review.js'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { relative, resolve } from 'node:path'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); async function savedFeedback(): Promise { const entries = (await readdir(output)).filter((id) => !id.startsWith('.')); @@ -39,13 +37,13 @@ async function exportedPrompt(page: Page, browserName: string): Promise return prompt.inputValue(); } -test('area selection saves image and feedback, survives reload, and shares status with the CLI files', async ({ page, browserName }) => { +test('area selection saves image and feedback, survives reload, and shares status with the CLI files', { tag: '@cross-engine' }, async ({ page, browserName }) => { await preparePromptExport(page, browserName); await page.goto('/'); await page.getByRole('button', { name: '設定', exact: true }).click(); await page.getByLabel('投稿者名').fill('Designer A'); await page.getByLabel('キャプチャ方式').selectOption('dom'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); await page.getByRole('button', { name: 'コメント', exact: true }).click(); const bounds = await page.locator('#product-title').boundingBox(); await page.mouse.move(bounds!.x - 10, bounds!.y - 10); @@ -102,11 +100,8 @@ test('the list shows lost polling connections and clears the notice once polling }); test('native tab capture records the selected element and actual browser frame', async ({ page, browserName }) => { - test.skip(browserName !== 'chromium', 'The automated native tab picker is supported by the Chromium launch configuration.'); await page.goto('/'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('tab'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await configureReview(page, { method: 'tab' }); await page.getByRole('button', { name: 'コメント', exact: true }).click(); await expect(page.locator('[data-needmark] .cover')).toBeVisible(); const button = await page.locator('#purchase-button').boundingBox(); @@ -136,8 +131,7 @@ test('native tab capture records the selected element and actual browser frame', }, { png: image.toString('base64'), rect: saved.target.rect, viewport: saved.viewport }); const expected = await page.locator('#purchase-button').evaluate((element) => getComputedStyle(element).backgroundColor.match(/\d+/g)!.slice(0, 3).map(Number)); - // Tab video can introduce small YUV/RGB rounding differences. - expected.forEach((channel, index) => expect(Math.abs(color[index]! - channel)).toBeLessThanOrEqual(2)); + expected.forEach((channel, index) => expect(Math.abs(color[index]! - channel)).toBeLessThanOrEqual(tabColorTolerance)); }); test('React comments retain source locations, runtime ownership and distinguish repeated component instances', async ({ page, browserName }) => { @@ -146,9 +140,7 @@ test('React comments retain source locations, runtime ownership and distinguish const purchase = page.locator('#purchase-two'); await purchase.click(); await expect(purchase).toHaveText('Add seats · 1'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('dom'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await configureReview(page, { method: 'dom' }); await page.getByRole('button', { name: 'コメント', exact: true }).click(); const bounds = await purchase.boundingBox(); await page.mouse.click(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2); diff --git a/tests/fixtures/page-operation.html b/tests/fixtures/page-operation.html deleted file mode 100644 index 57d6fc2..0000000 --- a/tests/fixtures/page-operation.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - NeedMark — Page operation fixture - - - -
-

ページ操作を伴うレビュー

-
- 0 - - - - - - - - - - -
- - - - -
- - - 0 -
-
-

ホスト側のダイアログ

- - - diff --git a/tests/frame-capture.spec.ts b/tests/frame-capture.spec.ts index 644d9ed..fb9a270 100644 --- a/tests/frame-capture.spec.ts +++ b/tests/frame-capture.spec.ts @@ -1,8 +1,9 @@ -import { test, expect, type Frame, type Locator, type Page, type TestInfo } from '@playwright/test'; +import { type Frame, type Locator, type Page, type TestInfo } from '@playwright/test'; +import { test, expect, output, tempRoot, configureReview, tabColorTolerance } from './support/review.js'; import { build } from 'esbuild'; import { createServer, type Server } from 'node:http'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import { startReviewProxy, type ReviewProxy } from '../dist/proxy.js'; import type { Feedback, Rectangle, RuntimeSourceRequest, RuntimeSourceResult, SourceLocation } from '../src/shared/types.js'; @@ -24,7 +25,7 @@ async function close(server: Server) { /** Actual React dev allocation frames, current local JSX, source maps and public protected proxy. */ async function withReview(page: Page, nested: boolean, run: (review: Review) => Promise, inline = false) { - const root = await mkdtemp('/private/tmp/needmark-frame-capture-'); + const root = await mkdtemp(join(tempRoot, 'needmark-frame-capture-')); let upstream: Server | undefined, proxy: ReviewProxy | undefined; try { const source = await readFile(fixturePath, 'utf8'); await mkdir(root + '/src'); await writeFile(root + '/' + sourceFile, source); @@ -81,8 +82,7 @@ function sourceOf(review: Review): SourceLocation { return { file: sourceFile, line: line + 1, column: lines[line]!.indexOf(' response.url() === review.origin + '/__needmark/source' && response.request().method() === 'POST'); @@ -106,7 +106,9 @@ async function save(page: Page, review: Review, comment: string): Promise getComputedStyle(element).backgroundColor)).toBe('rgb(217, 91, 36)'); return leaf; } async function point(locator: Locator, name: string, color: number[], inset = 12): Promise { @@ -173,7 +175,7 @@ function expectPixels(actual: Awaited>, samples: Sam } } -test('only the top document owns the review toolbar while a child opened directly remains reviewable', async ({ page }, testInfo) => { +test('only the top document owns the review toolbar while a child opened directly remains reviewable', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, true, async (review) => { const { outer, leaf } = await frames(page, true); const state = { topUrl: page.url(), outerUrl: outer.url(), leafUrl: leaf.url(), top: await page.locator('[data-needmark] .dock').count(), outer: await outer.locator('[data-needmark] .dock').count(), leaf: await leaf.locator('[data-needmark] .dock').count() }; @@ -189,7 +191,7 @@ test('only the top document owns the review toolbar while a child opened directl }); }); -test('CSS-sized iframe DOM PNG records current React pixels and edited text with source, DPR and private regions', async ({ page }, testInfo) => { +test('CSS-sized iframe DOM PNG records current React pixels and edited text with source, DPR and private regions', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, false, async (review) => { const leaf = await currentChild(page), { samples, note } = await captureSamples(page); const dimensions = await page.locator('#outer-capture-frame').evaluate((frame) => ({ width: frame.getAttribute('width'), height: frame.getAttribute('height'), rect: frame.getBoundingClientRect().toJSON() })); @@ -213,7 +215,7 @@ test('CSS-sized iframe DOM PNG records current React pixels and edited text with }); }); -test('nested iframe DOM capture preserves live pixels and private rectangles after child and root scrolling', async ({ page }, testInfo) => { +test('nested iframe DOM capture preserves live pixels and private rectangles after child and root scrolling', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, true, async (review) => { const { outer, leaf } = await frames(page, true); await currentChild(page, true); await page.evaluate(() => scrollTo(0, 80)); await outer.evaluate(() => scrollTo(0, 340)); await leaf.evaluate(() => scrollTo(0, 160)); @@ -234,20 +236,30 @@ test('nested iframe DOM capture preserves live pixels and private rectangles aft }); }); +test('a scrolled page with smooth scrolling still captures its iframe with DOM capture', { tag: '@cross-engine' }, async ({ page }, testInfo) => { + await withReview(page, false, async (review) => { + await currentChild(page); + await page.evaluate(() => { document.documentElement.style.scrollBehavior = 'smooth'; scrollTo({ top: 80, behavior: 'instant' }); }); + await expect.poll(() => page.evaluate(() => scrollY)).toBe(80); + await method(page, 'dom'); await select(page, review); const item = await save(page, review, 'なめらかなスクロールのページでも子画面を撮る'); + await attach(testInfo, 'smooth-scroll-frame-saved', await readFile(`${review.output}/${item.id}/screenshot.png`), { saved: item }); + expect(item.capture.method).toBe('dom'); expect(item.viewport.scrollY).toBe(80); + }); +}); + test('native tab PNG excludes review controls while preserving current child pixels and privacy masks', async ({ page, browserName }, testInfo) => { - test.skip(browserName !== 'chromium', 'The automated native tab picker is supported by the Chromium launch configuration.'); await withReview(page, false, async (review) => { await currentChild(page); const { samples } = await captureSamples(page); await method(page, 'tab'); const rect = await select(page, review), item = await save(page, review, '高精度の子画面画像にレビューツールを混ぜない'); const png = await readFile(`${review.output}/${item.id}/screenshot.png`), actual = await evidence(page, png, samples); - await attach(testInfo, 'native-frame-saved', png, { actual, saved: item }); expectPixels(actual, samples, 2); + await attach(testInfo, 'native-frame-saved', png, { actual, saved: item }); expectPixels(actual, samples, tabColorTolerance); expect(item.capture.method).toBe('tab'); expect(item.capture.width).toBe(actual.width); expect(item.capture.height).toBe(actual.height); expect(item.target.rect).toEqual(rect); expect(item.target.code!.elements[0]!.rect).toEqual(rect); expect(item.capture.width).toBeGreaterThan(500); }); }); -test('an inline frame keeps its current pixels between interleaved direct text and style siblings without changing original order', async ({ page }, testInfo) => { +test('an inline frame keeps its current pixels between interleaved direct text and style siblings without changing original order', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, false, async (review) => { await currentChild(page); const { samples } = await captureSamples(page); const originalLine = await page.locator('#inline-frame-line').evaluate((element) => ({ @@ -276,7 +288,7 @@ test('an inline frame keeps its current pixels between interleaved direct text a }); -test('an unrelated iframe added during real image acquisition rejects stale pixels and retains the typed comment for explicit image-free saving', async ({ page }, testInfo) => { +test('an unrelated iframe added during real image acquisition rejects stale pixels and retains the typed comment for image-free saving', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, false, async (review) => { const leaf = await currentChild(page); await method(page, 'dom'); let release = () => {}, entered = () => {}; @@ -296,7 +308,7 @@ test('an unrelated iframe added during real image acquisition rejects stale pixe const input = page.getByLabel('コメント本文'); await input.fill(comment); await expect(page.getByRole('img', { name: 'コメントした場所のキャプチャ', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '保存', exact: true })).toBeDisabled(); - await expect(page.getByRole('button', { name: '画像なしで保存する', exact: true })).toHaveCount(0); + await expect(page.getByText('画像を撮影できなかったため、画像なしで保存します。', { exact: true })).toHaveCount(0); expect(await leaf.locator('#held-real-resource').evaluate((image) => (image as HTMLImageElement).complete)).toBe(false); await page.evaluate((origin) => { const frame = document.createElement('iframe'); frame.id = 'unrelated-late-frame'; @@ -307,11 +319,10 @@ test('an unrelated iframe added during real image acquisition rejects stale pixe const newFrameState = await page.evaluate(() => ({ parentUrl: location.href, frames: Array.from(document.querySelectorAll('iframe')).map((frame) => ({ id: frame.id, url: frame.contentWindow!.location.href, privateNodes: frame.contentDocument!.querySelectorAll('input[type="password"],[data-needmark-redact]').length })) })); expect(newFrameState.frames.find((frame) => frame.id === 'unrelated-late-frame')?.privateNodes).toBe(0); release(); - await expect(page.getByRole('button', { name: '画像なしで保存する', exact: true })).toBeVisible(); + await expect(page.getByText('画像を撮影できなかったため、画像なしで保存します。', { exact: true })).toBeVisible(); await expect(input).toHaveValue(comment); await expect(page.getByRole('img', { name: 'コメントした場所のキャプチャ', exact: true })).toHaveCount(0); - await expect(page.getByRole('button', { name: '保存', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: '保存', exact: true })).toBeEnabled(); await testInfo.attach('new-frame-during-capture', { body: JSON.stringify({ original, after: await originalChildState(page), newFrameState, rect, typedComment: await input.inputValue() }, null, 2), contentType: 'application/json' }); - await page.getByRole('button', { name: '画像なしで保存する', exact: true }).click(); const item = await save(page, review, comment); expect(item.capture).toEqual({ method: 'none', width: 0, height: 0 }); expect(item.image).toBeUndefined(); expect(await readdir(`${review.output}/${item.id}`)).not.toContain('screenshot.png'); diff --git a/tests/frame-padding.spec.ts b/tests/frame-padding.spec.ts index b5ac00e..31da199 100644 --- a/tests/frame-padding.spec.ts +++ b/tests/frame-padding.spec.ts @@ -1,11 +1,11 @@ -import { test, expect, type Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, output, tempRoot, configureReview, tabColorTolerance } from './support/review.js'; import { build } from 'esbuild'; import { createServer, type Server } from 'node:http'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import { startReviewProxy, type ReviewProxy } from '../dist/proxy.js'; import type { Feedback, Rectangle, RuntimeSourceRequest, RuntimeSourceResult, SourceLocation } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; const fixturePath = resolve('tests/fixtures/frame-capture.jsx'), sourceFile = 'src/frame-capture.jsx'; const currentColor = [217, 91, 36, 255], outlineColor = [25, 101, 227, 255]; @@ -23,7 +23,7 @@ async function close(server: Server) { /** Real unmodified CaptureTarget, live React state, source maps and protected public proxy. */ async function withReview(page: Page, run: (review: { origin: string; output: string; source: string }) => Promise) { - const root = await mkdtemp('/private/tmp/needmark-frame-padding-'); + const root = await mkdtemp(join(tempRoot, 'needmark-frame-padding-')); let upstream: Server | undefined, proxy: ReviewProxy | undefined; try { const source = await readFile(fixturePath, 'utf8'); await mkdir(root + '/src'); await writeFile(root + '/' + sourceFile, source); @@ -81,7 +81,7 @@ async function pixels(page: Page, png: Buffer, rect: Rectangle, privateRects: Re } // One actual saved review: native rendered geometry is the independent oracle, not NeedMark's mapping. -test('a padded bordered iframe saves its original target geometry and DOM PNG outline at the native rendered position', async ({ page }, testInfo) => { +test('a padded bordered iframe saves its original target geometry and DOM PNG outline at the native rendered position', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, async ({ origin, output, source }) => { const frameLocator = page.locator('#outer-capture-frame'); await frameLocator.evaluate((element) => { @@ -109,8 +109,7 @@ test('a padded bordered iframe saves its original target geometry and DOM PNG ou const native = await page.screenshot({ caret: 'initial' }), nativePixels = await pixels(page, native, rect, privateRects); await testInfo.attach('padded-frame-native.png', { body: native, contentType: 'image/png' }); expect(nativePixels.inside).toEqual(currentColor); expectPrivacy(nativePixels.privacy, [153, 34, 169, 255]); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('dom'); await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await configureReview(page, { method: 'dom' }); const response = page.waitForResponse((item) => item.url() === origin + '/__needmark/source' && item.request().method() === 'POST'); await page.getByRole('button', { name: 'コメント', exact: true }).click(); await expect(page.locator('[data-needmark] .cover')).toBeVisible(); @@ -171,9 +170,7 @@ async function privacyRects(frame: import('@playwright/test').Frame): Promise entry.isDirectory() && !entry.name.startsWith('.')); @@ -203,7 +200,7 @@ function expectPrivacy(actual: Record, color: number[]) { for (const corners of Object.values(actual)) for (const corner of corners) expect(corner).toEqual(color); } -test('padded scaled frames select a thin original at its native center and retain exact native text-range geometry', async ({ page }, testInfo) => { +test('padded scaled frames select a thin original at its native center and retain exact native text-range geometry', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, async ({ origin, output, source }) => { const frame = await paddedFrame(page, true), target = frame.locator('[data-cy="frame-live-button"]'); await target.click(); await expect(target).toHaveText('Current child 1'); @@ -248,7 +245,6 @@ test('padded scaled frames select a thin original at its native center and retai }); test('native tab capture masks every padded scaled child corner at its actual rendered position', async ({ page, browserName }, testInfo) => { - test.skip(browserName !== 'chromium', 'The automated native tab picker is supported by the Chromium launch configuration.'); await withReview(page, async ({ origin, output, source }) => { const frame = await paddedFrame(page, true), target = frame.locator('[data-cy="frame-live-button"]'); await target.click(); await expect(target).toHaveText('Current child 1'); @@ -267,7 +263,7 @@ test('native tab capture masks every padded scaled child corner at its actual re await testInfo.attach('padded-scaled-tab-saved.png', { body: png, contentType: 'image/png' }); await testInfo.attach('padded-scaled-tab-privacy.json', { body: JSON.stringify({ nativePixels, actual, privateRects, rect, saved }, null, 2), contentType: 'application/json' }); expectPrivacy(actual.privacy, [46, 49, 52, 255]); - actual.inside.forEach((value, index) => expect(Math.abs(value - currentColor[index]!)).toBeLessThanOrEqual(2)); + actual.inside.forEach((value, index) => expect(Math.abs(value - currentColor[index]!)).toBeLessThanOrEqual(tabColorTolerance)); expect(saved.capture.method).toBe('tab'); expect(actual.width).toBe(saved.capture.width); expect(actual.height).toBe(saved.capture.height); expect(saved.capture.width).toBeGreaterThan(500); expect(saved.viewport.devicePixelRatio).toBe(2); expectGeometry(saved.target.rect, rect); expectGeometry(saved.target.code!.elements[0]!.rect, rect); expect(saved.target.code!.elements[0]).toMatchObject({ source: expectedSource(source), sourceRelation: 'self', sourceOrigin: 'runtime', frameUrls: [frame.url()] }); @@ -278,7 +274,7 @@ test('native tab capture masks every padded scaled child corner at its actual re }); }); -test('multi marquee excludes an unpainted child control whose geometry falls entirely in padded iframe space', async ({ page }, testInfo) => { +test('multi marquee excludes an unpainted child control whose geometry falls entirely in padded iframe space', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, async () => { const frame = await paddedFrame(page, true), target = frame.locator('[data-cy="frame-live-button"]'); await target.click(); await expect(target).toHaveText('Current child 1'); @@ -319,7 +315,7 @@ test('multi marquee excludes an unpainted child control whose geometry falls ent // Normal Area sampling must describe painted evidence, independently of exact JSX verification. -test('a normal area review over iframe padding never attributes a wholly unpainted child target to its image or AI prompt', async ({ page }, testInfo) => { +test('a normal area review over iframe padding never attributes a wholly unpainted child target to its image or AI prompt', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await page.addInitScript(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (value: string) => { (window as unknown as { copied: string }).copied = value; } } })); @@ -361,8 +357,7 @@ test('a normal area review over iframe padding never attributes a wholly unpaint expect(area.x).toBeGreaterThan(frameBounds.innerBorder); expect(area.x + area.width).toBeLessThan(frameBounds.contentLeft); expect(originalIdentity).toEqual({ sameDocument: true, sameNode: true }); expect(rect.width).toBeCloseTo(15); expect(rect.height).toBeCloseTo(9.6, 1); expect(area.width).toBeGreaterThan(5); expect(area.height).toBeGreaterThan(5); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('dom'); await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await configureReview(page, { method: 'dom' }); const frameBefore = await page.locator('#outer-capture-frame').evaluate((element) => element.outerHTML); const childBefore = await frame.evaluate(() => ({ html: document.body.innerHTML, url: location.href, scrollX, scrollY })); const native = await page.screenshot({ caret: 'initial' }); @@ -381,7 +376,7 @@ test('a normal area review over iframe padding never attributes a wholly unpaint await page.mouse.move(area.x, area.y); await page.mouse.down(); await page.mouse.move(area.x + area.width, area.y + area.height, { steps: 4 }); await page.mouse.up(); const comment = 'iframeの余白だけを囲んだコメントに、画面へ描画されていない子要素を含めない'; await page.getByLabel('コメント本文').fill(comment); - await expect.poll(async () => ({ image: await page.getByRole('img', { name: 'コメントした場所のキャプチャ', exact: true }).isVisible(), failed: await page.getByRole('button', { name: '画像なしで保存する', exact: true }).isVisible() })).not.toEqual({ image: false, failed: false }); + await expect.poll(async () => ({ image: await page.getByRole('img', { name: 'コメントした場所のキャプチャ', exact: true }).isVisible(), failed: await page.getByText('画像を撮影できなかったため、画像なしで保存します。', { exact: true }).isVisible() })).not.toEqual({ image: false, failed: false }); const captureState = await page.locator('[data-capture-state]').textContent(); await expect(page.locator('[data-source-state]')).not.toHaveText('対象のコードを確認中…'); const calls = await Promise.all(sourceCalls); diff --git a/tests/frame-pin-clipping.spec.ts b/tests/frame-pin-clipping.spec.ts index e4f8772..77b696e 100644 --- a/tests/frame-pin-clipping.spec.ts +++ b/tests/frame-pin-clipping.spec.ts @@ -1,11 +1,11 @@ -import { test, expect, type Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, output, tempRoot, configureReview } from './support/review.js'; import { build } from 'esbuild'; import { createServer, type Server } from 'node:http'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import { startReviewProxy, type ReviewProxy } from '../dist/proxy.js'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; const fixturePath = resolve('tests/fixtures/frame-route.jsx'); const sourceFile = 'src/frame-route.jsx'; @@ -24,7 +24,7 @@ async function close(server: Server) { /** Reuse the actual C17 React fixture and protected public proxy, with no /source or DOM identity mocks. */ async function withReview(page: Page, run: (review: { origin: string; output: string }) => Promise) { - const root = await mkdtemp('/private/tmp/needmark-frame-pin-'); + const root = await mkdtemp(join(tempRoot, 'needmark-frame-pin-')); let upstream: Server | undefined, proxy: ReviewProxy | undefined; try { await mkdir(root + '/src'); await writeFile(root + '/' + sourceFile, await readFile(fixturePath, 'utf8')); @@ -54,7 +54,7 @@ async function withReview(page: Page, run: (review: { origin: string; output: st } // Saved pins follow current painted bounds without changing stored target evidence. -test('a saved iframe element pin hides when its unchanged original is wholly clipped by child scrolling and recovers on scrollback', async ({ page }, testInfo) => { +test('a saved iframe element pin hides when its unchanged original is wholly clipped by child scrolling and recovers on scrollback', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, async ({ origin, output }) => { const frameHandle = await page.locator('#outer-review-frame').elementHandle(), frame = await frameHandle?.contentFrame(); await frameHandle?.dispose(); if (!frame) throw new Error('Child frame is unavailable'); @@ -65,9 +65,7 @@ test('a saved iframe element pin hides when its unchanged original is wholly cli await frame.evaluate(() => { document.body.style.minHeight = '1800px'; }); const parentUrl = page.url(), childUrl = frame.url(), before = await target.boundingBox(); if (!before) throw new Error('Original target has no rectangle'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('none'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await configureReview(page, { method: 'none' }); const response = page.waitForResponse((item) => item.url() === origin + '/__needmark/source' && item.request().method() === 'POST'); await page.getByRole('button', { name: 'コメント', exact: true }).click(); await expect(page.locator('[data-needmark] .cover')).toBeVisible(); @@ -125,9 +123,7 @@ test('a saved iframe element pin hides when its unchanged original is wholly cli }); async function configureNoImage(page: Page) { - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('none'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await configureReview(page, { method: 'none' }); } async function saveReview(page: Page, frame: import('@playwright/test').Frame, origin: string, output: string, comment: string) { @@ -167,7 +163,7 @@ async function reviewFrame(page: Page) { } // Both collision-adjusted centers must describe the remaining painted pixels, even when narrower than a marker. -test('two saved pins stay inside a narrow positive child viewport intersection and recover their full positions', async ({ page }, testInfo) => { +test('two saved pins stay inside a narrow positive child viewport intersection and recover their full positions', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, async ({ origin, output }) => { const { frame, original } = await reviewFrame(page), parentUrl = page.url(), childUrl = frame.url(); try { @@ -215,7 +211,7 @@ test('two saved pins stay inside a narrow positive child viewport intersection a }); // Browser hit testing establishes that ordinary overflow can be escaped; containing blocks then clip that same original. -test('saved child pins follow painted absolute and fixed escapes but hide at real containing block clips', async ({ page }, testInfo) => { +test('saved child pins follow painted absolute and fixed escapes but hide at real containing block clips', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, async ({ origin, output }) => { const { frame, original } = await reviewFrame(page), parentUrl = page.url(), childUrl = frame.url(); try { @@ -264,7 +260,7 @@ test('saved child pins follow painted absolute and fixed escapes but hide at rea }); }); -test('saved child pins respect iframe ancestor opacity and visibility while allowing an explicitly visible descendant', async ({ page }, testInfo) => { +test('saved child pins respect iframe ancestor opacity and visibility while allowing an explicitly visible descendant', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, async ({ origin, output }) => { // Wrap before capturing any identity: reparenting a live iframe itself creates a fresh child document. await page.locator('#outer-review-frame').evaluate((frame) => { const wrapper = document.createElement('div'); wrapper.id = 'saved-frame-owner'; frame.before(wrapper); wrapper.append(frame); }); @@ -302,7 +298,7 @@ test('saved child pins respect iframe ancestor opacity and visibility while allo }); }); -test('unconfirmed pins suppress only a unique invisible candidate on the recorded child route and preserve other fallbacks', async ({ page }, testInfo) => { +test('unconfirmed pins suppress only a unique invisible candidate on the recorded child route and preserve other fallbacks', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, async ({ origin, output }) => { const { frame, original } = await reviewFrame(page), parentUrl = page.url(), childUrl = frame.url(); try { diff --git a/tests/frame-route.spec.ts b/tests/frame-route.spec.ts index 77dc6a6..51acd62 100644 --- a/tests/frame-route.spec.ts +++ b/tests/frame-route.spec.ts @@ -1,11 +1,11 @@ -import { test, expect, type Frame, type Locator, type Page, type TestInfo } from '@playwright/test'; +import { type Frame, type Locator, type Page, type TestInfo } from '@playwright/test'; +import { test, expect, saveAndList, output, tempRoot, configureReview } from './support/review.js'; import { build } from 'esbuild'; import { createServer, type Server } from 'node:http'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import { startReviewProxy, type ReviewProxy } from '../dist/proxy.js'; import type { ElementContext, Feedback, RuntimeSourceRequest, RuntimeSourceResult, SourceLocation } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; type FrameContext = ElementContext & { frameUrls?: string[] }; type Review = { root: string; output: string; source: string; origin: string }; @@ -28,7 +28,7 @@ async function close(server: Server) { /** Real React frames, current JSX, HTTP source maps and protected public proxy; no /source response mocks. */ async function withReview(page: Page, nested: boolean, run: (review: Review) => Promise) { - const root = await mkdtemp('/private/tmp/needmark-frame-route-'); + const root = await mkdtemp(join(tempRoot, 'needmark-frame-route-')); let upstream: Server | undefined, proxy: ReviewProxy | undefined; try { const source = await readFile(fixturePath, 'utf8'); @@ -83,9 +83,7 @@ function sourceOf(review: Review): SourceLocation { return { file: sourceFile, line: line + 1, column: lines[line]!.indexOf(' response.url() === review.origin + '/__needmark/source' && response.request().method() === 'POST'); @@ -131,7 +129,7 @@ async function attachState(page: Page, frame: Frame, saved: Feedback, name: stri await testInfo.attach(name + '-screen', { body: await page.screenshot(), contentType: 'image/png' }); } -test('saved sourced child review refuses the identical target on another child route and recovers only the exact URL', async ({ page }, testInfo) => { +test('saved sourced child review refuses the identical target on another child route and recovers only the exact URL', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, false, async (review) => { await method(page, 'dom'); await select(page, review); const comment = '子画面Aにだけ対応するコメント', item = await save(page, review, comment); @@ -158,7 +156,7 @@ test('saved sourced child review refuses the identical target on another child r }); }); -test('connected child-only push, replace, hash and back changes notify automatically without DOM mutations and legacy contexts remain readable', async ({ page }, testInfo) => { +test('connected child-only push, replace, hash and back changes notify automatically without DOM mutations and legacy contexts remain readable', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, false, async (review) => { await method(page, 'none'); await select(page, review); const comment = 'pure child history', item = await save(page, review, comment); @@ -210,7 +208,7 @@ test('connected child-only push, replace, hash and back changes notify automatic }); }); -test('nested iframe, shadow and iframe evidence records only ordered frame URLs and rejects independent outer or inner route changes', async ({ page }) => { +test('nested iframe, shadow and iframe evidence records only ordered frame URLs and rejects independent outer or inner route changes', { tag: '@cross-engine' }, async ({ page }) => { await withReview(page, true, async (review) => { await method(page, 'none'); await select(page, review, true); const comment = 'nested child identity', item = await save(page, review, comment); @@ -234,7 +232,7 @@ test('nested iframe, shadow and iframe evidence records only ordered frame URLs }); }); -test('same connected child route drift rejects original-node and range capture and preserves typed feedback for explicit image-free saving', async ({ page }, testInfo) => { +test('same connected child route drift rejects original-node and range capture and preserves typed feedback for image-free saving', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, false, async (review) => { const guardResult = await page.evaluate(async (origin) => { const { contextForElement, contextForRange, createCaptureGuard, viewportRectOf, viewportRectForRange } = await import(origin + '/identity.js'); @@ -279,13 +277,12 @@ test('same connected child route drift rejects original-node and range capture a await frame.evaluate((path) => history.pushState({}, '', path), childB); expect(await original!.evaluate((element) => element.isConnected && element === element.ownerDocument.querySelector('button[data-cy]'))).toBe(true); await page.evaluate(() => (window as unknown as { releaseCaptureGate: () => void }).releaseCaptureGate()); - await expect(page.getByRole('button', { name: '画像なしで保存する', exact: true })).toBeVisible(); + await expect(page.getByText('画像を撮影できなかったため、画像なしで保存します。', { exact: true })).toBeVisible(); await expect(page.getByLabel('コメント本文')).toHaveValue(comment); await expect(page.getByLabel('コメント本文')).toBeFocused(); await expect(page.getByRole('img', { name: 'コメントした場所のキャプチャ', exact: true })).toBeHidden(); - await expect(page.getByRole('button', { name: '保存', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: '保存', exact: true })).toBeEnabled(); expect(page.url()).toBe(parentUrl); - await page.getByRole('button', { name: '画像なしで保存する', exact: true }).click(); const saved = await save(page, review, comment); expect(saved.capture).toEqual({ method: 'none', width: 0, height: 0 }); expect(saved.image).toBeUndefined(); expect((saved.target.code!.elements[0] as FrameContext).frameUrls).toEqual([review.origin + childA]); @@ -294,7 +291,7 @@ test('same connected child route drift rejects original-node and range capture a }); }); -test('child history observation preserves native behavior and reconnects without duplicate or obsolete callbacks', async ({ page }, testInfo) => { +test('child history observation preserves native behavior and reconnects without duplicate or obsolete callbacks', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, false, async (review) => { const result = await page.evaluate(async (origin) => { const { installPageChanges } = await import(origin + '/identity.js'); @@ -380,43 +377,3 @@ test('child history observation preserves native behavior and reconnects without expect(result.appState).toEqual({ app: true }); expect(result.appUrl).toBe(review.origin + '/inert?instance=9'); }); }); - -test('resuming retained multiple selection removes a child original from another route before completion instead of rebinding its source', async ({ page }) => { - await withReview(page, false, async (review) => { - await method(page, 'none'); - const frame = await leafFrame(page), child = await leafTarget(page).elementHandle(), parentUrl = page.url(); - if (!child) throw new Error('Original child target is missing'); - const heading = page.locator('#app h1'), headingRect = await heading.boundingBox(); - await page.getByRole('button', { name: 'コメント', exact: true }).click(); - const childRect = await leafTarget(page).boundingBox(); - if (!childRect || !headingRect) throw new Error('Selected originals need visible rectangles'); - await page.keyboard.down('Shift'); await page.mouse.click(childRect.x + childRect.width / 2, childRect.y + childRect.height / 2); await page.keyboard.up('Shift'); - // The full-width heading center is under the selection hint; its text at the left remains directly selectable. - await page.mouse.click(headingRect.x + 12, headingRect.y + headingRect.height / 2); - const outlines = page.locator('[data-needmark] .selected-outline'); - await expect(outlines).toHaveCount(2); - await page.getByRole('button', { name: 'ページを操作', exact: true }).click(); - const resume = page.getByRole('button', { name: '選択を再開', exact: true }); - await expect(resume).toHaveText('選択を再開 · 2件'); - await frame.evaluate((path) => history.pushState({}, '', path), childB); - expect(page.url()).toBe(parentUrl); - expect(await child.evaluate((element) => element.isConnected && element === element.ownerDocument.querySelector('button[data-cy]'))).toBe(true); - await resume.click(); await expect(outlines).toHaveCount(1); - await expect(page.getByText('1件の選択対象が消えたか変わったため、選択から外しました。', { exact: true })).toBeVisible(); - expect(await outlines.first().boundingBox()).toEqual(headingRect); - await page.getByRole('button', { name: '選択を完了', exact: true }).click(); - await page.getByLabel('コメント本文').fill('同じ親画面に残る見出しだけを修正する'); - await saveAndList(page); - await expect(page.getByText('コメントを保存しました。', { exact: true })).toBeVisible(); - const ids = (await readdir(review.output)).filter((id) => !id.startsWith('.')); expect(ids).toHaveLength(1); - const saved = JSON.parse(await readFile(review.output + '/' + ids[0] + '/context.json', 'utf8')) as Feedback; - expect(saved.target.kind).toBe('multi'); expect(saved.target.rect).toEqual(headingRect); - expect(saved.target.code?.elements).toHaveLength(1); - expect(saved.target.code!.elements[0]).toMatchObject({ tag: 'h1', text: 'Child route review' }); - expect(saved.target.code!.elements[0]!.attributes).not.toHaveProperty('data-cy'); - expect(saved.target.code!.elements[0]!.source?.component).not.toBe('RouteTarget'); - expect(JSON.stringify(saved)).not.toContain(childA); - expect(JSON.stringify(saved)).not.toContain(childB); - await child.dispose(); - }); -}); diff --git a/tests/handoff-actions.spec.ts b/tests/handoff-actions.spec.ts index 4e93350..d5a71d8 100644 --- a/tests/handoff-actions.spec.ts +++ b/tests/handoff-actions.spec.ts @@ -1,22 +1,15 @@ -import { test, expect, type Page } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, searchList, output, configureReview, enableWidgetOption } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { resolve } from 'node:path'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList, searchList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); -async function setup(page: Page, submit = false, method = 'none') { +async function setup(page: Page, submit = false, method: 'none' | 'dom' | 'tab' = 'none') { // The transport is controlled to exercise UI races. Server hook delivery is verified by API tests. - if (submit) await page.route('http://127.0.0.1:4192/', async (route) => { - const response = await route.fetch(); - await route.fulfill({ response, body: (await response.text()).replace('data-needmark-capture=', 'data-needmark-submit="true" data-needmark-capture=') }); - }); + if (submit) await enableWidgetOption(page, 'data-needmark-submit'); await page.goto('/'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption(method); - await page.getByRole('button', { name: '設定を保存' }).click(); + await configureReview(page, { method }); } async function select(page: Page, selector = '#purchase-button') { await page.getByRole('button', { name: 'コメント', exact: true }).click(); @@ -46,8 +39,11 @@ test('a single-item copy carries the source and selector to the AI without optio await expect(page.getByText('コメントを保存しました。', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'AIへ送信', exact: true })).toHaveCount(0); await page.locator('[data-needmark] .open-item').click(); - await page.getByRole('button', { name: 'プロンプトをコピー', exact: true }).click(); - await expect(page.getByRole('button', { name: 'コピーしました', exact: true })).toBeVisible(); + const header = page.locator('[data-needmark] .panel > header'); + await expect(header.getByRole('link', { name: 'VS Codeで開く', exact: true })).toBeVisible(); + await header.getByRole('button', { name: 'プロンプトをコピー', exact: true }).click(); + const done = header.getByRole('button', { name: 'コピーしました', exact: true }); + await expect(done).toBeVisible(); await expect.poll(() => copied(page)).toContain('ソースとセレクタをAIへ渡す'); await expect(page.getByRole('button', { name: 'プロンプトをコピー', exact: true })).toBeVisible({ timeout: 4000 }); expect(await copied(page)).toContain('#purchase-button'); expect(await copied(page)).toContain('demo/index.html:'); diff --git a/tests/hover-context.spec.ts b/tests/hover-context.spec.ts index 7717d5e..7e12df8 100644 --- a/tests/hover-context.spec.ts +++ b/tests/hover-context.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/review.js'; import { resolve } from 'node:path'; const moduleUrl = `/@fs/${resolve('src/client/context/code-context.ts')}`; diff --git a/tests/hover-preview.spec.ts b/tests/hover-preview.spec.ts index 16da7ed..a289fcb 100644 --- a/tests/hover-preview.spec.ts +++ b/tests/hover-preview.spec.ts @@ -1,9 +1,9 @@ -import { test, expect, type Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; +import { test, expect, output } from './support/review.js'; import { readFile, readdir, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import type { Feedback } from '../src/shared/types.js'; -const output = '/private/tmp/needmark-e2e-feedback'; const moduleUrl = `/@fs/${resolve('src/client/context/code-context.ts')}`; const preview = (page: Page) => page.locator('[data-needmark] .target-preview'); const outline = (page: Page) => page.locator('[data-needmark] .outline'); @@ -76,7 +76,7 @@ test('preview, outline and saved target agree without requesting source while ho await info.attach('capture-without-preview', { body: png, contentType: 'image/png' }); }); -test('stationary Ctrl and Cmd update the same hit and keep gesture depth latched when shortcuts are disabled', async ({ page }) => { +test('stationary Ctrl and Cmd update the same hit and keep gesture depth latched when shortcuts are disabled', { tag: '@cross-engine' }, async ({ page }) => { await start(page, false); await page.evaluate(() => { const node = document.createElement('div'); node.id = 'hover-layered'; @@ -129,7 +129,7 @@ test('new frame and shadow targets clear their preview on local mutations and sc await expect(preview(page)).toBeHidden(); await expect(outline(page)).toBeHidden(); }); -test('hint, page changes, cancellation and page operation clear the preview without losing selected targets', async ({ page }) => { +test('hint, page changes and cancellation clear the preview without losing selected targets', async ({ page }) => { await start(page); await page.evaluate(() => { document.body.style.minHeight = '2000px'; @@ -160,9 +160,6 @@ test('hint, page changes, cancellation and page operation clear the preview with await page.keyboard.down('Shift'); await page.mouse.click(170, 195); await page.keyboard.up('Shift'); await expect(preview(page)).toBeHidden(); await expect(page.locator('[data-needmark] .selected-outline')).toHaveCount(1); await page.mouse.move(171, 195); await expect(preview(page)).toBeVisible(); - await page.getByRole('button', { name: 'ページを操作', exact: true }).click(); await expect(preview(page)).toBeHidden(); - await page.keyboard.down('Control'); await page.keyboard.up('Control'); await expect(preview(page)).toBeHidden(); - await page.getByRole('button', { name: '選択を再開', exact: true }).click(); await expect(page.locator('[data-needmark] .selected-outline')).toHaveCount(1); await page.getByRole('button', { name: '選択を完了', exact: true }).click(); await expect(page.locator('[data-needmark] .draft-outline')).toHaveCount(1); @@ -189,7 +186,7 @@ test('a slotted leaf observes rendered shadow scrolling and mutations through it await expect(preview(page)).toBeHidden(); await expect(outline(page)).toBeHidden(); }); -test('long plain labels fit near viewport edges and a native modal remains selectable', async ({ page }, info) => { +test('long plain labels fit near viewport edges and a native modal remains selectable', { tag: '@cross-engine' }, async ({ page }, info) => { await start(page); await page.evaluate(() => { const button = document.createElement('button'); button.id = 'edge-booking'; button.textContent = 'Edge booking'; button.setAttribute('aria-label', '長い予約ボタン '.repeat(100)); diff --git a/tests/language-ui.spec.ts b/tests/language-ui.spec.ts index b052578..d2332e6 100644 --- a/tests/language-ui.spec.ts +++ b/tests/language-ui.spec.ts @@ -1,16 +1,14 @@ -import { test, expect, type Page } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, output } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; import { localizeClientError, resolveClientLanguage } from '../src/client/messages.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; const widget = (page: Page) => page.locator('[data-needmark]'); const comment = 'コメントを保存しました。という表示を変えてください。'; const author = '投稿者名:日本語'; const reason = '削除せず、この文言を維持するため'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); async function saved(): Promise { const ids = (await readdir(output)).filter((id) => !id.startsWith('.')); @@ -28,22 +26,20 @@ async function configurePageLanguage(page: Page, configured: string, preference? await page.goto('/'); } -test('first setup switches all feedback operations to English while saved Japanese content survives a switch back', async ({ page }) => { +test('switching settings to English switches all feedback operations while saved Japanese content survives a switch back', async ({ page }) => { await page.addInitScript(() => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text: string) => { (window as unknown as { copiedLocalePrompt: string }).copiedLocalePrompt = text; }, } }); }); - // The first-run panel has no author field, so a stored author must survive saving it. - await page.addInitScript((value) => { if (!localStorage.getItem('needmark-author')) localStorage.setItem('needmark-author', value); }, author); await page.goto('/'); - await page.getByRole('button', { name: 'コメント', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'レビューを始める' })).toBeVisible(); + await page.getByRole('button', { name: '設定', exact: true }).click(); await page.getByLabel('表示言語').selectOption('en'); - await expect(page.getByLabel('投稿者名')).toHaveCount(0); + await page.getByLabel('投稿者名').fill(author); await page.getByLabel('キャプチャ方式').selectOption('none'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); await expect(widget(page)).toHaveAttribute('lang', 'en'); + await page.getByRole('button', { name: 'Comment', exact: true }).click(); await expect(widget(page).locator('.cover')).toBeVisible(); await expect(widget(page).locator('[data-hint]')).toHaveText('Click or drag to select'); const bounds = (await page.locator('#purchase-button').boundingBox())!; @@ -77,7 +73,7 @@ test('first setup switches all feedback operations to English while saved Japane await page.getByRole('button', { name: 'Settings', exact: true }).click(); await expect(page.getByLabel('Language')).toHaveValue('en'); await page.getByLabel('Language').selectOption('ja'); - await page.getByRole('button', { name: 'Save settings', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.getByRole('button', { name: 'コメント', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toHaveCount(0); expect(await page.evaluate(() => localStorage.getItem('needmark-language'))).toBe('ja'); @@ -86,7 +82,7 @@ test('first setup switches all feedback operations to English while saved Japane await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('表示言語')).toHaveValue('ja'); await expect(page.getByLabel('投稿者名')).toHaveValue(author); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); await page.getByRole('button', { name: 'コメント一覧', exact: true }).click(); await page.getByText(comment, { exact: true }).click(); await expect(page.getByRole('heading', { name: 'コメント', exact: true })).toBeVisible(); @@ -111,8 +107,8 @@ for (const { configured, preference, language } of [ test(`language precedence: configuration ${configured}, preference ${preference} uses ${language}`, async ({ page }) => { await configurePageLanguage(page, configured, preference); await expect(widget(page)).toHaveAttribute('lang', language); - await page.getByRole('button', { name: language === 'en' ? 'Comment' : 'コメント', exact: true }).click(); - await expect(page.getByRole('heading', { name: language === 'en' ? 'Start review' : 'レビューを始める', exact: true })).toBeVisible(); + await page.getByRole('button', { name: language === 'en' ? 'Settings' : '設定', exact: true }).click(); + await expect(page.getByRole('heading', { name: language === 'en' ? 'Settings' : '設定', exact: true })).toBeVisible(); await expect(widget(page).locator('#nm-language')).toHaveValue(language); }); } diff --git a/tests/multi-complete.spec.ts b/tests/multi-complete.spec.ts index 4e513fe..c3dfed3 100644 --- a/tests/multi-complete.spec.ts +++ b/tests/multi-complete.spec.ts @@ -1,13 +1,13 @@ -import { test, expect, type ElementHandle, type Page, type TestInfo } from '@playwright/test'; +import { type ElementHandle, type Page, type TestInfo } from '@playwright/test'; +import { test, expect, saveAndList, output, tempRoot, configureReview } from './support/review.js'; import { build } from 'esbuild'; import { createServer, type Server } from 'node:http'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { startReviewProxy, type ReviewProxy } from '../dist/proxy.js'; import type { Feedback, Rectangle, RuntimeSourceRequest, RuntimeSourceResult, SourceLocation } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; const ids = Array.from({ length: 20 }, (_, index) => `multi-${String(index + 1).padStart(2, '0')}`); const sourceFile = 'src/multi-complete.jsx', fixturePath = resolve('tests/fixtures/multi-complete.jsx'); @@ -31,7 +31,7 @@ async function close(server: Server) { } /** Real current JSX, React dev allocation frames, source maps and the protected public proxy. */ async function withReview(page: Page, method: 'dom' | 'none', run: (review: Review) => Promise) { - const root = await mkdtemp('/private/tmp/needmark-multi-complete-'); + const root = await mkdtemp(join(tempRoot, 'needmark-multi-complete-')); let upstream: Server | undefined, proxy: ReviewProxy | undefined; const calls: Promise[] = []; const observe = (response: import('@playwright/test').Response) => { @@ -65,8 +65,7 @@ async function withReview(page: Page, method: 'dom' | 'none', run: (review: Revi } } })); page.on('response', observe); await page.goto(origin); await expect(page.locator('#target-grid button')).toHaveCount(20); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption(method); await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await configureReview(page, { method }); await run({ origin, output: `${root}/.needmark/feedback`, source, calls }); } finally { page.off('response', observe); await proxy?.close(); if (upstream?.listening) await close(upstream); await rm(root, { recursive: true, force: true }); @@ -184,7 +183,7 @@ test('forward marquee retains all twenty original targets through real DOM captu await page.getByRole('button', { name: 'プロンプトをコピー', exact: true }).click(); assertPrompt(await copied(page), item); for (const detail of ['compact', 'standard', 'detailed', 'forensic']) { await page.getByRole('button', { name: '設定', exact: true }).click(); await page.getByLabel('AIへの情報量').selectOption(detail); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); await page.getByRole('button', { name: 'コメント一覧', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); await page.getByRole('button', { name: 'コメント一覧', exact: true }).click(); await page.getByRole('button', { name: 'プロンプトをコピー', exact: true }).click(); assertPrompt(await copied(page), item); expect(await copied(page)).toContain('screenshot.png'); } const cli = await execute(process.execPath, [resolve('dist/cli.js'), 'export', review.output, '--detail', 'forensic']); assertPrompt(cli.stdout, item); expect(cli.stdout).toContain('screenshot.png'); @@ -198,11 +197,10 @@ test('twenty Shift-click targets survive toggles, Backspace, additive reverse ma for (const id of ids) await clickTarget(page, id); await page.keyboard.up('Shift'); await selection(page); await clickTarget(page, ids[19]!); await selection(page, ids.slice(0, 19)); await page.keyboard.press('Backspace'); await selection(page, ids.slice(0, 18)); await clickTarget(page, ids[18]!); await clickTarget(page, ids[19]!); await selection(page); await dragGrid(page, union(await rectangles(page)), true); await selection(page); - await page.getByRole('button', { name: 'ページを操作', exact: true }).click(); await expect(page.getByRole('button', { name: '選択を再開', exact: true })).toHaveText('選択を再開 · 20件'); - await page.locator('#native-counter').click(); await expect(page.getByLabel('Native click count')).toHaveText('1'); const before = await rectangles(page); - await page.locator('#reflow').click(); await expect.poll(async () => (await rectangles(page))[0]!.y).toBe(before[0]!.y + 36); await originalsRemain(originals); - await page.getByRole('button', { name: '選択を再開', exact: true }).click(); await selection(page); const current = await rectangles(page); - await complete(page, '明示した20要素を解除・追加・ページ操作後も欠けずに修正する'); const calls = await assertSourceCalls(review), item = await save(page, review); + const before = await rectangles(page); + await page.locator('#reflow').evaluate((button) => (button as HTMLButtonElement).click()); await expect.poll(async () => (await rectangles(page))[0]!.y).toBe(before[0]!.y + 36); await originalsRemain(originals); + await selection(page); const current = await rectangles(page); + await complete(page, '明示した20要素を解除・追加・再配置後も欠けずに修正する'); const calls = await assertSourceCalls(review), item = await save(page, review); assertComplete(item, review.source, current); expect(item.capture).toEqual({ method: 'none', width: 0, height: 0 }); expect(item.image).toBeUndefined(); await originalsRemain(originals); await info.attach('explicit-twenty-originals-after-reflow.json', { body: JSON.stringify({ item, before, current, sourceCalls: calls.map(({ token: _, ...value }) => value) }), contentType: 'application/json' }); }); diff --git a/tests/multi-selection.spec.ts b/tests/multi-selection.spec.ts index 9eb403a..c19d827 100644 --- a/tests/multi-selection.spec.ts +++ b/tests/multi-selection.spec.ts @@ -1,14 +1,12 @@ -import { test, expect, type Page, type TestInfo } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { type Page, type TestInfo } from '@playwright/test'; +import { test, expect, saveAndList, output } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import type { Feedback, Rectangle } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; const outlines = (page: Page) => page.locator('[data-needmark] .selected-outline'); const cover = (page: Page) => page.locator('[data-needmark] .cover'); const field = (page: Page) => page.getByLabel('コメント本文'); -const resume = (page: Page) => page.getByRole('button', { name: '選択を再開', exact: true }); async function fixture(page: Page, method: 'none' | 'dom' = 'none') { const html = await readFile(resolve('tests/fixtures/multi-selection.html'), 'utf8'); @@ -68,7 +66,6 @@ function union(rects: Rectangle[]): Rectangle { return { x, y, width: Math.max(...rects.map((r) => r.x + r.width)) - x, height: Math.max(...rects.map((r) => r.y + r.height)) - y }; } async function screenshot(page: Page, info: TestInfo, name: string) { await info.attach(name, { body: await page.screenshot(), contentType: 'image/png' }); } -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); test.afterEach(async ({ page }, info) => { if (info.status === info.expectedStatus) return; await screenshot(page, info, 'actual-multi-selection'); @@ -113,8 +110,7 @@ test('semantic batch deduplication retains every target and additive drags do no test('marquee originals save current union geometry, actual DOM PNG pixels and complete local AI handoff', async ({ page }, info) => { await fixture(page, 'dom'); await begin(page); await drag(page, [80, 160], [500, 250]); await selected(page, ['target-a', 'target-b']); - await page.getByRole('button', { name: 'ページを操作', exact: true }).click(); await page.locator('#move-originals').click(); - await resume(page).click(); await selected(page, ['target-a', 'target-b']); + await page.locator('#move-originals').evaluate((button) => (button as HTMLButtonElement).click()); await selected(page, ['target-a', 'target-b']); await drag(page, [645, 175], [655, 185]); await selected(page, ['target-a', 'target-b', 'partial']); const current = await Promise.all(['target-a', 'target-b', 'partial'].map(async (id) => (await page.locator(`#${id}`).boundingBox())!)); const item = await save(page, '移動後の3箇所の整列を画像と対象情報に基づいて直す'); @@ -181,17 +177,7 @@ test('secondary pointers and Enter cannot commit a primary marquee and every can } }); -test('page operation keeps marquee originals through host interactions and prunes identical replacement before additive resumption', async ({ page }) => { - await fixture(page); await begin(page); await drag(page, [80, 160], [500, 250]); await selected(page, ['target-a', 'target-b']); - await page.getByRole('button', { name: 'ページを操作', exact: true }).click(); await expect(resume(page)).toHaveText('選択を再開 · 2件'); - await page.locator('#host-counter').click(); await expect(page.locator('#counter')).toHaveText('1'); - await page.locator('#clone-original').click(); await resume(page).click(); await selected(page, ['target-b']); - await expect(page.getByText('1件の選択対象が消えたか変わったため、選択から外しました。', { exact: true })).toBeVisible(); - await drag(page, [645, 175], [655, 185]); await selected(page, ['target-b', 'partial']); - expect(idsOf(await save(page, '同じ見た目で置き換えたAを引き継がず残ったBと追加対象を保存する'))).toEqual(['target-b', 'partial']); -}); - -test('marquee traverses open roots, selects only visible frame content and excludes the NeedMark while retaining full target geometry', async ({ page }) => { +test('marquee traverses open roots, selects only visible frame content and excludes the NeedMark while retaining full target geometry', { tag: '@cross-engine' }, async ({ page }) => { await fixture(page); await page.evaluate(() => { for (const id of ['basic', 'semantic', 'caps', 'host-controls']) document.getElementById(id)!.hidden = true; }); await begin(page); await drag(page, [830, 160], [1190, 835]); await expect(outlines(page)).toHaveCount(3); @@ -231,7 +217,7 @@ test('marquee traverses open roots, selects only visible frame content and exclu }); -test('marquee follows native rendering for positioned overflow escapes, visible overrides and genuine clipping without shrinking stored geometry', async ({ page }, info) => { +test('marquee follows native rendering for positioned overflow escapes, visible overrides and genuine clipping without shrinking stored geometry', { tag: '@cross-engine' }, async ({ page }, info) => { await fixture(page); await page.evaluate(() => { for (const id of ['basic', 'semantic', 'caps', 'roots', 'host-controls']) document.getElementById(id)!.hidden = true; diff --git a/tests/page-operation.spec.ts b/tests/page-operation.spec.ts deleted file mode 100644 index 7384acb..0000000 --- a/tests/page-operation.spec.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { test, expect, type Page } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import type { Feedback, Rectangle } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; - -const output = '/private/tmp/needmark-e2e-feedback'; -const resume = (page: Page) => page.getByRole('button', { name: '選択を再開', exact: true }); -const outlines = (page: Page) => page.locator('[data-needmark] .selected-outline'); -type HostState = { counter: number; submitted: number; modalCancelled: number; keys: { key: string; prevented: boolean; composing: boolean }[] }; -type PermissionState = { devices: MediaDevices; streams: MediaStream[]; tracks: MediaStreamTrack[]; requests: number; deferInitial?: boolean; deliver(index: number): void }; -async function fixture(page: Page) { - const html = await readFile(resolve('tests/fixtures/page-operation.html'), 'utf8'); - await page.route('http://127.0.0.1:4192/', async (route) => { - const response = await route.fetch(); - const script = (await response.text()).match(/]*data-needmark-token=[^>]*>[\s\S]*?<\/script>/)?.[0]; - if (!script) throw new Error('The fixture needs the real protected widget script'); - await route.fulfill({ response, body: html.replace('', `${script}`) }); - }); - await page.goto('/'); -} -async function configure(page: Page, method = 'none', shortcuts = true) { - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption(method); - await page.getByLabel('キーボードショートカット').selectOption(String(shortcuts)); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); -} -async function choose(page: Page, ids = ['target-a', 'target-b']) { - await page.getByRole('button', { name: 'コメント', exact: true }).click(); - for (const [index, id] of ids.entries()) { const rect = (await page.locator(`#${id}`).boundingBox())!; if (!index) await page.keyboard.down('Shift'); await page.mouse.click(rect.x + rect.width / 2, rect.y + rect.height / 2); if (!index) await page.keyboard.up('Shift'); } - await expect(page.getByRole('button', { name: 'ページを操作', exact: true })).toBeVisible(); - await expect(outlines(page)).toHaveCount(ids.length); -} -async function operate(page: Page, count: number) { - await page.getByRole('button', { name: 'ページを操作', exact: true }).click(); - await expect(resume(page)).toHaveText(`選択を再開 · ${count}件`); - await expect(page.locator('[data-needmark] .cover')).toBeHidden(); - await expect(outlines(page)).toHaveCount(0); -} -async function save(page: Page, comment: string) { - await page.getByRole('button', { name: '選択を完了', exact: true }).click(); - await page.getByLabel('コメント本文').fill(comment); - await expect(page.getByRole('button', { name: '保存', exact: true })).toBeEnabled(); - await saveAndList(page); - await expect(page.getByText('コメントを保存しました。', { exact: true })).toBeVisible(); -} -async function stored(): Promise { - const ids = (await readdir(output).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return []; throw error; })).filter((id) => !id.startsWith('.')); - return Promise.all(ids.map(async (id) => JSON.parse(await readFile(`${output}/${id}/context.json`, 'utf8')) as Feedback)); -} -const host = (page: Page) => page.evaluate(() => (window as unknown as { pageOperationHost: HostState }).pageOperationHost); -function union(rects: Rectangle[]): Rectangle { - const x = Math.min(...rects.map((r) => r.x)), y = Math.min(...rects.map((r) => r.y)); - return { x, y, width: Math.max(...rects.map((r) => r.x + r.width)) - x, height: Math.max(...rects.map((r) => r.y + r.height)) - y }; -} -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); - -test('page operation releases real host controls and form keys while retaining multiple targets through resume and saving', async ({ page }) => { - await fixture(page); await configure(page); - await page.getByRole('button', { name: 'アニメーションを停止', exact: true }).click(); - await choose(page); await operate(page, 2); - await page.locator('#host-counter').click(); await expect(page.locator('#counter-value')).toHaveText('1'); - const field = page.getByLabel('ホスト入力', { exact: true }); - await field.focus(); await expect(field).toBeFocused(); - const initialCaret = await field.evaluate((node) => ({ start: (node as HTMLInputElement).selectionStart, end: (node as HTMLInputElement).selectionEnd })); - await field.evaluate((node) => { const input = node as HTMLInputElement; input.setSelectionRange(input.value.length, input.value.length); }); - const beforeEdit = await field.evaluate((node) => { const input = node as HTMLInputElement; return { value: input.value, start: input.selectionStart, end: input.selectionEnd, focused: document.activeElement === input }; }); - expect(beforeEdit).toEqual({ value: 'AB', start: 2, end: 2, focused: true }); await page.keyboard.press('Backspace'); - const afterEdit = await field.evaluate((node) => { const input = node as HTMLInputElement; return { value: input.value, start: input.selectionStart, end: input.selectionEnd, focused: document.activeElement === input }; }); - const editKeys = (await host(page)).keys; - await test.info().attach('host-native-edit.json', { body: JSON.stringify({ initialCaret, beforeEdit, afterEdit, keys: editKeys }, null, 2), contentType: 'application/json' }); - expect(editKeys.filter((event) => event.key === 'Backspace').at(-1)?.prevented).toBe(false); - await expect(page.getByLabel('ホスト入力', { exact: true })).toHaveValue('A'); await page.keyboard.press('Enter'); - await expect(page.locator('#submit-value')).toHaveText('1'); - await page.getByLabel('ホストの文章', { exact: true }).fill('ホストの文章'); - await page.keyboard.press('Enter'); await expect(page.getByLabel('ホストの文章', { exact: true })).toHaveValue('ホストの文章\n'); - await page.keyboard.press('Control+Enter'); await page.keyboard.press('Escape'); await expect(page.locator('#submit-value')).toHaveText('1'); - await expect(resume(page)).toHaveText('選択を再開 · 2件'); - const events = (await host(page)).keys; expect(events.map((event) => event.key)).toEqual(expect.arrayContaining(['Backspace', 'Enter', 'Escape'])); expect(events.every((event) => !event.prevented)).toBe(true); - await page.locator('#open-modal').click(); await expect(page.locator('#host-modal')).toBeVisible(); await page.keyboard.press('Escape'); - await expect(page.locator('#host-modal')).toBeHidden(); expect((await host(page)).modalCancelled).toBe(1); - expect(await stored()).toEqual([]); - await resume(page).click(); await expect(outlines(page)).toHaveCount(2); - const third = (await page.locator('#target-c').boundingBox())!; await page.mouse.click(third.x + third.width / 2, third.y + third.height / 2); - await save(page, 'ページを操作してから元の2件と追加した1件を直す'); - const items = await stored(); expect(items).toHaveLength(1); expect(items[0]!.target.kind).toBe('multi'); - expect(items[0]!.target.code?.elements.map((element) => element.attributes.id)).toEqual(['target-a', 'target-b', 'target-c']); - await expect(page.getByRole('button', { name: 'アニメーションを再開', exact: true })).toHaveAttribute('aria-pressed', 'true'); -}); - -test('resumed original nodes use current rectangles and DOM PNG pixels after host reflow and scrolling', async ({ page }) => { - await fixture(page); await configure(page, 'dom'); - const before = (await page.locator('#target-a').boundingBox())!; - await choose(page); await operate(page, 2); await page.locator('#move-targets').click(); - await page.mouse.move(1000, 500); await page.mouse.wheel(0, 60); await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(0); - const current = await Promise.all(['target-a', 'target-b'].map(async (id) => (await page.locator(`#${id}`).boundingBox())!)); - expect(current[0]!.x).toBe(before.x + 70); - await resume(page).click(); await expect(outlines(page)).toHaveCount(2); - const drawn = await outlines(page).evaluateAll((nodes) => nodes.map((node) => { const r = node.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })); - expect(drawn).toEqual(current); await save(page, '操作後の現在の位置で保存する'); - const item = (await stored())[0]!; - expect(item.target.rect).toEqual(union(current)); expect(item.target.code?.elements.map((element) => element.rect)).toEqual(current); - expect(item.viewport.scrollY).toBeGreaterThan(0); expect(item.capture.method).toBe('dom'); expect(item.image).toBe('screenshot.png'); - const png = await readFile(`${output}/${item.id}/screenshot.png`); - const pixels = await page.evaluate(async ({ src, selected, old, viewport }) => { - const image = new Image(); image.src = src; await image.decode(); const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; - const context = canvas.getContext('2d')!; context.drawImage(image, 0, 0); - const color = (x: number, y: number) => Array.from(context.getImageData(Math.round(x * image.width / viewport.width), Math.round(y * image.height / viewport.height), 1, 1).data); - return { width: image.width, height: image.height, center: color(selected.x + 20, selected.y + selected.height / 2), outline: color(selected.x, selected.y + selected.height / 2), old: color(old.x + 20, old.y + old.height / 2) }; - }, { src: `data:image/png;base64,${png.toString('base64')}`, selected: current[0]!, old: before, viewport: item.viewport }); - expect(pixels.center).toEqual([12, 174, 93, 255]); expect(pixels.outline).toEqual([25, 101, 227, 255]); expect(pixels.old).toEqual([255, 255, 255, 255]); - expect(item.capture).toMatchObject({ width: pixels.width, height: pixels.height }); -}); - -test('DOM capture renders every selected target when one moved outside the viewport during page operation', async ({ page }) => { - await fixture(page); await configure(page, 'dom'); - await choose(page); await operate(page, 2); await page.locator('#offscreen-b').click(); await resume(page).click(); - await expect(page.locator('[data-needmark] [data-hint]')).toContainText('2件を選択'); - const current = await Promise.all(['target-a', 'target-b'].map(async (id) => (await page.locator(`#${id}`).boundingBox())!)); - expect(current[1]!.y).toBeGreaterThan(850); - await save(page, '画面外へ移動した対象も画像に含める'); - const item = (await stored())[0]!; - expect(item.target.rect).toEqual(union(current)); expect(item.image).toBe('screenshot.png'); - const png = await readFile(`${output}/${item.id}/screenshot.png`); - const pixels = await page.evaluate(async ({ src, targets, region }) => { - const image = new Image(); image.src = src; await image.decode(); const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; - const context = canvas.getContext('2d')!; context.drawImage(image, 0, 0); - const scale = image.width / region.width; - const color = (x: number, y: number) => Array.from(context.getImageData(Math.round((x - region.x) * scale), Math.round((y - region.y) * scale), 1, 1).data); - return { height: image.height / scale, colors: targets.map((rect) => color(rect.x + 20, rect.y + rect.height / 2)) }; - }, { src: `data:image/png;base64,${png.toString('base64')}`, targets: current, - region: { x: 0, y: item.target.rect.y - 48, width: item.viewport.width } }); - expect(pixels.height).toBeCloseTo(item.target.rect.height + 96, 0); - expect(pixels.colors).toEqual([[12, 174, 93, 255], [25, 132, 209, 255]]); -}); - -test('an area drag held at the viewport edge scrolls the page and captures the whole extended area', async ({ page }) => { - await fixture(page); await configure(page, 'dom'); - await page.getByRole('button', { name: 'コメント', exact: true }).click(); - const a = (await page.locator('#target-a').boundingBox())!; - await page.mouse.move(60, 200); await page.mouse.down(); await page.mouse.move(600, 500); await page.mouse.move(600, 835); - await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(300); - await page.mouse.move(600, 836); await page.mouse.up(); - await page.getByLabel('コメント本文').fill('画面外まで広げた範囲'); - await expect(page.getByRole('button', { name: '保存', exact: true })).toBeEnabled(); - await saveAndList(page); - await expect(page.getByText('コメントを保存しました。', { exact: true })).toBeVisible(); - const item = (await stored())[0]!, scroll = item.viewport.scrollY; - expect(item.target.kind).toBe('area'); - expect(item.target.rect).toMatchObject({ x: 60, y: 200 - scroll, width: 540, height: 636 + scroll }); - const height = item.target.rect.height + 96, top = Math.min(Math.max(item.target.rect.y - 48, -scroll), 1600 - scroll - height); - const png = await readFile(`${output}/${item.id}/screenshot.png`); - const pixels = await page.evaluate(async ({ src, target, region }) => { - const image = new Image(); image.src = src; await image.decode(); const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; - const context = canvas.getContext('2d')!; context.drawImage(image, 0, 0); - const scale = image.width / region.width; - return { height: image.height / scale, color: Array.from(context.getImageData(Math.round((target.x + 20) * scale), Math.round((target.y - region.y) * scale), 1, 1).data) }; - }, { src: `data:image/png;base64,${png.toString('base64')}`, target: { x: a.x, y: a.y + a.height / 2 - scroll }, region: { y: top, width: item.viewport.width } }); - expect(pixels.height).toBeCloseTo(height, 0); expect(pixels.color).toEqual([12, 174, 93, 255]); -}); - -test('removed, cloned and reused-owner targets never rebind; all missing targets require selection and hidden originals fail capture explicitly', async ({ page }) => { - await fixture(page); await configure(page); - await page.evaluate(() => { - function OriginalOwner() {} function OtherOwner() {} - const node = document.querySelector('#target-c')!; - const fiber = { stateNode: node, type: 'button', return: { type: OriginalOwner } }; - Object.defineProperty(node, '__reactFiber$pageOperation', { enumerable: true, value: fiber }); - (window as unknown as { replacePageOwner: () => void }).replacePageOwner = () => { fiber.return.type = OtherOwner; }; - }); - await choose(page, ['target-a', 'target-b', 'target-c', 'target-d']); await operate(page, 4); - await page.locator('#clone-a').click(); await expect(resume(page)).toHaveText('選択を再開 · 3件'); - await page.locator('#remove-b').click(); await expect(resume(page)).toHaveText('選択を再開 · 2件'); - await page.evaluate(() => (window as unknown as { replacePageOwner: () => void }).replacePageOwner()); - await resume(page).click(); await expect(outlines(page)).toHaveCount(1); - await expect(page.getByText('1件の選択対象が消えたか変わったため、選択から外しました。', { exact: true })).toBeVisible(); - await save(page, '元の所有者が残るDだけを保存する'); expect((await stored())[0]!.target.code?.elements.map((element) => element.attributes.id)).toEqual(['target-d']); - await page.goto('/'); await choose(page); await operate(page, 2); await page.locator('#remove-all').click(); await resume(page).click(); - await expect(outlines(page)).toHaveCount(0); await expect(page.getByText(/もう一度場所を選んでください。/)).toBeVisible(); - await page.getByRole('button', { name: '選択を完了', exact: true }).click(); await expect(page.getByLabel('コメント本文')).toHaveCount(0); - const valid = (await page.locator('#target-c').boundingBox())!; await page.mouse.click(valid.x + valid.width / 2, valid.y + valid.height / 2); await save(page, '全対象が消えた後で選び直したC'); - expect((await stored()).find((item) => item.comment.endsWith('選び直したC'))!.target.code?.elements.map((element) => element.attributes.id)).toEqual(['target-c']); - await page.goto('/'); await configure(page, 'dom'); await choose(page); await operate(page, 2); - await page.locator('#hide-a').click(); await page.locator('#offscreen-b').click(); await resume(page).click(); - await expect(page.locator('[data-needmark] [data-hint]')).toContainText('2件を選択'); - await page.getByRole('button', { name: '選択を完了', exact: true }).click(); await page.getByLabel('コメント本文').fill('不可視の対象も黙って別の場所に割り当てない'); - await expect(page.getByRole('button', { name: '画像なしで保存する', exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: '保存', exact: true })).toBeDisabled(); await expect(page.getByLabel('コメント本文')).toHaveValue('不可視の対象も黙って別の場所に割り当てない'); - await page.getByRole('button', { name: '画像なしで保存する', exact: true }).click(); await expect(page.getByRole('button', { name: '保存', exact: true })).toBeEnabled(); - await page.getByRole('button', { name: 'キャンセル', exact: true }).click(); expect(await stored()).toHaveLength(2); -}); - -test('exact SPA, query and hash changes and explicit discard, mode, list and hide actions end retained selections', async ({ page }) => { - await fixture(page); await configure(page); - await choose(page, ['target-a']); await page.evaluate(() => history.pushState({}, '', '/?selection=changed')); - await expect(page.locator('[data-needmark] .cover')).toBeHidden(); await expect(outlines(page)).toHaveCount(0); - await expect(page.getByRole('button', { name: 'コメント', exact: true })).toBeVisible(); - for (const id of ['push-route', 'query-route', 'hash-route']) { - await page.goto('/'); await choose(page, ['target-a']); await operate(page, 1); await page.locator(`#${id}`).click(); - await expect(resume(page)).toHaveCount(0); await expect(page.getByRole('button', { name: 'コメント', exact: true })).toBeVisible(); - await expect(page.getByText('ページのURLが変わったため、保持していた選択を終了しました。', { exact: true })).toBeVisible(); - await choose(page, ['target-b']); await save(page, `URL変更後の新規対象 ${id}`); - const item = (await stored()).find((item) => item.comment.endsWith(id))!; expect(item.url).toBe(page.url()); expect(item.target.code?.elements.map((element) => element.attributes.id)).toEqual(['target-b']); - } - for (const action of ['discard', 'list', 'hide']) { - await page.goto('/'); await choose(page, ['target-a']); await operate(page, 1); - if (action === 'discard') await page.getByRole('button', { name: '選択を破棄', exact: true }).click(); - else if (action === 'list') { await page.getByRole('button', { name: 'コメント一覧', exact: true }).click(); await expect(page.getByRole('button', { name: 'キーワード検索', exact: true })).toBeVisible(); await page.getByRole('button', { name: 'パネルを閉じる', exact: true }).click(); } - else { await page.keyboard.press('Shift+H'); await expect(page.getByRole('button', { name: 'コメント', exact: true })).toBeHidden(); await page.keyboard.press('Shift+H'); } - await expect(resume(page)).toHaveCount(0); await choose(page, ['target-b']); await expect(outlines(page)).toHaveCount(1); await page.keyboard.press('Escape'); - } - expect(await stored()).toHaveLength(3); -}); - -test('ended native sharing asks again, cancellation retains original selections and late permission cannot revive a discarded session', async ({ page }) => { - await page.addInitScript(() => { - const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 48; - const devices = navigator.mediaDevices; - Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: devices }); - const pending = new Map void>(); - const state: PermissionState = { devices, streams: [], tracks: [], requests: 0, deliver(index) { pending.get(index)!(state.streams[index]!); pending.delete(index); } }; - Object.defineProperty(devices, 'getDisplayMedia', { configurable: true, value: () => { - const index = state.requests++, stream = canvas.captureStream(); state.streams.push(stream); - const track = stream.getVideoTracks()[0]!; state.tracks.push(track); - Object.defineProperty(track, 'getSettings', { value: () => ({ displaySurface: 'browser' }) }); - return index === 0 && !state.deferInitial ? Promise.resolve(stream) : new Promise((resolve) => { pending.set(index, resolve); }); - } }); - HTMLMediaElement.prototype.play = () => Promise.resolve(); (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission = state; - }); - await fixture(page); await configure(page, 'tab'); - const deviceEvidence = await page.evaluate(() => { - const state = (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission; - return { sameDevices: navigator.mediaDevices === state.devices, ownDisplayOverride: Object.hasOwn(navigator.mediaDevices, 'getDisplayMedia'), requests: state.requests }; - }); - await test.info().attach('native-device-before-start.json', { body: JSON.stringify(deviceEvidence, null, 2), contentType: 'application/json' }); - expect(deviceEvidence).toEqual({ sameDevices: true, ownDisplayOverride: true, requests: 0 }); - await page.getByRole('button', { name: 'コメント', exact: true }).click(); - await expect.poll(() => page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.requests)).toBe(1); - const trackEvidence = await page.evaluate(() => { - const state = (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission; - return { requests: state.requests, tracks: state.streams.map((stream, index) => { const current = stream.getVideoTracks()[0]!; - return { sameWrapper: current === state.tracks[index], currentSettings: current.getSettings(), storedSettings: state.tracks[index]!.getSettings(), - currentState: current.readyState, currentId: current.id, storedId: state.tracks[index]!.id, - handle: (current as MediaStreamTrack & { getCaptureHandle?: () => unknown }).getCaptureHandle?.() }; }) }; - }); - await test.info().attach('native-initial-track.json', { body: JSON.stringify(trackEvidence, null, 2), contentType: 'application/json' }); - expect(trackEvidence.requests).toBe(1); expect(trackEvidence.tracks[0]!.currentSettings.displaySurface).toBe('browser'); expect(trackEvidence.tracks[0]!.sameWrapper).toBe(true); expect(trackEvidence.tracks[0]!.currentState).toBe('live'); - for (const [index, id] of ['target-a', 'target-b'].entries()) { const r = (await page.locator(`#${id}`).boundingBox())!; if (!index) await page.keyboard.down('Shift'); await page.mouse.click(r.x + r.width / 2, r.y + r.height / 2); if (!index) await page.keyboard.up('Shift'); } - await expect(page.getByRole('button', { name: 'ページを操作', exact: true })).toBeVisible(); - await expect(outlines(page)).toHaveCount(2); await operate(page, 2); - await page.locator('#host-counter').click(); expect(await page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.requests)).toBe(1); - const end = () => page.evaluate(() => { const state = (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission; const track = state.streams.at(-1)!.getVideoTracks()[0]!; track.stop(); track.dispatchEvent(new Event('ended')); }); - await end(); await resume(page).click(); await expect(page.getByRole('heading', { name: 'このタブの撮影を開始', exact: true })).toBeVisible(); - await page.getByRole('button', { name: 'キャンセル', exact: true }).click(); await expect(resume(page)).toHaveText('選択を再開 · 2件'); - await expect(page.getByText('撮影の再開をキャンセルしました。選択は保持しています。', { exact: true })).toBeVisible(); - await page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.deliver(1)); - await expect.poll(() => page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.streams[1]!.getTracks().map((track) => track.readyState))).toEqual(['ended']); - await expect(page.locator('[data-needmark] .cover')).toBeHidden(); await expect(resume(page)).toHaveText('選択を再開 · 2件'); - await resume(page).click(); await expect.poll(() => page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.requests)).toBe(3); - await page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.deliver(2)); await expect(outlines(page)).toHaveCount(2); - await operate(page, 2); await end(); await resume(page).click(); await expect.poll(() => page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.requests)).toBe(4); - await page.getByRole('button', { name: 'コメント一覧', exact: true }).click(); await expect(page.getByRole('button', { name: 'キーワード検索', exact: true })).toBeVisible(); - await page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.deliver(3)); - await expect.poll(() => page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.streams[3]!.getTracks().map((track) => track.readyState))).toEqual(['ended']); - await expect(resume(page)).toHaveCount(0); await expect(page.locator('[data-needmark] .cover')).toBeHidden(); await expect(page.getByRole('button', { name: 'キーワード検索', exact: true })).toBeVisible(); expect(await stored()).toEqual([]); - // An initial sharing request has no retained selection; leaving it must invalidate its late result too. - for (const action of ['list', 'settings', 'hide', 'route']) { - await page.goto('/'); await page.evaluate(() => { (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.deferInitial = true; }); - await page.getByRole('button', { name: 'コメント', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'このタブの撮影を開始', exact: true })).toBeVisible(); - if (action === 'list') { await page.getByRole('button', { name: 'コメント一覧', exact: true }).click(); await expect(page.getByRole('button', { name: 'キーワード検索', exact: true })).toBeVisible(); } - else if (action === 'settings') { await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('キャプチャ方式')).toBeVisible(); } - else if (action === 'hide') { await page.keyboard.press('Shift+H'); await expect(page.getByRole('button', { name: 'コメント', exact: true })).toBeHidden(); } - else await page.locator('#push-route').click(); - await page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.deliver(0)); - await expect.poll(() => page.evaluate(() => (window as unknown as { pageOperationPermission: PermissionState }).pageOperationPermission.streams[0]!.getTracks().map((track) => track.readyState))).toEqual(['ended']); - await expect(page.locator('[data-needmark] .cover')).toBeHidden(); await expect(outlines(page)).toHaveCount(0); await expect(resume(page)).toHaveCount(0); - if (action === 'list') await expect(page.getByRole('button', { name: 'キーワード検索', exact: true })).toBeVisible(); - else if (action === 'settings') await expect(page.getByLabel('キャプチャ方式')).toBeVisible(); - else if (action === 'hide') { await page.keyboard.press('Shift+H'); await expect(page.getByRole('button', { name: 'コメント', exact: true })).toBeVisible(); } - } - expect(await stored()).toEqual([]); -}); - -test('page operation respects composing keys and disabled global shortcuts and cannot replace an unsaved composer', async ({ page }) => { - await fixture(page); await configure(page); await choose(page, ['target-a']); await operate(page, 1); - await page.locator('#host-counter').focus(); - await page.locator('#host-counter').evaluate((node) => { for (const key of ['C', 'P', 'H']) node.dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey: true, isComposing: true, bubbles: true, composed: true, cancelable: true })); }); - await expect(resume(page)).toHaveText('選択を再開 · 1件'); expect((await host(page)).keys.filter((event) => event.composing).map((event) => event.prevented)).toEqual([false, false, false]); - await configure(page, 'none', false); await expect(resume(page)).toHaveCount(0); await choose(page, ['target-b']); await operate(page, 1); - await page.locator('#host-counter').focus(); for (const key of ['Shift+C', 'Shift+P', 'Shift+H']) await page.keyboard.press(key); - await expect(resume(page)).toHaveText('選択を再開 · 1件'); expect((await host(page)).keys.slice(-3).map((event) => event.prevented)).toEqual([false, false, false]); - await expect(page.getByRole('button', { name: 'アニメーションを停止', exact: true })).toHaveAttribute('aria-pressed', 'false'); - await resume(page).click(); await page.getByRole('button', { name: '選択を完了', exact: true }).click(); await page.getByLabel('コメント本文').fill('未保存のコメントを保持する'); - await expect(page.getByRole('button', { name: 'ページを操作', exact: true })).toBeHidden(); await expect(resume(page)).toHaveCount(0); - await page.getByRole('button', { name: 'コメント', exact: true }).click(); await expect(page.getByLabel('コメント本文')).toHaveValue('未保存のコメントを保持する'); - await expect(page.getByLabel('コメント本文')).toBeVisible(); expect(await stored()).toEqual([]); -}); - -test('a narrow viewport supports actual operation, resume, completion and discard clicks without losing selected targets', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 850 }); - await fixture(page); await configure(page); await choose(page); await operate(page, 2); - await page.locator('#host-counter').click(); await expect(page.locator('#counter-value')).toHaveText('1'); - await resume(page).click(); await expect(outlines(page)).toHaveCount(2); - await save(page, '狭い画面でもページを操作してから選択を保存する'); - const items = await stored(); expect(items).toHaveLength(1); expect(items[0]!.target.code?.elements.map((element) => element.attributes.id)).toEqual(['target-a', 'target-b']); - await choose(page, ['target-a']); await operate(page, 1); - await page.getByRole('button', { name: '選択を破棄', exact: true }).click(); await expect(resume(page)).toHaveCount(0); - await page.getByRole('button', { name: 'コメント', exact: true }).click(); - await expect(page.locator('[data-needmark] [data-hint]')).toHaveText('クリック・ドラッグで選択'); - await page.keyboard.press('Escape'); expect(await stored()).toHaveLength(1); -}); diff --git a/tests/panel-position.spec.ts b/tests/panel-position.spec.ts index 76e1589..4bdbf88 100644 --- a/tests/panel-position.spec.ts +++ b/tests/panel-position.spec.ts @@ -1,10 +1,9 @@ -import { test, expect, type Page, type TestInfo } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { type Page, type TestInfo } from '@playwright/test'; +import { test, expect, saveAndList, searchList, output } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; import { input } from './fixtures.js'; -import { saveAndList, searchList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; const positionKey = 'needmark-toolbar-position'; const panel = (page: Page) => page.getByRole('region', { name: 'NeedMark', exact: true }); const dock = (page: Page) => page.locator('[data-needmark] .dock'); @@ -69,7 +68,6 @@ async function selection(field: ReturnType) { return field.evaluate((element) => ({ start: (element as HTMLTextAreaElement).selectionStart, end: (element as HTMLTextAreaElement).selectionEnd, scroll: (element as HTMLTextAreaElement).scrollTop })); } -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); test.afterEach(async ({ page }, info) => { if (info.status === info.expectedStatus) return; await screenshot(page, info, 'actual-panel-failure'); @@ -91,7 +89,7 @@ test('stored toolbar at y90 leaves mobile settings and a tall searchable review await page.getByLabel('投稿者名', { exact: true }).fill('狭い画面のレビュアー'); await accessiblePanel(page); await page.getByLabel('AIへの情報量').selectOption('compact'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); await expect(panel(page)).toBeHidden(); for (let index = 0; index < 12; index++) await seed(page, `一覧のコメント ${index}: ${'レビュー内容と修正理由。'.repeat(12)}`); await page.getByRole('button', { name: 'コメント一覧', exact: true }).click(); @@ -123,6 +121,23 @@ test('a mobile real-target composer retains typed feedback through image and sou expect((await readFile(`${output}/${item.id}/screenshot.png`)).subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); }); +test('the composer enables 保存 only after it has finished growing to fit the capture preview', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); await setup(page, { method: 'dom' }); + await page.evaluate(() => { + const events: string[] = (window as unknown as { panelEvents: string[] }).panelEvents = []; + const note = (event: string) => { if (events.at(-1) !== event) events.push(event); }; + new MutationObserver((mutations) => { for (const { target } of mutations) { + const element = target as HTMLElement; + if (element.matches('.panel')) note(element.classList.contains('morphing') ? 'morphing' : 'settled'); + else if (element.matches('button[type="submit"]')) note((element as HTMLButtonElement).disabled ? 'disabled' : 'enabled'); + } }).observe(document.querySelector('[data-needmark]')!.shadowRoot!, { subtree: true, attributeFilter: ['class', 'disabled'] }); + }); + await selectPurchase(page); + await expect(page.getByRole('button', { name: '保存', exact: true })).toBeEnabled(); + const events = await page.evaluate(() => (window as unknown as { panelEvents: string[] }).panelEvents); + expect(events.slice(events.lastIndexOf('morphing'))).toEqual(['morphing', 'settled', 'enabled']); +}); + test('toolbar drag cancellation and viewport reflow preserve a typed reply, caret and valid panel scroll', async ({ page }) => { await setup(page, { width: 800, height: 700 }); const raw = await storedPosition(page); await seed(page, '長いコメントの本文。\n'.repeat(45)); @@ -193,12 +208,12 @@ test('an extremely short viewport temporarily hides the toolbar while settings s await page.getByLabel('投稿者名', { exact: true }).fill('高さが足りなくても保存できる'); await page.setViewportSize({ width: 410, height: 220 }); await expect(dock(page)).toBeHidden(); await accessiblePanel(page); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); await expect(panel(page)).toBeHidden(); await expect(dock(page)).toBeVisible(); expect(await storedPosition(page)).toBe(raw); await page.setViewportSize({ width: 410, height: 814 }); await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('投稿者名', { exact: true })).toHaveValue('高さが足りなくても保存できる'); - await accessiblePanel(page); await page.getByRole('button', { name: 'パネルを閉じる', exact: true }).click(); + await accessiblePanel(page); await page.getByRole('button', { name: 'キャンセル', exact: true }).click(); await expect(dock(page)).toBeVisible(); expect(await storedPosition(page)).toBe(raw); }); @@ -291,12 +306,3 @@ test('delete confirmations replace the content of the open panel without moving await expect.poll(async () => { const box = (await panel(page).boundingBox())!; return [Math.round(box.x), Math.round(box.y)]; }).toEqual([Math.round(details.x), Math.round(details.y)]); }); -test('the panel header stays at the top while the panel body scrolls', async ({ page }) => { - await setup(page, { width: 800, height: 600 }); await seed(page, '長いコメントの本文。\n'.repeat(60)); - await page.getByRole('button', { name: 'コメント一覧', exact: true }).click(); await page.locator('[data-needmark] .open-item').click(); - await expect(page.getByLabel('返信', { exact: true })).toBeVisible(); - await panel(page).evaluate((element) => { element.scrollTop = element.scrollHeight; }); - const header = await panel(page).locator('header').evaluate((element) => { const box = element.getBoundingClientRect(), outer = element.parentElement!.getBoundingClientRect(); return { top: box.top - outer.top, scrolled: element.parentElement!.scrollTop }; }); - expect(header.scrolled).toBeGreaterThan(200); expect(header.top).toBeCloseTo(0, 0); - await expect(page.getByRole('button', { name: 'パネルを閉じる', exact: true })).toBeVisible(); -}); diff --git a/tests/reference-review.spec.ts b/tests/reference-review.spec.ts index a8a6269..1cdd2cb 100644 --- a/tests/reference-review.spec.ts +++ b/tests/reference-review.spec.ts @@ -1,21 +1,15 @@ -import { test, expect, type Page } from '@playwright/test'; -import { readdir, readFile, rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, searchList, output, configureReview, enableWidgetOption } from './support/review.js'; +import { readdir, readFile } from 'node:fs/promises'; import type { Feedback, FeedbackInput } from '../src/shared/types.js'; import { input } from './fixtures.js'; -import { saveAndList, searchList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); async function setup(page: Page) { await page.addInitScript(() => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (value: string) => { (window as unknown as { referenceCopied: string }).referenceCopied = value; } } }); }); - await page.route('http://127.0.0.1:4192/', async (route) => { - const response = await route.fetch(); - await route.fulfill({ response, body: (await response.text()).replace('data-needmark-capture=', 'data-needmark-submit="true" data-needmark-capture=') }); - }); - await page.goto('/'); await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('none'); await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await enableWidgetOption(page, 'data-needmark-submit'); + await page.goto('/'); await configureReview(page, { method: 'none' }); } async function headers(page: Page) { return { 'x-needmark-token': (await page.locator('script[data-needmark-token]').getAttribute('data-needmark-token'))! }; } // References can no longer be created in the widget, so earlier files are reproduced through the local API. diff --git a/tests/rename-persistence.spec.ts b/tests/rename-persistence.spec.ts index 3a74400..295f26e 100644 --- a/tests/rename-persistence.spec.ts +++ b/tests/rename-persistence.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, type Locator, type Page } from '@playwright/test'; +import { type Locator, type Page } from '@playwright/test'; +import { test, expect } from './support/review.js'; const dock = (page: Page) => page.locator('[data-needmark] .dock'); async function bounds(locator: Locator) { const rect = await locator.boundingBox(); expect(rect).not.toBeNull(); return rect!; } @@ -30,7 +31,7 @@ test('legacy browser preferences restore review behavior and canonical preferenc await expect(page.getByLabel('AIへの情報量')).toHaveValue('compact'); await expect(page.getByLabel('キーボードショートカット')).toHaveValue('false'); await expect(page.getByLabel('表示テーマ')).toHaveValue('dark'); - await page.getByRole('button', { name: 'パネルを閉じる', exact: true }).click(); + await page.getByRole('button', { name: 'キャンセル', exact: true }).click(); const canonical = { 'needmark-author': 'Current reviewer', 'needmark-detail': 'standard', 'needmark-shortcuts': 'true', 'needmark-theme': 'light', 'needmark-toolbar-position': JSON.stringify({ x: 220, y: 180 }), diff --git a/tests/review-flow.spec.ts b/tests/review-flow.spec.ts index c8955a5..20d3ca9 100644 --- a/tests/review-flow.spec.ts +++ b/tests/review-flow.spec.ts @@ -1,18 +1,14 @@ -import { test, expect, type Page } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, searchList, output, configureReview } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList, searchList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); async function saved(): Promise { const ids = (await readdir(output).catch(() => [] as string[])).filter((id) => !id.startsWith('.')); return Promise.all(ids.map(async (id) => JSON.parse(await readFile(`${output}/${id}/context.json`, 'utf8')) as Feedback)); } async function configure(page: Page) { - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('none'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await configureReview(page, { method: 'none' }); } async function clickAt(page: Page, selector: string) { const rect = (await page.locator(selector).boundingBox())!; @@ -20,17 +16,11 @@ async function clickAt(page: Page, selector: string) { } const review = (page: Page) => page.locator('[data-needmark]'); -test('first-run start button saves the displayed settings and begins selection immediately', async ({ page }) => { +test('the first comment begins selection immediately without opening settings', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: 'コメント', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'レビューを始める' })).toBeVisible(); - await page.getByLabel('キャプチャ方式').selectOption('none'); - await page.getByRole('button', { name: 'このまま始める' }).click(); await expect(review(page).locator('.cover')).toBeVisible(); - expect(await page.evaluate(() => [localStorage.getItem('needmark-configured'), localStorage.getItem('needmark-method')])).toEqual(['true', 'none']); - await page.keyboard.press('Escape'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await expect(page.getByRole('button', { name: 'このまま始める' })).toHaveCount(0); + await expect(page.getByRole('heading', { name: '設定', exact: true })).toHaveCount(0); }); test('the bottom-centred icon toolbar names each tool, explains its purpose in tooltips and follows the shortcut preference', async ({ page }) => { @@ -59,7 +49,7 @@ test('the bottom-centred icon toolbar names each tool, explains its purpose in t await page.getByRole('button', { name: '設定', exact: true }).click(); await page.getByLabel('キーボードショートカット').selectOption('false'); await page.getByLabel('表示言語').selectOption('en'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); await page.getByRole('button', { name: 'Comment', exact: true }).hover(); await expect(tip).toHaveText('CommentPick a spot on the page and leave a comment'); }); @@ -103,6 +93,15 @@ test('comment mode turns the bottom toolbar into the gesture hint and returns it await expect.poll(async () => Math.round((await dock.boundingBox())!.width)).toBe(Math.round(before.width)); }); +test('the guide link in the draggable toolbar opens the guide in a new tab', async ({ page }) => { + await page.context().route('https://github.com/**', (route) => route.fulfill({ contentType: 'text/html', body: 'guide' })); + await page.goto('/'); await configure(page); + await page.getByRole('button', { name: 'コメント', exact: true }).click(); + const popup = page.waitForEvent('popup'); + await review(page).locator('.dock').getByRole('link', { name: '使い方', exact: true }).click(); + expect(decodeURIComponent((await popup).url())).toBe('https://github.com/mssh21/needmark/blob/main/docs/getting-started.ja.md#コメント'); +}); + test('a toolbar tip pending when selection starts never appears for the button selection hides', async ({ page }) => { await page.goto('/'); await configure(page); const tip = review(page).locator('.toolbar-tip'); @@ -114,23 +113,18 @@ test('a toolbar tip pending when selection starts never appears for the button s expect(Math.abs(box.x + box.width / 2 - (end.x + end.width / 2))).toBeLessThan(2); }); -test('one comment tool selects text by Alt drag, by an existing page selection, and several elements after one Shift click', async ({ page }) => { +test('one comment tool selects text by Alt drag and several elements after one Shift click', async ({ page }) => { await page.goto('/'); await configure(page); const title = (await page.locator('#product-title').boundingBox())!; await page.getByRole('button', { name: 'コメント', exact: true }).click(); await expect(review(page).locator('[data-hint]')).toHaveText('クリック・ドラッグで選択'); - await expect(page.getByRole('button', { name: '選択中の文字にコメント', exact: true })).toBeHidden(); await page.keyboard.down('Alt'); await page.mouse.move(title.x + 2, title.y + title.height / 2); await page.mouse.down(); await page.mouse.move(title.x + 70, title.y + title.height / 2, { steps: 3 }); await page.mouse.up(); await page.keyboard.up('Alt'); await page.getByLabel('コメント本文').fill('Altドラッグの文字'); - await page.getByRole('button', { name: 'キャンセル', exact: true }).click(); - await page.evaluate(() => { const range = document.createRange(), node = document.querySelector('#product-title')!.firstChild!; range.setStart(node, 0); range.setEnd(node, 6); getSelection()!.removeAllRanges(); getSelection()!.addRange(range); }); - await page.keyboard.press('Shift+C'); - await page.getByRole('button', { name: '選択中の文字にコメント', exact: true }).click(); - await page.getByLabel('コメント本文').fill('選んでおいた文字'); await saveAndList(page); - await expect.poll(async () => (await saved()).map((item) => [item.target.kind, item.target.text])).toEqual([['text', 'Halden']]); + await expect.poll(async () => (await saved()).map((item) => [item.target.kind, item.comment])).toEqual([['text', 'Altドラッグの文字']]); + const [text] = await saved(); expect('Halden for Teams').toContain(text!.target.text); expect(text!.target.code?.elements[0]?.source?.file).toBe('demo/index.html'); await page.getByRole('button', { name: 'パネルを閉じる', exact: true }).click(); await page.getByRole('button', { name: 'コメント', exact: true }).click(); await page.keyboard.down('Shift'); await clickAt(page, '#purchase-button'); await page.keyboard.up('Shift'); @@ -184,7 +178,7 @@ test('category defaults to fix, is chosen above the comment without submitting o await expect(page.getByRole('combobox', { name: 'カテゴリ' })).toHaveCount(0); }); -test('the single save keeps reviewing without opening the list, by button or Ctrl/Cmd+Enter', async ({ page }) => { +test('the single save keeps reviewing without opening the list, by button or Ctrl/Cmd+Enter', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await configure(page); await page.getByRole('button', { name: 'コメント', exact: true }).click(); await clickAt(page, '#purchase-button'); await expect(review(page).locator('.draft-outline')).toHaveCount(1); diff --git a/tests/review-ux.spec.ts b/tests/review-ux.spec.ts index d5ccd5b..bbc12f5 100644 --- a/tests/review-ux.spec.ts +++ b/tests/review-ux.spec.ts @@ -1,26 +1,24 @@ -import { test, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { test, expect, saveAndList, searchList, toggleFilter, output } from './support/review.js'; import { readFile, readdir, rm, mkdir } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { resolve } from 'node:path'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList, searchList, toggleFilter } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); async function storedAll(): Promise { const ids = (await readdir(output)).filter((id) => !id.startsWith('.')); return Promise.all(ids.map(async (id) => JSON.parse(await readFile(`${output}/${id}/context.json`, 'utf8')) as Feedback)); } async function stored(): Promise { const [id] = (await readdir(output)).filter((id) => !id.startsWith('.')); return JSON.parse(await readFile(`${output}/${id}/context.json`, 'utf8')); } async function configure(page: import('@playwright/test').Page, method = 'none') { await page.getByRole('button', { name: '設定', exact: true }).click(); await page.getByLabel('投稿者名').fill('Reviewer'); await page.getByLabel('キャプチャ方式').selectOption(method); - await page.getByRole('button', { name: '設定を保存' }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); } async function clickTarget(page: import('@playwright/test').Page, selector = '#purchase-button') { await page.getByRole('button', { name: 'コメント', exact: true }).click(); const rect = await page.locator(selector).boundingBox(); await page.mouse.click(rect!.x + rect!.width / 2, rect!.y + rect!.height / 2); } -test('image-free comments edit, cancel, reply, survive reload and delete through the local API', async ({ page }) => { +test('image-free comments edit, cancel, reply, survive reload and delete through the local API', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await configure(page); await clickTarget(page); await page.getByLabel('コメント本文').fill('余白を20pxにしてください。'); await saveAndList(page); @@ -45,7 +43,7 @@ test('image-free comments edit, cancel, reply, survive reload and delete through await expect(page.getByText('変更しました。確認してください。', { exact: true })).toBeVisible(); expect((await stored()).replies?.[0]?.author).toBe('Reviewer'); await page.reload(); await page.getByRole('button', { name: '設定', exact: true }).click(); - await expect(page.getByLabel('キャプチャ方式')).toHaveValue('none'); await page.getByRole('button', { name: '設定を保存' }).click(); + await expect(page.getByLabel('キャプチャ方式')).toHaveValue('none'); await page.getByRole('button', { name: '保存', exact: true }).click(); await page.getByRole('button', { name: `コメント: ${(await stored()).comment}`, exact: true }).click(); await page.getByRole('button', { name: 'コメントの操作', exact: true }).click(); await page.getByRole('menuitem', { name: '削除', exact: true }).click(); await page.getByRole('button', { name: '削除する', exact: true }).click(); await expect(page.locator('[data-needmark] .card')).toHaveCount(0); expect((await readdir(output)).filter((id) => !id.startsWith('.'))).toHaveLength(0); @@ -88,19 +86,6 @@ test('failed save preserves text and image preview opens accessible zoom and pan await page.getByRole('img', { name: 'コメントのキャプチャ', exact: true }).click(); await expect(dialog).toBeVisible(); }); -test('multiple separate elements and exact text ranges retain code context without screenshots', async ({ page }) => { - await page.goto('/'); await configure(page); - await page.getByRole('button', { name: 'コメント', exact: true }).click(); - for (const [index, selector] of ['#purchase-button', '#product-title'].entries()) { const rect = await page.locator(selector).boundingBox(); if (!index) await page.keyboard.down('Shift'); await page.mouse.click(rect!.x + rect!.width / 2, rect!.y + rect!.height / 2); if (!index) await page.keyboard.up('Shift'); } - await page.getByRole('button', { name: '選択を完了' }).click(); await page.getByLabel('コメント本文').fill('選んだ2つの整列を直す'); await saveAndList(page); - await expect(page.getByRole('button', { name: '解決にする', exact: true })).toHaveCount(1); let saved = await stored(); expect(saved.target.kind).toBe('multi'); expect(saved.target.code?.elements.map((element) => element.attributes.id)).toEqual(['purchase-button', 'product-title']); - await page.getByRole('button', { name: 'コメント', exact: true }).click(); - const rect = await page.locator('#product-title').boundingBox(); await page.keyboard.down('Alt'); await page.mouse.move(rect!.x + 2, rect!.y + rect!.height / 2); await page.mouse.down(); await page.mouse.move(rect!.x + 85, rect!.y + rect!.height / 2, { steps: 3 }); await page.mouse.up(); await page.keyboard.up('Alt'); - await page.getByLabel('コメント本文').fill('この文字だけ変えて'); await saveAndList(page); await expect(page.getByRole('button', { name: '解決にする', exact: true })).toHaveCount(2); - const ids = (await readdir(output)).filter((id) => !id.startsWith('.')); const comments: Feedback[] = await Promise.all(ids.map(async (id) => JSON.parse(await readFile(`${output}/${id}/context.json`, 'utf8')))); saved = comments.find((item) => item.target.kind === 'text')!; - expect(saved.target.text.length).toBeGreaterThan(1); expect('Halden for Teams').toContain(saved.target.text); expect(saved.target.code?.elements[0]?.source?.file).toBe('demo/index.html'); -}); - test('an Alt/Option text drag previews and captures the text range instead of an area rectangle', async ({ page }) => { await page.goto('/'); await configure(page, 'dom'); const cursor = () => page.locator('[data-needmark] .cover').evaluate((node) => getComputedStyle(node).cursor); @@ -332,6 +317,26 @@ test('the target outline shown when details open stays on the commented element await expect.poll(matches).toEqual([0, 0, 0, 0]); }); +const leaveDetails = { + 'going back to the list': async (page: Page) => { await page.getByRole('button', { name: '一覧へ', exact: true }).click(); }, + 'closing the panel': async (page: Page) => { await page.getByRole('button', { name: 'パネルを閉じる', exact: true }).click(); }, + 'deleting the comment': async (page: Page) => { + await page.getByRole('button', { name: 'コメントの操作', exact: true }).click(); await page.getByRole('menuitem', { name: '削除', exact: true }).click(); + await page.getByRole('button', { name: '削除する', exact: true }).click(); await expect(page.locator('[data-needmark] .open-item')).toHaveCount(0); + }, +}; +for (const [exit, leave] of Object.entries(leaveDetails)) { + test(`${exit} hides the target outline that details showed`, async ({ page }) => { + await page.goto('/'); await configure(page); await clickTarget(page); + await page.getByLabel('コメント本文').fill('枠を消す'); await saveAndList(page); + await page.locator('[data-needmark] .open-item').click(); + const outline = page.locator('[data-needmark] > .outline'); + await expect(outline).toBeVisible(); + await leave(page); + await expect(outline).toBeHidden(); + }); +} + test('opening a comment from another page in the list goes to that page and shows its details there', async ({ page }) => { await page.goto('/?variant=a'); await configure(page); await clickTarget(page); await page.getByLabel('コメント本文').fill('別ページから開くコメント'); await saveAndList(page); @@ -407,3 +412,33 @@ test('focus rings appear after keyboard input but not after clicks that move foc await expect(page.getByRole('menuitemcheckbox', { name: 'このページのみ', exact: true })).toBeFocused(); expect(await ring()).toBe('none'); await page.keyboard.press('Escape'); await expect(page.getByRole('button', { name: '絞り込み', exact: true })).toBeFocused(); expect(await ring()).toBe('solid'); }); + +test('a pin stays put and opens while hovering it changes the text of its target', async ({ page }) => { + // The card mimics a chart tooltip: entering it adds text, so the saved text only matches while the pointer is elsewhere. + await page.addInitScript(() => addEventListener('DOMContentLoaded', () => { + const card = document.createElement('div'); card.id = 'hover-card'; card.textContent = 'Card'; + card.style.cssText = 'position:fixed;left:100px;top:100px;width:400px;height:200px;background:#eee;z-index:5'; + card.addEventListener('mouseenter', () => { const tip = document.createElement('span'); tip.className = 'tip'; tip.textContent = ' Mar $171,000'; card.append(tip); }); + card.addEventListener('mouseleave', () => card.querySelector('.tip')?.remove()); + document.body.append(card); + })); + await page.goto('/'); await configure(page); + const token = await page.locator('script[data-needmark-token]').getAttribute('data-needmark-token'); + const rect = { x: 300, y: 150, width: 40, height: 40 }; + const saved = await page.request.post('/__needmark/feedback', { headers: { 'x-needmark-token': token! }, data: { + comment: 'ホバーで中身が変わるカード', author: 'Reviewer', url: 'http://127.0.0.1:4192/', title: 'Review', capturedAt: new Date().toISOString(), + viewport: { width: 1200, height: 850, devicePixelRatio: 1, scrollX: 0, scrollY: 0 }, capture: { method: 'none', width: 0, height: 0 }, + target: { kind: 'element', text: 'Card', selector: '#hover-card', rect, code: { truncated: false, elements: [{ tag: 'div', selector: '#hover-card', text: 'Card', rect, + attributes: { id: 'hover-card' }, styles: {}, headings: [], components: [] }] } }, + } }); + expect(saved.status()).toBe(201); + const pin = page.locator('[data-needmark] .pin'); await expect(pin).toBeVisible({ timeout: 5000 }); + // Over the card the text no longer matches, so the pin falls back to its recorded spot inside the card. + await page.mouse.move(150, 280); await expect.poll(async () => (await pin.boundingBox())!.x + 12).toBeCloseTo(320, 0); + const box = (await pin.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.waitForTimeout(300); + expect(await pin.boundingBox()).toEqual(box); + await page.mouse.down(); await page.mouse.up(); + await expect(page.getByLabel('返信', { exact: true })).toBeVisible(); +}); diff --git a/tests/runtime-client.spec.ts b/tests/runtime-client.spec.ts index 5e6a288..b4421e4 100644 --- a/tests/runtime-client.spec.ts +++ b/tests/runtime-client.spec.ts @@ -1,20 +1,13 @@ -import { test, expect, type Page } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect, saveAndList, output, configureReview, enableWidgetOption } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); // The protocol response is controlled here to verify UI races. Live React extraction is tested separately. async function setup(page: Page) { - await page.route('http://127.0.0.1:4192/', async (route) => { - const response = await route.fetch(); - await route.fulfill({ response, body: (await response.text()).replace('data-needmark-capture=', 'data-needmark-runtime-source="true" data-needmark-capture=') }); - }); + await enableWidgetOption(page, 'data-needmark-runtime-source'); await page.goto('/'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('none'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await configureReview(page, { method: 'none' }); await page.evaluate(() => { document.querySelectorAll('[data-needmark-source]').forEach((element) => element.removeAttribute('data-needmark-source')); for (const selector of ['#purchase-button', '#product-title']) { diff --git a/tests/runtime-context.spec.ts b/tests/runtime-context.spec.ts index 9aed586..75c1311 100644 --- a/tests/runtime-context.spec.ts +++ b/tests/runtime-context.spec.ts @@ -1,10 +1,10 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './support/review.js'; import { resolve } from 'node:path'; import type { ElementContext, RuntimeSourceFrame } from '../src/shared/types.js'; const moduleUrl = `/@fs/${resolve('src/client/context/code-context.ts')}`; -test('real React dev host creation frames are aligned, bounded and do not use parent metadata', async ({ page }) => { +test('real React dev host creation frames are aligned, bounded and do not use parent metadata', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/react.html'); await expect(page.locator('#purchase-one')).toBeVisible(); const evidence = await page.evaluate(async (url) => { @@ -199,7 +199,7 @@ test('JavaScriptCore frame syntax is parsed conservatively in creation order wit expect(JSON.stringify(evidence.code)).not.toContain('http://'); }); -test('a real React component with a retained creation frame exposes its own source and captured identifiers in both engines', async ({ page }) => { +test('a real React component with a retained creation frame exposes its own source and captured identifiers in both engines', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/react.html'); const fixtureUrl = `/@fs/${resolve('tests/fixtures/runtime-nontail.tsx')}`; await page.evaluate(async (url) => { diff --git a/tests/runtime-identifiers.spec.ts b/tests/runtime-identifiers.spec.ts index 84028ba..5825669 100644 --- a/tests/runtime-identifiers.spec.ts +++ b/tests/runtime-identifiers.spec.ts @@ -1,11 +1,11 @@ -import { test, expect, type Locator, type Page } from '@playwright/test'; +import { type Locator, type Page } from '@playwright/test'; +import { test, expect, saveAndList, output, tempRoot, configureReview } from './support/review.js'; import { build } from 'esbuild'; import { createServer, type Server } from 'node:http'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import { startReviewProxy, type ReviewProxy } from '../dist/proxy.js'; import type { ElementContext, Feedback, RuntimeSourceRequest, RuntimeSourceResult, SourceLocation } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; type Scenario = 'static' | 'duplicate' | 'dynamic' | 'unconfigured' | 'roots'; type Review = { root: string; output: string; source: string; origin: string; mapRequests: () => number }; @@ -26,7 +26,7 @@ async function close(server: Server) { /** Real React dev frames, HTTP assets/maps, current JSX files, and the public review proxy. No /source responses are stubbed. */ async function withReview(page: Page, scenario: Scenario, run: (review: Review) => Promise) { - const root = await mkdtemp('/private/tmp/needmark-runtime-identifiers-'); + const root = await mkdtemp(join(tempRoot, 'needmark-runtime-identifiers-')); let upstream: Server | undefined, proxy: ReviewProxy | undefined; try { let source = await readFile(fixturePath, 'utf8'); @@ -60,9 +60,7 @@ async function withReview(page: Page, scenario: Scenario, run: (review: Review) }); await page.goto(`${origin}/${scenario === 'roots' ? '?roots' : ''}`); await expect(page.locator('.targets [data-cy]')).toBeVisible(); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('none'); - await page.getByRole('button', { name: '設定を保存', exact: true }).click(); + await configureReview(page, { method: 'none' }); await run({ root, output: `${root}/.needmark/feedback`, source, origin, mapRequests: () => maps }); } finally { await proxy?.close(); if (upstream?.listening) await close(upstream); await rm(root, { recursive: true, force: true }); @@ -100,7 +98,7 @@ async function save(page: Page, review: Review, comment: string): Promise page.evaluate(() => (window as unknown as { copied: string }).copied); -test('real proxy verifies cy, component and configured custom identifiers through saved source, editor, prompt and reordered pins', async ({ page }) => { +test('real proxy verifies cy, component and configured custom identifiers through saved source, editor, prompt and reordered pins', { tag: '@cross-engine' }, async ({ page }) => { await withReview(page, 'static', async (review) => { const targets = [ { attribute: 'data-cy', value: 'cy-checkout', component: 'CypressTarget', text: 'Cypress source review' }, @@ -144,7 +142,8 @@ test('real proxy verifies cy, component and configured custom identifiers throug const target = await cy.boundingBox(), marker = await pin.boundingBox(); return marker && target ? Math.abs((marker.y + marker.height / 2) - (target.y + target.height / 2)) : Infinity; }).toBeLessThan(1); - await pin.hover(); await expect(page.locator('[data-needmark] .tip')).not.toContainText('対象未確認'); + // Pins hold still while hovered, so the pointer leaves before the page changes the target. + await pin.hover(); await expect(page.locator('[data-needmark] .tip')).not.toContainText('対象未確認'); await page.mouse.move(10, 10); await cy.evaluate((element) => { const duplicate = document.createElement('button'); duplicate.hidden = true; duplicate.setAttribute('data-cy', 'cy-checkout'); document.body.append(duplicate); }); await expect.poll(async () => { const marker = await pin.boundingBox(); @@ -192,7 +191,7 @@ for (const scenario of ['duplicate', 'dynamic', 'unconfigured'] as const) { }); } -test('real shadow and iframe React targets keep per-root unique identifiers and source evidence after reload', async ({ page }, testInfo) => { +test('real shadow and iframe React targets keep per-root unique identifiers and source evidence after reload', { tag: '@cross-engine' }, async ({ page }, testInfo) => { await withReview(page, 'roots', async (review) => { const source = expectedSource(review, 'data-cy="cy-checkout"', 'CypressTarget'); const targets = [ @@ -230,7 +229,7 @@ test('real shadow and iframe React targets keep per-root unique identifiers and expect(identity).toMatchObject({ resolvedExactNode: true, hostFiberMatches: true, isConnected: true, ownerDocumentIsCurrent: true }); const pin = page.getByRole('button', { name: `コメント: ${target.comment}`, exact: true }); await expect(pin).toBeVisible(); await pin.hover(); - await expect(page.locator('[data-needmark] .tip')).not.toContainText('対象未確認'); + await expect(page.locator('[data-needmark] .tip')).not.toContainText('対象未確認'); await page.mouse.move(10, 10); const original = await target.target.elementHandle(); if (!original) throw new Error('Original root target is missing'); await original.evaluate((element) => element.setAttribute('data-cy', 'changed-root-target')); diff --git a/tests/save.ts b/tests/save.ts deleted file mode 100644 index 71b76ad..0000000 --- a/tests/save.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, type Page } from '@playwright/test'; - -// Saving a new comment returns to selection instead of opening the list, so specs that inspect the saved item leave selection and open the list themselves. -// 新しいコメントを保存すると一覧を開かずに選択へ戻るため、保存結果を確かめるテストは選択を終えて自分で一覧を開く。 -export async function saveAndList(page: Page, language: 'ja' | 'en' = 'ja') { - const cover = page.locator('[data-needmark] .cover'); - await page.getByRole('button', { name: language === 'ja' ? '保存' : 'Save', exact: true }).click(); - await expect(page.getByLabel(language === 'ja' ? 'コメント本文' : 'Comment text')).toBeHidden(); - await expect(cover).toBeVisible(); - await page.keyboard.press('Escape'); await expect(cover).toBeHidden(); - await page.getByRole('button', { name: language === 'ja' ? 'コメント一覧' : 'Comments', exact: true }).click(); - await expect(page.getByRole('button', { name: language === 'ja' ? 'キーワード検索' : 'Search by keyword', exact: true })).toBeVisible(); -} - -// Search and filters sit behind header icons, so specs open them the way a reviewer does. -// 検索と絞り込みはヘッダーのアイコンの奥にあるため、レビュアーと同じ操作で開く。 -export async function searchList(page: Page, text: string) { - const field = page.getByRole('textbox', { name: 'キーワード検索', exact: true }); - if (!await field.isVisible()) await page.getByRole('button', { name: 'キーワード検索', exact: true }).click(); - await field.fill(text); -} -export async function toggleFilter(page: Page, name: 'このページのみ' | '解決済みも表示') { - await page.getByRole('button', { name: '絞り込み', exact: true }).click(); - const option = page.getByRole('menuitemcheckbox', { name, exact: true }), checked = await option.getAttribute('aria-checked'); - await option.click(); await expect(page.getByRole('menuitemcheckbox', { name, exact: true })).toHaveAttribute('aria-checked', checked === 'true' ? 'false' : 'true'); - await page.keyboard.press('Escape'); -} diff --git a/tests/shortcut-scope.spec.ts b/tests/shortcut-scope.spec.ts index 30568c0..75d1322 100644 --- a/tests/shortcut-scope.spec.ts +++ b/tests/shortcut-scope.spec.ts @@ -1,15 +1,14 @@ -import { test, expect, type Page } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect, output } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); async function configure(page: Page, shortcuts = true) { await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('キーボードショートカット')).toHaveValue('true'); await page.getByLabel('キャプチャ方式').selectOption('none'); await page.getByLabel('キーボードショートカット').selectOption(String(shortcuts)); - await page.getByRole('button', { name: '設定を保存' }).click(); + await page.getByRole('button', { name: '保存', exact: true }).click(); } async function chooseTarget(page: Page) { await page.getByRole('button', { name: 'コメント', exact: true }).click(); @@ -39,7 +38,7 @@ async function hostState(page: Page) { return page.evaluate(() => (window as unknown as { shortcutHost: { submitted: number; keys: { key: string; prevented: boolean }[] } }).shortcutHost); } -test('enabled shortcuts never submit or intercept a host input, textarea or inherited editable region', async ({ page }) => { +test('enabled shortcuts never submit or intercept a host input, textarea or inherited editable region', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); await configure(page); await hostEvents(page); for (const name of ['Host input', 'Host textarea', 'Host editable', 'Host shadow textarea']) { await page.getByRole('textbox', { name, exact: true }).focus(); @@ -71,7 +70,7 @@ test('disabled global shortcuts pass through to the host and stay disabled after expect(await page.evaluate(() => localStorage.getItem('needmark-shortcuts'))).toBe('false'); await page.reload(); await page.getByRole('button', { name: '設定', exact: true }).click(); await expect(page.getByLabel('キーボードショートカット')).toHaveValue('false'); - await page.getByLabel('キーボードショートカット').selectOption('true'); await page.getByRole('button', { name: '設定を保存' }).click(); + await page.getByLabel('キーボードショートカット').selectOption('true'); await page.getByRole('button', { name: '保存', exact: true }).click(); await page.locator('#purchase-button').focus(); await page.keyboard.press('Shift+C'); await expect(page.locator('[data-needmark] .cover')).toBeVisible(); await page.keyboard.press('Escape'); await expect(page.locator('[data-needmark] .cover')).toBeHidden(); diff --git a/tests/style-isolation.spec.ts b/tests/style-isolation.spec.ts index 6f13681..4b5f186 100644 --- a/tests/style-isolation.spec.ts +++ b/tests/style-isolation.spec.ts @@ -1,9 +1,9 @@ -import { test, expect, type Page, type Locator, type TestInfo } from '@playwright/test'; -import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { type Page, type Locator, type TestInfo } from '@playwright/test'; +import { test, expect, saveAndList, output, tempRoot } from './support/review.js'; +import { join } from 'node:path'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; const widget = (page: Page) => page.locator('[data-needmark]'); const inheritedTypography = 'font-family:monospace;font-size:24px;font-weight:900;font-style:italic;letter-spacing:.15em;word-spacing:.3em;text-transform:uppercase;text-indent:64px;text-align:right;direction:rtl;white-space:nowrap'; const clientSamples = ['label[for="nm-author"]', '#nm-author', '.panel .mono', '[data-action="settings-save"]', '[data-hint]', '.dock button', '.badge']; @@ -29,8 +29,8 @@ async function sourceTypography(page: Page) { return typography(page.locator('body'), ['#app-typography-probe']); } async function evidence(page: Page, info: TestInfo, name: string) { - const path = `/private/tmp/needmark-style-evidence/${info.project.name}-${name}.png`; - await mkdir('/private/tmp/needmark-style-evidence', { recursive: true }); + const path = join(tempRoot, 'needmark-style-evidence', `${info.project.name}-${name}.png`); + await mkdir(join(tempRoot, 'needmark-style-evidence'), { recursive: true }); await page.screenshot({ path, animations: 'disabled' }); await info.attach(name, { path, contentType: 'image/png' }); const metadata = await page.evaluate(() => { @@ -67,9 +67,8 @@ async function saved() { return JSON.parse(await readFile(`${output}/${ids[0]}/context.json`, 'utf8')) as Feedback; } -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); -test('inherited html typography leaves review fields readable and editable while the RTL page retains its styles', async ({ page }, info) => { +test('inherited html typography leaves review fields readable and editable while the RTL page retains its styles', { tag: '@cross-engine' }, async ({ page }, info) => { await prepare(page); const expected = await typography(widget(page), clientSamples); await page.addStyleTag({ content: `html{${inheritedTypography}}` }); @@ -79,7 +78,7 @@ test('inherited html typography leaves review fields readable and editable while expect(appStyle['#app-typography-probe']?.direction).toBe('rtl'); await evidence(page, info, 'review-on-styled-rtl-page'); await page.getByLabel('Author name').fill('投稿者名をそのまま残す'); - await page.getByRole('button', { name: 'Save settings', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); await selectTarget(page, '#purchase-button'); const comment = '日本語と MixedCase をそのまま保存する'; await page.getByLabel('Comment text').fill(comment); @@ -90,7 +89,7 @@ test('inherited html typography leaves review fields readable and editable while await expect(widget(page)).toHaveAttribute('lang', 'en'); }); -test('body typography stays outside the review host before a native dialog and after reparenting into it', async ({ page }, info) => { +test('body typography stays outside the review host before a native dialog and after reparenting into it', { tag: '@cross-engine' }, async ({ page }, info) => { await prepare(page); const expected = await typography(widget(page), clientSamples); await page.addStyleTag({ content: `body{${inheritedTypography}}` }); @@ -108,7 +107,7 @@ test('body typography stays outside the review host before a native dialog and a expect(actual).toEqual(expected); await evidence(page, info, 'review-in-styled-native-dialog'); await page.getByLabel('Author name').fill('Dialog reviewer'); - await page.getByRole('button', { name: 'Save settings', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); await page.getByRole('button', { name: 'Modal action', exact: true }).click(); await expect(page.locator('#styled-native-dialog')).toHaveAttribute('data-clicks', '1'); await selectTarget(page, '#app-modal-target'); @@ -124,14 +123,14 @@ test('body typography stays outside the review host before a native dialog and a expect(await typography(widget(page), clientSamples)).toEqual(expected); }); -test('global important div rules preserve page clicks while selection still intercepts them', async ({ page }, info) => { +test('global important div rules preserve page clicks while selection still intercepts them', { tag: '@cross-engine' }, async ({ page }, info) => { await prepare(page); const expected = await typography(widget(page), clientSamples); await page.addStyleTag({ content: `div{${inheritedTypography.split(';').map((value) => `${value}!important`).join(';')};pointer-events:auto!important}` }); const appStyle = await sourceTypography(page), actual = await typography(widget(page), clientSamples); const pointerEvents = await widget(page).evaluate((node) => getComputedStyle(node).pointerEvents); await info.attach('global-rule-comparison', { body: JSON.stringify({ expected, actual, pointerEvents }, null, 2), contentType: 'application/json' }); - await page.getByRole('button', { name: 'Save settings', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); const appBounds = (await page.locator('#purchase-button').boundingBox())!; await page.mouse.click(appBounds.x + appBounds.width / 2, appBounds.y + appBounds.height / 2); await expect(page.locator('#purchase-status')).toHaveText('Trial reserved. Check your inbox for the setup link.'); diff --git a/tests/support/paths.ts b/tests/support/paths.ts new file mode 100644 index 0000000..30785b8 --- /dev/null +++ b/tests/support/paths.ts @@ -0,0 +1,10 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// macOS reports /tmp through the /private symlink while saved source paths are compared as real paths, so specs use the real directory there and the OS default elsewhere. +// macOSの/tmpは/privateへのシンボリックリンクで、保存されるソースのパスは実パスで比べるため、macOSでは実ディレクトリを、それ以外ではOSの既定を使う。 +export const tempRoot = process.platform === 'darwin' ? '/private/tmp' : tmpdir(); + +// The demo server started by playwright.config.ts writes every spec's feedback here. +// playwright.config.tsが起動するデモサーバーは、すべてのspecのフィードバックをここへ保存する。 +export const output = join(tempRoot, 'needmark-e2e-feedback'); diff --git a/tests/support/review.ts b/tests/support/review.ts new file mode 100644 index 0000000..e52cdaf --- /dev/null +++ b/tests/support/review.ts @@ -0,0 +1,60 @@ +import { test as base, expect, type Page } from '@playwright/test'; +import { rm } from 'node:fs/promises'; +import { output } from './paths.js'; + +export { output, tempRoot } from './paths.js'; +export { expect }; + +// Every spec shares one demo server and feedback folder, so the folder is emptied before each test instead of relying on each file to remember it. +// すべてのspecが1つのデモサーバーと保存先を共有するため、各ファイルの書き忘れに頼らず、テストごとに保存先を空にする。 +export const test = base.extend<{ cleanOutput: void }>({ + cleanOutput: [async ({}, use) => { await rm(output, { recursive: true, force: true }); await use(); }, { auto: true }], +}); + +// Native tab capture arrives as video, whose YUV to RGB conversion moves each channel by at most 2 on macOS but by up to 16 in Linux Chromium under Xvfb. +// ネイティブのタブ撮影は動画として届き、YUVからRGBへの変換で各チャンネルはmacOSでは最大2、XvfbのLinux Chromiumでは最大16ずれる。 +export const tabColorTolerance = process.platform === 'darwin' ? 2 : 20; + +// Settings are changed the way a reviewer does, so specs also exercise the panel that stores these preferences. +// レビュアーと同じ操作で設定を変え、設定を保存するパネル自体も通す。 +export async function configureReview(page: Page, options: { method?: 'none' | 'dom' | 'tab'; author?: string } = {}) { + await page.getByRole('button', { name: '設定', exact: true }).click(); + if (options.author !== undefined) await page.getByLabel('投稿者名').fill(options.author); + if (options.method) await page.getByLabel('キャプチャ方式').selectOption(options.method); + await page.getByRole('button', { name: '保存', exact: true }).click(); +} + +// Options such as submission and runtime source lookup are server configuration; rewriting the served script attribute enables them without another demo server. +// 送信や実行時のソース照合はサーバー側の設定なので、配信するスクリプトの属性を書き換え、別のデモサーバーを立てずに有効にする。 +export async function enableWidgetOption(page: Page, attribute: 'data-needmark-submit' | 'data-needmark-runtime-source') { + await page.route('http://127.0.0.1:4192/', async (route) => { + const response = await route.fetch(); + await route.fulfill({ response, body: (await response.text()).replace('data-needmark-capture=', `${attribute}="true" data-needmark-capture=`) }); + }); +} + +// Saving a new comment returns to selection instead of opening the list, so specs that inspect the saved item leave selection and open the list themselves. +// 新しいコメントを保存すると一覧を開かずに選択へ戻るため、保存結果を確かめるテストは選択を終えて自分で一覧を開く。 +export async function saveAndList(page: Page, language: 'ja' | 'en' = 'ja') { + const cover = page.locator('[data-needmark] .cover'); + await page.getByRole('button', { name: language === 'ja' ? '保存' : 'Save', exact: true }).click(); + await expect(page.getByLabel(language === 'ja' ? 'コメント本文' : 'Comment text')).toBeHidden(); + await expect(cover).toBeVisible(); + await page.keyboard.press('Escape'); await expect(cover).toBeHidden(); + await page.getByRole('button', { name: language === 'ja' ? 'コメント一覧' : 'Comments', exact: true }).click(); + await expect(page.getByRole('button', { name: language === 'ja' ? 'キーワード検索' : 'Search by keyword', exact: true })).toBeVisible(); +} + +// Search and filters sit behind header icons, so specs open them the way a reviewer does. +// 検索と絞り込みはヘッダーのアイコンの奥にあるため、レビュアーと同じ操作で開く。 +export async function searchList(page: Page, text: string) { + const field = page.getByRole('textbox', { name: 'キーワード検索', exact: true }); + if (!await field.isVisible()) await page.getByRole('button', { name: 'キーワード検索', exact: true }).click(); + await field.fill(text); +} +export async function toggleFilter(page: Page, name: 'このページのみ' | '解決済みも表示') { + await page.getByRole('button', { name: '絞り込み', exact: true }).click(); + const option = page.getByRole('menuitemcheckbox', { name, exact: true }), checked = await option.getAttribute('aria-checked'); + await option.click(); await expect(page.getByRole('menuitemcheckbox', { name, exact: true })).toHaveAttribute('aria-checked', checked === 'true' ? 'false' : 'true'); + await page.keyboard.press('Escape'); +} diff --git a/tests/toolbar-position.spec.ts b/tests/toolbar-position.spec.ts index fa70f26..09109fb 100644 --- a/tests/toolbar-position.spec.ts +++ b/tests/toolbar-position.spec.ts @@ -1,5 +1,5 @@ -import { test, expect, type Page } from '@playwright/test'; -import { rm } from 'node:fs/promises'; +import { type Page } from '@playwright/test'; +import { test, expect } from './support/review.js'; const key = 'needmark-toolbar-position'; const dock = (page: Page) => page.locator('[data-needmark] .dock'); @@ -39,7 +39,6 @@ async function withinViewport(page: Page) { }).toEqual({ left: true, top: true, right: true, bottom: true }); } -test.beforeEach(async () => { await rm('/private/tmp/needmark-e2e-feedback', { recursive: true, force: true }); }); test.afterEach(async ({ page }, info) => { if (info.status === info.expectedStatus) return; const events = await page.evaluate(() => ({ focus: document.hasFocus(), active: document.activeElement?.tagName, events: (window as unknown as { toolbarEvents: unknown[] }).toolbarEvents })); @@ -63,7 +62,7 @@ test('only a completed primary drag saves toolbar coordinates and reload restore expect(await bounds(page)).toEqual(moved); expect(await stored(page)).toBe(raw); }); -test('secondary pointers cannot replace the drag, a pointer cancellation keeps the shown spot and Escape rolls back without saving', async ({ page }) => { +test('secondary pointers cannot replace the drag, a pointer cancellation keeps the shown spot and Escape rolls back without saving', { tag: '@cross-engine' }, async ({ page }) => { await setup(page); await restore(page, { x: 150, y: 120 }); await recordPointer(page); const original = await bounds(page), raw = await stored(page); await startDrag(page, 100, 60); const moving = await bounds(page); expect(await captured(page)).toBe(true); @@ -104,7 +103,7 @@ test('restored toolbar positions clamp after hide, viewport and width changes wi await page.keyboard.press('Shift+H'); await expect(dock(page)).toBeVisible(); await withinViewport(page); const shown = await bounds(page); expect(shown.x + shown.width).toBeCloseTo(696, 1); // Simulate a wider localized control while retaining the real widget and observer. - await dock(page).locator('[data-action="select"] .label').evaluate((element) => { element.textContent = '選択を再開 · 複数の対象を選択中'; }); + await dock(page).locator('[data-action="list"]').evaluate((element) => { (element as HTMLElement).style.width = '200px'; }); await expect.poll(async () => (await bounds(page)).width).toBeGreaterThan(shown.width); await withinViewport(page); const wider = await bounds(page); expect(wider.x).toBeLessThan(shown.x); expect(wider.x + wider.width).toBeCloseTo(696, 1); expect(await stored(page)).toBe(raw); diff --git a/tests/top-layer.spec.ts b/tests/top-layer.spec.ts index 0df072e..5eaabc8 100644 --- a/tests/top-layer.spec.ts +++ b/tests/top-layer.spec.ts @@ -1,16 +1,11 @@ -import { test, expect } from '@playwright/test'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { test, expect, saveAndList, output, configureReview } from './support/review.js'; +import { readFile, readdir } from 'node:fs/promises'; import type { Feedback } from '../src/shared/types.js'; -import { saveAndList } from './save.js'; -const output = '/private/tmp/needmark-e2e-feedback'; -test.beforeEach(async () => { await rm(output, { recursive: true, force: true }); }); -test('native modal keeps page controls usable and review drafts through nested modal changes', async ({ page }) => { +test('native modal keeps page controls usable and review drafts through nested modal changes', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('none'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await configureReview(page, { method: 'none' }); await page.getByRole('button', { name:'コメント', exact:true }).click(); const target = await page.locator('#purchase-button').boundingBox(); await page.mouse.click(target!.x + target!.width / 2, target!.y + target!.height / 2); await page.getByLabel('コメント本文').fill('モーダルの外のコメント'); await saveAndList(page); await page.getByRole('button', { name:'パネルを閉じる' }).click(); await page.evaluate(() => { const outer = document.createElement('dialog'); outer.id = 'outer-modal'; @@ -52,11 +47,9 @@ test('native modal keeps page controls usable and review drafts through nested m await expect(page.getByRole('button', { name:'コメント: 移動しても下書きを保持する', exact:true })).toBeHidden(); }); -test('DOM capture preserves a native modal target and redaction while excluding review controls', async ({ page }) => { +test('DOM capture preserves a native modal target and redaction while excluding review controls', { tag: '@cross-engine' }, async ({ page }) => { await page.goto('/'); - await page.getByRole('button', { name: '設定', exact: true }).click(); - await page.getByLabel('キャプチャ方式').selectOption('dom'); - await page.getByRole('button', { name: '設定を保存' }).click(); + await configureReview(page, { method: 'dom' }); await page.evaluate(() => { const dialog = document.createElement('dialog'); dialog.id = 'capture-modal'; dialog.style.cssText = 'padding:28px;width:440px;border:3px solid rgb(80,90,100);background:rgb(245,242,235)';