diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..804bd4a
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+*.local
diff --git a/web/.stylelintrc.json b/web/.stylelintrc.json
new file mode 100644
index 0000000..80c0eaf
--- /dev/null
+++ b/web/.stylelintrc.json
@@ -0,0 +1,14 @@
+{
+ "extends": ["stylelint-config-standard"],
+ "ignoreFiles": ["src/styles/tokens.css"],
+ "rules": {
+ "color-no-hex": true,
+ "color-named": "never",
+ "function-disallowed-list": ["rgb", "rgba", "hsl", "hsla", "oklch", "color-mix"],
+ "selector-class-pattern": null,
+ "custom-property-pattern": null,
+ "declaration-property-value-allowed-list": {
+ "/^(border-radius|box-shadow|backdrop-filter)$/": ["/var[(]/", "none", "0", "inherit"]
+ }
+ }
+}
diff --git a/web/AGENTS.md b/web/AGENTS.md
new file mode 100644
index 0000000..6686932
--- /dev/null
+++ b/web/AGENTS.md
@@ -0,0 +1,106 @@
+# web/ 契约(给人和 AI 代理)
+
+本目录是 WebDrop 前端。动手前先读,冲突时以此文件与 `../docs/` 为准。
+
+## 权威顺序
+
+1. `../docs/threat-model.md`、`../docs/protocol.md`、`../docs/api.md`(协议与接口,不得违背)
+2. `../docs/ui-design.md`(视觉与交互)
+3. `../docs/frontend.md`(工程约定)
+4. 本文件
+5. 代码
+
+文档与代码不一致时,**以文档为准**。要改行为,先改文档再改代码。
+
+## 范围
+
+- 只修改 `web/` 与前端相关文档(`docs/ui-design.md`、`docs/frontend.md`)。
+- **不修改** `server/`、`docs/protocol.md`、`docs/api.md`、`docs/openapi.yaml`、`docs/threat-model.md`、`docs/architecture.md`。需要变更时,写明诉求交给负责人。
+- `@webdrop/core` 未就绪时,在 `src/core-bridge/` 按文档接口做适配与假实现,不自行发明协议。
+
+## 命令
+
+```
+npm run dev 开发
+npm run typecheck 类型检查
+npm run lint:css 样式检查(颜色、圆角、阴影、blur 必须走 token)
+npm test Vitest
+npm run build 生产构建
+```
+
+提交前 `typecheck`、`lint:css`、`test`、`build` 必须全部通过。
+
+## 必须遵守
+
+### 安全
+
+- 取出 URL fragment 后,在任何异步代码之前 `history.replaceState`。
+- 密钥 K 导入为不可导出的 `CryptoKey`;不把密钥、令牌、明文写入 `console`、URL、`localStorage`、错误信息。
+- 链接含 `k` 就只接受 enc 且认证通过的帧;模式与链接不符直接拒绝。
+- 同一 `(item_id, frame_index)` 只能加密一次;重试发送队列里已持久化的相同字节(protocol §5)。
+- 不使用 `dangerouslySetInnerHTML`;预览走沙箱 iframe;raw 内容只当文本。
+- 不引入第三方脚本、统计、CDN 字体。
+
+### 分层
+
+- 依赖单向:`app → features → ui`;`features → store → core-bridge`;`lib` 不依赖其他层。
+- `ui/` 不依赖业务;`features/` 之间不互相导入,**唯一例外**:`features/channel/`、`features/home/`、`features/share/` 是组合层,可以导入 `features/item/`、`features/scan/`(home、share 还可导入 channel 里的 ActionBar 与 ComposeSheet);组件不直接调用网络或加解密。
+- 不写 barrel(`index.ts` 汇总导出)。
+- 所有页面跳转经 `app/navigation.ts` 的 `navigate()`,不直接操作 `history`。页面一加载就在 `app/entry.ts` 里同步取走并清除 fragment。**唯一例外**:弹层的返回键关闭,在 `lib/useBackToClose.ts`。
+
+### 样式
+
+- 颜色字面量只允许出现在 `src/styles/tokens.css`。
+- 圆角、阴影、`backdrop-filter` 只能取 token。
+- `backdrop-filter` 仅用于顶栏和底部输入栏,同屏不超过 2 个;必须有 `prefers-reduced-transparency` 与不支持时的回退。
+- 阴影只用于浮层(菜单、弹窗、Toast);列表用 1px `--line` 分隔。
+- 不引入 Tailwind、CSS-in-JS、UI 组件库、图标库、动画库。
+
+### 状态与 React
+
+- 条目状态由日志推导,用判别联合,`switch` 必须穷尽。
+- 订阅最小派生值,不订阅整个 store;高频值(进度)不触发整列表重渲染。
+- 派生状态在渲染期计算,不用 effect 同步;不在组件内定义组件。
+- 条件渲染用三元,不用 `&&` 渲染可能为 `0` 的值。
+- 加解密、装配、哈希在 Worker,主线程不处理大文件。
+
+### 动效
+
+- `ViewTransition` 一律 `default="none"`,`enter` 与 `exit` 成对;只用于有空间关系的变化(层级导航、条目增删)。
+- 状态变化、重连、后台同步不加动画。
+- 动画 CSS 只放在 `styles/transitions.css`,取自技能配方;必须尊重 `prefers-reduced-motion`。
+- 方向性 `ViewTransition` 放在页面组件里,不放布局里。
+
+### 文案
+
+- 只做中文,文案直接写在组件里,不做 i18n。
+- 安全相关文案必须诚实(见 `ui-design.md §7.4`):`ui_restrict_copy_download` 不是安全保证;raw 模式必须明确提示服务端可见;不夸大「匿名」「不可追踪」。
+
+## 禁止
+
+- 自动加载有下载上限(GATED)的文件内容:缩略图、内联视频一律等用户点击,滚动列表不能消耗下载次数。
+
+- 为了「看起来现代」加装饰性渐变、霓虹、大圆角、pill 容器、眉标签、装饰性副标题。渐变只能用 `tokens.css` 里的渐变 token,且只表达状态或层次。
+- 弹层不用 portal 就渲染在带 `content-visibility` 的列表项内(`position: fixed` 会失效)。
+- 新增运行时依赖而没有书面理由(解决什么、为何不能自写、体积)。
+- 提前抽象。同样的代码第二次出现才抽,第三次必须抽。
+- 把颜色、间距、圆角写死在组件里。
+- 静默吞掉认证失败或篡改;`tampered` 必须可见。
+
+## 约定
+
+- 文件单一职责,组件约 150 行为上限。
+- 导出面最小化,不被使用的符号不导出。
+- 注释只写「为什么」,并指向文档章节,例如 `// protocol §5: 重试必须发相同字节`。
+- `store/` 与 `lib/` 的模块配 Vitest;用户可见流程配 Playwright。
+- 路径别名 `@/` 指向 `src/`。
+- 默认中文界面;提交信息沿用仓库风格。
+
+## 完成的定义
+
+一项改动完成,需要:
+
+1. 对应文档已更新(如行为有变)。
+2. `typecheck`、`lint:css`、`test`、`build` 全部通过。
+3. 亮暗两套、手机与桌面宽度都看过。
+4. 没有违反上面的「必须遵守」与「禁止」。
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..65a95bf
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+ WebDrop
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..2a723b9
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,3178 @@
+{
+ "name": "@webdrop/web",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@webdrop/web",
+ "version": "0.0.0",
+ "dependencies": {
+ "idb": "^8.0.3",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "zustand": "^5.0.15"
+ },
+ "devDependencies": {
+ "@types/node": "^26.6.3",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.1",
+ "stylelint": "^17.16.0",
+ "stylelint-config-standard": "^40.0.0",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.2",
+ "vitest": "^5.0.3"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@cacheable/memory": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
+ "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/utils": "^2.5.0",
+ "@keyv/bigmap": "^1.3.1",
+ "hookified": "^1.15.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@cacheable/utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
+ "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.5.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.2.tgz",
+ "integrity": "sha512-tL6LRF844sHjAKO7jt2w+uHo8Tocqpb9+KPUvIQETpR4RU87IaUzDuOV0QyzuQ37jmzFgwPHLLtLu/PDuH5qgg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
+ "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz",
+ "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
+ "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/media-query-list-parser": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.2.tgz",
+ "integrity": "sha512-qx19O8AbbLLbDSAcI6YOby/Vq8rV51psgIvlF5OZT/L8fQJDCyQifL6k+kYU4a8ySpzJ4RBzZkIs530bAn8y4Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/selector-resolve-nested": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.2.tgz",
+ "integrity": "sha512-JpjNBJFA+4OnAHKRK4AMp8xrYyLhI9XapOeBlP2PhRU/IylSw+1ep3BFNTksBJnrTS3KT6kqKNVfpzLLwZUW5Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "postcss-selector-parser": "^7.1.1"
+ }
+ },
+ "node_modules/@csstools/selector-specificity": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
+ "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "postcss-selector-parser": "^7.1.1"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@keyv/bigmap": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
+ "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.4.0",
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.152.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
+ "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
+ "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
+ "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
+ "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
+ "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
+ "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
+ "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
+ "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
+ "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
+ "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
+ "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
+ "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
+ "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
+ "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sindresorhus/merge-streams": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+ "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz",
+ "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
+ "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz",
+ "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.3",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz",
+ "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
+ "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/astral-regex": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cacheable": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
+ "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/memory": "^2.2.0",
+ "@cacheable/utils": "^2.5.0",
+ "hookified": "^1.15.0",
+ "keyv": "^5.6.0",
+ "qified": "^0.10.1"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/colord": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz",
+ "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cosmiconfig": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz",
+ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.1",
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/css-functions-list": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz",
+ "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fastest-levenshtein": {
+ "version": "1.0.16",
+ "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
+ "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.9.1"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
+ "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "11.1.5",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz",
+ "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^6.1.23"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "6.1.23",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
+ "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cacheable": "^2.5.0",
+ "flatted": "^3.4.2",
+ "hookified": "^1.15.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
+ "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/global-modules": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
+ "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "global-prefix": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/global-prefix": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz",
+ "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ini": "^1.3.5",
+ "kind-of": "^6.0.2",
+ "which": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/globby": {
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz",
+ "integrity": "sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/merge-streams": "^4.0.0",
+ "fast-glob": "^3.3.3",
+ "ignore": "^7.0.5",
+ "is-path-inside": "^4.0.0",
+ "micromatch": "^4.0.8",
+ "slash": "^5.1.0",
+ "unicorn-magic": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globjoin": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz",
+ "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-flag": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz",
+ "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/hashery": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
+ "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/hookified": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+ "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/html-tags": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz",
+ "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/idb": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
+ "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
+ "license": "ISC"
+ },
+ "node_modules/ignore": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
+ "integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/import-meta-resolve": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
+ "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
+ "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "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"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.truncate": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+ "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
+ "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/mathml-tag-names": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz",
+ "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/meow": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz",
+ "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-safe-parser": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz",
+ "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.31"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "7.1.6",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz",
+ "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/qified": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
+ "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/qified/node_modules/hookified": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
+ "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
+ "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.152.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.12",
+ "@rolldown/binding-android-arm64": "1.2.12",
+ "@rolldown/binding-darwin-arm64": "1.2.12",
+ "@rolldown/binding-darwin-x64": "1.2.12",
+ "@rolldown/binding-freebsd-x64": "1.2.12",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.12",
+ "@rolldown/binding-linux-arm64-musl": "1.2.12",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.12",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-musl": "1.2.12",
+ "@rolldown/binding-openharmony-arm64": "1.2.12",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.12",
+ "@rolldown/binding-win32-x64-msvc": "1.2.12"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/slash": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
+ "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/slice-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+ "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
+ "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/stylelint": {
+ "version": "17.16.0",
+ "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.16.0.tgz",
+ "integrity": "sha512-ejlWnbJ13x1GU32+zZXa6sYQJsKKkwSdv2B4rAdO3OWdEmvhSJKcXi5pOphQYSzyaj1kXpuGJr7pcMBNfDIuPw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/stylelint"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/stylelint"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.3.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.9",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "@csstools/media-query-list-parser": "^5.0.0",
+ "@csstools/selector-resolve-nested": "^4.0.1",
+ "@csstools/selector-specificity": "^6.0.0",
+ "colord": "^2.10.0",
+ "cosmiconfig": "^9.0.2",
+ "css-functions-list": "^3.3.3",
+ "css-tree": "^3.2.1",
+ "debug": "^4.4.3",
+ "fast-glob": "^3.3.3",
+ "fastest-levenshtein": "^1.0.16",
+ "file-entry-cache": "^11.1.5",
+ "global-modules": "^2.0.0",
+ "globby": "^16.2.4",
+ "globjoin": "^0.1.4",
+ "html-tags": "^5.1.0",
+ "ignore": "^7.0.6",
+ "import-meta-resolve": "^4.2.0",
+ "mathml-tag-names": "^4.0.0",
+ "meow": "^14.1.0",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.5.26",
+ "postcss-safe-parser": "^7.0.1",
+ "postcss-selector-parser": "^7.1.5",
+ "postcss-value-parser": "^4.2.0",
+ "string-width": "^8.2.2",
+ "supports-hyperlinks": "^4.5.0",
+ "svg-tags": "^1.0.0",
+ "table": "^6.9.0",
+ "write-file-atomic": "^7.0.1"
+ },
+ "bin": {
+ "stylelint": "bin/stylelint.mjs"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/stylelint-config-recommended": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz",
+ "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/stylelint"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/stylelint"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "stylelint": "^17.0.0"
+ }
+ },
+ "node_modules/stylelint-config-standard": {
+ "version": "40.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-40.0.0.tgz",
+ "integrity": "sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/stylelint"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/stylelint"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "stylelint-config-recommended": "^18.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "stylelint": "^17.0.0"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/supports-hyperlinks": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.6.0.tgz",
+ "integrity": "sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^5.0.1",
+ "supports-color": "^10.2.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
+ }
+ },
+ "node_modules/svg-tags": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz",
+ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==",
+ "dev": true
+ },
+ "node_modules/table": {
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz",
+ "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "ajv": "^8.0.1",
+ "lodash.truncate": "^4.4.2",
+ "slice-ansi": "^4.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/table/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/table/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/table/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
+ "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unicorn-magic": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz",
+ "integrity": "sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
+ "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.11",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "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
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz",
+ "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.3",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "^6.1.4",
+ "tinyexec": "^1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.3",
+ "@vitest/browser-preview": "5.0.3",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.3",
+ "@vitest/coverage-v8": "5.0.3",
+ "@vitest/ui": "5.0.3",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.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
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+ "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ },
+ "node_modules/write-file-atomic": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz",
+ "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "5.0.15",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
+ "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..790740b
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@webdrop/web",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc -b --noEmit",
+ "lint:css": "stylelint \"src/**/*.css\"",
+ "test": "vitest run --passWithNoTests"
+ },
+ "dependencies": {
+ "idb": "^8.0.3",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "zustand": "^5.0.15"
+ },
+ "devDependencies": {
+ "@types/node": "^26.6.3",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.1",
+ "stylelint": "^17.16.0",
+ "stylelint-config-standard": "^40.0.0",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.2",
+ "vitest": "^5.0.3"
+ }
+}
diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx
new file mode 100644
index 0000000..fa56a4a
--- /dev/null
+++ b/web/src/app/App.tsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import { Home } from '@/features/home/Home';
+import { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen';
+import { useChannels } from '@/store/channels';
+import { useWorkspace } from '@/store/workspace';
+import { ChannelScreen } from './ChannelScreen';
+import { ShareScreen } from './ShareScreen';
+import { channelPath, navigate, useRoute } from './navigation';
+import { Splash } from './Splash';
+
+const SEEN_KEY = 'wd.splash';
+
+function splashSeen(): boolean {
+ try {
+ return sessionStorage.getItem(SEEN_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+
+export function App() {
+ const route = useRoute();
+ const list = useChannels((s) => s.list);
+ const pendingLink = useChannels((s) => s.pendingLink);
+ const setPendingLink = useChannels((s) => s.setPendingLink);
+ const wsReady = useWorkspace((s) => s.ready);
+ const [splash, setSplash] = useState(() => !splashSeen());
+
+ const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
+ // 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程
+ const ready = !saved || wsReady;
+
+ function onSplashDone() {
+ try {
+ sessionStorage.setItem(SEEN_KEY, '1');
+ } catch {
+ /* 无存储时每次都播,不影响功能 */
+ }
+ setSplash(false);
+ }
+
+ let screen;
+ if (route.name === 'home') screen = ;
+ else if (route.name === 'share') screen = ;
+ else if (saved) screen = ;
+ else if (pendingLink?.id === route.id) {
+ screen = (
+ navigate(channelPath(c.id))}
+ onCancel={() => {
+ setPendingLink(null);
+ navigate('/');
+ }}
+ />
+ );
+ } else screen = navigate('/')} />;
+
+ return (
+ <>
+ {screen}
+ {splash ? : null}
+ >
+ );
+}
diff --git a/web/src/app/ChannelScreen.tsx b/web/src/app/ChannelScreen.tsx
new file mode 100644
index 0000000..2fcd195
--- /dev/null
+++ b/web/src/app/ChannelScreen.tsx
@@ -0,0 +1,13 @@
+import { useEffect } from 'react';
+import { Workspace } from '@/features/channel/Workspace';
+import { useWorkspace } from '@/store/workspace';
+
+/** 打开(订阅)频道,数据就绪后显示暂存区 */
+export function ChannelScreen({ id }: { id: string }) {
+ const open = useWorkspace((s) => s.open);
+ const ready = useWorkspace((s) => s.ready);
+
+ useEffect(() => open(id), [open, id]);
+
+ return ready ? : null;
+}
diff --git a/web/src/app/ShareScreen.tsx b/web/src/app/ShareScreen.tsx
new file mode 100644
index 0000000..61d67c2
--- /dev/null
+++ b/web/src/app/ShareScreen.tsx
@@ -0,0 +1,41 @@
+import { useEffect } from 'react';
+import { ItemSourceContext, shareSource } from '@/features/item/source';
+import { MissingAccess } from '@/features/scan/JoinScreen';
+import { ShareDraft } from '@/features/share/ShareDraft';
+import { ShareView } from '@/features/share/ShareView';
+import { useShares } from '@/store/shares';
+import { useShareSession } from '@/store/shareSession';
+import { navigate } from './navigation';
+
+/**
+ * 便捷分享页。已在「我的分享」里就直接打开;从链接进来的访客**直接加入**,不预览、不展示人数;
+ * 都不是就提示缺少链接。条目组件通过 ItemSourceContext 读分享自己的数据。
+ */
+export function ShareScreen({ id }: { id: string }) {
+ const known = useShares((s) => s.list.some((x) => x.id === id));
+ const pending = useShares((s) => s.pendingLink);
+ const join = useShares((s) => s.join);
+
+ useEffect(() => {
+ if (!known && pending?.id === id) void join(pending);
+ }, [known, pending, id, join]);
+
+ if (known) return ;
+ if (pending?.id === id) return null; // 正在加入
+ return navigate('/')} />;
+}
+
+function ShareSession({ id }: { id: string }) {
+ const open = useShareSession((s) => s.open);
+ const ready = useShareSession((s) => s.ready);
+ const share = useShareSession((s) => s.share);
+
+ useEffect(() => open(id), [open, id]);
+
+ if (!ready || !share) return null;
+ return (
+
+ {share.state === 'draft' && share.mine ? : }
+
+ );
+}
diff --git a/web/src/app/Splash.module.css b/web/src/app/Splash.module.css
new file mode 100644
index 0000000..dd4b81c
--- /dev/null
+++ b/web/src/app/Splash.module.css
@@ -0,0 +1,76 @@
+.splash {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-4);
+ background: var(--bg);
+ cursor: pointer;
+}
+
+.leaving {
+ animation: leave 0.2s ease-out forwards;
+ pointer-events: none;
+}
+
+.word {
+ width: min(62vw, 300px);
+ fill: none;
+ stroke: var(--text);
+ stroke-width: 3.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.stroke {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: draw 0.3s ease-out forwards;
+ animation-delay: calc(var(--i) * 0.13s);
+}
+
+.hint {
+ margin: 0;
+ color: var(--text-dim);
+ font-size: 13px;
+ opacity: 0;
+ animation: fade-in 0.3s ease-out 0.6s forwards;
+}
+
+@keyframes draw {
+ to {
+ stroke-dashoffset: 0;
+ }
+}
+
+@keyframes fade-in {
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes leave {
+ to {
+ opacity: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .stroke {
+ stroke-dashoffset: 0;
+ animation: none;
+ }
+
+ .hint {
+ opacity: 1;
+ animation: none;
+ }
+
+ .leaving {
+ animation: none;
+ opacity: 0;
+ }
+}
diff --git a/web/src/app/Splash.tsx b/web/src/app/Splash.tsx
new file mode 100644
index 0000000..9fd2a07
--- /dev/null
+++ b/web/src/app/Splash.tsx
@@ -0,0 +1,61 @@
+import { useEffect, useState, type AnimationEvent } from 'react';
+import styles from './Splash.module.css';
+
+/** 单线手写 WebDrop,逐笔画出。每个字母一条路径,按序号错开 */
+const STROKES = [
+ 'M4 14 L14 58 L24 30 L34 58 L44 14',
+ 'M52 42 H76 C76 28 66 26 60 31 C50 38 53 58 63 58 C69 59 74 56 77 52',
+ 'M88 8 L88 58 M88 46 C92 34 106 36 106 47 C106 59 92 62 88 52',
+ 'M120 14 L120 58 M120 14 C156 12 160 58 120 58',
+ 'M172 30 L172 58 M172 42 C176 31 186 29 192 33',
+ 'M204 46 C204 33 222 33 222 46 C222 59 204 59 204 46',
+ 'M236 32 L236 74 M236 46 C240 34 256 36 256 48 C256 60 242 64 236 54',
+];
+
+const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+interface Props {
+ ready: boolean;
+ onDone: () => void;
+}
+
+export function Splash({ ready, onDone }: Props) {
+ const [animDone, setAnimDone] = useState(reduced);
+ const [skipped, setSkipped] = useState(false);
+ const leaving = skipped || (animDone && ready);
+
+ useEffect(() => {
+ const skip = () => setSkipped(true);
+ window.addEventListener('keydown', skip);
+ return () => window.removeEventListener('keydown', skip);
+ }, []);
+
+ function onEnd(e: AnimationEvent) {
+ if (e.animationName.includes('draw') && e.target instanceof SVGElement && e.target.dataset.last) setAnimDone(true);
+ if (e.animationName.includes('leave') && e.target === e.currentTarget) onDone();
+ }
+
+ return (
+ setSkipped(true)}
+ onAnimationEnd={onEnd}
+ role="status"
+ aria-label="加载中"
+ >
+
+
加载中
+
+ );
+}
diff --git a/web/src/app/entry.ts b/web/src/app/entry.ts
new file mode 100644
index 0000000..4b188dc
--- /dev/null
+++ b/web/src/app/entry.ts
@@ -0,0 +1,21 @@
+import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink';
+import { parseShareLink, type ParsedShareLink } from '@/lib/shareLink';
+
+export type EntryLink =
+ | { kind: 'channel'; link: ParsedChannelLink }
+ | { kind: 'share'; link: ParsedShareLink };
+
+/**
+ * 页面一加载就取出 URL fragment 里的密钥,并立刻 history.replaceState 清掉(threat-model §4)。
+ * 必须在任何异步代码和渲染之前同步调用。
+ */
+export function takeLinkFromLocation(): EntryLink | null {
+ if (!location.hash) return null;
+ const href = location.href;
+ history.replaceState(null, '', location.pathname + location.search);
+
+ const share = parseShareLink(href);
+ if (share.ok) return { kind: 'share', link: share.link };
+ const channel = parseChannelLink(href);
+ return channel.ok ? { kind: 'channel', link: channel.link } : null;
+}
diff --git a/web/src/app/navigation.test.ts b/web/src/app/navigation.test.ts
new file mode 100644
index 0000000..71f1d7d
--- /dev/null
+++ b/web/src/app/navigation.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+
+// navigation.ts 在模块加载时读 location/window,这里只测纯函数,用最小的桩
+Object.assign(globalThis, {
+ location: { pathname: '/', origin: 'http://x' },
+ window: { addEventListener: () => {} },
+ history: { state: null },
+});
+
+const { parseRoute } = await import('./navigation');
+
+describe('parseRoute', () => {
+ it('根路径是首页', () => expect(parseRoute('/')).toEqual({ name: 'home' }));
+ it('/c/:id 是频道', () => {
+ expect(parseRoute('/c/k3Fq9xWm2LpA7sTdQ8vZ1n')).toEqual({ name: 'channel', id: 'k3Fq9xWm2LpA7sTdQ8vZ1n' });
+ });
+ it('/s/:id 是便捷分享', () => {
+ expect(parseRoute('/s/k3Fq9xWm2LpA7sTdQ8vZ1n')).toEqual({ name: 'share', id: 'k3Fq9xWm2LpA7sTdQ8vZ1n' });
+ });
+ it('id 格式不对回到首页', () => expect(parseRoute('/c/short')).toEqual({ name: 'home' }));
+ it('未知路径回到首页', () => expect(parseRoute('/other')).toEqual({ name: 'home' }));
+});
diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts
new file mode 100644
index 0000000..028a9f4
--- /dev/null
+++ b/web/src/app/navigation.ts
@@ -0,0 +1,50 @@
+import { useSyncExternalStore } from 'react';
+import { isSheetEntry } from '@/lib/useBackToClose';
+
+export type Route = { name: 'home' } | { name: 'channel'; id: string } | { name: 'share'; id: string };
+
+const CHANNEL_RE = /^\/c\/([A-Za-z0-9_-]{22})\/?$/;
+const SHARE_RE = /^\/s\/([A-Za-z0-9_-]{22})\/?$/;
+
+export function parseRoute(pathname: string): Route {
+ const c = CHANNEL_RE.exec(pathname);
+ if (c?.[1]) return { name: 'channel', id: c[1] };
+ const s = SHARE_RE.exec(pathname);
+ return s?.[1] ? { name: 'share', id: s[1] } : { name: 'home' };
+}
+
+let current: Route = parseRoute(location.pathname);
+const listeners = new Set<() => void>();
+
+const same = (a: Route, b: Route) => a.name === b.name && (a.name === 'home' || ('id' in b && a.id === b.id));
+
+function set(route: Route) {
+ if (same(current, route)) return;
+ current = route;
+ for (const l of listeners) l();
+}
+
+window.addEventListener('popstate', () => set(parseRoute(location.pathname)));
+
+/**
+ * 所有页面跳转的唯一入口。
+ * 从弹层里跳转时替换掉弹层压入的那条历史,避免返回键多退一步(lib/useBackToClose.ts)。
+ */
+export function navigate(path: string): void {
+ if (isSheetEntry()) history.replaceState(null, '', path);
+ else history.pushState(null, '', path);
+ set(parseRoute(path));
+}
+
+export function useRoute(): Route {
+ return useSyncExternalStore(
+ (cb) => {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ () => current,
+ );
+}
+
+export const channelPath = (id: string) => `/c/${id}`;
+export const sharePath = (id: string) => `/s/${id}`;
diff --git a/web/src/core-bridge/api.ts b/web/src/core-bridge/api.ts
new file mode 100644
index 0000000..4d8e993
--- /dev/null
+++ b/web/src/core-bridge/api.ts
@@ -0,0 +1,32 @@
+import type { ParsedChannelLink } from '@/lib/channelLink';
+import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, WorkspaceEvent } from './types';
+
+/**
+ * 常驻频道的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。
+ * 便捷分享有自己的边界,见 shareApi.ts。
+ */
+export interface WorkspaceApi {
+ /** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
+ createChannel(opts: CreateOptions): Promise;
+ /** 通过链接加入:先 GET 频道信息预览,再 POST seats */
+ joinChannel(link: ParsedChannelLink): Promise;
+ /** 仅假实现提供:载入演示用的频道 */
+ loadDemo?(): ChannelInfo[];
+
+ /** 订阅某个频道的事件流(对应追加日志的推送),返回取消订阅函数 */
+ connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
+ sendText(text: string): Promise;
+ sendFiles(files: File[]): Promise;
+ /** 修改 = 追加一个替代旧条目的新版本,后写者胜 */
+ editText(id: string, text: string): Promise;
+ resolveConflict(id: string, choice: 'mine' | 'theirs'): Promise;
+ remove(id: string): Promise;
+ setSnapshot(text: string): Promise;
+ /** 取文件内容(GATED 条目会计一次下载,见 api.md);带 versionId 取旧版本 */
+ getBlob(id: string, versionId?: string): Promise;
+ listArchive(id: string): Promise;
+ /** 标记「别的设备的修改」已看过(本地状态) */
+ markSeen(id: string): Promise;
+ /** 仅假实现提供,用于演示连接状态 */
+ simulateConnection?(state: ConnectionState): void;
+}
diff --git a/web/src/core-bridge/instance.ts b/web/src/core-bridge/instance.ts
new file mode 100644
index 0000000..450da9e
--- /dev/null
+++ b/web/src/core-bridge/instance.ts
@@ -0,0 +1,8 @@
+import type { WorkspaceApi } from './api';
+import { createMockApi } from './mock';
+import { createMockShareApi } from './mockShare';
+import type { ShareApi } from './shareApi';
+
+/** 接真实后端时,只改这两行 */
+export const api: WorkspaceApi = createMockApi();
+export const shareApi: ShareApi = createMockShareApi();
diff --git a/web/src/core-bridge/mock.ts b/web/src/core-bridge/mock.ts
new file mode 100644
index 0000000..c6a7f2d
--- /dev/null
+++ b/web/src/core-bridge/mock.ts
@@ -0,0 +1,209 @@
+import type { ParsedChannelLink } from '@/lib/channelLink';
+import { previewKindOf } from '@/lib/fileKind';
+import type { WorkspaceApi } from './api';
+import { b64url } from './mockLinks';
+import { PHONE, SELF, archiveEntries, channel as demoChannel, devices as demoDevices, makeSvgBlob, makeVideoBlob, seedItems, snapshot as demoSnapshot } from './mockSeed';
+import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, Item, Snapshot, WorkspaceEvent } from './types';
+
+const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+/** protocol §2:链接形态。enc 带 k,raw 不带 */
+function buildLinks(id: string, o: CreateOptions): Pick {
+ const base = `${location.origin}/c/${id}`;
+ const a = b64url(32);
+ if (o.mode === 'enc') {
+ const k = b64url(32);
+ return { memberLink: `${base}#k=${k}`, ownerLink: `${base}#k=${k}&a=${a}` };
+ }
+ const w = o.membersCanUpload ? b64url(32) : null;
+ return { memberLink: w ? `${base}#w=${w}` : base, ownerLink: `${base}#a=${a}${w ? `&w=${w}` : ''}` };
+}
+
+interface Runtime {
+ info: ChannelInfo;
+ items: Map;
+ blobs: Map>;
+ devices: Device[];
+ snapshot: Snapshot;
+ demo: boolean;
+}
+
+const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', online: true, self: true };
+
+/** 频道的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */
+export function createMockApi(): WorkspaceApi {
+ const runtimes = new Map();
+ const timers = new Set();
+ let current: Runtime | undefined;
+ let emit: (e: WorkspaceEvent) => void = () => {};
+
+ const later = (ms: number, fn: () => void) => {
+ const id = window.setTimeout(() => {
+ timers.delete(id);
+ fn();
+ }, ms);
+ timers.add(id);
+ };
+ const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`;
+ const put = (item: Item) => {
+ current?.items.set(item.id, item);
+ emit({ t: 'item', item });
+ };
+
+ function newRuntime(info: ChannelInfo, devices: Device[]): Runtime {
+ const rt: Runtime = { info, items: new Map(), blobs: new Map(), devices, snapshot: { text: '', updatedBy: '', updatedAt: 0 }, demo: false };
+ runtimes.set(info.id, rt);
+ return rt;
+ }
+
+ function seedDemo(): Runtime {
+ const rt = newRuntime(demoChannel, demoDevices);
+ rt.demo = true;
+ rt.snapshot = demoSnapshot;
+ for (const item of seedItems().items) rt.items.set(item.id, item);
+ rt.blobs.set('it-image', makeSvgBlob());
+ rt.blobs.set('it-video', makeVideoBlob().then((b) => b ?? Promise.reject(new Error('unsupported'))));
+ rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' }));
+ rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' }));
+ return rt;
+ }
+
+ /** 演示频道里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */
+ function runDemoActivity(rt: Runtime) {
+ later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' }));
+ later(14000, () => {
+ const cur = rt.items.get('it-image');
+ if (cur?.kind !== 'file') return;
+ const prev = { id: `v${(cur.versions?.length ?? 0) + 1}`, ts: cur.ts, deviceId: cur.deviceId, size: cur.size };
+ put({ ...cur, ts: Date.now(), deviceId: PHONE, size: cur.size + 140, versions: [prev, ...(cur.versions ?? [])], unseen: true });
+ });
+ later(5000, () => emit({ t: 'devices', devices: rt.devices.map((d) => (d.id === PHONE ? { ...d, editing: true } : d)) }));
+ later(11000, () => emit({ t: 'devices', devices: rt.devices }));
+ }
+
+ return {
+ async createChannel(opts) {
+ await wait(300);
+ const id = b64url(16);
+ const info: ChannelInfo = { id, mode: opts.mode, role: 'owner', seats: opts.seats, createdAt: Date.now(), ...buildLinks(id, opts) };
+ newRuntime(info, [selfDevice]);
+ return info;
+ },
+
+ async joinChannel(link: ParsedChannelLink) {
+ await wait(500);
+ const known = runtimes.get(link.id);
+ if (known) return { ...known.info, role: 'member', ownerLink: undefined };
+ const base = `${location.origin}/c/${link.id}`;
+ const frag = (keys: Record) =>
+ Object.entries(keys).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join('&');
+ const member = frag({ k: link.keys.k, w: link.keys.w });
+ const info: ChannelInfo = {
+ id: link.id,
+ mode: link.mode,
+ role: link.isOwner ? 'owner' : 'member',
+ seats: 3,
+ createdAt: Date.now(),
+ memberLink: member ? `${base}#${member}` : base,
+ ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined,
+ };
+ newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', online: true }]);
+ return info;
+ },
+
+ loadDemo() {
+ return [(runtimes.get(demoChannel.id) ?? seedDemo()).info];
+ },
+
+ connect(channelId, onEvent) {
+ emit = onEvent;
+ current = runtimes.get(channelId);
+ const rt = current;
+ if (!rt) return () => {};
+ later(500, () => {
+ emit({ t: 'channel', info: rt.info });
+ emit({ t: 'devices', devices: rt.devices });
+ emit({ t: 'snapshot', snapshot: rt.snapshot });
+ for (const item of rt.items.values()) emit({ t: 'item', item });
+ emit({ t: 'connection', state: 'online' });
+ emit({ t: 'ready' });
+ });
+ if (rt.demo) runDemoActivity(rt);
+ return () => {
+ for (const id of timers) clearTimeout(id);
+ timers.clear();
+ emit = () => {};
+ };
+ },
+
+ async sendText(text) {
+ put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text });
+ },
+
+ async sendFiles(files) {
+ for (const file of files) {
+ const id = uid();
+ const base: FileItem = {
+ id, kind: 'file', ts: Date.now(), deviceId: SELF, state: { kind: 'receiving', progress: 0 },
+ name: file.name, mime: file.type, size: file.size, remaining: 10,
+ preview: previewKindOf(file.type, file.name),
+ };
+ current?.blobs.set(id, file);
+ put(base);
+ for (let p = 0.2; p < 1; p += 0.2) {
+ await wait(250);
+ put({ ...base, state: { kind: 'receiving', progress: p } });
+ }
+ await wait(250);
+ put({ ...base, state: { kind: 'complete' } });
+ }
+ },
+
+ async editText(id, text) {
+ const cur = current?.items.get(id);
+ if (cur?.kind !== 'text') return;
+ put({ ...cur, text, editedAt: Date.now() });
+ },
+
+ async resolveConflict(id, choice) {
+ const cur = current?.items.get(id);
+ if (cur?.kind !== 'text' || !cur.conflict) return;
+ put({ ...cur, text: choice === 'theirs' ? cur.conflict.text : cur.text, conflict: undefined });
+ },
+
+ async remove(id) {
+ current?.items.delete(id);
+ current?.blobs.delete(id);
+ emit({ t: 'removed', id });
+ },
+
+ async setSnapshot(text) {
+ const snapshot = { text, updatedBy: SELF, updatedAt: Date.now() };
+ if (current) current.snapshot = snapshot;
+ emit({ t: 'snapshot', snapshot });
+ },
+
+ async getBlob(id) {
+ const b = current?.blobs.get(id);
+ if (b) return b;
+ const item = current?.items.get(id);
+ if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' });
+ throw new Error('not found');
+ },
+
+ async markSeen(id) {
+ const cur = current?.items.get(id);
+ if (cur?.kind !== 'file' || !cur.unseen) return;
+ put({ ...cur, unseen: false });
+ },
+
+ async listArchive() {
+ await wait(250);
+ return archiveEntries;
+ },
+
+ simulateConnection(state: ConnectionState) {
+ emit({ t: 'connection', state });
+ },
+ };
+}
diff --git a/web/src/core-bridge/mockLinks.ts b/web/src/core-bridge/mockLinks.ts
new file mode 100644
index 0000000..72505af
--- /dev/null
+++ b/web/src/core-bridge/mockLinks.ts
@@ -0,0 +1,5 @@
+/** 假实现用的随机 id 与密钥:n 字节 → base64url 无填充(16 字节 22 字符,32 字节 43 字符) */
+export function b64url(bytes: number): string {
+ const b = crypto.getRandomValues(new Uint8Array(bytes));
+ return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
diff --git a/web/src/core-bridge/mockSeed.ts b/web/src/core-bridge/mockSeed.ts
new file mode 100644
index 0000000..874d3ce
--- /dev/null
+++ b/web/src/core-bridge/mockSeed.ts
@@ -0,0 +1,145 @@
+import type { ArchiveEntry, ChannelInfo, Device, Item, Snapshot } from './types';
+
+/** 仅供假实现使用的演示数据与演示用文件内容 */
+
+export const SELF = 'self';
+export const PHONE = 'phone';
+export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
+
+const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
+
+export const devices: Device[] = [
+ { id: SELF, name: '本机 · Chrome', kind: 'desktop', online: true, self: true },
+ { id: PHONE, name: 'iPhone', kind: 'phone', online: true },
+ { id: 'tablet', name: 'iPad', kind: 'tablet', online: false, lastSeen: Date.now() - 3 * 3600_000 },
+];
+
+export const channel: ChannelInfo = {
+ id: DEMO_ID,
+ mode: 'enc',
+ role: 'owner',
+ seats: 3,
+ createdAt: Date.now() - 3 * 3600_000,
+ memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`,
+ ownerLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}&a=${KEY}`,
+};
+
+export const snapshot: Snapshot = {
+ text: '待办\n- 取快递\n- 发周报\n- 备份相册',
+ updatedBy: PHONE,
+ updatedAt: Date.now() - 20 * 60_000,
+};
+
+export const archiveEntries: ArchiveEntry[] = [
+ { path: 'project-assets/', size: 0, dir: true },
+ { path: 'project-assets/README.md', size: 2_140, dir: false },
+ { path: 'project-assets/design/', size: 0, dir: true },
+ { path: 'project-assets/design/logo.svg', size: 8_420, dir: false },
+ { path: 'project-assets/design/cover.png', size: 3_281_990, dir: false },
+ { path: 'project-assets/design/mockup.fig', size: 18_204_113, dir: false },
+ { path: 'project-assets/fonts/', size: 0, dir: true },
+ { path: 'project-assets/fonts/SourceHanSans-Regular.otf', size: 16_532_204, dir: false },
+ { path: 'project-assets/data/', size: 0, dir: true },
+ { path: 'project-assets/data/export.csv', size: 412_873, dir: false },
+ { path: 'project-assets/notes.txt', size: 1_822, dir: false },
+];
+
+const ago = (min: number) => Date.now() - min * 60_000;
+
+export function seedItems(): { items: Item[]; sizeOverride: Record } {
+ const logText = Array.from({ length: 180 }, (_, i) => {
+ const level = i % 17 === 0 ? 'WARN' : 'INFO';
+ return `2026-10-01 14:${String(i % 60).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')} ${level} worker-${i % 4} handled request #${1000 + i} in ${20 + ((i * 13) % 80)}ms`;
+ }).join('\n');
+
+ const items: Item[] = [
+ {
+ id: 'it-video', kind: 'file', ts: ago(3), deviceId: PHONE, state: { kind: 'complete' },
+ name: '屏幕录制.webm', mime: 'video/webm', size: 1_840_000, remaining: null, preview: 'video',
+ },
+ {
+ id: 'it-zip', kind: 'file', ts: ago(7), deviceId: PHONE, state: { kind: 'complete' },
+ name: 'project-assets.zip', mime: 'application/zip', size: 48_200_000, remaining: 8, preview: 'archive',
+ },
+ {
+ id: 'it-image', kind: 'file', ts: ago(12), deviceId: PHONE, state: { kind: 'complete' },
+ name: '设计稿.svg', mime: 'image/svg+xml', size: 6_420, remaining: null, preview: 'image',
+ versions: [
+ { id: 'v2', ts: ago(55), deviceId: SELF, size: 6_110 },
+ { id: 'v1', ts: ago(130), deviceId: SELF, size: 5_820 },
+ ],
+ unseen: true,
+ },
+ {
+ id: 'it-log', kind: 'text', ts: ago(18), deviceId: SELF, state: { kind: 'complete' },
+ text: logText,
+ },
+ {
+ id: 'it-conflict', kind: 'text', ts: ago(26), deviceId: SELF, state: { kind: 'complete' },
+ text: '会议室 B204', editedAt: ago(25), conflict: { deviceId: PHONE, text: '会议室 B305' },
+ },
+ {
+ id: 'it-json', kind: 'text', ts: ago(30), deviceId: PHONE, state: { kind: 'complete' },
+ text: '{"name":"webdrop","version":1,"tags":["share","e2ee"],"limits":{"seats":3,"ttl":86400}}',
+ },
+ {
+ id: 'it-short', kind: 'text', ts: ago(41), deviceId: PHONE, state: { kind: 'complete' },
+ text: '明天 10 点开会,带电脑',
+ },
+ {
+ id: 'it-pdf', kind: 'file', ts: ago(95), deviceId: SELF, state: { kind: 'complete' },
+ name: 'invoice-2026-09.pdf', mime: 'application/pdf', size: 1_240_000, remaining: null, preview: 'none',
+ },
+ ];
+ return { items, sizeOverride: {} };
+}
+
+export function makeSvgBlob(): Blob {
+ const svg = ``;
+ return new Blob([svg], { type: 'image/svg+xml' });
+}
+
+/** 用 canvas + MediaRecorder 临时生成一段几秒的演示视频;浏览器不支持则返回 null */
+export async function makeVideoBlob(): Promise {
+ try {
+ if (typeof MediaRecorder === 'undefined') return null;
+ const canvas = document.createElement('canvas');
+ canvas.width = 480;
+ canvas.height = 270;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return null;
+ const rec = new MediaRecorder(canvas.captureStream(24), { mimeType: 'video/webm' });
+ const chunks: Blob[] = [];
+ rec.ondataavailable = (e) => chunks.push(e.data);
+ const done = new Promise((res) => {
+ rec.onstop = () => res(new Blob(chunks, { type: 'video/webm' }));
+ });
+ rec.start();
+ const t0 = performance.now();
+ await new Promise((resolve) => {
+ const draw = () => {
+ const t = (performance.now() - t0) / 1000;
+ ctx.fillStyle = `hsl(${(t * 60) % 360} 10% ${30 + 10 * Math.sin(t * 2)}%)`;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = '#fff';
+ ctx.font = '28px sans-serif';
+ ctx.fillText(`演示视频 ${t.toFixed(1)}s`, 150, 140);
+ if (t < 3) requestAnimationFrame(draw);
+ else resolve();
+ };
+ draw();
+ });
+ rec.stop();
+ return await done;
+ } catch {
+ return null;
+ }
+}
+
+export function makeLogBlob(text: string): Blob {
+ return new Blob([text], { type: 'text/plain' });
+}
diff --git a/web/src/core-bridge/mockShare.ts b/web/src/core-bridge/mockShare.ts
new file mode 100644
index 0000000..a2d8fa1
--- /dev/null
+++ b/web/src/core-bridge/mockShare.ts
@@ -0,0 +1,191 @@
+import { previewKindOf } from '@/lib/fileKind';
+import type { ShareApi } from './shareApi';
+import { b64url } from './mockLinks';
+import { PHONE, archiveEntries, makeSvgBlob, seedItems } from './mockSeed';
+import type { FileItem, Item, Share, ShareEvent, ShareSettings } from './types';
+
+const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
+const CREATOR = 'creator';
+export const DEMO_SHARE_ID = 'ShareDemoShareDemoAbcd';
+const DEMO_KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
+
+interface Runtime {
+ share: Share;
+ items: Map;
+ blobs: Map>;
+}
+
+/** 便捷分享的假实现:不联网,数据都在内存里,刷新即丢。接口见 shareApi.ts */
+export function createMockShareApi(): ShareApi {
+ const runtimes = new Map();
+ let current: Runtime | undefined;
+ let emit: (e: ShareEvent) => void = () => {};
+ const timers = new Set();
+
+ const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`;
+ const put = (item: Item) => {
+ current?.items.set(item.id, item);
+ emit({ t: 'item', item });
+ };
+ const draftOnly = () => {
+ if (!current || current.share.state !== 'draft') throw new Error('forbidden');
+ return current;
+ };
+
+ function seedDemo(): Runtime {
+ const share: Share = {
+ id: DEMO_SHARE_ID,
+ state: 'sealed',
+ mine: false,
+ link: `${location.origin}/s/${DEMO_SHARE_ID}#k=${DEMO_KEY}`,
+ createdAt: Date.now() - 2 * 3600_000,
+ ttlSeconds: 86400,
+ seats: 10,
+ maxDownloads: 5,
+ };
+ const rt: Runtime = { share, items: new Map(), blobs: new Map() };
+ const keep = new Set(['it-zip', 'it-image', 'it-json', 'it-short', 'it-pdf']);
+ for (const item of seedItems().items) {
+ if (!keep.has(item.id)) continue;
+ rt.items.set(item.id, item.kind === 'file'
+ ? { ...item, deviceId: CREATOR, versions: undefined, unseen: false, remaining: share.maxDownloads }
+ : { ...item, deviceId: CREATOR });
+ }
+ rt.blobs.set('it-image', makeSvgBlob());
+ rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' }));
+ rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' }));
+ runtimes.set(share.id, rt);
+ return rt;
+ }
+
+ return {
+ async createDraft(settings) {
+ await wait(300);
+ const id = b64url(16);
+ const share: Share = {
+ id,
+ state: 'draft',
+ mine: true,
+ link: `${location.origin}/s/${id}#k=${b64url(32)}`,
+ createdAt: Date.now(),
+ ...settings,
+ };
+ runtimes.set(id, { share, items: new Map(), blobs: new Map() });
+ return share;
+ },
+
+ async updateSettings(id, patch: Partial) {
+ const rt = runtimes.get(id);
+ if (!rt || rt.share.state !== 'draft') throw new Error('forbidden');
+ rt.share = { ...rt.share, ...patch };
+ emit({ t: 'share', share: rt.share });
+ return rt.share;
+ },
+
+ async seal(id) {
+ await wait(300);
+ const rt = runtimes.get(id);
+ if (!rt || rt.share.state !== 'draft') throw new Error('forbidden');
+ // 丢弃所有者密钥:此后没有人能改,创建者也只是一个读者
+ rt.share = { ...rt.share, state: 'sealed' };
+ for (const item of rt.items.values()) {
+ if (item.kind === 'file') rt.items.set(item.id, { ...item, remaining: rt.share.maxDownloads });
+ }
+ emit({ t: 'share', share: rt.share });
+ for (const item of rt.items.values()) emit({ t: 'item', item });
+ return rt.share;
+ },
+
+ async join(link) {
+ await wait(300);
+ const known = runtimes.get(link.id);
+ if (known) return known.share;
+ // 原型里不认识的链接:当作一个已封存、没有内容的分享
+ const share: Share = {
+ id: link.id,
+ state: 'sealed',
+ mine: false,
+ link: `${location.origin}/s/${link.id}#k=${link.key}`,
+ createdAt: Date.now(),
+ ttlSeconds: 86400,
+ seats: 10,
+ maxDownloads: null,
+ };
+ runtimes.set(link.id, { share, items: new Map(), blobs: new Map() });
+ return share;
+ },
+
+ loadDemo() {
+ return [(runtimes.get(DEMO_SHARE_ID) ?? seedDemo()).share];
+ },
+
+ connect(shareId, onEvent) {
+ emit = onEvent;
+ current = runtimes.get(shareId);
+ const rt = current;
+ if (!rt) return () => {};
+ const id = window.setTimeout(() => {
+ timers.delete(id);
+ emit({ t: 'share', share: rt.share });
+ for (const item of rt.items.values()) emit({ t: 'item', item });
+ emit({ t: 'ready' });
+ }, 400);
+ timers.add(id);
+ return () => {
+ for (const t of timers) clearTimeout(t);
+ timers.clear();
+ emit = () => {};
+ };
+ },
+
+ async addText(text) {
+ draftOnly();
+ put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text });
+ },
+
+ async addFiles(files) {
+ const rt = draftOnly();
+ for (const file of files) {
+ const id = uid();
+ const base: FileItem = {
+ id, kind: 'file', ts: Date.now(), deviceId: PHONE, state: { kind: 'receiving', progress: 0 },
+ name: file.name, mime: file.type, size: file.size, remaining: null,
+ preview: previewKindOf(file.type, file.name),
+ };
+ rt.blobs.set(id, file);
+ put(base);
+ for (let p = 0.25; p < 1; p += 0.25) {
+ await wait(200);
+ put({ ...base, state: { kind: 'receiving', progress: p } });
+ }
+ await wait(200);
+ put({ ...base, state: { kind: 'complete' } });
+ }
+ },
+
+ async removeItem(id) {
+ const rt = draftOnly();
+ rt.items.delete(id);
+ rt.blobs.delete(id);
+ emit({ t: 'removed', id });
+ },
+
+ async getBlob(id) {
+ const item = current?.items.get(id);
+ // 封存后每次拉取消耗一次下载(GATED,api.md);用完即 410
+ if (item?.kind === 'file' && current?.share.state === 'sealed' && item.remaining !== null) {
+ if (item.remaining <= 0) throw new Error('gone');
+ put({ ...item, remaining: item.remaining - 1 });
+ }
+ const b = current?.blobs.get(id);
+ if (b) return b;
+ if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' });
+ throw new Error('not found');
+ },
+
+ async listArchive() {
+ await wait(250);
+ return archiveEntries;
+ },
+ };
+}
diff --git a/web/src/core-bridge/shareApi.ts b/web/src/core-bridge/shareApi.ts
new file mode 100644
index 0000000..424e361
--- /dev/null
+++ b/web/src/core-bridge/shareApi.ts
@@ -0,0 +1,32 @@
+import type { ParsedShareLink } from '@/lib/shareLink';
+import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types';
+
+/**
+ * 便捷分享的后端边界。和频道分开:分享没有设备、便签、冲突与历史,只有
+ * 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。
+ */
+export interface ShareApi {
+ /** 新建草稿:创建者握着所有者密钥。此时还没有可分享的链接 */
+ createDraft(settings: ShareSettings): Promise;
+ /** 草稿阶段调整设置(PATCH settings,需所有者令牌) */
+ updateSettings(id: string, patch: Partial): Promise;
+ /**
+ * 完成分享:创建者丢弃所有者密钥 A,此后任何人都不能追加、改设置或删除,只能等 TTL。
+ * 客户端丢弃是自觉行为;让服务端也强制,需要后端提供「封存」使 owner_hash 失效。
+ */
+ seal(id: string): Promise;
+ /** 访客通过链接直接加入(不预览、不展示人数),返回这个分享 */
+ join(link: ParsedShareLink): Promise;
+ /** 仅假实现提供:载入演示用的分享 */
+ loadDemo?(): Share[];
+
+ /** 订阅某个分享的事件流,返回取消订阅函数 */
+ connect(shareId: string, onEvent: (e: ShareEvent) => void): () => void;
+ /** 草稿阶段添加与移除内容 */
+ addText(text: string): Promise;
+ addFiles(files: File[]): Promise;
+ removeItem(id: string): Promise;
+ /** 取内容(封存后每次拉取消耗一次下载,见 api.md) */
+ getBlob(id: string): Promise;
+ listArchive(id: string): Promise;
+}
diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts
new file mode 100644
index 0000000..4ed7596
--- /dev/null
+++ b/web/src/core-bridge/types.ts
@@ -0,0 +1,138 @@
+export type DeviceKind = 'desktop' | 'phone' | 'tablet';
+export type ConnectionState = 'online' | 'reconnecting' | 'offline';
+export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none';
+
+export interface Device {
+ id: string;
+ name: string;
+ kind: DeviceKind;
+ online: boolean;
+ self?: boolean;
+ /** 正在编辑便签 */
+ editing?: boolean;
+ lastSeen?: number;
+}
+
+/** 常驻频道:自己多台设备之间长期同步 */
+export interface ChannelInfo {
+ id: string;
+ mode: 'enc' | 'raw';
+ role: 'owner' | 'member';
+ seats: number;
+ memberLink: string;
+ /** 只有所有者有 */
+ ownerLink?: string;
+ createdAt: number;
+}
+
+/** 新建频道的选项(protocol §12 设置的子集) */
+export interface CreateOptions {
+ mode: 'enc' | 'raw';
+ seats: number;
+ ttlSeconds: number;
+ membersCanUpload: boolean;
+}
+
+/**
+ * 便捷分享:把一批文件和文本做成只读清单发给别人。草稿阶段创建者握着所有者密钥,
+ * 完成分享时丢弃,此后无人可改,只能等过期。始终是加密模式。
+ */
+export interface Share {
+ id: string;
+ /** draft:创建者可编辑;sealed:已封存,只读 */
+ state: 'draft' | 'sealed';
+ /** 创建在本机 */
+ mine: boolean;
+ /** 访客链接 `/s/:id#k=…`。草稿阶段不展示 */
+ link: string;
+ createdAt: number;
+ ttlSeconds: number;
+ /** 最多访客(创建者的设置,不向访客展示) */
+ seats: number;
+ /** 每个文件最多下载次数,null 为不限(protocol §12 file_max_downloads) */
+ maxDownloads: number | null;
+}
+
+/** 草稿阶段可调整的设置 */
+export interface ShareSettings {
+ ttlSeconds: number;
+ seats: number;
+ maxDownloads: number | null;
+}
+
+/** protocol §7、§14:由帧推导,不单独存储 */
+export type ItemState =
+ | { kind: 'receiving'; progress: number }
+ | { kind: 'complete' }
+ | { kind: 'incomplete' }
+ | { kind: 'tampered' };
+
+interface ItemBase {
+ id: string;
+ ts: number;
+ deviceId: string;
+ state: ItemState;
+}
+
+export interface TextItem extends ItemBase {
+ kind: 'text';
+ text: string;
+ editedAt?: number;
+ /** 另一台设备同时改过(后写者胜,见 ui-design §8) */
+ conflict?: { deviceId: string; text: string };
+}
+
+export interface FileItem extends ItemBase {
+ kind: 'file';
+ name: string;
+ mime: string;
+ size: number;
+ /** 剩余下载次数,null 为不限(protocol §12 file_max_downloads) */
+ remaining: number | null;
+ preview: PreviewKind;
+ /**
+ * 旧版本,新的在前,不含当前版本。
+ * 约定(待写入 protocol §7):新版本是一个新条目,其加密的 meta_json 带 `replaces`,
+ * 客户端据此把它们折叠成同一个文件的历史。后端不解析 meta。
+ */
+ versions?: FileVersion[];
+ /** 当前版本是别的设备改的,而你还没看过 */
+ unseen?: boolean;
+}
+
+export interface FileVersion {
+ id: string;
+ ts: number;
+ deviceId: string;
+ size: number;
+}
+
+export type Item = TextItem | FileItem;
+
+export interface ArchiveEntry {
+ path: string;
+ size: number;
+ dir: boolean;
+}
+
+/** protocol §9:频道唯一的快照槽,最后写入者胜 */
+export interface Snapshot {
+ text: string;
+ updatedBy: string;
+ updatedAt: number;
+}
+
+export type WorkspaceEvent =
+ | { t: 'ready' }
+ | { t: 'connection'; state: ConnectionState }
+ | { t: 'channel'; info: ChannelInfo }
+ | { t: 'devices'; devices: Device[] }
+ | { t: 'item'; item: Item }
+ | { t: 'removed'; id: string }
+ | { t: 'snapshot'; snapshot: Snapshot };
+
+export type ShareEvent =
+ | { t: 'ready' }
+ | { t: 'share'; share: Share }
+ | { t: 'item'; item: Item }
+ | { t: 'removed'; id: string };
diff --git a/web/src/features/channel/ActionBar.module.css b/web/src/features/channel/ActionBar.module.css
new file mode 100644
index 0000000..4beb1da
--- /dev/null
+++ b/web/src/features/channel/ActionBar.module.css
@@ -0,0 +1,72 @@
+.action {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+}
+
+.action:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+.dock {
+ position: sticky;
+ bottom: 0;
+ z-index: 10;
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 1fr;
+ padding: var(--space-1) var(--space-2);
+ padding-bottom: calc(var(--space-1) + env(safe-area-inset-bottom));
+ background: var(--glass);
+ /* stylelint-disable-next-line property-no-vendor-prefix */
+ -webkit-backdrop-filter: var(--blur-bar);
+ backdrop-filter: var(--blur-bar);
+ view-transition-name: bar-bottom;
+}
+
+.dock .action {
+ flex-direction: column;
+ gap: 2px;
+ min-height: 56px;
+ border-radius: var(--radius-md);
+ font-size: 12px;
+}
+
+.toolbar {
+ display: flex;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--line);
+}
+
+.toolbar .action {
+ flex: 1;
+ gap: var(--space-2);
+ min-height: 36px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+}
+
+@media (hover: hover) {
+ .toolbar .action:hover {
+ background: var(--accent-soft);
+ }
+}
+
+.dock .action:active {
+ background: var(--accent-soft);
+}
+
+@media (height < 500px) {
+ .dock .action {
+ flex-direction: row;
+ gap: var(--space-2);
+ min-height: 44px;
+ }
+}
diff --git a/web/src/features/channel/ActionBar.tsx b/web/src/features/channel/ActionBar.tsx
new file mode 100644
index 0000000..3602f7e
--- /dev/null
+++ b/web/src/features/channel/ActionBar.tsx
@@ -0,0 +1,67 @@
+import { useRef } from 'react';
+import { readClipboard } from '@/lib/clipboard';
+import { useMediaQuery } from '@/lib/useMediaQuery';
+import { Icon, type IconName } from '@/ui/Icon';
+import styles from './ActionBar.module.css';
+
+interface Props {
+ /** dock:手机底部动作栏;toolbar:电脑列表上方的工具条 */
+ layout: 'dock' | 'toolbar';
+ /** 添加文件、文本:频道发到频道里,分享放进草稿 */
+ onFiles: (files: File[]) => void;
+ onText: (text: string) => void;
+ onCompose: () => void;
+ /** 不传则不显示扫码(分享草稿里不需要) */
+ onScan?: () => void;
+}
+
+interface Action {
+ key: string;
+ label: string;
+ icon: IconName;
+ /** 触屏专有(相册、拍照) */
+ touchOnly?: boolean;
+ run: () => void;
+}
+
+export function ActionBar({ layout, onFiles, onText, onCompose, onScan }: Props) {
+ const touch = useMediaQuery('(pointer: coarse)');
+ const fileRef = useRef(null);
+ const galleryRef = useRef(null);
+ const cameraRef = useRef(null);
+
+ async function paste() {
+ const clip = await readClipboard();
+ if (!clip) return onCompose(); // 无权限时退回到手动输入
+ if (clip.files.length > 0) onFiles(clip.files);
+ else if (clip.text.trim()) onText(clip.text.trim());
+ }
+
+ const actions: Action[] = [
+ { key: 'paste', label: '粘贴', icon: 'paste', run: () => void paste() },
+ { key: 'file', label: '文件', icon: 'file', run: () => fileRef.current?.click() },
+ { key: 'gallery', label: '相册', icon: 'image', touchOnly: true, run: () => galleryRef.current?.click() },
+ { key: 'camera', label: '拍照', icon: 'camera', touchOnly: true, run: () => cameraRef.current?.click() },
+ ...(onScan ? [{ key: 'scan', label: '扫码', icon: 'scan' as const, run: onScan }] : []),
+ { key: 'write', label: '写', icon: 'edit', run: onCompose },
+ ];
+ const shown = actions.filter((a) => layout === 'dock' || touch || !a.touchOnly);
+
+ function onPick(files: FileList | null) {
+ if (files?.length) onFiles([...files]);
+ }
+
+ return (
+
+ { onPick(e.target.files); e.target.value = ''; }} />
+ { onPick(e.target.files); e.target.value = ''; }} />
+ { onPick(e.target.files); e.target.value = ''; }} />
+ {shown.map((a) => (
+
+ ))}
+
+ );
+}
diff --git a/web/src/features/channel/ComposeSheet.module.css b/web/src/features/channel/ComposeSheet.module.css
new file mode 100644
index 0000000..412f5cf
--- /dev/null
+++ b/web/src/features/channel/ComposeSheet.module.css
@@ -0,0 +1,13 @@
+.input {
+ width: 100%;
+ padding: var(--space-3);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ resize: vertical;
+}
+
+.input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
diff --git a/web/src/features/channel/ComposeSheet.tsx b/web/src/features/channel/ComposeSheet.tsx
new file mode 100644
index 0000000..0c771c2
--- /dev/null
+++ b/web/src/features/channel/ComposeSheet.tsx
@@ -0,0 +1,38 @@
+import { useState, type KeyboardEvent } from 'react';
+import { Button } from '@/ui/Button';
+import { Sheet } from '@/ui/Sheet';
+import styles from './ComposeSheet.module.css';
+
+export function ComposeSheet({ onClose, onSubmit }: { onClose: () => void; onSubmit: (text: string) => void }) {
+ const [text, setText] = useState('');
+ const empty = text.trim() === '';
+
+ function submit() {
+ if (empty) return;
+ onSubmit(text.trim());
+ onClose();
+ }
+
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submit();
+ }
+
+ return (
+ 发送}
+ >
+
+ );
+}
diff --git a/web/src/features/channel/DevicePanel.module.css b/web/src/features/channel/DevicePanel.module.css
new file mode 100644
index 0000000..99aa50f
--- /dev/null
+++ b/web/src/features/channel/DevicePanel.module.css
@@ -0,0 +1,111 @@
+.panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.h {
+ margin: 0 0 var(--space-3);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.dim {
+ margin-left: var(--space-2);
+ color: var(--text-dim);
+ font-weight: 400;
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.device {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ min-height: 40px;
+ border-bottom: 1px solid var(--line);
+}
+
+.name {
+ flex: 1;
+ min-width: 0;
+}
+
+.online,
+.offline {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 12px;
+}
+
+.online {
+ color: var(--ok);
+}
+
+.online::before {
+ content: "";
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-lg);
+ background: var(--ok);
+ animation: breathe 2.4s ease-in-out infinite;
+}
+
+.offline {
+ color: var(--text-dim);
+}
+
+.link {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-bottom: var(--space-4);
+}
+
+.label {
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.warn {
+ margin-left: var(--space-1);
+ color: var(--warn);
+}
+
+.code {
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ background: var(--surface-2);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+.row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.note {
+ margin: 0;
+ color: var(--text-dim);
+ font-size: 12px;
+}
+
+@keyframes breathe {
+ 50% {
+ opacity: 0.4;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .online::before {
+ animation: none;
+ }
+}
diff --git a/web/src/features/channel/DevicePanel.tsx b/web/src/features/channel/DevicePanel.tsx
new file mode 100644
index 0000000..32508d8
--- /dev/null
+++ b/web/src/features/channel/DevicePanel.tsx
@@ -0,0 +1,77 @@
+import { useState } from 'react';
+import { formatAgo } from '@/lib/format';
+import { useWorkspace } from '@/store/workspace';
+import { Button } from '@/ui/Button';
+import { CopyButton } from '@/ui/CopyButton';
+import { Icon } from '@/ui/Icon';
+import { QrCode } from '@/ui/QrCode';
+import styles from './DevicePanel.module.css';
+
+export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
+ const devices = useWorkspace((s) => s.devices);
+ const channel = useWorkspace((s) => s.channel);
+ const simulate = useWorkspace((s) => s.simulateConnection);
+ const [showOwner, setShowOwner] = useState(false);
+ const [showQr, setShowQr] = useState(qrOpen);
+ const online = devices.filter((d) => d.online).length;
+
+ return (
+
+ );
+}
diff --git a/web/src/features/channel/FilterBar.module.css b/web/src/features/channel/FilterBar.module.css
new file mode 100644
index 0000000..dc7da52
--- /dev/null
+++ b/web/src/features/channel/FilterBar.module.css
@@ -0,0 +1,55 @@
+.bar {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--line);
+}
+
+.input {
+ min-height: 36px;
+ padding: 0 var(--space-3);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+}
+
+.input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+.chips {
+ display: flex;
+ gap: var(--space-1);
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+
+.chip {
+ flex: none;
+ min-height: 32px;
+ padding: 0 var(--space-3);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--text-dim);
+ cursor: pointer;
+}
+
+.chip[aria-checked="true"] {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: var(--on-accent);
+}
+
+.chip:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+@media (pointer: coarse) {
+ .chip {
+ min-height: 40px;
+ }
+}
diff --git a/web/src/features/channel/FilterBar.tsx b/web/src/features/channel/FilterBar.tsx
new file mode 100644
index 0000000..9149bcf
--- /dev/null
+++ b/web/src/features/channel/FilterBar.tsx
@@ -0,0 +1,49 @@
+import type { ItemFilter, KindFilter } from '@/lib/filter';
+import styles from './FilterBar.module.css';
+
+const KINDS: { value: KindFilter; label: string }[] = [
+ { value: 'all', label: '全部' },
+ { value: 'file', label: '文件' },
+ { value: 'text', label: '文本' },
+ { value: 'image', label: '图片' },
+ { value: 'video', label: '视频' },
+ { value: 'archive', label: '压缩包' },
+];
+
+interface Props {
+ filter: ItemFilter;
+ searchOpen: boolean;
+ onChange: (f: ItemFilter) => void;
+}
+
+/** 类型分组常驻;搜索框由顶栏的放大镜展开 */
+export function FilterBar({ filter, searchOpen, onChange }: Props) {
+ return (
+
+ {searchOpen ? (
+
onChange({ ...filter, query: e.target.value })}
+ />
+ ) : null}
+
+ {KINDS.map((k) => (
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/features/channel/ItemList.module.css b/web/src/features/channel/ItemList.module.css
new file mode 100644
index 0000000..569a413
--- /dev/null
+++ b/web/src/features/channel/ItemList.module.css
@@ -0,0 +1,16 @@
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.list > li {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 96px;
+}
+
+.empty {
+ margin: 0;
+ padding: var(--space-6) var(--space-4);
+ color: var(--text-dim);
+}
diff --git a/web/src/features/channel/ItemList.tsx b/web/src/features/channel/ItemList.tsx
new file mode 100644
index 0000000..c5d4dc7
--- /dev/null
+++ b/web/src/features/channel/ItemList.tsx
@@ -0,0 +1,30 @@
+import { CompactRow } from '@/features/item/CompactRow';
+import styles from './ItemList.module.css';
+
+interface Props {
+ ids: string[];
+ selectedId?: string;
+ /** 桌面端才有多选 */
+ checked?: ReadonlySet;
+ empty: string;
+ onSelect: (id: string) => void;
+ onToggle?: (id: string) => void;
+}
+
+export function ItemList({ ids, selectedId, checked, empty, onSelect, onToggle }: Props) {
+ if (ids.length === 0) return {empty}
;
+ return (
+
+ {ids.map((id) => (
+
+ ))}
+
+ );
+}
diff --git a/web/src/features/channel/LatestCard.module.css b/web/src/features/channel/LatestCard.module.css
new file mode 100644
index 0000000..b87897e
--- /dev/null
+++ b/web/src/features/channel/LatestCard.module.css
@@ -0,0 +1,76 @@
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4) var(--space-4);
+ border-bottom: 1px solid var(--line);
+ background: linear-gradient(to bottom, var(--surface-2), transparent);
+}
+
+.label {
+ color: var(--text-dim);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.open {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+
+.text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.title {
+ display: -webkit-box;
+ overflow: hidden;
+ font-size: 16px;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+
+.sub {
+ color: var(--text-dim);
+ font-size: 12px;
+}
+
+.actions {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.actions > * {
+ flex: 1;
+ min-height: 44px;
+}
+
+.bar {
+ height: 4px;
+ overflow: hidden;
+ border-radius: var(--radius-sm);
+ background: var(--busy-soft);
+}
+
+.fill {
+ height: 100%;
+ background: var(--progress-gradient);
+ transition: width 0.25s linear;
+}
+
+/* 横屏手机高度很小,首屏留给列表 */
+@media (height < 500px) {
+ .card {
+ display: none;
+ }
+}
diff --git a/web/src/features/channel/LatestCard.tsx b/web/src/features/channel/LatestCard.tsx
new file mode 100644
index 0000000..34955c6
--- /dev/null
+++ b/web/src/features/channel/LatestCard.tsx
@@ -0,0 +1,58 @@
+import { formatSize, formatTime } from '@/lib/format';
+import { useDownload } from '@/features/item/download';
+import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
+import { shortDevice, titleOf } from '@/features/item/summary';
+import { Tile } from '@/features/item/Tile';
+import { useWorkspace } from '@/store/workspace';
+import { Button } from '@/ui/Button';
+import { CopyButton } from '@/ui/CopyButton';
+import styles from './LatestCard.module.css';
+
+interface Props {
+ id: string;
+ onOpen: (id: string) => void;
+}
+
+/** 手机首屏:最新一项放大,拇指区一键取走 */
+export function LatestCard({ id, onOpen }: Props) {
+ const item = useWorkspace((s) => s.byId[id]);
+ const device = useWorkspace((s) => s.devices.find((d) => d.id === s.byId[id]?.deviceId)?.name);
+ const download = useDownload();
+ if (!item) return null;
+
+ const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
+ const inline = inlineKindOf(item);
+ const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
+
+ return (
+
+ 最新
+
+ {inline ? onOpen(id)} /> : null}
+ {receiving !== null ? (
+
+ ) : (
+
+ {item.kind === 'text' ? (
+
+ ) : (
+ <>
+ {item.preview !== 'none' ? : null}
+
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/web/src/features/channel/Notepad.module.css b/web/src/features/channel/Notepad.module.css
new file mode 100644
index 0000000..87fc987
--- /dev/null
+++ b/web/src/features/channel/Notepad.module.css
@@ -0,0 +1,74 @@
+.pad {
+ padding: var(--space-2) var(--space-4);
+ border-bottom: 1px solid var(--line);
+}
+
+.head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ min-height: 40px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 13px;
+ text-align: left;
+}
+
+.head:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.chev {
+ display: inline-flex;
+ color: var(--text-dim);
+ transition: rotate 0.15s;
+}
+
+.open {
+ rotate: 90deg;
+}
+
+.title {
+ font-weight: 600;
+}
+
+.preview {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--text-dim);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.editing {
+ margin-left: auto;
+ color: var(--busy);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.input {
+ width: 100%;
+ margin-bottom: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ resize: vertical;
+}
+
+.input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .chev {
+ transition: none;
+ }
+}
diff --git a/web/src/features/channel/Notepad.tsx b/web/src/features/channel/Notepad.tsx
new file mode 100644
index 0000000..d42419e
--- /dev/null
+++ b/web/src/features/channel/Notepad.tsx
@@ -0,0 +1,48 @@
+import { useEffect, useRef, useState } from 'react';
+import { useWorkspace } from '@/store/workspace';
+import { Icon } from '@/ui/Icon';
+import styles from './Notepad.module.css';
+
+const SAVE_DELAY_MS = 400;
+
+/** 共享便签:频道唯一的快照槽,最后写入者胜(protocol §9)。默认折叠,不占首屏 */
+export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
+ const snapshot = useWorkspace((s) => s.snapshot);
+ const setSnapshot = useWorkspace((s) => s.setSnapshot);
+ const editor = useWorkspace((s) => s.devices.find((d) => d.editing && !d.self)?.name);
+ const [open, setOpen] = useState(defaultOpen);
+ const [draft, setDraft] = useState(null);
+ const timer = useRef(undefined);
+
+ useEffect(() => () => clearTimeout(timer.current), []);
+
+ function onChange(text: string) {
+ setDraft(text);
+ clearTimeout(timer.current);
+ timer.current = window.setTimeout(() => void setSnapshot(text), SAVE_DELAY_MS);
+ }
+
+ const text = draft ?? snapshot.text;
+ const firstLine = text.split('\n').find((l) => l.trim() !== '') ?? '空';
+
+ return (
+
+
+ {open ? (
+
+ );
+}
diff --git a/web/src/features/channel/SideRail.module.css b/web/src/features/channel/SideRail.module.css
new file mode 100644
index 0000000..7bd6f13
--- /dev/null
+++ b/web/src/features/channel/SideRail.module.css
@@ -0,0 +1,50 @@
+.rail,
+.open {
+ grid-row: 1 / -1;
+ grid-column: 3;
+ border-left: 1px solid var(--line);
+ background: var(--surface);
+}
+
+.rail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-3);
+ width: 56px;
+ padding: var(--space-3) 0;
+}
+
+.count {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-1);
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+ font-size: 12px;
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-lg);
+ background: var(--ok);
+}
+
+.open {
+ width: 320px;
+ padding: var(--space-3) var(--space-4) var(--space-4);
+ overflow-y: auto;
+}
+
+.head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-3);
+}
+
+.heading {
+ font-weight: 600;
+}
diff --git a/web/src/features/channel/SideRail.tsx b/web/src/features/channel/SideRail.tsx
new file mode 100644
index 0000000..7cdfa9e
--- /dev/null
+++ b/web/src/features/channel/SideRail.tsx
@@ -0,0 +1,39 @@
+import { useWorkspace } from '@/store/workspace';
+import { Button } from '@/ui/Button';
+import { DevicePanel } from './DevicePanel';
+import styles from './SideRail.module.css';
+
+interface Props {
+ open: boolean;
+ qrOpen: boolean;
+ onToggle: () => void;
+}
+
+/** 电脑右侧的设备与分享:低频,默认收成窄栏,需要时展开 */
+export function SideRail({ open, qrOpen, onToggle }: Props) {
+ const online = useWorkspace((s) => s.devices.filter((d) => d.online).length);
+ const seats = useWorkspace((s) => s.channel?.seats);
+
+ if (open) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/web/src/features/channel/TopBar.module.css b/web/src/features/channel/TopBar.module.css
new file mode 100644
index 0000000..448940d
--- /dev/null
+++ b/web/src/features/channel/TopBar.module.css
@@ -0,0 +1,140 @@
+.bar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2) var(--space-3);
+ min-height: var(--bar-h);
+ padding: 0 var(--space-4);
+ padding-top: env(safe-area-inset-top);
+ background: var(--glass);
+ /* stylelint-disable-next-line property-no-vendor-prefix */
+ -webkit-backdrop-filter: var(--blur-bar);
+ backdrop-filter: var(--blur-bar);
+ view-transition-name: bar-top;
+}
+
+/* 底边渐隐,代替一条硬分隔线 */
+.bar::after {
+ content: "";
+ position: absolute;
+ inset: 100% 0 auto;
+ height: 14px;
+ background: var(--fade-down);
+ pointer-events: none;
+}
+
+.raw::after {
+ height: 3px;
+ background: var(--warn-bar);
+}
+
+.id {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.name {
+ min-width: 0;
+ overflow: hidden;
+ font-family: var(--font-mono);
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.badge,
+.badgeWarn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 1px var(--space-2);
+ border-radius: var(--radius-sm);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.badge {
+ background: var(--ok-soft);
+ color: var(--ok);
+}
+
+.badgeWarn {
+ background: var(--warn-soft);
+ color: var(--warn);
+}
+
+.presence {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-1) var(--space-2);
+ border: 0;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--text-dim);
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.presence:disabled {
+ cursor: default;
+}
+
+.dots {
+ display: inline-flex;
+ gap: var(--space-1);
+}
+
+.on,
+.off {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-lg);
+}
+
+.on {
+ background: var(--ok);
+}
+
+.off {
+ background: var(--line-strong);
+}
+
+.active {
+ background: var(--accent-soft);
+ color: var(--busy);
+}
+
+.conn {
+ font-size: 12px;
+}
+
+.online {
+ display: none;
+}
+
+.reconnecting {
+ color: var(--busy);
+}
+
+.offline {
+ color: var(--danger);
+}
+
+/* 窄屏(如 iPhone SE、小屏 Android):只留在线点,收起数字与徽标文字 */
+@media (width < 360px) {
+ .count {
+ display: none;
+ }
+
+ .bar {
+ gap: var(--space-1);
+ padding-inline: var(--space-3);
+ }
+}
diff --git a/web/src/features/channel/TopBar.tsx b/web/src/features/channel/TopBar.tsx
new file mode 100644
index 0000000..43044be
--- /dev/null
+++ b/web/src/features/channel/TopBar.tsx
@@ -0,0 +1,64 @@
+import { useWorkspace } from '@/store/workspace';
+import { Button } from '@/ui/Button';
+import { Icon } from '@/ui/Icon';
+import { ThemeMenu } from '@/ui/ThemeMenu';
+import styles from './TopBar.module.css';
+
+const CONNECTION_TEXT = { online: '在线', reconnecting: '重连中', offline: '离线' } as const;
+
+interface Props {
+ onBack: () => void;
+ /** 手机上点开设备与分享面板;桌面端面板在右侧栏,不传 */
+ onOpenPanel?: () => void;
+ searchOpen: boolean;
+ /** 有筛选条件在生效 */
+ filtering: boolean;
+ onToggleSearch: () => void;
+}
+
+export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSearch }: Props) {
+ const channel = useWorkspace((s) => s.channel);
+ const connection = useWorkspace((s) => s.connection);
+ const devices = useWorkspace((s) => s.devices);
+ const online = devices.filter((d) => d.online).length;
+ const raw = channel?.mode === 'raw';
+
+ return (
+
+ );
+}
diff --git a/web/src/features/channel/UndoBar.module.css b/web/src/features/channel/UndoBar.module.css
new file mode 100644
index 0000000..3a1a8b1
--- /dev/null
+++ b/web/src/features/channel/UndoBar.module.css
@@ -0,0 +1,35 @@
+.bar {
+ position: fixed;
+ bottom: calc(88px + env(safe-area-inset-bottom));
+ left: 50%;
+ z-index: 30;
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-1) var(--space-2) var(--space-1) var(--space-4);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ box-shadow: var(--shadow-pop);
+ translate: -50% 0;
+ animation: bar-in 0.15s ease-out;
+}
+
+@media (width >= 900px) {
+ .bar {
+ bottom: var(--space-6);
+ }
+}
+
+@keyframes bar-in {
+ from {
+ opacity: 0;
+ translate: -50% 8px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bar {
+ animation: none;
+ }
+}
diff --git a/web/src/features/channel/UndoBar.tsx b/web/src/features/channel/UndoBar.tsx
new file mode 100644
index 0000000..6b03acc
--- /dev/null
+++ b/web/src/features/channel/UndoBar.tsx
@@ -0,0 +1,12 @@
+import { Button } from '@/ui/Button';
+import styles from './UndoBar.module.css';
+
+/** 删除后 5 秒内可撤销(ui-design §8) */
+export function UndoBar({ onUndo }: { onUndo: () => void }) {
+ return (
+
+ 已删除
+
+
+ );
+}
diff --git a/web/src/features/channel/Workspace.module.css b/web/src/features/channel/Workspace.module.css
new file mode 100644
index 0000000..482690f
--- /dev/null
+++ b/web/src/features/channel/Workspace.module.css
@@ -0,0 +1,70 @@
+/* 电脑:左列表 | 内联预览 | 右侧窄栏 */
+.desktop {
+ display: grid;
+ grid-template-columns: 380px minmax(0, 1fr) auto;
+ grid-template-rows: auto minmax(0, 1fr);
+ height: 100vh;
+ height: 100dvh;
+}
+
+.desktop > header {
+ grid-column: 1 / 3;
+ border-bottom: 1px solid var(--line);
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-right: 1px solid var(--line);
+}
+
+.scroll {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.batch {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-2) var(--space-4);
+ border-bottom: 1px solid var(--line);
+ background: linear-gradient(90deg, var(--busy-soft), transparent);
+ font-size: 13px;
+}
+
+/* 手机:整列滚动,顶栏与底部动作栏 sticky,内容从它们下方滑过 */
+.mobile {
+ display: flex;
+ height: 100vh;
+ height: 100dvh;
+ padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
+}
+
+.column {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-width: 0;
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
+}
+
+.grow {
+ flex: 1;
+}
+
+.drop {
+ position: fixed;
+ inset: var(--space-4);
+ z-index: 40;
+ display: grid;
+ place-items: center;
+ border: 2px dashed var(--line-strong);
+ border-radius: var(--radius-lg);
+ background: var(--scrim);
+ color: var(--on-accent);
+ font-size: 18px;
+ pointer-events: none;
+}
diff --git a/web/src/features/channel/Workspace.tsx b/web/src/features/channel/Workspace.tsx
new file mode 100644
index 0000000..b4ce6c9
--- /dev/null
+++ b/web/src/features/channel/Workspace.tsx
@@ -0,0 +1,209 @@
+import { lazy, Suspense, useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { channelPath, navigate, sharePath } from '@/app/navigation';
+import { useDownload } from '@/features/item/download';
+import { isFiltering, matchesFilter, NO_FILTER } from '@/lib/filter';
+import type { ClipboardPayload } from '@/lib/clipboard';
+import { useGlobalPaste } from '@/lib/useGlobalPaste';
+import { useMediaQuery } from '@/lib/useMediaQuery';
+import { useChannels } from '@/store/channels';
+import { useWorkspace } from '@/store/workspace';
+import { Button } from '@/ui/Button';
+import { Sheet } from '@/ui/Sheet';
+import { ActionBar } from './ActionBar';
+import { ComposeSheet } from './ComposeSheet';
+import { DevicePanel } from './DevicePanel';
+import { FilterBar } from './FilterBar';
+import { ItemList } from './ItemList';
+import { LatestCard } from './LatestCard';
+import { Notepad } from './Notepad';
+import { SideRail } from './SideRail';
+import { TopBar } from './TopBar';
+import { UndoBar } from './UndoBar';
+import styles from './Workspace.module.css';
+
+const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet'));
+const PreviewPane = lazy(() => import('@/features/item/PreviewPane'));
+const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
+
+const UNDO_MS = 5000;
+
+export function Workspace() {
+ const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)');
+ const sendFiles = useWorkspace((s) => s.sendFiles);
+ const sendText = useWorkspace((s) => s.sendText);
+ const remove = useWorkspace((s) => s.remove);
+ const download = useDownload();
+
+ // 刚创建的频道:直接展示分享面板和二维码,让对方马上能扫
+ const [justCreated] = useState(() => useChannels.getState().justCreatedId !== null);
+ const [panelOpen, setPanelOpen] = useState(justCreated); // 手机:设备与分享抽屉
+ const [railOpen, setRailOpen] = useState(justCreated); // 电脑:右侧栏展开
+ const [searchOpen, setSearchOpen] = useState(false);
+ const [filter, setFilter] = useState(NO_FILTER);
+ const [selectedId, setSelectedId] = useState();
+ const [sheetId, setSheetId] = useState();
+ const [checked, setChecked] = useState>(new Set());
+ const [composeOpen, setComposeOpen] = useState(false);
+ const [scanOpen, setScanOpen] = useState(false);
+ const [dragging, setDragging] = useState(false);
+ const [pending, setPending] = useState();
+ const pendingRef = useRef<{ id: string; timer: number }>(undefined);
+
+ const sendPayload = useCallback(
+ (p: ClipboardPayload) => {
+ if (p.files.length > 0) void sendFiles(p.files);
+ else if (p.text) void sendText(p.text);
+ },
+ [sendFiles, sendText],
+ );
+ useGlobalPaste(sendPayload);
+
+ // 从首页「快速分享」带过来的内容,进入频道后发送
+ useEffect(() => {
+ const { consumePayload, clearJustCreated } = useChannels.getState();
+ const p = consumePayload();
+ if (p) sendPayload(p);
+ clearJustCreated();
+ }, [sendPayload]);
+
+ const ids = useWorkspace(
+ useShallow((s) =>
+ s.order.filter((id) => {
+ const item = s.byId[id];
+ return id !== pending && item !== undefined && matchesFilter(item, filter);
+ }),
+ ),
+ );
+ const filtering = isFiltering(filter);
+ const latestId = !desktop && !filtering ? ids[0] : undefined;
+ const listIds = latestId ? ids.slice(1) : ids;
+ const activeId = selectedId && ids.includes(selectedId) ? selectedId : ids[0];
+
+ const closeSheet = useCallback(() => setSheetId(undefined), []);
+ const closePanel = useCallback(() => setPanelOpen(false), []);
+ const closeCompose = useCallback(() => setComposeOpen(false), []);
+ const closeScan = useCallback(() => setScanOpen(false), []);
+
+ function requestDelete(id: string) {
+ if (pendingRef.current) {
+ clearTimeout(pendingRef.current.timer);
+ void remove(pendingRef.current.id);
+ }
+ const timer = window.setTimeout(() => {
+ void remove(id);
+ pendingRef.current = undefined;
+ setPending(undefined);
+ }, UNDO_MS);
+ pendingRef.current = { id, timer };
+ setPending(id);
+ }
+
+ function undoDelete() {
+ if (!pendingRef.current) return;
+ clearTimeout(pendingRef.current.timer);
+ pendingRef.current = undefined;
+ setPending(undefined);
+ }
+
+ function toggleSearch() {
+ if (searchOpen) setFilter((f) => ({ ...f, query: '' }));
+ setSearchOpen((v) => !v);
+ }
+
+ function toggleChecked(id: string) {
+ setChecked((prev) => {
+ const next = new Set(prev);
+ if (!next.delete(id)) next.add(id);
+ return next;
+ });
+ }
+
+ function downloadChecked() {
+ const { byId } = useWorkspace.getState();
+ for (const id of checked) {
+ const item = byId[id];
+ if (item) void download(item);
+ }
+ setChecked(new Set());
+ }
+
+ function onDragOver(e: DragEvent) {
+ if (!e.dataTransfer.types.includes('Files')) return;
+ e.preventDefault();
+ setDragging(true);
+ }
+
+ function onDrop(e: DragEvent) {
+ e.preventDefault();
+ setDragging(false);
+ const files = [...e.dataTransfer.files];
+ if (files.length) void sendFiles(files);
+ }
+
+ const emptyText = filtering ? '没有符合条件的内容。' : '还没有内容。粘贴、拖入文件,或在其他设备上打开此频道。';
+ const topBar = (
+ navigate('/')}
+ onOpenPanel={desktop ? undefined : () => setPanelOpen(true)}
+ searchOpen={searchOpen}
+ filtering={filtering}
+ onToggleSearch={toggleSearch}
+ />
+ );
+
+ return (
+ setDragging(false)} onDrop={onDrop}>
+ {desktop ? (
+ <>
+ {topBar}
+
+
void sendFiles(f)} onText={(t) => void sendText(t)} onCompose={() => setComposeOpen(true)} onScan={() => setScanOpen(true)} />
+
+
+ {checked.size > 0 ? (
+
+ 已选 {checked.size} 项
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
setRailOpen((v) => !v)} />
+ >
+ ) : (
+
+ {topBar}
+ {latestId ?
: null}
+
+
+
+
+
+
void sendFiles(f)} onText={(t) => void sendText(t)} onCompose={() => setComposeOpen(true)} onScan={() => setScanOpen(true)} />
+
+ )}
+
+ {!desktop && panelOpen ? (
+
+
+
+ ) : null}
+
+ {!desktop && sheetId ? : null}
+ {scanOpen ? navigate(channelPath(c.id))} onShare={(sh) => navigate(sharePath(sh.id))} /> : null}
+
+ {composeOpen ? void sendText(t)} /> : null}
+ {pending ? : null}
+ {dragging ? 松开以发送文件
: null}
+
+ );
+}
diff --git a/web/src/features/home/ChannelList.module.css b/web/src/features/home/ChannelList.module.css
new file mode 100644
index 0000000..bb17066
--- /dev/null
+++ b/web/src/features/home/ChannelList.module.css
@@ -0,0 +1,64 @@
+.h {
+ margin: 0 0 var(--space-2);
+ font-size: 13px;
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ border-bottom: 1px solid var(--line);
+}
+
+.open {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ min-height: 56px;
+ padding: var(--space-2) 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+
+.open:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.id {
+ font-family: var(--font-mono);
+ font-weight: 600;
+}
+
+.meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+ color: var(--text-dim);
+ font-size: 12px;
+}
+
+.enc {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ color: var(--ok);
+}
+
+.raw {
+ color: var(--warn);
+}
+
+.confirm {
+ display: flex;
+}
diff --git a/web/src/features/home/ChannelList.tsx b/web/src/features/home/ChannelList.tsx
new file mode 100644
index 0000000..f4ba540
--- /dev/null
+++ b/web/src/features/home/ChannelList.tsx
@@ -0,0 +1,66 @@
+import { useState } from 'react';
+import type { ChannelInfo, Share } from '@/core-bridge/types';
+import { formatAgo, formatRemaining } from '@/lib/format';
+import { useChannels } from '@/store/channels';
+import { useShares } from '@/store/shares';
+import { Button } from '@/ui/Button';
+import { Icon } from '@/ui/Icon';
+import styles from './ChannelList.module.css';
+
+interface Props {
+ kind: 'channel' | 'share';
+ title: string;
+ onOpen: (c: ChannelInfo | Share) => void;
+}
+
+export function ChannelList({ kind, title, onOpen }: Props) {
+ const channels = useChannels((s) => s.list);
+ const forgetChannel = useChannels((s) => s.forget);
+ const shares = useShares((s) => s.list);
+ const forgetShare = useShares((s) => s.forget);
+ const list = kind === 'share' ? shares : channels;
+ const [confirmId, setConfirmId] = useState();
+ if (list.length === 0) return null;
+
+ return (
+
+ {title}
+
+ {list.map((c) => (
+ -
+
+ {confirmId === c.id ? (
+
+
+
+
+ ) : (
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/features/home/CreateSheet.module.css b/web/src/features/home/CreateSheet.module.css
new file mode 100644
index 0000000..18b3e12
--- /dev/null
+++ b/web/src/features/home/CreateSheet.module.css
@@ -0,0 +1,69 @@
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 13px;
+}
+
+.field legend {
+ padding: 0;
+ margin-bottom: var(--space-2);
+}
+
+.field select {
+ min-height: 40px;
+ padding: 0 var(--space-3);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ font-size: 15px;
+}
+
+.seg {
+ display: flex;
+ overflow: hidden;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-md);
+}
+
+.seg button {
+ flex: 1;
+ min-height: 40px;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.seg button[aria-checked="true"] {
+ background: var(--accent);
+ color: var(--on-accent);
+ font-weight: 600;
+}
+
+.check {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: 40px;
+}
+
+.check input {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--accent);
+}
+
+.warn {
+ margin: 0;
+ color: var(--warn);
+ font-size: 13px;
+}
diff --git a/web/src/features/home/CreateSheet.tsx b/web/src/features/home/CreateSheet.tsx
new file mode 100644
index 0000000..3f3adfc
--- /dev/null
+++ b/web/src/features/home/CreateSheet.tsx
@@ -0,0 +1,75 @@
+import { useState } from 'react';
+import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
+import { useChannels } from '@/store/channels';
+import { Button } from '@/ui/Button';
+import { Sheet } from '@/ui/Sheet';
+import styles from './CreateSheet.module.css';
+
+const SEATS = [2, 3, 5, 10];
+const TTLS = [
+ { label: '1 小时', value: 3600 },
+ { label: '24 小时', value: 86400 },
+ { label: '7 天', value: 604800 },
+];
+
+interface Props {
+ onClose: () => void;
+ onCreated: (c: ChannelInfo) => void;
+}
+
+/** 新建频道:协议 §12 里与创建相关的设置子集 */
+export function CreateSheet({ onClose, onCreated }: Props) {
+ const create = useChannels((s) => s.create);
+ const [opts, setOpts] = useState({ kind: 'channel', mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true, maxDownloads: null });
+ const [busy, setBusy] = useState(false);
+ const [failed, setFailed] = useState(false);
+ const set = (k: K, v: CreateOptions[K]) => setOpts((o) => ({ ...o, [k]: v }));
+
+ async function submit() {
+ setBusy(true);
+ setFailed(false);
+ try {
+ onCreated(await create(opts));
+ } catch {
+ setFailed(true);
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {failed ?
创建失败,请重试。
: null}
+
+
+
+ );
+}
diff --git a/web/src/features/home/Home.module.css b/web/src/features/home/Home.module.css
new file mode 100644
index 0000000..9f8ee54
--- /dev/null
+++ b/web/src/features/home/Home.module.css
@@ -0,0 +1,74 @@
+.page {
+ height: 100vh;
+ height: 100dvh;
+ padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
+ overflow-y: auto;
+ animation: page-in 0.21s ease-out;
+}
+
+.main {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ max-width: 560px;
+ margin: 0 auto;
+ padding: var(--space-6) var(--space-4);
+}
+
+.head {
+ padding: var(--space-4) 0 var(--space-2);
+ text-align: center;
+}
+
+.logo {
+ margin: 0;
+ font-size: 28px;
+ letter-spacing: 0.5px;
+}
+
+.sub {
+ margin: var(--space-2) 0 0;
+ color: var(--text-dim);
+}
+
+.divider {
+ margin-top: var(--space-2);
+ color: var(--text-dim);
+ font-size: 12px;
+ text-align: center;
+}
+
+.actions {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.actions > * {
+ flex: 1;
+}
+
+.note {
+ margin: 0;
+ color: var(--text-dim);
+ font-size: 12px;
+ text-align: center;
+}
+
+.error {
+ margin: calc(var(--space-3) * -1) 0 0;
+ color: var(--danger);
+ font-size: 13px;
+ text-align: center;
+}
+
+@keyframes page-in {
+ from {
+ opacity: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .page {
+ animation: none;
+ }
+}
diff --git a/web/src/features/home/Home.tsx b/web/src/features/home/Home.tsx
new file mode 100644
index 0000000..b58ab5b
--- /dev/null
+++ b/web/src/features/home/Home.tsx
@@ -0,0 +1,99 @@
+import { lazy, Suspense, useCallback, useState, type DragEvent } from 'react';
+import { channelPath, navigate, sharePath } from '@/app/navigation';
+import type { ChannelInfo, Share } from '@/core-bridge/types';
+import { ComposeSheet } from '@/features/channel/ComposeSheet';
+import type { ClipboardPayload } from '@/lib/clipboard';
+import { useGlobalPaste } from '@/lib/useGlobalPaste';
+import { useChannels } from '@/store/channels';
+import { useShares } from '@/store/shares';
+import { Button } from '@/ui/Button';
+import { ChannelList } from './ChannelList';
+import { CreateSheet } from './CreateSheet';
+import { QuickShare } from './QuickShare';
+import styles from './Home.module.css';
+
+const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
+
+/** 初始状态:什么都没创建时看到的页面。核心是「快速分享」 */
+export function Home() {
+ const createDraft = useShares((s) => s.createDraft);
+ const loadDemo = useChannels((s) => s.loadDemo);
+ const loadShareDemo = useShares((s) => s.loadDemo);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [scanOpen, setScanOpen] = useState(false);
+ const [composeOpen, setComposeOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [dragging, setDragging] = useState(false);
+ const [failed, setFailed] = useState(false);
+
+ const open = (c: ChannelInfo | Share) => navigate('state' in c ? sharePath(c.id) : channelPath(c.id));
+
+ const share = useCallback(
+ async (p: ClipboardPayload) => {
+ if (busy || (p.files.length === 0 && !p.text)) return;
+ setBusy(true);
+ setFailed(false);
+ try {
+ open(await createDraft(p));
+ } catch {
+ setFailed(true);
+ } finally {
+ setBusy(false);
+ }
+ },
+ [busy, createDraft],
+ );
+
+ useGlobalPaste(share);
+
+ function onDragOver(e: DragEvent) {
+ if (!e.dataTransfer.types.includes('Files')) return;
+ e.preventDefault();
+ setDragging(true);
+ }
+
+ function onDrop(e: DragEvent) {
+ e.preventDefault();
+ setDragging(false);
+ void share({ files: [...e.dataTransfer.files], text: '' });
+ }
+
+ return (
+ setDragging(false)} onDrop={onDrop}>
+
+
+
+ setComposeOpen(true)} />
+ {failed ? 创建分享失败,请重试。
: null}
+
+
+
+ 常驻频道:多台自己的设备之间同步
+
+
+
+
+
+
+
+
+ 加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。
+
+ {import.meta.env.DEV ? (
+
+ ) : null}
+
+
+ {createOpen ?
setCreateOpen(false)} onCreated={open} /> : null}
+
+ {scanOpen ? setScanOpen(false)} onJoined={open} /> : null}
+
+ {composeOpen ? (
+ setComposeOpen(false)} onSubmit={(text) => void share({ files: [], text })} />
+ ) : null}
+
+ );
+}
diff --git a/web/src/features/home/QuickShare.module.css b/web/src/features/home/QuickShare.module.css
new file mode 100644
index 0000000..ef3410a
--- /dev/null
+++ b/web/src/features/home/QuickShare.module.css
@@ -0,0 +1,69 @@
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-6) var(--space-4);
+ border: 2px dashed var(--line-strong);
+ border-radius: var(--radius-lg);
+ background: var(--surface);
+ text-align: center;
+ transition: border-color 0.15s;
+}
+
+.dragging {
+ border-color: var(--accent);
+ background: var(--busy-soft);
+}
+
+.title {
+ margin: 0;
+ font-size: 20px;
+}
+
+.hint {
+ margin: 0;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+
+.buttons {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: var(--space-2);
+}
+
+.btn {
+ display: inline-flex;
+ flex: 1 1 120px;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ min-height: 48px;
+ padding: 0 var(--space-4);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.btn:active:not(:disabled) {
+ background: var(--accent-soft);
+}
+
+@media (hover: hover) {
+ .btn:hover:not(:disabled) {
+ background: var(--accent-soft);
+ }
+}
diff --git a/web/src/features/home/QuickShare.tsx b/web/src/features/home/QuickShare.tsx
new file mode 100644
index 0000000..7f49426
--- /dev/null
+++ b/web/src/features/home/QuickShare.tsx
@@ -0,0 +1,60 @@
+import { useRef } from 'react';
+import type { ClipboardPayload } from '@/lib/clipboard';
+import { readClipboard } from '@/lib/clipboard';
+import { useMediaQuery } from '@/lib/useMediaQuery';
+import { Icon, type IconName } from '@/ui/Icon';
+import styles from './QuickShare.module.css';
+
+interface Props {
+ busy: boolean;
+ dragging: boolean;
+ onShare: (p: ClipboardPayload) => void;
+ onCompose: () => void;
+}
+
+/** 快速分享:拖入、粘贴或选择内容,自动创建一个临时加密频道并给出分享链接 */
+export function QuickShare({ busy, dragging, onShare, onCompose }: Props) {
+ const touch = useMediaQuery('(pointer: coarse)');
+ const fileRef = useRef(null);
+ const galleryRef = useRef(null);
+ const cameraRef = useRef(null);
+
+ async function paste() {
+ const clip = await readClipboard();
+ if (!clip) return onCompose(); // 无权限时退回手动输入
+ onShare({ files: clip.files, text: clip.files.length > 0 ? '' : clip.text.trim() });
+ }
+
+ function pick(files: FileList | null) {
+ if (files?.length) onShare({ files: [...files], text: '' });
+ }
+
+ const buttons: { key: string; label: string; icon: IconName; run: () => void; touchOnly?: boolean }[] = [
+ { key: 'file', label: '选择文件', icon: 'file', run: () => fileRef.current?.click() },
+ { key: 'paste', label: '粘贴', icon: 'paste', run: () => void paste() },
+ { key: 'write', label: '写文本', icon: 'edit', run: onCompose },
+ { key: 'gallery', label: '相册', icon: 'image', run: () => galleryRef.current?.click(), touchOnly: true },
+ { key: 'camera', label: '拍照', icon: 'camera', run: () => cameraRef.current?.click(), touchOnly: true },
+ ];
+
+ return (
+
+ );
+}
diff --git a/web/src/features/item/CompactRow.module.css b/web/src/features/item/CompactRow.module.css
new file mode 100644
index 0000000..73508fc
--- /dev/null
+++ b/web/src/features/item/CompactRow.module.css
@@ -0,0 +1,128 @@
+.row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4);
+ border-bottom: 1px solid var(--line);
+ animation: row-in 0.21s ease-out;
+}
+
+.selected {
+ background: var(--accent-soft);
+}
+
+.check {
+ flex: none;
+ width: 16px;
+ height: 16px;
+ margin: var(--space-3) 0 0;
+ accent-color: var(--accent);
+}
+
+.body {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: var(--space-2);
+ min-width: 0;
+ padding: var(--space-1) 0;
+}
+
+.head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ min-width: 0;
+ min-height: 28px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+
+.head:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.inline {
+ min-width: 0;
+}
+
+.text {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.title {
+ overflow: hidden;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sub {
+ color: var(--text-dim);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+.tag,
+.update,
+.conflict {
+ margin-left: var(--space-2);
+ padding: 0 var(--space-1);
+ border-radius: var(--radius-sm);
+}
+
+.tag {
+ background: var(--accent-soft);
+}
+
+.update {
+ background: var(--busy-soft);
+ color: var(--busy);
+}
+
+.conflict {
+ background: var(--warn-soft);
+ color: var(--warn);
+}
+
+.bar {
+ display: block;
+ height: 3px;
+ margin-top: var(--space-1);
+ overflow: hidden;
+ border-radius: var(--radius-sm);
+ background: var(--busy-soft);
+}
+
+.fill {
+ display: block;
+ height: 100%;
+ background: var(--progress-gradient);
+ transition: width 0.25s linear;
+}
+
+.tampered {
+ border-left: 3px solid var(--danger);
+ background: linear-gradient(90deg, var(--danger-soft), transparent);
+ color: var(--danger);
+}
+
+@keyframes row-in {
+ from {
+ opacity: 0;
+ translate: 0 -6px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .row {
+ animation: none;
+ }
+}
diff --git a/web/src/features/item/CompactRow.tsx b/web/src/features/item/CompactRow.tsx
new file mode 100644
index 0000000..52989cd
--- /dev/null
+++ b/web/src/features/item/CompactRow.tsx
@@ -0,0 +1,93 @@
+import { memo } from 'react';
+import { formatSize, formatTime } from '@/lib/format';
+import { Button } from '@/ui/Button';
+import { CopyButton } from '@/ui/CopyButton';
+import { useDownload } from './download';
+import { InlineContent, inlineKindOf } from './InlineContent';
+import { useItemSource } from './source';
+import { shortDevice, titleOf, versionOf } from './summary';
+import { Tile } from './Tile';
+import styles from './CompactRow.module.css';
+
+interface Props {
+ id: string;
+ selected: boolean;
+ /** 桌面端才有多选 */
+ checked?: boolean;
+ onSelect: (id: string) => void;
+ onToggle?: (id: string) => void;
+}
+
+/**
+ * 暂存区里的一行。文本、JSON 和不大的视频直接画在行里,不用点开;
+ * 其他内容显示缩略图或类型图标 + 标题 + 一行小字。
+ */
+export const CompactRow = memo(function CompactRow({ id, selected, checked, onSelect, onToggle }: Props) {
+ const source = useItemSource();
+ const item = source.useItem(id);
+ const nameOf = source.useDeviceNamer();
+ const download = useDownload();
+ if (!item) return null;
+
+ if (item.state.kind === 'tampered') {
+ return 内容被篡改,已丢弃;
+ }
+
+ const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
+ const version = versionOf(item);
+ const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
+ const inline = inlineKindOf(item);
+
+ const meta = (
+ <>
+ {shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
+ {item.kind === 'file' && item.remaining !== null ? ` · 剩 ${item.remaining} 次` : ''}
+ {version > 1 ? v{version} : null}
+ {item.kind === 'file' && item.unseen ? 有更新 : null}
+ {item.kind === 'text' && item.conflict ? 冲突 : null}
+ >
+ );
+
+ return (
+
+ {onToggle ? (
+ onToggle(id)} aria-label="选择" />
+ ) : null}
+
+
+ {inline === 'text' ? (
+ // 文本没有标题,内容本身就是标题;这个按钮只显示小字,供键盘进入详情
+
+ ) : (
+
+ )}
+
+ {inline ? (
+
+ onSelect(id)} />
+
+ ) : null}
+
+ {receiving !== null ? (
+
+
+
+ ) : null}
+
+
+ {item.kind === 'text' ? (
+
+ ) : (
+
+ );
+});
diff --git a/web/src/features/item/HistoryList.module.css b/web/src/features/item/HistoryList.module.css
new file mode 100644
index 0000000..d4fa515
--- /dev/null
+++ b/web/src/features/item/HistoryList.module.css
@@ -0,0 +1,44 @@
+.history {
+ padding-top: var(--space-3);
+ border-top: 1px solid var(--line);
+}
+
+.h {
+ margin: 0 0 var(--space-2);
+ font-size: 13px;
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ min-height: 40px;
+ border-bottom: 1px solid var(--line);
+}
+
+.v {
+ width: 28px;
+ font-family: var(--font-mono);
+ font-weight: 600;
+}
+
+.info {
+ flex: 1;
+ min-width: 0;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+
+.current {
+ padding: 0 var(--space-2);
+ border-radius: var(--radius-sm);
+ background: var(--ok-soft);
+ color: var(--ok);
+ font-size: 12px;
+}
diff --git a/web/src/features/item/HistoryList.tsx b/web/src/features/item/HistoryList.tsx
new file mode 100644
index 0000000..57456cc
--- /dev/null
+++ b/web/src/features/item/HistoryList.tsx
@@ -0,0 +1,41 @@
+import type { FileItem } from '@/core-bridge/types';
+import { formatSize, formatTime } from '@/lib/format';
+import { saveBlob } from '@/lib/download';
+import { Button } from '@/ui/Button';
+import { useItemSource } from './source';
+import { shortDevice } from './summary';
+import styles from './HistoryList.module.css';
+
+/** 文件的修改历史:当前版本在最上,旧版本可单独下载 */
+export function HistoryList({ item }: { item: FileItem }) {
+ const { getBlob, useDeviceNamer } = useItemSource();
+ const namer = useDeviceNamer();
+ const versions = item.versions ?? [];
+ const nameOf = (id: string) => shortDevice(namer(id));
+ const total = versions.length + 1;
+
+ return (
+
+ 修改历史
+
+ -
+ v{total}
+ {nameOf(item.deviceId)} · {formatTime(item.ts)} · {formatSize(item.size)}
+ 当前
+
+ {versions.map((v, i) => (
+ -
+ v{total - 1 - i}
+ {nameOf(v.deviceId)} · {formatTime(v.ts)} · {formatSize(v.size)}
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/features/item/InlineContent.module.css b/web/src/features/item/InlineContent.module.css
new file mode 100644
index 0000000..9dc5f72
--- /dev/null
+++ b/web/src/features/item/InlineContent.module.css
@@ -0,0 +1,55 @@
+.text {
+ margin: 0;
+ font-family: inherit;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ cursor: pointer;
+}
+
+.json {
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ background: var(--surface-2);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.clamp {
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-line-clamp: 6;
+ -webkit-box-orient: vertical;
+}
+
+.video {
+ display: block;
+ width: 100%;
+ max-height: 260px;
+ border-radius: var(--radius-md);
+ background: var(--surface-2);
+}
+
+.poster {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ width: 100%;
+ min-height: 140px;
+ border: 1px dashed var(--line-strong);
+ border-radius: var(--radius-md);
+ background: var(--tile-gradient);
+ color: var(--text-dim);
+ font-size: 13px;
+}
+
+button.poster {
+ cursor: pointer;
+}
+
+button.poster:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
diff --git a/web/src/features/item/InlineContent.tsx b/web/src/features/item/InlineContent.tsx
new file mode 100644
index 0000000..945afda
--- /dev/null
+++ b/web/src/features/item/InlineContent.tsx
@@ -0,0 +1,52 @@
+import { useMemo, useState } from 'react';
+import type { FileItem, Item, TextItem } from '@/core-bridge/types';
+import { textView, VIDEO_INLINE_MAX } from '@/lib/inline';
+import { Icon } from '@/ui/Icon';
+import { useBlobUrl } from './useBlobUrl';
+import styles from './InlineContent.module.css';
+
+/** 列表里能直接画出来的内容:文本与 JSON(不管多短都画),不大的视频 */
+export function inlineKindOf(item: Item): 'text' | 'video' | null {
+ if (item.kind === 'text') return 'text';
+ if (item.preview === 'video' && item.size <= VIDEO_INLINE_MAX && item.state.kind === 'complete') return 'video';
+ return null;
+}
+
+export function InlineContent({ item, onOpen }: { item: Item; onOpen: () => void }) {
+ return item.kind === 'text' ? : ;
+}
+
+function InlineText({ item, onOpen }: { item: TextItem; onOpen: () => void }) {
+ const v = useMemo(() => textView(item.text), [item.text]);
+ // 纯展示的区域,键盘用户从标题按钮进入详情;这里的点击只是给鼠标和触屏多一个入口
+ return (
+
+ {v.body}
+
+ );
+}
+
+/**
+ * 视频直接在列表里播放。没有下载上限时自动加载;有上限时,拉取内容会消耗一次下载(GATED,api.md),
+ * 所以先放占位,点了才加载,避免滚动列表就把次数用光。
+ */
+function InlineVideo({ item }: { item: FileItem }) {
+ const free = item.remaining === null;
+ const [play, setPlay] = useState(free);
+ const blob = useBlobUrl(item.id, play);
+
+ if (!play) {
+ return (
+
+ );
+ }
+ if (blob.status === 'loading') return 加载中…
;
+ if (blob.status === 'error') return 无法播放,可以直接下载。
;
+ return ;
+}
diff --git a/web/src/features/item/ItemDetail.module.css b/web/src/features/item/ItemDetail.module.css
new file mode 100644
index 0000000..1127728
--- /dev/null
+++ b/web/src/features/item/ItemDetail.module.css
@@ -0,0 +1,55 @@
+.detail {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.title {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-size: 18px;
+}
+
+.meta {
+ margin: 0;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.banner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border-left: 3px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+}
+
+.update {
+ border-color: var(--busy);
+ background: linear-gradient(90deg, var(--busy-soft), transparent);
+}
+
+.conflict {
+ border-color: var(--warn);
+ background: linear-gradient(90deg, var(--warn-soft), transparent);
+}
+
+.body {
+ min-width: 0;
+}
+
+.dim {
+ margin: 0;
+ color: var(--text-dim);
+}
diff --git a/web/src/features/item/ItemDetail.tsx b/web/src/features/item/ItemDetail.tsx
new file mode 100644
index 0000000..19b7ed9
--- /dev/null
+++ b/web/src/features/item/ItemDetail.tsx
@@ -0,0 +1,100 @@
+import { useState } from 'react';
+import type { Item } from '@/core-bridge/types';
+import { isLongText } from '@/lib/fileKind';
+import { formatSize, formatTime } from '@/lib/format';
+import { Button } from '@/ui/Button';
+import { CopyButton } from '@/ui/CopyButton';
+import { useDownload } from './download';
+import { HistoryList } from './HistoryList';
+import { ArchivePreview } from './previews/ArchivePreview';
+import { MediaPreview } from './previews/MediaPreview';
+import { TextPreview } from './previews/TextPreview';
+import { useItemSource } from './source';
+import { shortDevice, titleOf } from './summary';
+import { TextEditor } from './TextEditor';
+import styles from './ItemDetail.module.css';
+
+interface Props {
+ id: string;
+ /** 桌面内联预览区需要标题;抽屉里标题在抽屉头部 */
+ showTitle?: boolean;
+ /** 只读分享:不显示编辑、删除、冲突与更新提醒 */
+ readOnly?: boolean;
+ onDelete: (id: string) => void;
+}
+
+/** 条目详情:预览 + 操作 + 历史。桌面是右侧内联预览区,手机是底部抽屉 */
+export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props) {
+ const source = useItemSource();
+ const item = source.useItem(id);
+ const nameOf = source.useDeviceNamer();
+ const download = useDownload();
+ const actions = source.actions;
+ const [editing, setEditing] = useState(false);
+ if (!item) return null;
+
+ const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
+ const long = item.kind === 'text' && isLongText(item.text);
+ const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
+
+ return (
+
+ {showTitle ?
{titleOf(item)}
: null}
+
+ {shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
+ {item.kind === 'file' && item.remaining !== null ? ` · 剩余 ${item.remaining} 次下载` : ''}
+
+
+
+ {item.kind === 'text' ? : null}
+
+ {item.kind === 'text' && !readOnly ? : null}
+ {readOnly ? null :
+
+ {!readOnly && item.kind === 'file' && item.unseen ? (
+
+ {shortDevice(nameOf(item.deviceId))} 更新了此文件 · {formatTime(item.ts)}
+
+
+ ) : null}
+ {!readOnly && item.kind === 'text' && item.conflict ? (
+
+ {shortDevice(item.kind === 'text' ? nameOf(item.conflict?.deviceId) : undefined)} 同时改过:「{item.conflict.text}」
+
+
+
+
+
+ ) : null}
+
+
+ {receiving !== null ?
传输中 {Math.round(receiving * 100)}%,完成后可预览。
: null}
+ {receiving === null && editing && item.kind === 'text' ? (
+
setEditing(false)} />
+ ) : null}
+ {receiving === null && !editing ? : null}
+
+
+ {item.kind === 'file' && item.versions?.length ?
: null}
+
+ );
+}
+
+function Body({ item }: { item: Item }) {
+ if (item.kind === 'text') return ;
+ switch (item.preview) {
+ case 'text':
+ return ;
+ case 'archive':
+ return ;
+ case 'image':
+ case 'video':
+ case 'audio':
+ return ;
+ case 'none':
+ return 此类型暂不支持预览,可以直接下载。
;
+ }
+}
diff --git a/web/src/features/item/PreviewPane.module.css b/web/src/features/item/PreviewPane.module.css
new file mode 100644
index 0000000..656d484
--- /dev/null
+++ b/web/src/features/item/PreviewPane.module.css
@@ -0,0 +1,10 @@
+.pane {
+ min-width: 0;
+ padding: var(--space-6);
+ overflow-y: auto;
+}
+
+.empty {
+ margin: 0;
+ color: var(--text-dim);
+}
diff --git a/web/src/features/item/PreviewPane.tsx b/web/src/features/item/PreviewPane.tsx
new file mode 100644
index 0000000..75ec9aa
--- /dev/null
+++ b/web/src/features/item/PreviewPane.tsx
@@ -0,0 +1,16 @@
+import ItemDetail from './ItemDetail';
+import styles from './PreviewPane.module.css';
+
+interface Props {
+ id: string | undefined;
+ onDelete: (id: string) => void;
+}
+
+/** 电脑上的条目详情:列表右侧的内联预览区,不弹窗 */
+export default function PreviewPane({ id, onDelete }: Props) {
+ return (
+
+ {id ? : 选择左侧的内容进行预览。
}
+
+ );
+}
diff --git a/web/src/features/item/PreviewSheet.module.css b/web/src/features/item/PreviewSheet.module.css
new file mode 100644
index 0000000..497d2b2
--- /dev/null
+++ b/web/src/features/item/PreviewSheet.module.css
@@ -0,0 +1,12 @@
+.size {
+ margin-left: var(--space-2);
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 400;
+}
+
+.none {
+ margin: 0;
+ color: var(--text-dim);
+}
diff --git a/web/src/features/item/PreviewSheet.tsx b/web/src/features/item/PreviewSheet.tsx
new file mode 100644
index 0000000..f7b4846
--- /dev/null
+++ b/web/src/features/item/PreviewSheet.tsx
@@ -0,0 +1,28 @@
+import { Sheet } from '@/ui/Sheet';
+import ItemDetail from './ItemDetail';
+import { useItemSource } from './source';
+import { titleOf } from './summary';
+
+interface Props {
+ id: string;
+ onClose: () => void;
+ readOnly?: boolean;
+ onDelete?: (id: string) => void;
+}
+
+/** 手机上的条目详情:底部抽屉 */
+export default function PreviewSheet({ id, onClose, readOnly, onDelete }: Props) {
+ const item = useItemSource().useItem(id);
+ return (
+
+ {
+ onDelete?.(i);
+ onClose();
+ }}
+ />
+
+ );
+}
diff --git a/web/src/features/item/TextEditor.module.css b/web/src/features/item/TextEditor.module.css
new file mode 100644
index 0000000..8ab1ef7
--- /dev/null
+++ b/web/src/features/item/TextEditor.module.css
@@ -0,0 +1,26 @@
+.editor {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.input {
+ width: 100%;
+ padding: var(--space-3);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ font-family: var(--font-mono);
+ font-size: 13px;
+ resize: vertical;
+}
+
+.input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+.actions {
+ display: flex;
+ gap: var(--space-2);
+}
diff --git a/web/src/features/item/TextEditor.tsx b/web/src/features/item/TextEditor.tsx
new file mode 100644
index 0000000..db2475c
--- /dev/null
+++ b/web/src/features/item/TextEditor.tsx
@@ -0,0 +1,30 @@
+import { useState } from 'react';
+import type { TextItem } from '@/core-bridge/types';
+import { useItemSource } from './source';
+import { Button } from '@/ui/Button';
+import styles from './TextEditor.module.css';
+
+/** 修改 = 追加新版本,后写者胜(ui-design §6.4) */
+export function TextEditor({ item, onDone }: { item: TextItem; onDone: () => void }) {
+ const editText = useItemSource().actions?.editText;
+ const [draft, setDraft] = useState(item.text);
+
+ return (
+
+ );
+}
diff --git a/web/src/features/item/Tile.module.css b/web/src/features/item/Tile.module.css
new file mode 100644
index 0000000..eab2133
--- /dev/null
+++ b/web/src/features/item/Tile.module.css
@@ -0,0 +1,16 @@
+.tile {
+ display: grid;
+ flex: none;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--tile-gradient);
+ color: var(--text-dim);
+}
+
+.thumb {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
diff --git a/web/src/features/item/Tile.tsx b/web/src/features/item/Tile.tsx
new file mode 100644
index 0000000..ac519d7
--- /dev/null
+++ b/web/src/features/item/Tile.tsx
@@ -0,0 +1,23 @@
+import type { Item } from '@/core-bridge/types';
+import { Icon } from '@/ui/Icon';
+import { iconOf } from './summary';
+import { useBlobUrl } from './useBlobUrl';
+import styles from './Tile.module.css';
+
+function Thumb({ id }: { id: string }) {
+ const blob = useBlobUrl(id);
+ return blob.status === 'ready' ?
: null;
+}
+
+/**
+ * 条目的缩略图:图片显示缩略图,其余显示类型图标。
+ * 有下载上限的文件(GATED)拉取一次就消耗一次下载,所以不自动拉取缩略图。
+ */
+export function Tile({ item, size = 44 }: { item: Item; size?: number }) {
+ const thumb = item.kind === 'file' && item.preview === 'image' && item.state.kind === 'complete' && item.remaining === null;
+ return (
+
+ {thumb ? : }
+
+ );
+}
diff --git a/web/src/features/item/download.ts b/web/src/features/item/download.ts
new file mode 100644
index 0000000..4ca8cea
--- /dev/null
+++ b/web/src/features/item/download.ts
@@ -0,0 +1,20 @@
+import type { Item } from '@/core-bridge/types';
+import { saveBlob } from '@/lib/download';
+import { useItemSource } from './source';
+
+export function itemFileName(item: Item): string {
+ return item.kind === 'file' ? item.name : `文本-${item.id}.txt`;
+}
+
+/** 返回一个下载函数,取内容的来源由当前上下文(频道或分享)决定 */
+export function useDownload(): (item: Item) => Promise {
+ const { getBlob } = useItemSource();
+ return async (item) => {
+ try {
+ saveBlob(await getBlob(item.id), itemFileName(item));
+ } catch {
+ // 原型用 alert 兜底;下载次数用完是 410 gone(api.md)
+ alert('无法下载:下载次数已用完,或内容已过期。');
+ }
+ };
+}
diff --git a/web/src/features/item/previews/ArchivePreview.tsx b/web/src/features/item/previews/ArchivePreview.tsx
new file mode 100644
index 0000000..163ebdc
--- /dev/null
+++ b/web/src/features/item/previews/ArchivePreview.tsx
@@ -0,0 +1,51 @@
+import { useEffect, useState } from 'react';
+import type { ArchiveEntry } from '@/core-bridge/types';
+import { formatSize } from '@/lib/format';
+import { Icon } from '@/ui/Icon';
+import { useItemSource } from '../source';
+import styles from './previews.module.css';
+
+/** 列出压缩包内的文件(不解压、不执行) */
+export function ArchivePreview({ id }: { id: string }) {
+ const { listArchive } = useItemSource();
+ const [entries, setEntries] = useState(null);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ listArchive(id).then(
+ (e) => !cancelled && setEntries(e),
+ () => !cancelled && setFailed(true),
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, [id, listArchive]);
+
+ if (failed) return 无法读取压缩包目录,可以直接下载。
;
+ if (!entries) return 读取目录…
;
+
+ const files = entries.filter((e) => !e.dir);
+ const total = files.reduce((sum, e) => sum + e.size, 0);
+
+ return (
+ <>
+
+ {files.length} 个文件,解压后约 {formatSize(total)}
+
+
+ {entries.map((e) => {
+ const depth = e.path.replace(/\/$/, '').split('/').length - 1;
+ const name = e.path.replace(/\/$/, '').split('/').pop();
+ return (
+ -
+
+ {name}
+ {e.dir ? null : {formatSize(e.size)}}
+
+ );
+ })}
+
+ >
+ );
+}
diff --git a/web/src/features/item/previews/MediaPreview.tsx b/web/src/features/item/previews/MediaPreview.tsx
new file mode 100644
index 0000000..b6b2b4b
--- /dev/null
+++ b/web/src/features/item/previews/MediaPreview.tsx
@@ -0,0 +1,21 @@
+import { useState } from 'react';
+import { useBlobUrl } from '../useBlobUrl';
+import styles from './previews.module.css';
+
+interface Props {
+ id: string;
+ kind: 'image' | 'video' | 'audio';
+}
+
+/** 媒体用原生元素加载 blob URL,不执行任何内容里的脚本 */
+export function MediaPreview({ id, kind }: Props) {
+ const blob = useBlobUrl(id);
+ const [broken, setBroken] = useState(false);
+ if (blob.status === 'loading') return 加载中…
;
+ if (blob.status === 'error') return 无法预览此内容,可以直接下载。
;
+
+ if (broken) return 此浏览器无法显示该格式(例如 iPhone 的 HEIC 照片),可以直接下载。
;
+ if (kind === 'image') return
setBroken(true)} />;
+ if (kind === 'video') return