diff --git a/.changeset/spotty-bottles-approve.md b/.changeset/spotty-bottles-approve.md
new file mode 100644
index 0000000..5952b3c
--- /dev/null
+++ b/.changeset/spotty-bottles-approve.md
@@ -0,0 +1,8 @@
+---
+"@agentic-react/core": minor
+"@agentic-react/vite": minor
+"@agentic-react/webpack": minor
+"@agentic-react/next": minor
+---
+
+Add persistent toolbox settings for shortcuts and launcher icon customization, plus explicit Done/Enter selection confirmation.
diff --git a/.changeset/tidy-tools-resolve.md b/.changeset/tidy-tools-resolve.md
new file mode 100644
index 0000000..6bda22e
--- /dev/null
+++ b/.changeset/tidy-tools-resolve.md
@@ -0,0 +1,8 @@
+---
+"@agentic-react/core": patch
+"@agentic-react/next": patch
+"@agentic-react/vite": patch
+"@agentic-react/webpack": patch
+---
+
+Resolve selected component source locations over the runtime bridge so Next.js applications no longer receive missing source-lookup route requests.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fd8cd61..310b4a3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI
on:
pull_request:
- branches: [ main, master ]
+ branches: [dev, main, master]
push:
- branches: [ main, master ]
+ branches: [dev, main, master]
jobs:
build:
@@ -36,11 +36,14 @@ jobs:
run: pnpm --filter agentic-react-vite-playground exec playwright install --with-deps chromium
- name: Lint
- run: pnpm run lint
+ run: pnpm exec biome lint .
- name: Release smoke test
run: pnpm run test:release-smoke
+ - name: Unit tests
+ run: pnpm run test:unit
+
- name: Build
run: pnpm run build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 56ad0aa..cfe1f2b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,20 +5,90 @@ on:
branches: [main]
concurrency:
- group: release-${{ github.ref }}
+ group: release-main
cancel-in-progress: false
-permissions:
- contents: write
- id-token: write
-
jobs:
+ preflight:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ release-required: ${{ steps.release-state.outputs.release-required }}
+ release-mode: ${{ steps.release-state.outputs.release-mode }}
+ release-sha: ${{ steps.release-state.outputs.release-sha }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Inspect release state
+ id: release-state
+ run: |
+ git fetch origin main:refs/remotes/origin/main
+ release_sha="$(git rev-parse origin/main)"
+ git checkout --detach "$release_sha"
+
+ if [ -f .changeset/release-pending.json ]; then
+ marker_commit="$(git log -1 --format=%H -- .changeset/release-pending.json)"
+ marker_parent="$(git rev-parse "$marker_commit^")"
+ marker_source="$(sed -n 's/.*"sourceSha": "\([0-9a-f]*\)".*/\1/p' .changeset/release-pending.json)"
+ marker_subject="$(git show -s --format=%s "$marker_commit")"
+ marker_author="$(git show -s --format='%an <%ae>' "$marker_commit")"
+
+ if [ "$marker_parent" != "$marker_source" ] || \
+ [ "$marker_subject" != "chore(release): version packages" ] || \
+ [ "$marker_author" != "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" ] || \
+ ! git ls-tree -r --name-only "$marker_parent" .changeset | \
+ grep -E '^\.changeset/[^/]+\.md$' | \
+ grep -v '^\.changeset/README\.md$' | grep -q .; then
+ echo "::error::The release recovery marker was not created by the release workflow."
+ exit 1
+ fi
+
+ release_sha="$marker_commit"
+ release_required=true
+ release_mode=recovery
+ reason="A previous package publication is pending."
+ elif find .changeset -maxdepth 1 -name "*.md" ! -name "README.md" | grep -q .; then
+ release_required=true
+ release_mode=version
+ reason="Pending changesets require a package release."
+ else
+ release_required=false
+ release_mode=none
+ reason="No pending changesets or interrupted release were found."
+ fi
+
+ {
+ echo "release-required=$release_required"
+ echo "release-mode=$release_mode"
+ echo "release-sha=$release_sha"
+ } >> "$GITHUB_OUTPUT"
+
+ {
+ echo "### Release preflight"
+ echo
+ echo "- Required: $release_required"
+ echo "- Mode: $release_mode"
+ echo "- Main SHA: $release_sha"
+ echo "- Reason: $reason"
+ } >> "$GITHUB_STEP_SUMMARY"
+
release:
+ needs: preflight
+ if: github.ref == 'refs/heads/main' && needs.preflight.outputs.release-required == 'true'
runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
+ ref: ${{ needs.preflight.outputs.release-sha }}
fetch-depth: 0
- name: Setup PNPM
@@ -34,31 +104,111 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- - name: Configure Git
+ - name: Version packages from changesets
+ if: needs.preflight.outputs.release-mode == 'version'
run: |
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ pnpm changeset status --output .changeset/release-plan.json
+ pnpm run version-packages
+ printf '{\n "sourceSha": "%s"\n}\n' \
+ "${{ needs.preflight.outputs.release-sha }}" \
+ > .changeset/release-pending.json
+ git add -A
- - name: Version packages from changesets
+ - name: Validate recovery state
+ if: needs.preflight.outputs.release-mode == 'recovery'
run: |
- if find .changeset -maxdepth 1 -name "*.md" ! -name "README.md" | grep -q .; then
- pnpm run version-packages
+ test -f .changeset/release-pending.json
+ test -f .changeset/release-plan.json
+
+ verification_dir="$(mktemp -d)"
+ trap 'git worktree remove --force "$verification_dir"' EXIT
+ git worktree add --detach \
+ "$verification_dir" \
+ "${{ needs.preflight.outputs.release-sha }}^"
+
+ (
+ cd "$verification_dir"
+ node "$GITHUB_WORKSPACE/node_modules/@changesets/cli/bin.js" \
+ status --output .changeset/release-plan.json
+ node "$GITHUB_WORKSPACE/node_modules/@changesets/cli/bin.js" version
+ printf '{\n "sourceSha": "%s"\n}\n' \
+ "$(git rev-parse HEAD)" \
+ > .changeset/release-pending.json
git add -A
- git commit -m "chore: version packages"
- git push origin HEAD:main
- else
- echo "No pending changesets to version."
- fi
+
+ expected_tree="$(git write-tree)"
+ release_tree="$(git rev-parse '${{ needs.preflight.outputs.release-sha }}^{tree}')"
+ test "$expected_tree" = "$release_tree"
+ )
+
+ - name: Install Playwright Chromium
+ run: pnpm --filter agentic-react-vite-playground exec playwright install --with-deps chromium
+
+ - name: Lint
+ run: pnpm exec biome lint .
+
+ - name: Release smoke test
+ run: pnpm run test:release-smoke
+
+ - name: Unit tests
+ run: pnpm run test:unit
- name: Build packages
run: pnpm run build
+ - name: Test
+ run: pnpm run test:e2e -- --reporter=line --workers=1
+
+ - name: Configure Git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ - name: Commit version packages
+ if: needs.preflight.outputs.release-mode == 'version'
+ run: |
+ git commit -m "chore(release): version packages"
+ git push origin HEAD:main
+
- name: Publish packages
+ id: publish
+ continue-on-error: true
run: pnpm run release
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ - name: Ensure package publication succeeded
+ if: steps.publish.outcome != 'success'
+ run: |
+ echo "Package publication failed; recovery state has been retained."
+ exit 1
+
+ - name: Reconcile release tags
+ run: |
+ node --input-type=module <<'NODE'
+ import { execFileSync } from 'node:child_process';
+ import { readFileSync } from 'node:fs';
+
+ const plan = JSON.parse(
+ readFileSync('.changeset/release-plan.json', 'utf8'),
+ );
+
+ for (const release of plan.releases) {
+ const tag = `${release.name}@${release.newVersion}`;
+
+ try {
+ execFileSync('git', ['rev-parse', '--verify', `refs/tags/${tag}`], {
+ stdio: 'ignore',
+ });
+ console.info(`Existing tag: ${tag}`);
+ } catch {
+ execFileSync('git', ['tag', tag]);
+ console.info(`New tag: ${tag}`);
+ }
+ }
+ NODE
+
- name: Push release tags
run: git push origin --tags
@@ -80,3 +230,17 @@ jobs:
gh release create "$tag" --title "$tag" --generate-notes --latest
fi
done
+
+ - name: Clear recovery state
+ run: |
+ git fetch origin main:refs/remotes/origin/main
+ git checkout --detach origin/main
+
+ if [ ! -f .changeset/release-pending.json ]; then
+ echo "Recovery state was already cleared."
+ exit 0
+ fi
+
+ git rm .changeset/release-pending.json .changeset/release-plan.json
+ git commit -m "chore(release): complete package publication"
+ git push origin HEAD:main
diff --git a/README.md b/README.md
index f5de310..d82fc58 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,23 @@
# Agentic React
-Agentic React is a monorepo for React runtime inspection and local-dev MCP integrations. The packages are published under the `@agentic-react` namespace.
+[English](./README.md) | [中文](./README.zh-CN.md)
+
+**Give coding agents the exact React context behind the UI they are editing.**
+
+Agentic React annotates selected React UI with component names, DOM selectors, source file locations, source snippets, owner traces, and local MCP tools, so an agent can move from "this thing on the page" to the right code without guessing.
+
+```text
+
+component: ProfileField
+selector: #profile-field-email
+source: src/components/UserProfile/ProfileField.jsx:19
+source trace:
+ -> at src/components/UserProfile/ProfileField.jsx:19
+ -> at src/components/UserProfile/ProfileContent.jsx:54
+
+```
+
+Install one dev adapter, select one or many elements in the browser, and pass source-aware React context directly to your agent.
## Demos
@@ -12,6 +29,16 @@ Agentic React is a monorepo for React runtime inspection and local-dev MCP integ

+## Why Agentic React
+
+- **Source-aware selection:** click real UI and capture the React component, stable selector, source location, and nearby source code.
+- **Agent-ready context:** copy the selection as text or JSON, or expose it through MCP for local coding agents.
+- **Bundler-native adapters:** use the same runtime with Vite, Webpack, Next.js, and Nx/module-federation playgrounds.
+- **Dev-only bridge:** keep local source lookup and MCP transport in development tooling, outside production bundles.
+- **Multi-select and tuning:** collect several UI targets, inspect styling, and turn visual adjustments into prompt-ready instructions.
+
+The packages are published under the `@agentic-react` namespace.
+
## Packages
- `@agentic-react/core`: bundler-agnostic browser runtime, React selection toolkit, and MCP primitives.
@@ -135,8 +162,82 @@ Bundler adapters add local-dev capabilities that the runtime cannot know on its
- attach a local MCP Streamable HTTP `/mcp` endpoint
- bridge MCP calls from Node to the browser runtime
- provide local source-root context for source lookup
+- persist toolbox Settings through the adapter-hosted bridge
- keep dev-only tooling out of production bundles
+## Settings and Selection Confirmation
+
+The toolbox includes a Settings section when it runs through a supported dev adapter. Settings can configure the single-select, multiselect, toolbox-toggle, and Done shortcuts, and can replace the launcher icon with a cropped custom image.
+
+User settings are global to the local machine and are stored under `~/.agentic-react/`. They are not scoped to a project, browser origin, hostname, port, or bundler. Effective values are resolved in this order:
+
+```text
+global user override
+ > project configuration default
+ > package default
+```
+
+Resetting a setting removes only the global override, revealing the project default when one exists and otherwise the package default. Project defaults come from adapter options: `toolkit.settings.shortcuts` for shortcut defaults and `toolkit.iconUrl` for the default toolbox icon.
+
+The package defaults are:
+
+| Action | Default shortcut |
+| --- | --- |
+| Single select | `Ctrl+Alt+Shift+S` |
+| Multi select | `Ctrl+Alt+Shift+M` |
+| Toggle toolbox | `Ctrl+Alt+Shift+A` |
+| Done | `Enter` |
+
+`Escape` is reserved for cancellation and is not configurable. Shortcut recording validates one non-modifier key, supported keys only, and no duplicate normalized shortcuts.
+
+`settingsRoot` is an adapter option for isolated environments such as tests, demos, and temporary sandboxes. Production-like local usage should normally omit it so Agentic React uses the user's global `~/.agentic-react` directory.
+
+```ts
+// Vite
+AgenticReact({
+ settingsRoot: '/tmp/agentic-react-settings',
+ toolkit: {
+ iconUrl: '/agentic-react-logo.png',
+ settings: {
+ shortcuts: {
+ singleSelect: 'Ctrl+Shift+S',
+ done: 'Enter',
+ },
+ },
+ },
+});
+
+// Webpack
+withAgenticReactWebpack(config, { mode: 'development' }, {
+ settingsRoot: '/tmp/agentic-react-settings',
+ toolkit: {
+ iconUrl: '/agentic-react-logo.png',
+ settings: {
+ shortcuts: {
+ multiSelect: 'Ctrl+Shift+M',
+ },
+ },
+ },
+});
+
+// Next.js
+withAgenticReactNext(nextConfig, {
+ settingsRoot: '/tmp/agentic-react-settings',
+ toolkit: {
+ iconUrl: '/agentic-react-logo.png',
+ settings: {
+ shortcuts: {
+ toggleToolbox: 'Ctrl+Shift+A',
+ },
+ },
+ },
+});
+```
+
+Selection is an explicit transaction. Clicking a target captures and displays context as pending; it does not copy immediately. Click Done or press the configured Done shortcut, `Enter` by default, to copy and commit the pending selection. In multiselect mode, Done copies the complete pending set. `Escape` cancels pending single and multiselect work without copying and preserves the last committed selection.
+
+Runtime-only `@agentic-react/core` usage can still run browser-side selection APIs and code-provided toolkit defaults, but it has no persistent Settings without an adapter-hosted bridge.
+
## Tuning Modal API
The selection overlay includes a tuning modal for turning visual adjustments into prompt text. Configure it through the adapter `toolkit` option, or at runtime with `window.__AGENTIC_REACT__.setToolkitConfig()`.
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000..d092dca
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,285 @@
+# Agentic React
+
+[English](./README.md) | [中文](./README.zh-CN.md)
+
+**把 UI 背后的 React 上下文和源码位置,直接交给 coding agent。**
+
+Agentic React 会为你选中的 React UI 标注 component name、DOM selector、source file location、source snippet、owner trace,并通过本地 MCP tools 暴露给 agent。这样 agent 不需要靠猜测从“页面上这个东西”定位到代码,而是可以直接拿到可执行的 React/source context。
+
+```text
+
+component: ProfileField
+selector: #profile-field-email
+source: src/components/UserProfile/ProfileField.jsx:19
+source trace:
+ -> at src/components/UserProfile/ProfileField.jsx:19
+ -> at src/components/UserProfile/ProfileContent.jsx:54
+
+```
+
+安装一个 dev adapter,在浏览器里选择一个或多个元素,然后把 source-aware React context 直接传给你的 agent。
+
+## Demo
+
+### Single Select
+
+
+
+### Multiselect
+
+
+
+## 为什么用 Agentic React
+
+- **Source-aware selection:** 点击真实 UI,捕获 React component、稳定 selector、源码位置和附近源码。
+- **Agent-ready context:** selection 可以复制成 text/JSON,也可以通过 MCP 暴露给本地 coding agent。
+- **Bundler-native adapters:** 同一套 runtime 支持 Vite、Webpack、Next.js,以及 Nx/module-federation playground。
+- **Dev-only bridge:** 本地源码查找和 MCP transport 只存在于开发工具链,不进入 production bundle。
+- **Multi-select and tuning:** 一次收集多个 UI target,检查样式,并把视觉调整转成 prompt-ready instructions。
+
+packages 发布在 `@agentic-react` namespace 下。
+
+## Packages
+
+- `@agentic-react/core`: bundler-agnostic browser runtime、React selection toolkit 和 MCP primitives。
+- `@agentic-react/vite`: Vite 本地开发 adapter。
+- `@agentic-react/webpack`: Webpack 本地开发 adapter。
+- `@agentic-react/next`: Next.js 本地开发 adapter。
+
+完整的 local-dev 功能需要安装对应 bundler 的 adapter。adapter 内部依赖 `@agentic-react/core`,所以 app 用户通常不需要同时安装二者。
+
+## Local Dev Usage
+
+### Vite
+
+```bash
+pnpm install @agentic-react/vite -D
+```
+
+```ts
+// vite.config.ts
+import { defineConfig } from 'vite';
+import AgenticReact from '@agentic-react/vite';
+
+export default defineConfig({
+ plugins: [AgenticReact()],
+});
+```
+
+Vite adapter 会注入 core browser runtime,把 runtime bridge 挂到 dev server,并通过下面的地址暴露 MCP:
+
+```text
+http://localhost:/mcp
+```
+
+### Webpack
+
+```bash
+pnpm install @agentic-react/webpack -D
+```
+
+```js
+// webpack.config.mjs
+import withAgenticReactWebpack from '@agentic-react/webpack';
+
+export default (env, argv) =>
+ withAgenticReactWebpack(config, { mode: argv.mode });
+```
+
+Webpack adapter 会 prepend generated runtime entry,把 runtime bridge 挂到 webpack-dev-server,并在这里暴露 MCP:
+
+```text
+http://localhost:/mcp
+```
+
+### Next.js
+
+```bash
+pnpm install @agentic-react/next -D
+```
+
+```js
+// next.config.mjs
+import withAgenticReactNext from '@agentic-react/next';
+
+export default withAgenticReactNext(nextConfig);
+```
+
+Next adapter 会通过 Next 的 Webpack config 注入 browser runtime,并启动本地 bridge server。默认 MCP 地址:
+
+```text
+http://127.0.0.1:51426/mcp
+```
+
+## Runtime-Only Usage
+
+如果只需要 browser-side selection/runtime APIs,不需要本地源码查找或 MCP dev-server wiring,可以直接使用 `@agentic-react/core`。
+
+```bash
+pnpm install @agentic-react/core
+```
+
+```ts
+import { createSelectionToolkit } from '@agentic-react/core';
+
+const toolkit = createSelectionToolkit();
+toolkit.enable();
+```
+
+runtime-only mode 可以检查 live browser tree,但不能读取或编辑本地文件。source lookup 和 local MCP transport 是 adapter layer 的职责。
+
+## Adapters 额外提供什么
+
+`@agentic-react/core` 运行在浏览器中,可以选择元素、检查 React fibers、高亮组件、格式化 selection context,并暴露 `window.__AGENTIC_REACT__` / `window.__AGENTIC_REACT_TOOLS__`。
+
+Bundler adapters 会增加 runtime 自己无法知道的 local-dev 能力:
+
+- 自动注入 core runtime
+- 挂载本地 MCP Streamable HTTP `/mcp` endpoint
+- 把 MCP calls 从 Node bridge 到 browser runtime
+- 提供 source-root context,用于 source lookup
+- 让 dev-only tooling 不进入 production bundles
+
+## Tuning Modal API
+
+selection overlay 包含 tuning modal,可以把视觉调整转换成 prompt text。你可以通过 adapter 的 `toolkit` option 配置它,也可以在 runtime 中调用 `window.__AGENTIC_REACT__.setToolkitConfig()`。
+
+```ts
+import type { ToolkitConfig } from '@agentic-react/vite';
+
+// Import ToolkitConfig from the adapter package you use.
+const toolkit: ToolkitConfig = {
+ enabled: true,
+ defaultVisible: true,
+ defaultExpanded: false,
+ position: 'bottom-right',
+ offset: { x: 20, y: 20 },
+ accentColor: '#111827',
+ zIndex: 2147483000,
+ iconUrl: '/agentic-react-logo.png',
+ tuningModal: {
+ classNames: {
+ surface: 'my-tuning-surface',
+ panel: 'my-tuning-panel',
+ control: 'my-tuning-control',
+ },
+ styles: {
+ panel: { border: '1px solid rgba(15, 23, 42, 0.18)' },
+ targetTag: { background: '#f8fafc', color: '#0f172a' },
+ },
+ tokens: {
+ panelRadius: '14px',
+ controlRadius: '10px',
+ primaryButtonBackground: '#0f766e',
+ primaryButtonColor: '#ffffff',
+ panelShadow: '0 24px 72px rgba(15, 118, 110, 0.22)',
+ },
+ },
+};
+```
+
+`tuningModal.classNames` 会把 classes 加到 modal slots 上。`tuningModal.styles` 会把 inline style objects 应用到同样的 slots。支持的 slots 包括 `root`, `surface`, `panel`, `arrow`, `title`, `body`, `targetTag`, `customPromptForm`, `customPromptInput`, `customPromptButton`, `sectionTitle`, `row`, `label`, `controlWrap`, `control`, `colorInput`, `numberInput`, `stepperButton`, `select`, `textarea`, `suffix`, 和 `closeButton`。
+
+`tuningModal.tokens` 会把 camelCase token names 映射到以 `--agentic-react-tuning-` 开头的 CSS variables。比如 `panelRadius` 会变成 `--agentic-react-tuning-panel-radius`,`primaryButtonBackground` 会变成 `--agentic-react-tuning-primary-button-background`。
+
+adapter 这样传入 config:
+
+```ts
+// Vite
+AgenticReact({ toolkit });
+
+// Webpack
+withAgenticReactWebpack(config, { mode: 'development' }, { toolkit });
+
+// Next.js
+withAgenticReactNext(nextConfig, { toolkit });
+```
+
+如果需要结构级扩展,可以注册 browser-side tuning modal extension。slot renderers 可以在 fields 前后或 footer 中加入自定义 DOM。`wrapModal` 会收到 `surfaceElement` 和 `panelElement`,方便 design system 加 wrappers、data attributes、observers、portals 或 cleanup-aware 行为。
+
+```ts
+const unregister = window.__AGENTIC_REACT__?.registerTuningModalExtension({
+ id: 'design-system-audit',
+ beforeFields({ container, context }) {
+ const badge = document.createElement('div');
+ badge.textContent = `Editing ${context.tagName}`;
+ badge.className = 'agentic-design-system-badge';
+ container.appendChild(badge);
+ },
+ wrapModal({ surfaceElement, actions }) {
+ surfaceElement.dataset.designSystem = 'acme';
+ const onTransitionEnd = () => actions.requestReposition();
+ surfaceElement.addEventListener('transitionend', onTransitionEnd);
+ return () => {
+ surfaceElement.removeEventListener('transitionend', onTransitionEnd);
+ };
+ },
+});
+```
+
+## Custom Tools
+
+Adapters 支持 browser-side custom tools。shared types 可以从你正在使用的 adapter package 中 import:
+
+```ts
+import type { ToolResultValue } from '@agentic-react/vite';
+
+export default function logMessage(args: { message: string }): ToolResultValue {
+ return {
+ success: true,
+ message: `Received: ${args.message}`,
+ };
+}
+```
+
+```ts
+import { defineConfig } from 'vite';
+import AgenticReact from '@agentic-react/vite';
+import { z } from 'zod';
+import logMessage from './src/tools/logMessage';
+
+export default defineConfig({
+ plugins: [
+ AgenticReact({
+ customTools: [
+ {
+ name: 'log-message',
+ description: 'Log a message in the browser runtime',
+ schema: z.object({ message: z.string() }),
+ clientFunction: logMessage,
+ },
+ ],
+ }),
+ ],
+});
+```
+
+## Release Smoke Test
+
+本地运行 release automation smoke test:
+
+```bash
+pnpm run test:release-smoke
+```
+
+这个测试不会 publish 到 npm,也不会调用 GitHub。它会读取 `.github/workflows/release.yml`,创建临时 git fixtures,并使用 fake `pnpm` 和 `gh` commands 覆盖 release automation branches。
+
+## Development
+
+```bash
+pnpm run build
+pnpm run playground:vite
+pnpm run playground:webpack
+pnpm run playground:next
+pnpm run playground:nx-mf
+```
+
+e2e automation 使用 playground package configs 中固定的本地端口。
+
+## Acknowledgement
+
+This project is inspired by [vite-plugin-vue-mcp](https://github.com/webfansplz/vite-plugin-vue-mcp).
+
+## License
+
+MIT
diff --git a/docs/plans/toolbox-icon-customization.md b/docs/plans/toolbox-icon-customization.md
new file mode 100644
index 0000000..6eec656
--- /dev/null
+++ b/docs/plans/toolbox-icon-customization.md
@@ -0,0 +1,325 @@
+# Toolbox Settings and Selection Confirmation Plan
+
+## Status
+
+Accepted implementation plan. The global settings engine and browser integration described here are the source of truth for this feature.
+
+## Goal
+
+Give the Agentic React toolbox one coherent Settings surface for:
+
+- single-select, multiselect, toolbox-toggle, and Done shortcuts;
+- a globally customized launcher icon;
+- clear source labels and per-setting reset actions.
+
+At the same time, make selection an explicit transaction:
+
+- clicking a target captures context but does not copy it;
+- Done copies and commits the pending selection;
+- Escape cancels the pending selection without copying;
+- interactions with Agentic React UI do not activate or dismiss host-app UI.
+
+All implementation and defaults live in this repository. A consuming project only supplies optional project defaults through its Agentic React configuration.
+
+## Product Decisions
+
+### Global ownership and precedence
+
+User settings are global to the local machine. They are not scoped to a repository, browser origin, hostname, port, or bundler.
+
+The precedence for every configurable value is:
+
+```text
+global user override
+ > project configuration default
+ > package default
+```
+
+Reset removes only the global override. It reveals the project default when one exists, otherwise the package default.
+
+The Settings UI identifies the effective source as `Global override`, `Project configuration`, or `Default`.
+
+### Persistence
+
+Persist settings under the user's home directory:
+
+```text
+~/.agentic-react/settings.json
+~/.agentic-react/toolbox-icon.webp
+```
+
+Use `toolbox-icon.png` only when PNG is required as the encoding fallback.
+
+There is deliberately no project hash and no project-local settings file. Moving or switching projects does not change the user's shortcuts or icon. The consuming project never owns the persisted user preference.
+
+Tests and playgrounds must inject an isolated settings root and must never read or write the developer's real `~/.agentic-react` directory.
+
+### Why browser code can use home-directory settings
+
+Browser code cannot read the local filesystem directly. Persistence is therefore a runtime operation, not a bundler-time file import:
+
+1. A supported adapter starts Agentic React's existing local development bridge.
+2. The Node side reads `~/.agentic-react` and merges global overrides with project and package defaults.
+3. The adapter injects a sanitized initial snapshot and an unguessable session capability into the development client.
+4. The browser reads or updates settings through narrow request/response operations on that existing bridge.
+5. Node validates and atomically persists mutations, then returns a new effective snapshot.
+
+This extends the adapter-hosted Agentic React bridge. It does not replace Vite HMR, Webpack's dev transport, or Next's development transport, and it does not introduce a second public bridge API. Vite hosts the Agentic React WebSocket path on its existing HTTP server; that path is separate from Vite's HMR protocol.
+
+No filesystem path is sent to the browser. Persisted icons are returned as bounded, validated data URLs.
+
+### No browser-storage fallback or file watcher
+
+Do not use `localStorage` or IndexedDB as a fallback. Runtime-only `@agentic-react/core` usage may use code defaults, but Settings persistence is unavailable without an adapter-hosted bridge.
+
+The first version does not watch `settings.json` for external edits. Settings change through the toolbox UI and take effect from the RPC response. A page reload reads the latest persisted state.
+
+## Selection Semantics
+
+### Single select
+
+Single select uses a pending transaction:
+
+1. Enter selection mode from the button or configured shortcut.
+2. Click a host element to capture and display its context.
+3. Keep the selection pending and show an enabled Done action.
+4. Do not write to the clipboard yet.
+5. Clicking Done or pressing the configured Done shortcut copies the context and commits it as the last confirmed selection.
+
+Explicit programmatic copy APIs remain available, but they copy only the last committed selection. They must not expose or copy an unconfirmed pending single selection.
+
+If clipboard writing fails, keep the pending selection so the user can retry.
+
+### Multiselect
+
+Multiselect remains additive. Captured contexts and overlays stay pending until Done. Done copies the complete pending set. Clear All removes only the pending multiselect set.
+
+### Escape
+
+Escape is reserved and is not configurable.
+
+While Agentic React owns an active or pending selection, it captures Escape during the capture phase, prevents the default action, and stops propagation. It must consume the complete physical key cycle, including repeated keydown events and the matching keyup.
+
+Escape:
+
+- exits single-select or multiselect mode;
+- discards pending single and multiselect contexts;
+- removes pending hover, dim, selection, and tuning overlays;
+- does not copy anything;
+- preserves the last committed selection;
+- keeps the toolbox open and displays `Selection cancelled`.
+
+## Host Event Isolation
+
+Every interactive Agentic React surface must form an activation-event boundary, including:
+
+- launcher and toolbox panel;
+- Settings controls;
+- selected-element actions;
+- multiselect actions;
+- tuning UI;
+- icon crop modal and backdrop.
+
+At a minimum, stop propagation for:
+
+```text
+pointerdown
+pointerup
+mousedown
+mouseup
+click
+touchstart
+touchend
+contextmenu
+```
+
+Internal handlers still run normally. Isolation prevents common host outside-click and activation handlers from closing transient menus, popovers, dialogs, or other short-lived UI while the user operates the toolbox.
+
+## Settings UI
+
+Add a Settings section inside the existing toolbox panel. It contains `Shortcuts` and `Appearance` subsections.
+
+When persistence is unavailable, show effective code/package defaults and a concise capability explanation. Do not show controls that pretend a change can be saved.
+
+### Shortcuts
+
+The configurable actions are:
+
+| Action | Package default |
+| --- | --- |
+| Single select | `Ctrl+Alt+Shift+S` |
+| Multi select | `Ctrl+Alt+Shift+M` |
+| Toggle toolbox | `Ctrl+Alt+Shift+A` |
+| Done | `Enter` |
+
+Each row shows the action, effective shortcut, source badge, record action, and reset action.
+
+Recording captures one normalized combination. Validation occurs both in the browser and in the Node settings engine. Reject:
+
+- Escape;
+- a modifier without a non-modifier key;
+- unsupported keys;
+- more than one non-modifier key;
+- a shortcut that duplicates another configured action after normalization.
+
+The Node write path validates the merged effective shortcut set so direct runtime/RPC callers cannot bypass UI validation.
+
+### Appearance
+
+The Toolbox icon row contains:
+
+- a circular preview;
+- an effective-source badge;
+- Change;
+- Reset when a global override exists;
+- an inline capability or validation error when applicable.
+
+The project default remains the existing `toolkit.iconUrl`. The first version does not require a new URL/file source union: the project supplies a browser-safe default URL, and user customization is stored globally through Settings.
+
+## Image Customization
+
+### Crop workflow
+
+Every accepted upload opens a 1:1 crop modal with:
+
+- a circular launcher preview;
+- drag-to-position;
+- zoom;
+- left and right 90-degree rotation;
+- a centered default crop;
+- Apply and Cancel.
+
+Preview changes are transient. The launcher and persisted data change only after Apply has been encoded, validated, and persisted successfully. Cancel has no side effects.
+
+The result is a static square `256 x 256` image. Prefer WebP and fall back to PNG. Preserve transparency where supported and strip metadata through canvas re-encoding. Animated inputs become a single static frame. SVG is rejected.
+
+### Validation limits
+
+Validate decoded content rather than trusting the extension or reported MIME type:
+
+- maximum source size: `20 MB`;
+- maximum side: `8192 px`;
+- maximum decoded area: `40 megapixels`;
+- browser-decodable raster input only;
+- SVG and malformed input rejected;
+- persisted encoded result capped at `1 MB`;
+- Node verifies declared MIME, fixed filename, size, and magic bytes.
+
+Never send the original upload to Node. Send only the encoded 256-square result.
+
+### Failure behavior
+
+- Preload a candidate before replacing the launcher image.
+- Keep the previous visible icon until Apply succeeds.
+- Roll back image bytes if settings metadata cannot be committed.
+- On missing or corrupt persisted icon data, return a structured error and render the next valid default without a broken-image indicator.
+- Write settings through a temporary sibling and atomic rename.
+- Reset commits metadata before deleting the old icon file.
+
+### Accessibility
+
+- All controls have accessible names and are keyboard operable.
+- The crop modal traps focus and restores it to Change when closed.
+- Errors use a live status region.
+- Escape closes the crop modal when selection cancellation does not have priority.
+- Circular masking is preview-only; the persisted file remains square.
+
+## Architecture
+
+### Browser modules
+
+Keep cohesive browser logic under `packages/core/src/core/settings/`:
+
+- `browser.ts`: typed settings RPC client and cached snapshot;
+- `shortcuts.ts`: normalization, identity comparison, and dispatch;
+- `ui.ts`: small Settings UI and event-boundary helpers;
+- `image_cropper.ts`: decode validation, crop math, rendering, and encoding;
+- `icon_modal.ts`: modal lifecycle, focus management, and cropper controls.
+
+`selection_toolkit.ts` coordinates selection state and consumes these modules. It owns no filesystem behavior.
+
+### Node settings engine
+
+The shared Node implementation under `packages/core/src/core/settings/` owns:
+
+- home-root resolution with a test-only root override;
+- schema parsing and forward-safe fallback;
+- global/project/package merge and source metadata;
+- shortcut validation on writes;
+- bounded icon verification and data-URL resolution;
+- atomic apply, reset, and rollback;
+- capability-protected RPC handling.
+
+Vite, Webpack, and Next construct the same engine and register it with their existing Agentic React bridge.
+
+### RPC operations
+
+Keep the browser-to-Node request surface fixed and typed:
+
+```text
+settings:get-effective
+settings:update-shortcuts
+settings:reset-shortcut
+settings:reset-shortcuts
+settings:apply-icon
+settings:reset-icon
+```
+
+Requests never accept a filesystem path. Every mutation requires the injected session capability. An unauthorized response must not disclose global settings.
+
+## Development and Production Boundaries
+
+Adapter integration remains development-only:
+
+- Vite runs the plugin with `apply: 'serve'`;
+- Next remains gated by development client compilation;
+- Webpack follows its existing development injection gate;
+- production builds do not start the Agentic React bridge or inject settings capability data.
+
+Direct `@agentic-react/core` imports remain an intentional runtime API outside the adapter production guarantee.
+
+## Verification Strategy
+
+### Unit and adapter tests
+
+Cover:
+
+- global > project > package precedence;
+- injected settings-root isolation;
+- corrupt settings fallback;
+- normalized shortcut writes, duplicate rejection, and per-action reset;
+- unauthorized RPC non-disclosure;
+- icon format/size validation, reload resolution, rollback, and reset ordering;
+- crop center, drag bounds, zoom bounds, and 90-degree rotation;
+- Vite, Webpack, and Next bootstrap the same effective settings shape.
+
+### Browser E2E
+
+The Vite playground owns the full interaction matrix:
+
+- all toolbox and crop-modal activation events stay out of host bubble handlers;
+- a transient host popover remains open during toolbox interaction;
+- single capture leaves clipboard unchanged until Done;
+- the Done button and default Enter shortcut copy the pending single selection;
+- multiselect remains additive and copies only on Done;
+- Escape before capture and after pending single/multi selection discards pending state, consumes keydown and keyup, and leaves clipboard unchanged;
+- shortcut recording rejects duplicates, persists to an isolated global store, survives reload, and resets per action;
+- icon upload, crop, zoom, rotate, Apply, reload, and Reset work end to end;
+- reset reveals the project `iconUrl` default.
+
+Smaller adapter tests cover shared engine/bootstrap behavior without duplicating the full browser suite.
+
+## Acceptance Criteria
+
+- Toolbox UI interaction does not close host UI through bubbled activation events.
+- Single select and multiselect copy only after explicit Done confirmation.
+- The configured Done shortcut works for pending single and multiselect state.
+- Escape is reserved, consumes the full key cycle during selection ownership, cancels without copying, and preserves committed context.
+- Users can configure and individually reset four shortcuts from Settings.
+- Invalid or duplicate shortcuts cannot be persisted through either UI or direct settings RPC.
+- Users can upload, crop, rotate, zoom, apply, reload, and reset a global launcher icon.
+- Global settings apply across projects, origins, ports, and supported adapters on the same machine.
+- Project configuration supplies defaults only; it does not own user settings.
+- Runtime-only use fails closed for persistence without a browser-storage fallback.
+- Vite, Webpack, and Next reuse the existing Agentic React bridge and share the same settings engine.
+- Tests use an isolated root and never mutate the real `~/.agentic-react` directory.
diff --git a/package.json b/package.json
index d5fe6c9..7cd1d9d 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"changeset:status": "changeset status --since=origin/main",
"version-packages": "changeset version",
"release": "changeset publish",
- "test": "pnpm run test:e2e",
+ "test": "pnpm run test:unit && pnpm run test:e2e",
+ "test:unit": "pnpm --filter @agentic-react/core run test && pnpm --filter @agentic-react/next run test && pnpm --filter @agentic-react/webpack run test",
"test:e2e": "node scripts/run-e2e.mjs",
"test:release-smoke": "node scripts/release-smoke-test.mjs"
},
diff --git a/packages/core/README.md b/packages/core/README.md
index 1f1c03c..f14621f 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -19,6 +19,28 @@ For full local-dev MCP features, install an adapter instead:
Adapters depend on `@agentic-react/core` internally and add runtime injection, bridge transport, MCP endpoints, and source-root context.
+## Settings Availability
+
+`@agentic-react/core` contains the browser UI and shared types for toolbox Settings, but persistent Settings require an adapter-hosted bridge. Direct runtime usage cannot read or write `~/.agentic-react/settings.json`, cannot persist a custom toolbox icon, and cannot save shortcut changes unless you provide your own `AgenticReactSettingsClient`.
+
+Without that bridge, the selection toolkit can still use package defaults and code-provided `toolkit` defaults. The package shortcut defaults are `Ctrl+Alt+Shift+S` for single select, `Ctrl+Alt+Shift+M` for multiselect, `Ctrl+Alt+Shift+A` for toggling the toolbox, and `Enter` for Done. `Escape` is reserved for cancelling pending selection.
+
+Adapter-backed Settings resolve values as:
+
+```text
+global user override
+ > project configuration default
+ > package default
+```
+
+Project defaults are supplied by adapters through `toolkit.settings.shortcuts` and `toolkit.iconUrl`. Runtime-only integrations should treat those as code defaults, not persisted user preferences.
+
+## Selection Confirmation
+
+Single selection is pending until the user confirms it. Clicking a target captures and highlights the context, but does not copy it. Clicking Done, or pressing the configured Done shortcut, copies that pending context and commits it as the last selection. If clipboard writing fails, the pending context stays available for retry.
+
+Multiselect collects pending contexts until Done copies the complete set. `Escape` cancels pending single and multiselect contexts without copying and leaves the last committed selection intact.
+
## Tuning Modal Runtime API
The browser runtime exposes the tuning modal through the selection toolkit and `window.__AGENTIC_REACT__`.
diff --git a/packages/core/package.json b/packages/core/package.json
index e1cc38b..a668de6 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -7,7 +7,8 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "shx rm -rf dist && tsc && shx cp -r src/assets dist/assets && shx cp ../../CHANGELOG dist/CHANGELOG",
- "build:src": "shx rm -rf dist && tsc --project tsconfig.json --outDir dist --rootDir src && shx cp -r src/assets dist/assets && shx cp ../../CHANGELOG dist/CHANGELOG"
+ "build:src": "shx rm -rf dist && tsc --project tsconfig.json --outDir dist --rootDir src && shx cp -r src/assets dist/assets && shx cp ../../CHANGELOG dist/CHANGELOG",
+ "test": "pnpm run build && node --test test/*.test.mjs"
},
"keywords": [
"agentic-react",
@@ -64,6 +65,10 @@
"types": "./dist/select.d.ts",
"default": "./dist/select.js"
},
+ "./source-lookup": {
+ "types": "./dist/source_lookup.d.ts",
+ "default": "./dist/source_lookup.js"
+ },
"./shared/const": {
"types": "./dist/shared/const.d.ts",
"default": "./dist/shared/const.js"
diff --git a/packages/core/src/bridge/server.ts b/packages/core/src/bridge/server.ts
index 467c009..8b341c8 100644
--- a/packages/core/src/bridge/server.ts
+++ b/packages/core/src/bridge/server.ts
@@ -22,6 +22,10 @@ interface RuntimeBridgeRequestOptions {
timeoutMs?: number;
}
+type BrowserBridgeRequestHandler = (
+ payload: unknown,
+) => unknown | Promise;
+
const isBridgeMessage = (value: unknown): value is BridgeMessage => {
if (!value || typeof value !== 'object') {
return false;
@@ -33,12 +37,25 @@ const isBridgeMessage = (value: unknown): value is BridgeMessage => {
);
};
+const MAX_BRIDGE_MESSAGE_BYTES = 2 * 1024 * 1024;
+
export class RuntimeBridgeServer {
private wsServer: WebSocketServer | null = null;
private activeSocket: WebSocket | null = null;
private readonly pendingRequests = new Map();
+ private readonly requestHandlers = new Map<
+ BridgeRequestEvent,
+ BrowserBridgeRequestHandler
+ >();
private readonly sockets = new Set();
+ registerHandler(
+ event: BridgeRequestEvent,
+ handler: BrowserBridgeRequestHandler,
+ ) {
+ this.requestHandlers.set(event, handler);
+ }
+
attach(httpServer: any) {
this.wsServer = new WebSocketServer({ noServer: true });
@@ -61,13 +78,22 @@ export class RuntimeBridgeServer {
websocket.on('message', (messageBuffer) => {
try {
- const parsedMessage = JSON.parse(messageBuffer.toString()) as unknown;
+ const messageText = messageBuffer.toString();
+ if (
+ Buffer.byteLength(messageText, 'utf8') > MAX_BRIDGE_MESSAGE_BYTES
+ ) {
+ websocket.close(1009, 'Bridge message too large');
+ return;
+ }
+
+ const parsedMessage = JSON.parse(messageText) as unknown;
if (!isBridgeMessage(parsedMessage)) {
return;
}
- if (parsedMessage.type !== 'bridge:response') {
+ if (parsedMessage.type === 'bridge:request') {
+ void this.handleBrowserRequest(websocket, parsedMessage);
return;
}
@@ -232,6 +258,49 @@ export class RuntimeBridgeServer {
});
}
+ private async handleBrowserRequest(
+ socket: WebSocket,
+ message: Extract,
+ ) {
+ const handler = this.requestHandlers.get(message.event);
+ if (!handler) {
+ this.sendResponse(socket, {
+ type: 'bridge:response',
+ id: message.id,
+ ok: false,
+ error: `Unsupported bridge event: ${message.event}`,
+ });
+ return;
+ }
+
+ try {
+ const payload = await handler(message.payload);
+ this.sendResponse(socket, {
+ type: 'bridge:response',
+ id: message.id,
+ ok: true,
+ payload,
+ });
+ } catch (error) {
+ this.sendResponse(socket, {
+ type: 'bridge:response',
+ id: message.id,
+ ok: false,
+ error: error instanceof Error ? error.message : 'Bridge request failed',
+ });
+ }
+ }
+
+ private sendResponse(
+ socket: WebSocket,
+ message: Extract,
+ ) {
+ if (socket.readyState !== socket.OPEN) {
+ return;
+ }
+ socket.send(JSON.stringify(message));
+ }
+
private getOpenSockets(): WebSocket[] {
const sockets = Array.from(this.sockets).filter(
(socket) => socket.readyState === socket.OPEN,
diff --git a/packages/core/src/core/settings/browser.ts b/packages/core/src/core/settings/browser.ts
new file mode 100644
index 0000000..79e5762
--- /dev/null
+++ b/packages/core/src/core/settings/browser.ts
@@ -0,0 +1,134 @@
+import type {
+ AgenticReactSettingsBootstrap,
+ AgenticReactSettingsCapability,
+ AgenticReactSettingsError,
+ AgenticReactSettingsRpcResult,
+ AgenticReactSettingsSnapshot,
+ AgenticReactShortcutKey,
+ AgenticReactShortcutSettings,
+ AgenticReactToolboxIconMime,
+} from '../../shared/types.js';
+import { createUnavailableSettingsSnapshot } from './schema.js';
+
+export type BrowserSettingsBridgeRequest = (
+ event:
+ | 'settings:get-effective'
+ | 'settings:update-shortcuts'
+ | 'settings:reset-shortcut'
+ | 'settings:apply-icon'
+ | 'settings:reset-icon'
+ | 'settings:reset-shortcuts',
+ payload: unknown,
+ timeoutMs?: number,
+) => Promise;
+
+export interface BrowserSettingsClientOptions {
+ initialSettings?: AgenticReactSettingsBootstrap;
+ request: BrowserSettingsBridgeRequest;
+}
+
+const DEFAULT_SETTINGS_RPC_TIMEOUT_MS = 10000;
+
+const createSettingsError = (
+ code: AgenticReactSettingsError['code'],
+ message: string,
+): AgenticReactSettingsError => ({
+ code,
+ message,
+});
+
+export const createAgenticReactSettingsClient = ({
+ initialSettings,
+ request,
+}: BrowserSettingsClientOptions) => {
+ let snapshot: AgenticReactSettingsSnapshot =
+ initialSettings || createUnavailableSettingsSnapshot();
+ const capability: AgenticReactSettingsCapability =
+ initialSettings?.capability || {
+ available: false,
+ reason: 'Settings capability was not provided by the dev adapter.',
+ };
+
+ const requestSettings = async (
+ event: Parameters[0],
+ payload: Record = {},
+ ): Promise => {
+ if (!capability.available || !capability.token) {
+ return {
+ success: false,
+ ...snapshot,
+ error: createSettingsError(
+ 'settings_unavailable',
+ capability.reason || 'Agentic React settings are unavailable.',
+ ),
+ };
+ }
+
+ try {
+ const result = await request(
+ event,
+ {
+ ...payload,
+ token: capability.token,
+ },
+ DEFAULT_SETTINGS_RPC_TIMEOUT_MS,
+ );
+ if (!isSettingsRpcResult(result)) {
+ return {
+ success: false,
+ ...snapshot,
+ error: createSettingsError(
+ 'invalid_payload',
+ 'Settings bridge returned an invalid response.',
+ ),
+ };
+ }
+
+ snapshot = {
+ effectiveSettings: result.effectiveSettings,
+ sources: result.sources,
+ errors: result.errors,
+ };
+ return result;
+ } catch (error) {
+ return {
+ success: false,
+ ...snapshot,
+ error: createSettingsError(
+ 'settings_unavailable',
+ error instanceof Error ? error.message : 'Settings bridge failed.',
+ ),
+ };
+ }
+ };
+
+ return {
+ getEffectiveSettings: () => requestSettings('settings:get-effective'),
+ updateShortcuts: (shortcuts: Partial) =>
+ requestSettings('settings:update-shortcuts', { shortcuts }),
+ resetShortcut: (key: AgenticReactShortcutKey) =>
+ requestSettings('settings:reset-shortcut', { key }),
+ applyIcon: (input: { data: string; mime?: AgenticReactToolboxIconMime }) =>
+ requestSettings('settings:apply-icon', { icon: input }),
+ resetIcon: () => requestSettings('settings:reset-icon'),
+ resetShortcuts: () => requestSettings('settings:reset-shortcuts'),
+ getCachedSnapshot: () => snapshot,
+ getCapability: () => ({ ...capability }),
+ };
+};
+
+const isSettingsRpcResult = (
+ value: unknown,
+): value is AgenticReactSettingsRpcResult => {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const result = value as Partial;
+ return (
+ typeof result.success === 'boolean' &&
+ !!result.effectiveSettings &&
+ !!result.sources &&
+ Array.isArray(result.errors)
+ );
+};
diff --git a/packages/core/src/core/settings/icon_modal.ts b/packages/core/src/core/settings/icon_modal.ts
new file mode 100644
index 0000000..1a8015d
--- /dev/null
+++ b/packages/core/src/core/settings/icon_modal.ts
@@ -0,0 +1,345 @@
+import type { AgenticReactToolboxIconMime } from '../../shared/types.js';
+import {
+ type ImageCropState,
+ type ImageCropperSource,
+ createDefaultImageCropState,
+ decodeImageCropperSource,
+ dragImageCropState,
+ encodeImageCrop,
+ isImageCropperError,
+ renderImageCropToCanvas,
+ rotateImageCropState,
+ zoomImageCropState,
+} from './image_cropper.js';
+import { stopHostActivationEvents } from './ui.js';
+
+export interface ToolboxIconCropperOptions {
+ file: File;
+ zIndex: number;
+ restoreFocusTo: HTMLElement;
+ onApply: (icon: {
+ data: string;
+ mime: AgenticReactToolboxIconMime;
+ }) => Promise;
+ onCancel?: () => void;
+ onError?: (message: string) => void;
+}
+
+const PREVIEW_SIZE = 256;
+
+export const openToolboxIconCropper = async ({
+ file,
+ zIndex,
+ restoreFocusTo,
+ onApply,
+ onCancel,
+ onError,
+}: ToolboxIconCropperOptions): Promise => {
+ let source: ImageCropperSource;
+ try {
+ source = await decodeImageCropperSource(file);
+ } catch (error) {
+ const message = getCropperErrorMessage(error);
+ onError?.(message);
+ throw error;
+ }
+
+ createToolboxIconCropperModal({
+ source,
+ zIndex,
+ restoreFocusTo,
+ onApply,
+ onCancel,
+ onError,
+ });
+};
+
+const createToolboxIconCropperModal = ({
+ source,
+ zIndex,
+ restoreFocusTo,
+ onApply,
+ onCancel,
+ onError,
+}: Omit & {
+ source: ImageCropperSource;
+}) => {
+ let state: ImageCropState = createDefaultImageCropState(source);
+ let dragStart: { x: number; y: number; state: ImageCropState } | null = null;
+ let isApplying = false;
+ const previousActiveElement =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : restoreFocusTo;
+
+ const root = document.createElement('div');
+ const dialog = document.createElement('div');
+ const title = document.createElement('div');
+ const previewFrame = document.createElement('div');
+ const previewCanvas = document.createElement('canvas');
+ const finalPreview = document.createElement('canvas');
+ const zoomLabel = document.createElement('label');
+ const zoomInput = document.createElement('input');
+ const rotateRow = document.createElement('div');
+ const rotateLeftButton = createModalButton('Rotate left', true);
+ const rotateRightButton = createModalButton('Rotate right', true);
+ const errorElement = document.createElement('div');
+ const footer = document.createElement('div');
+ const cancelButton = createModalButton('Cancel', true);
+ const applyButton = createModalButton('Apply', false);
+
+ root.style.position = 'fixed';
+ root.style.inset = '0';
+ root.style.zIndex = String(zIndex);
+ root.style.display = 'grid';
+ root.style.placeItems = 'center';
+ root.style.padding = '16px';
+ root.style.background = 'rgba(15, 23, 42, 0.42)';
+ root.style.fontFamily = 'ui-sans-serif, system-ui, sans-serif';
+ stopHostActivationEvents(root);
+ root.addEventListener('click', (event) => {
+ if (event.target === root) {
+ close(false);
+ }
+ });
+
+ dialog.setAttribute('role', 'dialog');
+ dialog.setAttribute('aria-modal', 'true');
+ dialog.setAttribute('aria-labelledby', 'agentic-react-icon-crop-title');
+ dialog.tabIndex = -1;
+ dialog.style.width = 'min(360px, calc(100vw - 32px))';
+ dialog.style.background = '#ffffff';
+ dialog.style.border = '1px solid rgba(15, 23, 42, 0.16)';
+ dialog.style.borderRadius = '10px';
+ dialog.style.boxShadow = '0 24px 70px rgba(15, 23, 42, 0.34)';
+ dialog.style.padding = '14px';
+ dialog.style.display = 'flex';
+ dialog.style.flexDirection = 'column';
+ dialog.style.gap = '12px';
+ dialog.style.color = '#111827';
+
+ title.id = 'agentic-react-icon-crop-title';
+ title.textContent = 'Crop toolbox icon';
+ title.style.fontSize = '14px';
+ title.style.fontWeight = '800';
+
+ previewFrame.style.width = `${PREVIEW_SIZE}px`;
+ previewFrame.style.maxWidth = '100%';
+ previewFrame.style.aspectRatio = '1 / 1';
+ previewFrame.style.margin = '0 auto';
+ previewFrame.style.borderRadius = '999px';
+ previewFrame.style.overflow = 'hidden';
+ previewFrame.style.cursor = 'grab';
+ previewFrame.style.touchAction = 'none';
+ previewFrame.style.boxShadow =
+ '0 0 0 1px rgba(15, 23, 42, 0.16), 0 12px 30px rgba(15, 23, 42, 0.18)';
+
+ previewCanvas.style.width = '100%';
+ previewCanvas.style.height = '100%';
+ previewCanvas.style.display = 'block';
+ previewFrame.appendChild(previewCanvas);
+
+ zoomInput.type = 'range';
+ zoomInput.min = String(state.zoom);
+ zoomInput.max = String(state.zoom * 4);
+ zoomInput.step = '0.01';
+ zoomInput.value = String(state.zoom);
+ zoomInput.setAttribute('aria-label', 'Zoom icon crop');
+ zoomLabel.textContent = 'Zoom';
+ zoomLabel.style.display = 'grid';
+ zoomLabel.style.gridTemplateColumns = '52px minmax(0, 1fr)';
+ zoomLabel.style.alignItems = 'center';
+ zoomLabel.style.gap = '8px';
+ zoomLabel.style.fontSize = '12px';
+ zoomLabel.style.fontWeight = '700';
+ zoomLabel.appendChild(zoomInput);
+
+ finalPreview.style.width = '48px';
+ finalPreview.style.height = '48px';
+ finalPreview.style.borderRadius = '999px';
+ finalPreview.style.boxShadow = '0 0 0 1px rgba(15, 23, 42, 0.2)';
+ rotateLeftButton.textContent = 'Rotate left';
+ rotateRightButton.textContent = 'Rotate right';
+ rotateRow.style.display = 'grid';
+ rotateRow.style.gridTemplateColumns = '1fr 1fr auto';
+ rotateRow.style.gap = '8px';
+ rotateRow.style.alignItems = 'center';
+ rotateRow.appendChild(rotateLeftButton);
+ rotateRow.appendChild(rotateRightButton);
+ rotateRow.appendChild(finalPreview);
+
+ errorElement.setAttribute('role', 'status');
+ errorElement.setAttribute('aria-live', 'polite');
+ errorElement.style.minHeight = '16px';
+ errorElement.style.fontSize = '12px';
+ errorElement.style.color = '#b91c1c';
+
+ cancelButton.textContent = 'Cancel';
+ applyButton.textContent = 'Apply';
+ footer.style.display = 'grid';
+ footer.style.gridTemplateColumns = '1fr 1fr';
+ footer.style.gap = '8px';
+ footer.appendChild(cancelButton);
+ footer.appendChild(applyButton);
+
+ dialog.appendChild(title);
+ dialog.appendChild(previewFrame);
+ dialog.appendChild(zoomLabel);
+ dialog.appendChild(rotateRow);
+ dialog.appendChild(errorElement);
+ dialog.appendChild(footer);
+ root.appendChild(dialog);
+ document.body.appendChild(root);
+
+ const setError = (message: string) => {
+ errorElement.textContent = message;
+ onError?.(message);
+ };
+ const render = () => {
+ renderImageCropToCanvas(previewCanvas, source, state, {
+ size: PREVIEW_SIZE,
+ pixelRatio: window.devicePixelRatio || 1,
+ });
+ renderImageCropToCanvas(finalPreview, source, state, {
+ size: 48,
+ pixelRatio: window.devicePixelRatio || 1,
+ });
+ };
+ const close = (applied: boolean) => {
+ root.remove();
+ source.dispose();
+ const restoreTarget = restoreFocusTo.isConnected
+ ? restoreFocusTo
+ : previousActiveElement;
+ restoreTarget?.focus?.();
+ if (!applied) {
+ onCancel?.();
+ }
+ };
+
+ previewFrame.addEventListener('pointerdown', (event) => {
+ event.preventDefault();
+ previewFrame.setPointerCapture(event.pointerId);
+ previewFrame.style.cursor = 'grabbing';
+ dragStart = { x: event.clientX, y: event.clientY, state };
+ });
+ previewFrame.addEventListener('pointermove', (event) => {
+ if (!dragStart) return;
+ event.preventDefault();
+ state = dragImageCropState(
+ dragStart.state,
+ {
+ deltaX: event.clientX - dragStart.x,
+ deltaY: event.clientY - dragStart.y,
+ },
+ source,
+ { viewportSize: PREVIEW_SIZE },
+ );
+ render();
+ });
+ previewFrame.addEventListener('pointerup', (event) => {
+ previewFrame.releasePointerCapture(event.pointerId);
+ previewFrame.style.cursor = 'grab';
+ dragStart = null;
+ });
+ previewFrame.addEventListener('pointercancel', () => {
+ previewFrame.style.cursor = 'grab';
+ dragStart = null;
+ });
+ zoomInput.addEventListener('input', () => {
+ state = zoomImageCropState(state, Number(zoomInput.value), source);
+ render();
+ });
+ rotateLeftButton.addEventListener('click', () => {
+ state = rotateImageCropState(state, 'left', source);
+ render();
+ });
+ rotateRightButton.addEventListener('click', () => {
+ state = rotateImageCropState(state, 'right', source);
+ render();
+ });
+ cancelButton.addEventListener('click', () => close(false));
+ applyButton.addEventListener('click', () => {
+ if (isApplying) return;
+ isApplying = true;
+ applyButton.disabled = true;
+ applyButton.textContent = 'Applying...';
+ setError('');
+ void encodeImageCrop(source, state)
+ .then((result) =>
+ onApply({
+ data: result.data,
+ mime: result.mime,
+ }),
+ )
+ .then(() => close(true))
+ .catch((error) => {
+ isApplying = false;
+ applyButton.disabled = false;
+ applyButton.textContent = 'Apply';
+ setError(getCropperErrorMessage(error));
+ });
+ });
+ root.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ close(false);
+ return;
+ }
+ trapModalFocus(event, dialog);
+ });
+
+ render();
+ dialog.focus();
+};
+
+const createModalButton = (
+ label: string,
+ muted: boolean,
+): HTMLButtonElement => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.setAttribute('aria-label', label);
+ button.style.height = '34px';
+ button.style.border = '1px solid rgba(15, 23, 42, 0.16)';
+ button.style.borderRadius = '8px';
+ button.style.background = muted ? '#f8fafc' : '#111827';
+ button.style.color = muted ? '#111827' : '#ffffff';
+ button.style.fontSize = '12px';
+ button.style.fontWeight = '700';
+ button.style.cursor = 'pointer';
+ return button;
+};
+
+const trapModalFocus = (event: KeyboardEvent, container: HTMLElement) => {
+ if (event.key !== 'Tab') return;
+
+ const focusable = Array.from(
+ container.querySelectorAll(
+ 'button, input, select, textarea, [href], [tabindex]:not([tabindex="-1"])',
+ ),
+ ).filter((element) => !element.hasAttribute('disabled'));
+ if (focusable.length === 0) {
+ event.preventDefault();
+ container.focus();
+ return;
+ }
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+};
+
+const getCropperErrorMessage = (error: unknown): string => {
+ if (isImageCropperError(error)) {
+ return error.message;
+ }
+ return error instanceof Error ? error.message : 'Failed to process image.';
+};
diff --git a/packages/core/src/core/settings/image_cropper.ts b/packages/core/src/core/settings/image_cropper.ts
new file mode 100644
index 0000000..15ae80b
--- /dev/null
+++ b/packages/core/src/core/settings/image_cropper.ts
@@ -0,0 +1,642 @@
+export type ImageCropperInputMime =
+ | 'image/png'
+ | 'image/jpeg'
+ | 'image/webp'
+ | 'image/gif';
+
+export type ImageCropperOutputMime = 'image/webp' | 'image/png';
+
+export type ImageCropperRotation = 0 | 90 | 180 | 270;
+
+export type ImageCropperErrorCode =
+ | 'unsupported_type'
+ | 'source_too_large'
+ | 'image_too_large'
+ | 'decode_failed'
+ | 'encode_failed';
+
+export interface ImageCropperStructuredError {
+ code: ImageCropperErrorCode;
+ message: string;
+ detail?: string;
+}
+
+export interface ImageCropperDimensions {
+ width: number;
+ height: number;
+}
+
+export interface ImageCropState {
+ offsetX: number;
+ offsetY: number;
+ zoom: number;
+ rotation: ImageCropperRotation;
+}
+
+export interface ImageCropperSource {
+ image: ImageBitmap | HTMLCanvasElement;
+ width: number;
+ height: number;
+ mime: ImageCropperInputMime;
+ dispose: () => void;
+}
+
+export interface DecodeImageCropperSourceOptions {
+ maxBytes?: number;
+ maxDimension?: number;
+ maxPixels?: number;
+}
+
+export interface CreateImageCropStateOptions {
+ outputSize?: number;
+ rotation?: ImageCropperRotation;
+}
+
+export interface ImageCropDragDelta {
+ deltaX: number;
+ deltaY: number;
+}
+
+export interface ImageCropViewportOptions {
+ viewportSize?: number;
+ outputSize?: number;
+}
+
+export interface ImageCropRenderOptions {
+ size?: number;
+ pixelRatio?: number;
+ smoothingQuality?: ImageSmoothingQuality;
+}
+
+export interface ImageCropEncodeOptions {
+ size?: number;
+ maxBytes?: number;
+ webpQuality?: number;
+ pngQuality?: number;
+}
+
+export interface ImageCropEncodeResult {
+ data: string;
+ mime: ImageCropperOutputMime;
+ previewUrl: string;
+}
+
+const DEFAULT_OUTPUT_SIZE = 256;
+const MAX_SOURCE_BYTES = 20 * 1024 * 1024;
+const MAX_SOURCE_DIMENSION = 8192;
+const MAX_SOURCE_PIXELS = 40 * 1000 * 1000;
+const MAX_ENCODED_BYTES = 1024 * 1024;
+const SVG_SNIFF_BYTES = 1024;
+const BASE64_CHUNK_SIZE = 0x8000;
+
+const SUPPORTED_INPUT_MIMES: ImageCropperInputMime[] = [
+ 'image/png',
+ 'image/jpeg',
+ 'image/webp',
+ 'image/gif',
+];
+
+export class ImageCropperError
+ extends Error
+ implements ImageCropperStructuredError
+{
+ readonly code: ImageCropperErrorCode;
+ readonly detail?: string;
+
+ constructor(error: ImageCropperStructuredError) {
+ super(error.message);
+ this.name = 'ImageCropperError';
+ this.code = error.code;
+ this.detail = error.detail;
+ }
+}
+
+export const isImageCropperError = (
+ error: unknown,
+): error is ImageCropperError =>
+ error instanceof ImageCropperError ||
+ (!!error &&
+ typeof error === 'object' &&
+ typeof (error as Partial).code === 'string' &&
+ typeof (error as Partial).message === 'string');
+
+export const createImageCropperError = (
+ code: ImageCropperErrorCode,
+ message: string,
+ detail?: string,
+): ImageCropperError =>
+ new ImageCropperError({
+ code,
+ message,
+ ...(detail ? { detail } : {}),
+ });
+
+export const validateImageCropperInput = async (
+ input: Blob,
+ options: DecodeImageCropperSourceOptions = {},
+): Promise => {
+ const maxBytes = options.maxBytes ?? MAX_SOURCE_BYTES;
+ if (input.size <= 0) {
+ throw createImageCropperError(
+ 'unsupported_type',
+ 'Choose a PNG, JPEG, WebP, or GIF image.',
+ );
+ }
+ if (input.size > maxBytes) {
+ throw createImageCropperError(
+ 'source_too_large',
+ 'Image must be 20 MB or smaller.',
+ );
+ }
+
+ const head = new Uint8Array(
+ await input.slice(0, SVG_SNIFF_BYTES).arrayBuffer(),
+ );
+ if (isSvgInput(input, head)) {
+ throw createImageCropperError(
+ 'unsupported_type',
+ 'SVG images are not supported. Choose a raster PNG, JPEG, WebP, or GIF.',
+ );
+ }
+
+ const mime = detectRasterMime(input, head);
+ if (!mime) {
+ throw createImageCropperError(
+ 'unsupported_type',
+ 'Choose a PNG, JPEG, WebP, or GIF image.',
+ );
+ }
+
+ return mime;
+};
+
+export const decodeImageCropperSource = async (
+ input: Blob,
+ options: DecodeImageCropperSourceOptions = {},
+): Promise => {
+ const mime = await validateImageCropperInput(input, options);
+ let bitmapDecodeError: unknown;
+
+ if (typeof createImageBitmap === 'function') {
+ try {
+ const bitmap = await createImageBitmap(input, {
+ imageOrientation: 'from-image',
+ } as ImageBitmapOptions);
+ try {
+ validateDecodedDimensions(bitmap, options);
+ } catch (error) {
+ bitmap.close();
+ throw error;
+ }
+ return {
+ image: bitmap,
+ width: bitmap.width,
+ height: bitmap.height,
+ mime,
+ dispose: () => bitmap.close(),
+ };
+ } catch (error) {
+ if (isImageCropperError(error)) {
+ throw error;
+ }
+ bitmapDecodeError = error;
+ }
+ }
+
+ try {
+ return await decodeImageWithElement(input, mime, options);
+ } catch (error) {
+ if (isImageCropperError(error)) {
+ throw error;
+ }
+ throw createImageCropperError(
+ 'decode_failed',
+ 'Image could not be decoded. Choose a different PNG, JPEG, WebP, or GIF.',
+ getDecodeFailureDetail(error, bitmapDecodeError),
+ );
+ }
+};
+
+export const getRotatedImageDimensions = (
+ dimensions: ImageCropperDimensions,
+ rotation: ImageCropperRotation,
+): ImageCropperDimensions =>
+ rotation === 90 || rotation === 270
+ ? { width: dimensions.height, height: dimensions.width }
+ : { width: dimensions.width, height: dimensions.height };
+
+export const getImageCropCoverZoom = (
+ dimensions: ImageCropperDimensions,
+ rotation: ImageCropperRotation = 0,
+ outputSize = DEFAULT_OUTPUT_SIZE,
+): number => {
+ const rotated = getRotatedImageDimensions(dimensions, rotation);
+ return Math.max(outputSize / rotated.width, outputSize / rotated.height);
+};
+
+export const createDefaultImageCropState = (
+ dimensions: ImageCropperDimensions,
+ options: CreateImageCropStateOptions = {},
+): ImageCropState => {
+ const outputSize = options.outputSize ?? DEFAULT_OUTPUT_SIZE;
+ const rotation = normalizeRotation(options.rotation ?? 0);
+ return {
+ offsetX: 0,
+ offsetY: 0,
+ zoom: getImageCropCoverZoom(dimensions, rotation, outputSize),
+ rotation,
+ };
+};
+
+export const normalizeImageCropState = (
+ state: ImageCropState,
+ dimensions: ImageCropperDimensions,
+ outputSize = DEFAULT_OUTPUT_SIZE,
+): ImageCropState => {
+ const rotation = normalizeRotation(state.rotation);
+ const coverZoom = getImageCropCoverZoom(dimensions, rotation, outputSize);
+ const zoom = Math.max(
+ Number.isFinite(state.zoom) ? state.zoom : coverZoom,
+ coverZoom,
+ );
+ const limits = getImageCropOffsetLimits(
+ dimensions,
+ rotation,
+ zoom,
+ outputSize,
+ );
+
+ return {
+ offsetX: clampFinite(state.offsetX, -limits.x, limits.x),
+ offsetY: clampFinite(state.offsetY, -limits.y, limits.y),
+ zoom,
+ rotation,
+ };
+};
+
+export const dragImageCropState = (
+ state: ImageCropState,
+ delta: ImageCropDragDelta,
+ dimensions: ImageCropperDimensions,
+ options: ImageCropViewportOptions = {},
+): ImageCropState => {
+ const outputSize = options.outputSize ?? DEFAULT_OUTPUT_SIZE;
+ const viewportSize = options.viewportSize ?? outputSize;
+ const scale = outputSize / viewportSize;
+ return normalizeImageCropState(
+ {
+ ...state,
+ offsetX: state.offsetX + delta.deltaX * scale,
+ offsetY: state.offsetY + delta.deltaY * scale,
+ },
+ dimensions,
+ outputSize,
+ );
+};
+
+export const zoomImageCropState = (
+ state: ImageCropState,
+ zoom: number,
+ dimensions: ImageCropperDimensions,
+ outputSize = DEFAULT_OUTPUT_SIZE,
+): ImageCropState =>
+ normalizeImageCropState(
+ {
+ ...state,
+ zoom,
+ },
+ dimensions,
+ outputSize,
+ );
+
+export const rotateImageCropState = (
+ state: ImageCropState,
+ direction: 'left' | 'right',
+ dimensions: ImageCropperDimensions,
+ outputSize = DEFAULT_OUTPUT_SIZE,
+): ImageCropState => {
+ const nextRotation = normalizeRotation(
+ state.rotation + (direction === 'left' ? -90 : 90),
+ );
+ return normalizeImageCropState(
+ {
+ ...state,
+ rotation: nextRotation,
+ },
+ dimensions,
+ outputSize,
+ );
+};
+
+export const renderImageCropToCanvas = (
+ canvas: HTMLCanvasElement,
+ source: ImageCropperSource,
+ state: ImageCropState,
+ options: ImageCropRenderOptions = {},
+): void => {
+ const logicalSize = options.size ?? DEFAULT_OUTPUT_SIZE;
+ const pixelRatio = options.pixelRatio ?? globalThis.devicePixelRatio ?? 1;
+ const backingSize = Math.max(1, Math.round(logicalSize * pixelRatio));
+
+ if (canvas.width !== backingSize) {
+ canvas.width = backingSize;
+ }
+ if (canvas.height !== backingSize) {
+ canvas.height = backingSize;
+ }
+
+ const context = canvas.getContext('2d');
+ if (!context) {
+ throw createImageCropperError(
+ 'encode_failed',
+ 'Canvas rendering is unavailable in this browser.',
+ );
+ }
+
+ const normalized = normalizeImageCropState(
+ state,
+ source,
+ DEFAULT_OUTPUT_SIZE,
+ );
+ const previewScale = logicalSize / DEFAULT_OUTPUT_SIZE;
+
+ context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
+ context.clearRect(0, 0, logicalSize, logicalSize);
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = options.smoothingQuality ?? 'high';
+
+ context.save();
+ context.translate(
+ logicalSize / 2 + normalized.offsetX * previewScale,
+ logicalSize / 2 + normalized.offsetY * previewScale,
+ );
+ context.rotate((normalized.rotation * Math.PI) / 180);
+ context.scale(normalized.zoom * previewScale, normalized.zoom * previewScale);
+ context.drawImage(
+ source.image,
+ -source.width / 2,
+ -source.height / 2,
+ source.width,
+ source.height,
+ );
+ context.restore();
+};
+
+export const encodeImageCrop = async (
+ source: ImageCropperSource,
+ state: ImageCropState,
+ options: ImageCropEncodeOptions = {},
+): Promise => {
+ const size = options.size ?? DEFAULT_OUTPUT_SIZE;
+ const maxBytes = options.maxBytes ?? MAX_ENCODED_BYTES;
+ const canvas = document.createElement('canvas');
+ renderImageCropToCanvas(canvas, source, state, {
+ size,
+ pixelRatio: 1,
+ smoothingQuality: 'high',
+ });
+
+ const webp = await canvasToBlob(
+ canvas,
+ 'image/webp',
+ options.webpQuality ?? 0.92,
+ );
+ if (webp && webp.type === 'image/webp' && webp.size <= maxBytes) {
+ return blobToEncodeResult(webp, 'image/webp');
+ }
+
+ const png = await canvasToBlob(
+ canvas,
+ 'image/png',
+ options.pngQuality ?? undefined,
+ );
+ if (png && png.size <= maxBytes) {
+ return blobToEncodeResult(png, 'image/png');
+ }
+
+ throw createImageCropperError(
+ 'encode_failed',
+ 'Cropped image must encode to 1 MB or smaller.',
+ );
+};
+
+const validateDecodedDimensions = (
+ dimensions: ImageCropperDimensions,
+ options: DecodeImageCropperSourceOptions,
+) => {
+ const maxDimension = options.maxDimension ?? MAX_SOURCE_DIMENSION;
+ const maxPixels = options.maxPixels ?? MAX_SOURCE_PIXELS;
+ if (
+ dimensions.width <= 0 ||
+ dimensions.height <= 0 ||
+ dimensions.width > maxDimension ||
+ dimensions.height > maxDimension ||
+ dimensions.width * dimensions.height > maxPixels
+ ) {
+ throw createImageCropperError(
+ 'image_too_large',
+ 'Image dimensions must be 8192px or smaller on each side and 40 megapixels or less.',
+ );
+ }
+};
+
+const decodeImageWithElement = async (
+ input: Blob,
+ mime: ImageCropperInputMime,
+ options: DecodeImageCropperSourceOptions,
+): Promise => {
+ const objectUrl = URL.createObjectURL(input);
+ const image = new Image();
+ image.decoding = 'async';
+
+ try {
+ await new Promise((resolve, reject) => {
+ image.onload = () => resolve();
+ image.onerror = () =>
+ reject(new Error('Browser image element rejected the selected image.'));
+ image.src = objectUrl;
+ });
+ if (typeof image.decode === 'function') {
+ await image.decode();
+ }
+
+ const width = image.naturalWidth || image.width;
+ const height = image.naturalHeight || image.height;
+ validateDecodedDimensions({ width, height }, options);
+
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext('2d');
+ if (!context) {
+ throw new Error('Canvas 2D context is unavailable.');
+ }
+ context.clearRect(0, 0, width, height);
+ context.drawImage(image, 0, 0, width, height);
+ URL.revokeObjectURL(objectUrl);
+
+ return {
+ image: canvas,
+ width,
+ height,
+ mime,
+ dispose: () => {
+ canvas.width = 0;
+ canvas.height = 0;
+ },
+ };
+ } catch (error) {
+ URL.revokeObjectURL(objectUrl);
+ throw error;
+ }
+};
+
+const getDecodeFailureDetail = (
+ fallbackError: unknown,
+ bitmapDecodeError: unknown,
+): string => {
+ const fallbackDetail =
+ fallbackError instanceof Error
+ ? fallbackError.message
+ : String(fallbackError);
+ if (!bitmapDecodeError) {
+ return fallbackDetail;
+ }
+
+ const bitmapDetail =
+ bitmapDecodeError instanceof Error
+ ? bitmapDecodeError.message
+ : String(bitmapDecodeError);
+ return `createImageBitmap failed: ${bitmapDetail}; HTMLImageElement fallback failed: ${fallbackDetail}`;
+};
+
+const isSvgInput = (input: Blob, head: Uint8Array): boolean => {
+ const type = input.type.toLowerCase();
+ const fileName =
+ typeof File !== 'undefined' && input instanceof File
+ ? input.name.trim().toLowerCase()
+ : '';
+ if (
+ type === 'image/svg+xml' ||
+ fileName.endsWith('.svg') ||
+ fileName.endsWith('.svgz')
+ ) {
+ return true;
+ }
+
+ const text = asciiHead(head)
+ .toLowerCase()
+ .replace(/^\uFEFF/, '')
+ .trimStart();
+ return (
+ text.startsWith('