diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000..fa48364 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,56 @@ +name: Frontend CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frontend-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + frontend-checks: + name: Frontend checks + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CI: 'true' + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm run test + + - name: Build + run: npm run build diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md new file mode 100644 index 0000000..0c326e6 --- /dev/null +++ b/frontend-architecture-v3.md @@ -0,0 +1,115 @@ +# Windup 前端架构 + +本文记录当前前端的模块划分、依赖规则和已经落地的首个工作流纵切。 + +--- + +## 1. 模块划分 + +业务模块都在 `src/entities/` 下: + +| 模块 | 职责 | +|---|---| +| `project` | 项目级全局约束:视角、朝向数、精灵尺寸、画风 | +| `character` | 角色资产。造型、动作、帧是它内部的一棵树 | +| `action-template` | 能跨角色复用的动作配方 | +| `generation` | 一次生成任务这份业务数据 | +| `media` | 已上传媒体的不透明引用 | +| `task` | 后端异步步骤的状态 | +| `workflow-run` | 制作流程的运行记录 | + +**模块判据:这个东西能不能被单独取到。** + +能单独取,说明它需要自己的一套取数逻辑,才值得一个模块;取不到的,它只是别人身上的一个字段。 + +按这条判据,`Outfit`、`Action`、`Frame` 没有独立模块——它们不能脱离 `Character` 被取到,所以是 `character` 内部的类型。`ActionTemplate` 有独立模块,因为它能被不同角色复用。 + +--- + +## 2. 层次 + +```text +pages -> features -> entities -> shared +``` + +| 层 | 内容 | +|---|---| +| `pages` | 八个路由页面 | +| `features` | 用户操作:角色设置、生成、审核、导出;以及流程推进 `workflow-controller` | +| `entities` | 上表业务模块 | +| `shared` | 无业务语义的形状,目前只有分页 | + +`app` 只做启动和路由,不构造服务、不向下注入。 + +### 依赖规则 + +1. 只能向下依赖,不允许反向。 +2. 同层模块之间不互相导入。要共用就往下沉。 +3. 跨模块只从模块目录的 `index.ts` 进入;`entities` 统一从 `@/entities` 使用。 +4. `entities` 内部模块之间可以互相导入,对外仍是一个门。 + +--- + +## 3. 接口命名 + +需要访问后端资源的模块暴露一组接口,统一叫 `XxxApis`: + +```text +ProjectApis CharacterApis ActionTemplateApis GenerationApis +TaskApis +``` + +**不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 + +`WorkflowRun` 是前端运行态,不声明后端接口。后端不读取、不推进、也不持久化它。 + +--- + +## 4. 流程推进 + +`features/workflow-controller` 是快速开始与手动工作流共用的推进边界,不含界面。 + +Controller 围绕同一份 WorkflowRun 提供创建、读取、订阅、当前步骤更新、推进、 +任务恢复、结果写回和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 + +步骤顺序固定八步: + +```text +角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 +``` + +**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun, +只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。固定八步是当前 +产品流程,不是为了通用编排而写的可配置工作流。 + +当前存储版本只支持一个 Revision。从历史步骤重开尚未进入产品定义,Controller +不提前暴露该操作;实现时必须同步升级本地存储版本和迁移规则。 + +快速开始与手动模式将共用同一份推进逻辑,但连续自动推进属于 Quick Start 页面接入范围, +当前 Controller 只实现一次推进一个步骤。 + +Controller 的提交锁和任务订阅属于实例状态。页面接入时必须复用同一个 Feature 实例, +不能在组件渲染或路由切换时重复创建。 + +--- + +## 5. 当前实现范围 + +- `WorkflowRun` 的内存状态、版本化 localStorage 镜像和刷新校验 +- `角色资料 → 角色图生成 → 候选选择` 的 Controller 纵切 +- Store、Controller 和纵向流程测试 + +页面、Workflow Editor、Quick Start 自动推进、后五步和真实后端适配器仍未实现。 + +### 恢复边界 + +- 已取得 `taskId`:刷新后先查询任务当前状态,未结束才重新订阅。 +- 请求已经发出但尚未取得 `taskId`:后端没有幂等键或按请求标识查询的能力, + 前端将本地 Run 标为失败,不自动重提,避免静默创建重复任务。 +- localStorage 写入失败时当前会话继续使用内存快照;页面提示与重新持久化策略在 UI 接入时补充。 + +--- + +## 6. 未与后端对齐的部分 + +明细见 `frontend/API_CONTRACT.md`。 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..00baad0 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Vercel 本地项目链接元数据,不提交账号和项目 ID +.vercel + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.env* diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json new file mode 100644 index 0000000..293ef6a --- /dev/null +++ b/frontend/.oxfmtrc.json @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "endOfLine": "lf", + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "ignorePatterns": ["**/*.md"] +} diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md new file mode 100644 index 0000000..427b6eb --- /dev/null +++ b/frontend/API_CONTRACT.md @@ -0,0 +1,119 @@ +# 前后端接口对齐清单 + +前端各模块的 `XxxApis` 与后端 2026-07-30 接口文档逐条比对结果。 + +后端现有四个相关模块:`project`、`character`、`generation`、`media`。`asset` 与 `wearable` 已按 07-30 评审要求删除。 + +--- + +## 一、已经确认的边界 + +- `WorkflowRun` 是前端固定工作流的运行态。后端不读取、不推进、也不持久化,前端不声明 `WorkflowRunApis`。 +- `Character` 不使用独立 `name` 字段;前端已删除。 +- 前端保留 `jump` 动作类型,由后端补充对应枚举。 +- 查询生成任务统一携带 `projectId + taskId`。 +- 前端工作流节点不与后端 `GenerationType` 一一对应,按下表调用: + +| 前端工作流节点 | 后端接口 | 后端任务类型 | +|---|---|---| +| `character_template` | `POST /generation/image` | `character_image` | +| `first_frame` | `POST /generation/image`,以上一步角色图作为参考图 | `character_image` | +| `complete_animation` | `POST /generation/action`,以已确认动作首帧作为参考图 | `character_action` | + +图片生成和动作生成只返回任务及结果,不自动修改 WorkflowRun 或角色资产。用户最终确认后,前端再通过角色更新接口保存角色图和完整动作数据。 + +--- + +## 二、前端预期有、后端目前没有 + +**这些接口仍需要确定由后端提供,还是改为前端本地能力。** + +| 前端接口 | 后端情况 | +|---|---| +| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | +| `ProjectApis.update` | 没有 `PATCH /projects/{project_id}` | + +前端已按服务端现状去掉 `TaskApis.cancel`——后端没有取消能力,不声明前端用不到的接口。 + +--- + +## 三、形状不一致 + +这些差异可以在前端接口层转换,不要求领域类型与后端 DTO 使用相同命名。 + +| 项 | 后端 | 前端 | +|---|---|---| +| 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | +| 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | +| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.subscribe`;适配器先立即回放当前快照,再继续轮询 | +| 图片生成数量 | 入参有 `num_images`,结果只有一个 `image_url` | 角色图候选结果是 `images[]` | +| 动作类型 | `walk` `idle` `attack` `custom`;待增加 `jump` | `walk` `idle` `attack` `jump` `custom` | +| 角色视角 | `character_perspective` 为 `1~3`,文档中 2、3 都写成“正面” | `side` `top-down` `isometric` | + +ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不需要后端改动。 + +--- + +## 四、后端有、前端没接 + +| 后端 | 说明 | +|---|---| +| `delete_character` | 前端 `CharacterApis` 没有删除 | +| `Character.description` | 后端存在实体上;前端只在创建入参里,创建完查不到 | +| `Character.reference_image_url` | 后端存在实体上;前端 `Character` 类型没有这个字段 | +| `MediaService.upload` | 前端本次未提交上传模块 | + +--- + +## 五、前端资产字段在后端没有落点 + +后端 `character_data` 的嵌套结构(见 `character/model.py`): + +```text +outfits[] → id / name / preview_url / actions[] +actions[] → id / type / name / loop / fps / frame_count / frames[] +frames[] → index / image_url / duration_ms +``` + +前端以下字段在后端结构里没有落点: + +- `Action.kind`(preset / custom 来源) +- `Action.keyFrameIndex` +- `Frame.rootMotion` +- `Outfit.candidateCharacterTemplates`(母版候选列表) +- `Outfit.characterTemplateUrl`(每套造型的已确认角色图) +- `Outfit.baseFrames` + +`candidateCharacterTemplates` 属于生成过程数据;若只在当前 WorkflowRun 中使用,可以留在前端。其余字段若要随最终资产恢复,需要后端增加字段,或者前端在 MVP 中删除。 + +--- + +## 六、概念不一致 + +后端 `character/model.py` 字段说明: + +> `reference_image_url`: 角色参考图,即旧概念中的 Character Template + +前端把这两者当成不同的东西: + +- 用户上传的参考图 —— 创建角色时的输入 +- AI 生成后用户选定的角色图(母版)—— `Outfit.characterTemplateUrl` + +**后端合成了一个字段。** 07-30 评审也提到「模板」这个叫法容易与 action template 混淆,暂改称「角色图」。三方对这里是几个概念的理解需要统一。 + +--- + +## 待确认 + +- [ ] `ActionTemplateApis` 由后端提供还是前端内置 +- [ ] 母版候选几张 +- [ ] 参考图与角色图是一个字段还是两个 +- [ ] `Character.description` 前端要不要跟着存 +- [ ] `Action.kind` / `Action.keyFrameIndex` / `Frame.rootMotion` 是否进入最终资产 +- [ ] 上传模块何时提交 + +## 已分工 + +- [x] 前端删除 `WorkflowRunApis`,WorkflowRun 全程由前端管理 +- [x] 前端删除 `Character.name` +- [ ] 后端增加 `jump` 动作类型 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a7878ed --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# Windup 前端 + +React + Vite + TypeScript。 + +## 开发 + +```bash +npm ci +npm run dev +``` + +## 检查 + +```bash +npm run format:check # 格式 +npm run lint # 静态检查 +npm run typecheck # 类型 +npm run test # 单元与纵向集成测试 +npm run build # 构建 +``` + +CI 按上面顺序全跑一遍。 + +## 结构 + +模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 + +当前已实现纯前端 `WorkflowRun` 存储,以及 +`角色资料 → 角色图生成 → 候选选择` 的首个 Controller 纵切。页面仍是占位外壳, +后五步和真实 `XxxApis` 实现按模块拆成后续 PR。 + +与后端尚未对齐的接口见 `API_CONTRACT.md`。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..cd1df0c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Windup · 2D 角色资产生成 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d513d30 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3630 @@ +{ + "name": "windup-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "windup-frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "oxfmt": "^0.61.0", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "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" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "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.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "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.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "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.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "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/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.61.0.tgz", + "integrity": "sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.61.0.tgz", + "integrity": "sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.61.0.tgz", + "integrity": "sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.61.0.tgz", + "integrity": "sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.61.0.tgz", + "integrity": "sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.61.0.tgz", + "integrity": "sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.61.0.tgz", + "integrity": "sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.61.0.tgz", + "integrity": "sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.61.0.tgz", + "integrity": "sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.61.0.tgz", + "integrity": "sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.61.0.tgz", + "integrity": "sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.61.0.tgz", + "integrity": "sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.61.0.tgz", + "integrity": "sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.61.0.tgz", + "integrity": "sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.61.0.tgz", + "integrity": "sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.61.0.tgz", + "integrity": "sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.61.0.tgz", + "integrity": "sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.61.0.tgz", + "integrity": "sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.61.0.tgz", + "integrity": "sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz", + "integrity": "sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.75.0.tgz", + "integrity": "sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz", + "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.75.0.tgz", + "integrity": "sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.75.0.tgz", + "integrity": "sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.75.0.tgz", + "integrity": "sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.75.0.tgz", + "integrity": "sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.75.0.tgz", + "integrity": "sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.75.0.tgz", + "integrity": "sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.75.0.tgz", + "integrity": "sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.75.0.tgz", + "integrity": "sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.75.0.tgz", + "integrity": "sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.75.0.tgz", + "integrity": "sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.75.0.tgz", + "integrity": "sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.75.0.tgz", + "integrity": "sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.75.0.tgz", + "integrity": "sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.75.0.tgz", + "integrity": "sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.75.0.tgz", + "integrity": "sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.75.0.tgz", + "integrity": "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "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/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "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.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "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/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "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/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "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/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "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/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "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/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "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/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "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/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "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", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "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/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "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/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "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/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/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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oxfmt": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.61.0.tgz", + "integrity": "sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.61.0", + "@oxfmt/binding-android-arm64": "0.61.0", + "@oxfmt/binding-darwin-arm64": "0.61.0", + "@oxfmt/binding-darwin-x64": "0.61.0", + "@oxfmt/binding-freebsd-x64": "0.61.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.61.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.61.0", + "@oxfmt/binding-linux-arm64-gnu": "0.61.0", + "@oxfmt/binding-linux-arm64-musl": "0.61.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.61.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.61.0", + "@oxfmt/binding-linux-riscv64-musl": "0.61.0", + "@oxfmt/binding-linux-s390x-gnu": "0.61.0", + "@oxfmt/binding-linux-x64-gnu": "0.61.0", + "@oxfmt/binding-linux-x64-musl": "0.61.0", + "@oxfmt/binding-openharmony-arm64": "0.61.0", + "@oxfmt/binding-win32-arm64-msvc": "0.61.0", + "@oxfmt/binding-win32-ia32-msvc": "0.61.0", + "@oxfmt/binding-win32-x64-msvc": "0.61.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz", + "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.75.0", + "@oxlint/binding-android-arm64": "1.75.0", + "@oxlint/binding-darwin-arm64": "1.75.0", + "@oxlint/binding-darwin-x64": "1.75.0", + "@oxlint/binding-freebsd-x64": "1.75.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.75.0", + "@oxlint/binding-linux-arm-musleabihf": "1.75.0", + "@oxlint/binding-linux-arm64-gnu": "1.75.0", + "@oxlint/binding-linux-arm64-musl": "1.75.0", + "@oxlint/binding-linux-ppc64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-musl": "1.75.0", + "@oxlint/binding-linux-s390x-gnu": "1.75.0", + "@oxlint/binding-linux-x64-gnu": "1.75.0", + "@oxlint/binding-linux-x64-musl": "1.75.0", + "@oxlint/binding-openharmony-arm64": "1.75.0", + "@oxlint/binding-win32-arm64-msvc": "1.75.0", + "@oxlint/binding-win32-ia32-msvc": "1.75.0", + "@oxlint/binding-win32-x64-msvc": "1.75.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "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/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "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/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "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.3.0", + "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/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0a845f7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "windup-frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "format": "oxfmt", + "format:check": "oxfmt --check", + "preview": "vite preview", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -b" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "oxfmt": "^0.61.0", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10" + } +} diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx new file mode 100644 index 0000000..b46ae87 --- /dev/null +++ b/frontend/src/app/app.tsx @@ -0,0 +1,36 @@ +import { BrowserRouter, Route, Routes } from 'react-router' + +import { AssetLibraryPage } from '@/pages/asset-library' +import { HomePage } from '@/pages/home' +import { NotFoundPage } from '@/pages/not-found' +import { PlaytestPage } from '@/pages/playtest' +import { ProjectDetailPage } from '@/pages/project-detail' +import { ProjectsPage } from '@/pages/projects' +import { QuickStartPage } from '@/pages/quick-start' +import { WorkflowEditorPage } from '@/pages/workflow-editor' +import { AppShell } from './layout' + +/** + * 路由表与全局外壳。 + * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 + */ +export function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/frontend/src/app/index.ts b/frontend/src/app/index.ts new file mode 100644 index 0000000..f1c81c7 --- /dev/null +++ b/frontend/src/app/index.ts @@ -0,0 +1 @@ +export { App } from './app' diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx new file mode 100644 index 0000000..aa21b82 --- /dev/null +++ b/frontend/src/app/layout/index.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react' +import { Link } from 'react-router' + +/** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */ + +export interface AppShellProps { + /** 渲染在全局导航下方的当前路由页面。 */ + children: ReactNode +} + +/** 全站外壳,全局导航常驻。 */ +export function AppShell({ children }: AppShellProps) { + return ( +
+ +
{children}
+
+ ) +} diff --git a/frontend/src/entities/action-template/index.ts b/frontend/src/entities/action-template/index.ts new file mode 100644 index 0000000..2dafa39 --- /dev/null +++ b/frontend/src/entities/action-template/index.ts @@ -0,0 +1,14 @@ +interface ActionTemplateBase { + id: string + name: string + prompt: string +} + +/** 系统内置模板没有项目归属;项目自定义模板必须携带所属 Project ID。 */ +export type ActionTemplate = ActionTemplateBase & + ({ scope: 'system'; projectId: null } | { scope: 'project'; projectId: string }) + +/** ActionTemplate 对应的一组后端接口。 */ +export interface ActionTemplateApis { + listAvailable(projectId: string): Promise +} diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts new file mode 100644 index 0000000..616b5db --- /dev/null +++ b/frontend/src/entities/character/index.ts @@ -0,0 +1,137 @@ +/** + * 动作「如何被定义」的来源维度:preset 复用预设定义,custom 由用户自定义。 + * 它与动作做什么的 ActionType 相互独立,例如 custom + walk 和 preset + custom 都是合法组合。 + */ +export type ActionKind = 'preset' | 'custom' + +/** + * 动作「做什么」的业务语义维度;custom 表示不属于当前内置语义枚举。 + * 它不表示定义来源:custom 来源仍可描述 walk,preset 来源也可承载 custom 业务语义。 + */ +export type ActionType = 'walk' | 'idle' | 'attack' | 'jump' | 'custom' + +/** 单帧相对动作首帧的根位移,单位为像素。 */ +export interface FrameRootMotion { + dx: number + /** 正值表示向上。 */ + dy: number +} + +/** + * 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 + * + * 不带任何审核字段:服务端只交付生成好的帧,不返回质检结论;用户侧的审核也只是查看, + * 没有打回。前端若要做自动质检,那是读取帧之后在本地算出来的临时结论,不属于资产数据。 + */ +export interface Frame { + imageUrl: string + /** + * 此帧的显示时长,单位毫秒。 + * null 表示该帧没有独立时长,读取方才使用所属 Action.fps 计算等时长回退值。 + */ + durationMs: number | null + /** null 表示不提供根位移,Playtest 与 Export 不应据此施加任何位移。 */ + rootMotion: FrameRootMotion | null +} + +/** 一张母版候选;attemptId 用于区分候选所属的生成尝试。 */ +export interface CharacterTemplateCandidate { + id: string + imageUrl: string + attemptId: string +} + +/** 确认母版候选时同时命名造型与候选,避免两个字符串 ID 在调用处颠倒。 */ +export interface ConfirmCharacterTemplateInput { + outfitId: string + candidateId: string +} + +/** 造型的基础参考帧;方向与质检结构等待真实资产契约后再扩展。 */ +export interface BaseFrame { + readonly imageUrl: string +} + +/** 某个角色造型下的一段动画动作。 */ +export interface Action { + /** + * 仅在所属 Outfit 内唯一。动作没有自己的表,整棵树存在 character 记录里, + * 因此不存在全局唯一的动作 ID:任何按 ID 定位动作的地方都必须同时带上造型。 + */ + id: string + outfitId: Outfit['id'] + name: string + /** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */ + kind: ActionKind + /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ + type: ActionType + /** + * 每秒播放帧数。仅当某帧 durationMs 为 null 时用于等时长回退; + * Playtest 与 Export 不得用前端全局常量替代,也不得覆盖帧自己的 durationMs。 + */ + fps: number + /** + * 攻击触点、跳跃顶点等关键时刻在 frames 中的零基下标;null 表示没有明确关键帧。 + * 非 null 值必须指向当前 frames 数组内的成员。 + */ + keyFrameIndex: number | null + /** + * 按播放顺序排列的帧;数组下标就是零基帧序号。 + * 当前只表达单朝向。Project.directionalMovement 的四向/八向要如何落到这里 + * (本层再分组,还是一个朝向一条 Action)尚未有产品定义,不要凭猜先定结构。 + */ + frames: Frame[] +} + +/** 同一角色的一套独立造型;MVP UI 只展示第一套,但数据结构不折叠该层。 */ +export interface Outfit { + /** + * 仅在所属 Character 内唯一。造型没有自己的表,与动作一起存在 character 记录里, + * 因此不存在全局唯一的造型 ID:任何按 ID 定位造型的地方都必须同时带上角色。 + */ + id: string + characterId: string + name: string + /** 母版生成阶段返回的候选;生成完成前可以为空数组。 */ + candidateCharacterTemplates: CharacterTemplateCandidate[] + /** 用户从候选图中选定的角色母版 URL;尚未选定时为 null。 */ + characterTemplateUrl: string | null + /** 供后续动作生成使用的只读基础帧入口。 */ + readonly baseFrames: readonly BaseFrame[] + /** 每个 Action.outfitId 必须等于本造型 ID。 */ + actions: Action[] +} + +/** + * 项目下的角色资产;造型拥有各自的母版和动作帧。 + * + * 这棵树只承载已导出到资产库的内容,因此其中的动作一律是已确认的,不带生成过程状态。 + * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的步骤里,直到用户确认导出才整体写入。 + */ +export interface Character { + id: string + projectId: string + /** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */ + outfits: Outfit[] + createdAt: string + updatedAt: string +} + +/** 创建角色并发起母版生成所需的入参。 */ +export interface CreateCharacterInput { + projectId: string + /** 交给模型生成母版。 */ + description: string + referenceImageUrl?: string | null +} + +/** + * Character 对应的一组后端接口。 + * 造型、动作和帧是 Character 内的完整树,不通过独立粒度方法写入;每次确认后整棵更新。 + */ +export interface CharacterApis { + get(id: Character['id']): Promise + listByProject(projectId: string): Promise + create(input: CreateCharacterInput): Promise + update(character: Character): Promise +} diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts new file mode 100644 index 0000000..7ca1889 --- /dev/null +++ b/frontend/src/entities/generation/index.ts @@ -0,0 +1,128 @@ +import type { ActionType } from '../character' +import type { MediaReference } from '../media' +import type { Task, TaskStatus } from '../task' + +/** + * Generation 是业务数据,不是「调用图片生成能力」。 + * 前端只创建 generation 并订阅它的状态;真正调用模型的是后端,前端不接触那一层。 + */ + +/** 生成对应的三个前端可见异步步骤。 */ +export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' + +interface GenerationInputBase { + projectId: string + /** 可选参考媒体;没有参考图时传空数组。 */ + referenceMedia: readonly MediaReference[] +} + +/** 角色母版候选生成。 */ +export interface CharacterTemplateGenerationInput extends GenerationInputBase { + type: 'character_template' + /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ + prompt: string +} + +/** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ +export interface FirstFrameGenerationInput extends GenerationInputBase { + type: 'first_frame' + characterId: string + outfitId: string + actionType: ActionType + /** 自定义动作或额外动作要求;没有时为 null。 */ + prompt: string | null +} + +/** 以已确认首帧为起点生成完整动画。 */ +export interface CompleteAnimationGenerationInput extends GenerationInputBase { + type: 'complete_animation' + characterId: string + outfitId: string + actionType: ActionType + /** 已确认的生成首帧 URL。 */ + firstFrameUrl: string + prompt: string | null +} + +export type GenerationInput = + | CharacterTemplateGenerationInput + | FirstFrameGenerationInput + | CompleteAnimationGenerationInput + +/** 后端当前能交付给前端的最小图片结果。 */ +export interface GeneratedImage { + url: string +} + +/** 结果按 type 分别定义,不共用一个 urls 数组。 */ +export interface CharacterTemplateGenerationResult { + type: 'character_template' + images: readonly GeneratedImage[] +} + +/** Task.result 来自运行时边界,写回 WorkflowRun 前必须按生成类型收窄。 */ +export function parseCharacterTemplateGenerationResult( + value: unknown, +): CharacterTemplateGenerationResult | null { + if (!isRecord(value) || value.type !== 'character_template' || !Array.isArray(value.images)) { + return null + } + const images = value.images.filter( + (image): image is GeneratedImage => + isRecord(image) && typeof image.url === 'string' && image.url.length > 0, + ) + if (images.length === 0 || images.length !== value.images.length) return null + return { type: 'character_template', images } +} + +export interface FirstFrameGenerationResult { + type: 'first_frame' + image: GeneratedImage +} + +/** 帧顺序由数组位置表达。 */ +export interface CompleteAnimationGenerationResult { + type: 'complete_animation' + frames: readonly GeneratedImage[] +} + +export type GenerationResult = + | CharacterTemplateGenerationResult + | FirstFrameGenerationResult + | CompleteAnimationGenerationResult + +export type GenerationResultFor = + T extends CharacterTemplateGenerationInput + ? CharacterTemplateGenerationResult + : T extends FirstFrameGenerationInput + ? FirstFrameGenerationResult + : CompleteAnimationGenerationResult + +/** + * 一次生成任务的完整快照。 + * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 + */ +export interface Generation { + /** 创建接口返回的后端 Task ID;后续查询与订阅必须把它交给 TaskApis。 */ + id: Task['id'] + projectId: string + /** 与创建时的输入判别字段保持同一字面量类型。 */ + type: TType + status: TaskStatus + /** 完成前为 null;完成后形状由 type 决定。 */ + result: GenerationResult | null + /** status 为 failed 时有值。 */ + error: string | null +} + +/** Generation 对应的一组后端接口。 */ +export interface GenerationApis { + /** 创建一次生成任务。 */ + create(input: T): Promise> + /** 按所属项目和任务 ID 读取生成任务的最新快照。 */ + get(projectId: Generation['projectId'], id: Generation['id']): Promise +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts new file mode 100644 index 0000000..33b1ff8 --- /dev/null +++ b/frontend/src/entities/index.ts @@ -0,0 +1,80 @@ +/** + * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 + * 外部只从这里使用实体契约与已经落地的实体能力。 + */ + +/* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ +export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export type { + CharacterPerspective, + CreateProjectInput, + DirectionalMovement, + Project, + ProjectApis, + UpdateProjectInput, +} from './project' + +/* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ +export type { + Action, + ActionKind, + ActionType, + BaseFrame, + Character, + CharacterApis, + CharacterTemplateCandidate, + ConfirmCharacterTemplateInput, + CreateCharacterInput, + Frame, + FrameRootMotion, + Outfit, +} from './character' + +/* 动作模板 —— 能跨角色复用的配方 */ +export type { ActionTemplate, ActionTemplateApis } from './action-template' + +/* 生成 —— 业务数据,不是「调用生成能力」 */ +export { parseCharacterTemplateGenerationResult } from './generation' +export type { + CharacterTemplateGenerationInput, + CharacterTemplateGenerationResult, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, + FirstFrameGenerationInput, + FirstFrameGenerationResult, + GeneratedImage, + Generation, + GenerationApis, + GenerationInput, + GenerationResult, + GenerationResultFor, + GenerationType, +} from './generation' + +/* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ +export type { MediaReference } from './media' + +/* 后端异步任务 —— 与工作流节点是两回事 */ +export type { Task, TaskApis, TaskEvent, TaskStatus, TaskType } from './task' + +/* 工作流 —— 节点与运行状态都由前端管理 */ +export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' +export type { + CharacterSetupStepInput, + CharacterSetupWorkflowStep, + CharacterTemplateWorkflowStep, + CreateWorkflowRunStoreOptions, + CreateWorkflowRunInput, + ExportStatus, + GenerationStatus, + WorkflowDriver, + WorkflowStep, + WorkflowStepStatus, + WorkflowStepType, + WorkflowRevision, + WorkflowRevisionStatus, + WorkflowRun, + WorkflowRunStore, + WorkflowRunPurpose, + WorkflowRunStatus, +} from './workflow-run' diff --git a/frontend/src/entities/media/index.ts b/frontend/src/entities/media/index.ts new file mode 100644 index 0000000..347e626 --- /dev/null +++ b/frontend/src/entities/media/index.ts @@ -0,0 +1,9 @@ +declare const mediaReferenceBrand: unique symbol + +/** + * 已上传媒体的不透明引用。 + * 当前不承诺运行时字符串代表 URL、media_id 或其他后端标识。 + */ +export type MediaReference = string & { + readonly [mediaReferenceBrand]: 'MediaReference' +} diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts new file mode 100644 index 0000000..4c71164 --- /dev/null +++ b/frontend/src/entities/project/index.ts @@ -0,0 +1,82 @@ +import type { Paged, PageQuery } from '@/shared/pagination' + +/** Project 前端领域形状;字段只表达当前页面需要,不对应任何已确认后端 DTO。 */ +export interface Project { + id: string + /** Project 所属用户 ID;认证来源尚未冻结。 */ + ownerId: string + name: string + /** 游戏视角,见 CHARACTER_PERSPECTIVE。 */ + perspective: CharacterPerspective + /** 移动方向,见 DIRECTIONAL_MOVEMENT。 */ + directionalMovement: DirectionalMovement + /** 当前页面使用建议档位,后端范围尚未确认。 */ + spriteSize: { width: number; height: number } + /** 项目级画风描述,作为本项目所有角色和动作生成的视觉约束。 */ + gameStyle: string | null + /** + * 项目级画风参考图,本项目所有角色和动作都遵循它的视觉风格。 + * 它不决定某个角色具体长什么样;角色自身参考图由 CreateCharacterInput.referenceImageUrl 表达。 + */ + sampleImageUrl: string | null + /** ISO 8601 字符串。 */ + createdAt: string + /** ISO 8601 字符串。 */ + updatedAt: string +} + +/** 新建项目的入参。 */ +export interface CreateProjectInput { + name: string + perspective: CharacterPerspective + directionalMovement: DirectionalMovement + spriteSize: { width: number; height: number } + gameStyle?: string | null + sampleImageUrl?: string | null +} + +/** 更新项目设置的入参;未提供的字段保持不变。 */ +export interface UpdateProjectInput { + name?: string + perspective?: CharacterPerspective + directionalMovement?: DirectionalMovement + spriteSize?: { width: number; height: number } + gameStyle?: string | null + sampleImageUrl?: string | null +} + +/** 前端使用的游戏视角枚举;后端映射尚未冻结。 */ +export type CharacterPerspective = 'side' | 'top-down' | 'isometric' + +/** 前端使用的移动方向枚举;后端映射尚未冻结。 */ +export type DirectionalMovement = 'single' | 'four-way' | 'eight-way' + +/** 游戏视角的页面文案。 */ +export const CHARACTER_PERSPECTIVE: Record = { + side: '横版视角', + 'top-down': '俯视', + isometric: '2.5D', +} + +/** + * 移动方向,决定一个动作要生成几套朝向的帧。 + * 多朝向在 Action 上如何存放尚未定义,当前 Action.frames 只表达单朝向; + * 选了四向/八向的项目,生成侧还接不上,见 Action.frames 的说明。 + */ +export const DIRECTIONAL_MOVEMENT: Record = { + single: '单向', + 'four-way': '四向', + 'eight-way': '八向', +} + +/** UI 使用的建议尺寸档位;不代表后端约束。 */ +export const SPRITE_SIZES = [32, 64, 128, 256, 512, 1024, 2048] as const + +/** Project 对应的一组后端接口。 */ +export interface ProjectApis { + list(query?: PageQuery): Promise> + get(id: Project['id']): Promise + create(input: CreateProjectInput): Promise + update(id: Project['id'], input: UpdateProjectInput): Promise + remove(id: Project['id']): Promise +} diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts new file mode 100644 index 0000000..37e0950 --- /dev/null +++ b/frontend/src/entities/task/index.ts @@ -0,0 +1,52 @@ +/** + * 异步任务实体,与 WorkflowRun 的页面节点是两回事。 + * + * 任务粒度 = 前端可见的一个异步步骤,而不是内部某次模型调用。 + * 任务生命周期不等于工作流节点状态,两者只由 WorkflowStep.taskId 单向关联: + * 步骤记得自己发起过哪个任务,任务不认识步骤。 + */ + +/** 后端异步任务状态;pending 表示已提交但尚未执行。 */ +export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' + +/** + * MS2 前端需要展示和恢复的三类异步步骤。 + * 完整动画内部可包含视频生成、截帧和多次图像处理,但对前端仍是一个 Task。 + */ +export type TaskType = 'character_template' | 'first_frame' | 'complete_animation' + +/** + * 创建、查询和断线恢复都使用的完整任务快照。 + * TType 在调用边界已知时保留精确任务类型;按 ID 恢复时使用默认值,等待运行时解析后再收窄。 + */ +export interface Task { + /** 后端生成的任务 ID,是查询状态和订阅事件的唯一标识。 */ + id: string + /** 后端 task_type 对应的领域判别字段,不接受任意字符串。 */ + type: TType + /** 后端任务当前状态;它不会直接改变工作流节点状态。 */ + status: TaskStatus + /** 失败原因,status 为 failed 时有值。completed 不表示工作流节点已通过。 */ + error: string | null + /** 任务产出;契约冻结前保持 unknown,避免调用方依赖猜测结构。 */ + result: unknown +} + +/** 每条事件携带同类型 Task 的完整状态,taskId 对应 Task.id。 */ +export interface TaskEvent extends Omit, 'id'> { + taskId: Task['id'] +} + +/** Task 对应的一组后端接口。服务端没有取消能力,因此这里不声明 cancel。 */ +export interface TaskApis { + /** + * 按所属项目和后端 Task ID 读取最新快照。 + * projectId 不能从 taskId 推导;后端查询接口要求两者同时传入。 + */ + get(projectId: string, taskId: Task['id']): Promise + /** + * 订阅后必须立即发送一次最新完整快照,随后再发送状态变化;任务已经终止也必须发送。 + * 该语义关闭 get/create 与开始监听之间的终态竞态。 + */ + subscribe(projectId: string, taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void +} diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts new file mode 100644 index 0000000..16714ea --- /dev/null +++ b/frontend/src/entities/workflow-run/constants.ts @@ -0,0 +1,19 @@ +export const WORKFLOW_DRIVERS = ['ai', 'manual'] as const +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const +export const WORKFLOW_REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const +export const WORKFLOW_STEP_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 当前产品工作流的固定八步,也是存储校验与进度 UI 的唯一顺序来源。 */ +export const WORKFLOW_STEP_ORDER = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-setup', + 'first-frame', + 'complete-animation', + 'review', + 'export', +] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts new file mode 100644 index 0000000..f6bd2a1 --- /dev/null +++ b/frontend/src/entities/workflow-run/index.ts @@ -0,0 +1,197 @@ +import type { + CharacterTemplateGenerationInput, + CharacterTemplateGenerationResult, +} from '../generation' +import type { MediaReference } from '../media' +import type { Task } from '../task' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export { WORKFLOW_STEP_ORDER } from './constants' + +/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ +export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] + +/** 创建 WorkflowRun 时要完成的用户意图。 */ +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] + +/** + * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 + * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.steps 的数组位置表达。 + */ +/** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */ +export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] + +/** + * 步骤的可用性和执行结果;不直接复用后端任务状态。 + * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 + */ +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] + +/** + * 单个版本的生命周期。 + * abandoned 表示停止沿用但仍保留为历史。 + */ +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] + +/** + * 整次流程的汇总状态。 + * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 + * 后端 Task 是否真正停止是独立问题;当前纵切不提供重启操作。 + */ +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] + +/** 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 */ +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] + +/** 当前版本在导出阶段的汇总状态。 */ +export type ExportStatus = (typeof EXPORT_STATUSES)[number] + +interface WorkflowStepBase { + /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ + id: string + status: WorkflowStepStatus + /** + * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 + * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 + * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 + * 任务本身不认识步骤,反向关联不存在。 + */ + taskId: Task['id'] | null + /** + * 前端开始提交、但后端 taskId 尚未返回时的本地尝试标识。 + * 它非 null 而 taskId 为 null 时不能重复提交;若页面在这个窗口刷新, + * Controller 会把本地 Run 标为失败。它不是后端字段,也不冒充幂等键。 + */ + submissionId: string | null + /** 步骤失败后供页面解释原因;未失败时必须为 null。 */ + error: string | null + /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ + referenceStepIds: string[] +} + +/** 角色资料步骤保存的输入;参考媒体为空表示仅使用文字描述。 */ +export interface CharacterSetupStepInput { + description: string + referenceMedia: readonly MediaReference[] +} + +export interface CharacterSetupWorkflowStep extends WorkflowStepBase { + type: 'character-setup' + input: CharacterSetupStepInput | null + output: null +} + +export interface CharacterTemplateWorkflowStep extends WorkflowStepBase { + type: 'character-template' + /** 发起任务前为 null;提交时保存实际发送给 GenerationApis 的输入快照。 */ + input: CharacterTemplateGenerationInput | null + output: CharacterTemplateGenerationResult | null +} + +type RemainingWorkflowStepType = Exclude + +interface RemainingWorkflowStep extends WorkflowStepBase { + type: RemainingWorkflowStepType + /** 候选选择及后五步尚未实现,输入输出等对应纵切开始时再收窄。 */ + input: unknown + output: unknown +} + +/** + * 一个 Revision 中已经进入执行线的流程步骤。 + * 前两个执行步骤已冻结输入输出;候选选择及后五步进入对应纵切时再收窄, + * 不提前猜页面尚未产生的数据形状。 + */ +export type WorkflowStep = + | CharacterSetupWorkflowStep + | CharacterTemplateWorkflowStep + | RemainingWorkflowStep + +/** + * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 + * + * 当前本地存储版本只接受单条执行线:revisions 恒为一个成员, + * basedOnRevisionId 与 restartStepId 恒为 null。「从历史步骤重开并保留旧版本」 + * 尚未进入产品定义;实现时需要同步升级存储版本和迁移规则。 + */ +export interface WorkflowRevision { + id: string + /** 首次创建的版本没有来源,因此为 null。 */ + basedOnRevisionId: string | null + /** 在来源版本中选择的重启步骤 ID;非重启创建的版本为 null。 */ + restartStepId: string | null + status: WorkflowRevisionStatus + /** + * 当前版本固定保存全部八步;数组位置是步骤顺序的唯一来源。 + * 完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 + */ + steps: WorkflowStep[] + generationStatus: GenerationStatus + exportStatus: ExportStatus + createdAt: string +} + +/** + * 一次由前端推进的页面流程。 + * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。 + * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。 + */ +export interface WorkflowRun { + id: string + projectId: string + /** 已关联的 Character ID;角色尚未创建或确认时为 null。 */ + characterId: string | null + /** 已有角色加动作时的目标造型;新建角色时为 null。 */ + outfitId: string | null + purpose: WorkflowRunPurpose + driver: WorkflowDriver + status: WorkflowRunStatus + /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */ + currentRevisionId: string + /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */ + revisions: WorkflowRevision[] + /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */ + prompt: string | null +} + +/** 两种入口共享的创建字段。 */ +interface CreateWorkflowRunInputBase { + projectId: string + driver: WorkflowDriver + /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ + prompt?: string +} + +/** + * 创建 WorkflowRun 的输入。 + * add_action 分支把已有角色、造型、母版和基准帧设为必填,避免创建无法恢复的半成品运行。 + */ +export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & + ( + | { + purpose: 'create_character' + characterId?: never + outfitId?: never + characterTemplateUrl?: never + baseFrameUrls?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + characterTemplateUrl: string + baseFrameUrls: readonly string[] + } + ) + +export { createWorkflowRunStore } from './store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts new file mode 100644 index 0000000..c165a4e --- /dev/null +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, vi } from 'vitest' + +import { WORKFLOW_STEP_ORDER } from './constants' +import type { WorkflowRun, WorkflowStep } from './index' +import { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './store' + +class TestStorage { + value: string | null + failOnSet = false + + constructor(value: string | null = null) { + this.value = value + } + + getItem(): string | null { + return this.value + } + + setItem(_key: string, value: string): void { + if (this.failOnSet) throw new Error('storage unavailable') + this.value = value + } +} + +function createSteps(): WorkflowStep[] { + return WORKFLOW_STEP_ORDER.map((type, index) => { + const common = { + id: `revision-1:${type}`, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + if (type === 'character-setup') { + return { + ...common, + type, + input: { description: 'slime', referenceMedia: [] }, + output: null, + } + } + if (type === 'character-template') { + return { ...common, type, input: null, output: null } + } + return { ...common, type, input: null, output: null } as WorkflowStep + }) +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + revisions: [ + { + id: 'revision-1', + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: createSteps(), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-07-30T12:00:00.000Z', + }, + ], + prompt: 'Create a slime', + } +} + +describe('createWorkflowRunStore', () => { + it('stores a versioned snapshot and returns defensive clones', () => { + const storage = new TestStorage() + const store = createWorkflowRunStore({ storage }) + const source = createRun() + + store.save(source) + source.prompt = 'mutated outside' + + const firstRead = store.get(source.id) + expect(firstRead?.prompt).toBe('Create a slime') + + firstRead!.revisions[0].steps[0].status = 'failed' + expect(store.get(source.id)?.revisions[0].steps[0].status).toBe('active') + + expect(JSON.parse(storage.value!)).toEqual({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [createRun()], + }) + }) + + it('hydrates valid runs from localStorage', () => { + const run = createRun() + const storage = new TestStorage( + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [run], + }), + ) + + const store = createWorkflowRunStore({ storage }) + + expect(store.get(run.id)).toEqual(run) + }) + + it.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 2, runs: [createRun()] })], + ['invalid payload', JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: {} })], + [ + 'invalid run', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), currentRevisionId: 'missing-revision' }], + }), + ], + [ + 'inconsistent run status', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), status: 'failed' }], + }), + ], + [ + 'multiple revisions in storage version 1', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRun(), + revisions: [ + ...createRun().revisions, + { + ...createRun().revisions[0], + id: 'revision-2', + status: 'abandoned', + }, + ], + }, + ], + }), + ], + [ + 'restart metadata in storage version 1', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRun(), + revisions: [ + { + ...createRun().revisions[0], + restartStepId: 'revision-1:character-setup', + }, + ], + }, + ], + }), + ], + ])('ignores %s in localStorage', (_label, serialized) => { + const store = createWorkflowRunStore({ storage: new TestStorage(serialized) }) + + expect(store.get('run-1')).toBeNull() + }) + + it('keeps the memory snapshot and notifies subscribers when persistence fails', () => { + const storage = new TestStorage() + storage.failOnSet = true + const store = createWorkflowRunStore({ storage }) + const listener = vi.fn() + const run = createRun() + + store.subscribe(run.id, listener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(listener).toHaveBeenCalledWith(run) + }) + + it('isolates subscriber values and stops notifications after unsubscribe', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + const unsubscribeFirst = store.subscribe(run.id, (savedRun) => { + savedRun.prompt = 'mutated by first listener' + }) + const unsubscribeSecond = store.subscribe(run.id, secondListener) + + store.save(run) + + expect(secondListener).toHaveBeenLastCalledWith(run) + expect(store.get(run.id)).toEqual(run) + + unsubscribeFirst() + unsubscribeSecond() + store.save({ ...run, prompt: 'new prompt' }) + + expect(secondListener).toHaveBeenCalledTimes(1) + }) + + it('does not let one failing subscriber block the saved state or other subscribers', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + + store.subscribe(run.id, () => { + throw new Error('render failed') + }) + store.subscribe(run.id, secondListener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(secondListener).toHaveBeenCalledWith(run) + }) + + it('uses the stable storage key by default', () => { + const setItem = vi.fn() + const store = createWorkflowRunStore({ + storage: { + getItem: vi.fn(() => null), + setItem, + }, + }) + + store.save(createRun()) + + expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + }) +}) diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts new file mode 100644 index 0000000..1a2f94f --- /dev/null +++ b/frontend/src/entities/workflow-run/store.ts @@ -0,0 +1,265 @@ +import type { WorkflowRun } from './index' +import { parseCharacterTemplateGenerationResult } from '../generation' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' +export const WORKFLOW_RUN_STORAGE_VERSION = 1 + +type WorkflowRunListener = (run: WorkflowRun) => void + +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface WorkflowRunStore { + get(runId: WorkflowRun['id']): WorkflowRun | null + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void +} + +export interface CreateWorkflowRunStoreOptions { + /** + * 传 null 可显式创建仅内存存储;不传时在浏览器中使用 localStorage。 + * 该入口也让纯逻辑测试无需模拟完整 DOM。 + */ + storage?: WorkflowRunStorage | null +} + +interface PersistedWorkflowRuns { + version: typeof WORKFLOW_RUN_STORAGE_VERSION + runs: WorkflowRun[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNullableString(value: unknown): value is string | null { + return typeof value === 'string' || value === null +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function isMember(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T) +} + +function isWorkflowStep(value: unknown): boolean { + if (!isRecord(value)) return false + + const commonFieldsAreValid = + typeof value.id === 'string' && + isMember(value.type, WORKFLOW_STEP_ORDER) && + isMember(value.status, WORKFLOW_STEP_STATUSES) && + isNullableString(value.taskId) && + isNullableString(value.submissionId) && + isStringArray(value.referenceStepIds) && + 'input' in value && + 'output' in value + if (!commonFieldsAreValid) return false + const error = value.error + if (!isNullableString(error)) return false + if ( + (value.status === 'failed' && (error === null || error.trim().length === 0)) || + (value.status !== 'failed' && error !== null) + ) { + return false + } + + if (value.type === 'character-setup') { + return ( + value.output === null && + (value.input === null || + (isRecord(value.input) && + typeof value.input.description === 'string' && + isStringArray(value.input.referenceMedia))) + ) + } + if (value.type === 'character-template') { + return ( + (value.input === null || + (isRecord(value.input) && + value.input.type === 'character_template' && + typeof value.input.projectId === 'string' && + typeof value.input.prompt === 'string' && + isStringArray(value.input.referenceMedia))) && + (value.output === null || parseCharacterTemplateGenerationResult(value.output) !== null) + ) + } + return true +} + +function isWorkflowRevision(value: unknown): boolean { + if (!isRecord(value)) return false + + return ( + typeof value.id === 'string' && + isNullableString(value.basedOnRevisionId) && + isNullableString(value.restartStepId) && + isMember(value.status, WORKFLOW_REVISION_STATUSES) && + Array.isArray(value.steps) && + value.steps.length === WORKFLOW_STEP_ORDER.length && + value.steps.every( + (step, index) => + isWorkflowStep(step) && isRecord(step) && step.type === WORKFLOW_STEP_ORDER[index], + ) && + isMember(value.generationStatus, GENERATION_STATUSES) && + isMember(value.exportStatus, EXPORT_STATUSES) && + typeof value.createdAt === 'string' + ) +} + +function isWorkflowRun(value: unknown): value is WorkflowRun { + if (!isRecord(value) || !Array.isArray(value.revisions)) return false + + const fieldsAreValid = + typeof value.id === 'string' && + typeof value.projectId === 'string' && + isNullableString(value.characterId) && + isNullableString(value.outfitId) && + isMember(value.purpose, WORKFLOW_PURPOSES) && + isMember(value.driver, WORKFLOW_DRIVERS) && + isMember(value.status, WORKFLOW_RUN_STATUSES) && + typeof value.currentRevisionId === 'string' && + value.revisions.length === 1 && + value.revisions.every(isWorkflowRevision) && + value.revisions.some( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) && + isNullableString(value.prompt) + if (!fieldsAreValid) return false + + const currentRevision = value.revisions.find( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) + if (!isRecord(currentRevision) || !Array.isArray(currentRevision.steps)) return false + if (currentRevision.basedOnRevisionId !== null || currentRevision.restartStepId !== null) { + return false + } + + const expectedRevisionStatus = + value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' + if (currentRevision.status !== expectedRevisionStatus) return false + + const activeStepCount = currentRevision.steps.filter( + (step) => isRecord(step) && step.status === 'active', + ).length + if ( + ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || + ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + ) { + return false + } + + return value.revisions.every( + (revision) => + isRecord(revision) && + Array.isArray(revision.steps) && + revision.steps.every((step) => { + if (!isRecord(step)) return false + const taskId = step.taskId + const submissionId = step.submissionId + if (taskId !== null && submissionId !== null) return false + if (taskId === null && submissionId === null) return true + return step.type === 'character-template' && step.status === 'active' + }), + ) +} + +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { + if (storage === null) return [] + + try { + const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (serialized === null) return [] + + const persisted: unknown = JSON.parse(serialized) + if ( + !isRecord(persisted) || + persisted.version !== WORKFLOW_RUN_STORAGE_VERSION || + !Array.isArray(persisted.runs) + ) { + return [] + } + + return persisted.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } catch { + return [] + } +} + +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + + try { + return window.localStorage + } catch { + return null + } +} + +/** + * WorkflowRun 的内存快照是当前会话的权威状态,localStorage 只负责刷新恢复。 + * 因此 save 先更新内存;浏览器拒绝写入时,本次运行仍能继续读取和订阅。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage + const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + const listeners = new Map>() + + return { + get(runId) { + const run = runs.get(runId) + return run === undefined ? null : structuredClone(run) + }, + + save(run) { + const savedRun = structuredClone(run) + runs.set(savedRun.id, savedRun) + + const persisted: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + + try { + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) + } catch { + // 内存已成功更新;持久化失败不能中断当前会话中的工作流。 + } + + for (const listener of listeners.get(savedRun.id) ?? []) { + try { + listener(structuredClone(savedRun)) + } catch { + // 订阅方渲染失败不能撤销已经保存的运行状态,也不能阻断其他订阅方。 + } + } + }, + + subscribe(runId, listener) { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + + return () => { + runListeners.delete(listener) + if (runListeners.size === 0) listeners.delete(runId) + } + }, + } +} diff --git a/frontend/src/features/character-setup/index.test.ts b/frontend/src/features/character-setup/index.test.ts new file mode 100644 index 0000000..60a05ec --- /dev/null +++ b/frontend/src/features/character-setup/index.test.ts @@ -0,0 +1,10 @@ +import { expectTypeOf, it } from 'vitest' + +import type { CharacterSetupStepInput } from '@/entities' +import type { CharacterSetupProps } from '.' + +it('submits WorkflowRun character setup input', () => { + expectTypeOf() + .parameter(0) + .toEqualTypeOf() +}) diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts new file mode 100644 index 0000000..13827b1 --- /dev/null +++ b/frontend/src/features/character-setup/index.ts @@ -0,0 +1,7 @@ +import type { CharacterSetupStepInput } from '@/entities' + +/** 填写角色资料并提交母版生成。 */ +export interface CharacterSetupProps { + projectId: string + onSubmit(input: CharacterSetupStepInput): void +} diff --git a/frontend/src/features/export/index.ts b/frontend/src/features/export/index.ts new file mode 100644 index 0000000..adfc5aa --- /dev/null +++ b/frontend/src/features/export/index.ts @@ -0,0 +1,6 @@ +/** 把确认后的造型与动作导出到资产库。 */ +export interface ExportProps { + runId: string + characterId: string + outfitId: string +} diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts new file mode 100644 index 0000000..2015752 --- /dev/null +++ b/frontend/src/features/generation/index.ts @@ -0,0 +1,10 @@ +/** 触发并展示一次生成;不感知后端调用的是哪个模型。 */ +export interface GenerationProps { + runId: string + /** + * 目标动作,生成母版等非动作任务可以省略。 + * 动作 ID 只在造型内唯一,所以造型由 runId 对应的 WorkflowRun.outfitId 决定, + * 不能脱离 run 单独使用这个字段。 + */ + actionId?: string +} diff --git a/frontend/src/features/review/index.ts b/frontend/src/features/review/index.ts new file mode 100644 index 0000000..3a48b66 --- /dev/null +++ b/frontend/src/features/review/index.ts @@ -0,0 +1,12 @@ +/** + * 逐帧查看已生成的动作,供用户在导出前过一遍。 + * + * 只看不改:服务端不返回质检结论,产品上也不设「打回此帧」, + * 所以这里没有任何写操作,帧数据不会因为查看而改变。 + */ +export interface ReviewProps { + /** 动作 ID 只在造型内唯一,调用方须自行持有所属造型,不能拿它跨角色定位。 */ + actionId: string + frameIndex: number + onSelectFrame(index: number): void +} diff --git a/frontend/src/features/workflow-controller/character-template-task.ts b/frontend/src/features/workflow-controller/character-template-task.ts new file mode 100644 index 0000000..d0656b7 --- /dev/null +++ b/frontend/src/features/workflow-controller/character-template-task.ts @@ -0,0 +1,457 @@ +import { + parseCharacterTemplateGenerationResult, + type GenerationApis, + type Task, + type TaskApis, + type TaskEvent, + type WorkflowRevision, + type WorkflowRun, + type WorkflowRunStore, + type WorkflowStep, +} from '@/entities' +import { + getActiveStep, + getCurrentRevision, + replaceWorkflowStep, + type WorkflowStepTarget, +} from './workflow-state' + +interface ApplyServerResultInput extends WorkflowStepTarget { + /** 结果必须仍属于步骤当前记录的任务;重试前的旧结果会被忽略。 */ + taskId: string + result: unknown +} + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +export interface CharacterTemplateTask { + /** 启动或继续目标角色图步骤;同一实例内的重复调用共享一次提交。 */ + start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 停止指定运行记录的前端任务订阅,不改变 WorkflowRun 状态。 */ + stop(runId: WorkflowRun['id']): void +} + +interface CreateCharacterTemplateTaskOptions { + store: WorkflowRunStore + generationApis: Pick + taskApis: TaskApis + createSubmissionId: () => string +} + +/** + * 角色图异步任务的生命周期。 + * + * 它只处理当前角色图步骤与后端 Task 的关联,不决定整个工作流下一步走什么。 + * submissions 与 subscriptions 属于实例锁;生产环境必须复用同一个实例。 + */ +export function createCharacterTemplateTask({ + store, + generationApis, + taskApis, + createSubmissionId, +}: CreateCharacterTemplateTaskOptions): CharacterTemplateTask { + const submissions = new Map>() + const subscriptions = new Map() + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise { + const run = requireWorkflow(runId) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + revision.id !== target.revisionId || + !step || + step.type !== 'character-template' || + step.status !== 'active' + ) { + return Promise.resolve(run) + } + if (step.taskId) { + ensureTaskSubscription(run, target.revisionId, target.stepId, step.taskId) + return Promise.resolve(requireWorkflow(runId)) + } + if (!step.input) throw new Error('角色图生成步骤缺少输入快照') + return submit(runId, target) + } + + function submit(runId: WorkflowRun['id'], target: WorkflowStepTarget) { + const key = submissionKey(runId, target.revisionId, target.stepId) + const pending = submissions.get(key) + if (pending) return pending + + const submission = performSubmission(runId, target).finally(() => { + submissions.delete(key) + }) + submissions.set(key, submission) + return submission + } + + async function performSubmission( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + ): Promise { + const before = requireWorkflow(runId) + const beforeRevision = getCurrentRevision(before) + const beforeStep = beforeRevision.steps.find((step) => step.id === target.stepId) + if ( + before.status !== 'active' || + beforeRevision.id !== target.revisionId || + !beforeStep || + beforeStep.type !== 'character-template' || + beforeStep.status !== 'active' || + !beforeStep.input + ) { + return before + } + if (beforeStep.taskId) { + ensureTaskSubscription(before, target.revisionId, target.stepId, beforeStep.taskId) + return before + } + if (beforeStep.submissionId) { + throw new Error('角色图生成请求仍在等待后端确认,不能重复提交') + } + + const submissionId = createSubmissionId() + const submitting = replaceWorkflowStep(before, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, submissionId } + }) + save(submitting) + + try { + const generation = await generationApis.create(beforeStep.input) + const latest = requireWorkflow(runId) + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === target.stepId) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + latestRevision.id !== target.revisionId || + !latestStep || + latestStep.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId || + latestStep.submissionId !== submissionId + ) { + return latest + } + if (generation.type !== 'character_template' || generation.projectId !== latest.projectId) { + throw new Error('生成任务返回的类型或项目与当前 WorkflowRun 不匹配') + } + + const withTask = replaceWorkflowStep(latest, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, taskId: generation.id, submissionId: null } + }) + save(withTask) + + if (latest.status === 'interrupted') return withTask + if (generation.status === 'failed') { + return markFailed( + runId, + target, + generation.id, + null, + generation.error?.trim() || '角色图生成任务失败', + ) + } + if (generation.status === 'completed') { + return applyServerResult(runId, { + ...target, + taskId: generation.id, + result: generation.result, + }) + } + + ensureTaskSubscription(withTask, target.revisionId, target.stepId, generation.id) + return requireWorkflow(runId) + } catch (cause) { + markFailed(runId, target, null, submissionId, errorMessage(cause, '角色图生成请求失败')) + throw cause instanceof Error ? cause : new Error(String(cause)) + } + } + + function ensureTaskSubscription( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, + ) { + const key = subscriptionKey(run.id, revisionId, stepId, taskId) + if (subscriptions.has(key)) return + + subscriptions.set(key, { runId: run.id, stop: () => undefined }) + try { + const stop = taskApis.subscribe(run.projectId, taskId, (event) => { + handleTaskEvent(run.id, { revisionId, stepId }, taskId, event) + }) + const active = subscriptions.get(key) + if (active) subscriptions.set(key, { ...active, stop }) + else stop() + } catch (cause) { + subscriptions.delete(key) + throw cause + } + } + + function handleTaskEvent( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + taskId: string, + event: TaskEvent, + ) { + if (event.taskId !== taskId) return + if (event.status === 'pending' || event.status === 'running') return + if (event.status === 'failed') { + markFailed(runId, target, taskId, null, event.error?.trim() || '角色图生成任务失败') + return + } + if (event.type !== 'character_template') { + markFailed(runId, target, taskId, null, '任务结果类型与角色图生成步骤不匹配') + return + } + applyServerResult(runId, { + ...target, + taskId, + result: event.result, + }) + } + + async function resume(runId: WorkflowRun['id']): Promise { + const run = getWorkflow(runId) + if (!run || run.status !== 'active') return run + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (activeStep?.type !== 'character-template' || activeStep.status !== 'active') { + return run + } + const target = { revisionId: revision.id, stepId: activeStep.id } + + if (activeStep.submissionId && !activeStep.taskId) { + if (submissions.has(submissionKey(run.id, revision.id, activeStep.id))) { + return run + } + return markFailed( + run.id, + target, + null, + activeStep.submissionId, + '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + ) + } + if (activeStep.taskId) { + const task = await taskApis.get(run.projectId, activeStep.taskId) + const latest = getWorkflow(run.id) + if (!latest || latest.status !== 'active' || latest.currentRevisionId !== revision.id) { + return latest + } + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === activeStep.id) + if ( + latestStep?.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId !== activeStep.taskId + ) { + return latest + } + if (task.id !== latestStep.taskId) { + throw new Error('任务查询结果与 WorkflowRun 记录的 taskId 不匹配') + } + if (task.type !== 'character_template') { + return markFailed( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + null, + '任务查询结果类型与角色图生成步骤不匹配', + ) + } + if (task.status === 'pending' || task.status === 'running') { + ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) + } else { + handleTaskEvent( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + taskEvent(task), + ) + } + } + return getWorkflow(runId) + } + + function applyServerResult(runId: WorkflowRun['id'], input: ApplyServerResultInput): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== input.revisionId) { + return run + } + + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === input.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + step.taskId !== input.taskId + ) { + return run + } + + const result = parseCharacterTemplateGenerationResult(input.result) + if (!result) { + return markFailed( + runId, + { revisionId: revision.id, stepId: step.id }, + input.taskId, + null, + '角色图生成任务返回了无法识别的结果', + ) + } + const candidateStep = revision.steps.find((item) => item.type === 'template-candidate') + if (!candidateStep) throw new Error('WorkflowRun 缺少 template-candidate 步骤') + + const updated: WorkflowRun = { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((current) => { + if (current.id === step.id && current.type === 'character-template') { + return { + ...current, + status: 'passed' as const, + output: result, + taskId: null, + submissionId: null, + } + } + if (current.id === candidateStep.id && current.type === 'template-candidate') { + return { ...current, status: 'active' as const } + } + return current + }), + } + }), + } + stopSubscription(subscriptionKey(run.id, revision.id, step.id, input.taskId)) + return save(updated) + } + + function markFailed( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + expectedTaskId: string | null, + expectedSubmissionId: string | null, + error: string, + ) { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== target.revisionId) return run + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + (expectedTaskId !== null && step.taskId !== expectedTaskId) || + (expectedSubmissionId !== null && step.submissionId !== expectedSubmissionId) + ) { + return run + } + + const failureMessage = error.trim() || '角色图生成失败' + const failed: WorkflowRun = { + ...replaceWorkflowStep( + run, + target.revisionId, + target.stepId, + (current) => ({ + ...current, + status: 'failed', + taskId: null, + submissionId: null, + error: failureMessage, + }), + (current) => ({ + ...current, + status: 'failed', + generationStatus: 'failed', + }), + ), + status: 'failed', + } + if (step.taskId) { + stopSubscription(subscriptionKey(run.id, revision.id, step.id, step.taskId)) + } + return save(failed) + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key) + subscriptions.delete(key) + try { + subscription?.stop() + } catch { + // 取消轮询失败不能反向破坏已经落盘的 WorkflowRun 状态。 + } + } + + function stop(runId: WorkflowRun['id']) { + for (const [key, subscription] of subscriptions) { + if (subscription.runId === runId) stopSubscription(key) + } + } + + return { start, resume, stop } +} + +function taskEvent(task: Task): TaskEvent { + return { + taskId: task.id, + type: task.type, + status: task.status, + error: task.error, + result: task.result, + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} + +function subscriptionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, +) { + return `${runId}:${revisionId}:${stepId}:${taskId}` +} + +function submissionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], +) { + return `${runId}:${revisionId}:${stepId}` +} diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts new file mode 100644 index 0000000..b2acd99 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,607 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + WORKFLOW_STEP_ORDER, + type Generation, + type GenerationApis, + type GenerationInput, + type Task, + type TaskApis, + type TaskEvent, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepType, +} from '@/entities' +import { createWorkflowController } from '.' + +const NOW = '2026-07-30T12:00:00.000Z' + +type RunListener = (run: WorkflowRun) => void + +function cloneRun(run: WorkflowRun): WorkflowRun { + return structuredClone(run) +} + +function createMemoryStore() { + const runs = new Map() + const listeners = new Map>() + + const get = vi.fn((runId: string): WorkflowRun | null => { + const run = runs.get(runId) + return run ? cloneRun(run) : null + }) + + const save = vi.fn((run: WorkflowRun): void => { + const snapshot = cloneRun(run) + runs.set(run.id, snapshot) + + for (const listener of listeners.get(run.id) ?? []) { + listener(cloneRun(snapshot)) + } + }) + + const subscribe = vi.fn((runId: string, listener: RunListener): (() => void) => { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + + return () => { + runListeners.delete(listener) + } + }) + + return { get, save, subscribe } +} + +function createIdFactory() { + let nextId = 0 + return vi.fn(() => `id-${++nextId}`) +} + +function deferNextGeneration(harness: ReturnType) { + let resolve!: (generation: Generation<'character_template'>) => void + const promise = new Promise>((resolvePromise) => { + resolve = resolvePromise + }) + vi.mocked(harness.generationApis.create).mockImplementationOnce( + async () => (await promise) as Generation, + ) + return { resolve } +} + +function pendingCharacterTemplateGeneration(): Generation<'character_template'> { + return { + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + } +} + +function createHarness() { + const store = createMemoryStore() + const taskListeners = new Map void>() + + const createGeneration: GenerationApis['create'] = async (input: T) => + ({ + id: 'task-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const generationApis: Pick = { + create: vi.fn(createGeneration), + } + + const subscribeTask = vi.fn( + (projectId: string, taskId: string, onEvent: (event: TaskEvent) => void) => { + taskListeners.set(`${projectId}:${taskId}`, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + taskListeners.delete(`${projectId}:${taskId}`) + } + }, + ) + + const taskApis: TaskApis = { + get: vi.fn(async () => { + throw new Error('TaskApis.get is not used until a run is resumed') + }), + subscribe: subscribeTask, + } + + const controller = createWorkflowController({ + store, + generationApis, + taskApis, + createId: createIdFactory(), + now: () => NOW, + }) + + return { + controller, + generationApis, + subscribeTask, + store, + taskApis, + getTaskListener(projectId: string, taskId: string) { + return taskListeners.get(`${projectId}:${taskId}`) ?? null + }, + emitTask(projectId: string, taskId: string, event: TaskEvent) { + const listener = taskListeners.get(`${projectId}:${taskId}`) + expect(listener, `missing task subscription for ${projectId}:${taskId}`).toBeTypeOf( + 'function', + ) + listener?.(event) + }, + } +} + +function currentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find(({ id }) => id === run.currentRevisionId) + if (!revision) { + throw new Error(`Current revision ${run.currentRevisionId} is missing`) + } + return revision +} + +function step(run: WorkflowRun, type: WorkflowStepType): WorkflowStep { + const workflowStep = currentRevision(run).steps.find((item) => item.type === type) + if (!workflowStep) { + throw new Error(`Workflow step ${type} is missing`) + } + return workflowStep +} + +async function createAiRun(harness: ReturnType) { + return harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }) +} + +async function startCharacterTemplate(harness: ReturnType) { + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id) + return run +} + +describe('createWorkflowController', () => { + it('creates one revision with the fixed eight steps and seeds AI input from the prompt', async () => { + const harness = createHarness() + + expect(harness.controller).toEqual( + expect.objectContaining({ + create: expect.any(Function), + getWorkflow: expect.any(Function), + subscribe: expect.any(Function), + updateCharacterSetup: expect.any(Function), + nextStep: expect.any(Function), + resume: expect.any(Function), + interrupt: expect.any(Function), + }), + ) + + const run = await createAiRun(harness) + const revision = currentRevision(run) + + expect(run.prompt).toBe('pixel knight') + expect(run.projectId).toBe('project-1') + expect(run.revisions).toHaveLength(1) + expect(run.currentRevisionId).toBe(revision.id) + expect(revision.basedOnRevisionId).toBeNull() + expect(revision.restartStepId).toBeNull() + expect(revision.createdAt).toBe(NOW) + expect(revision.steps.map(({ type }) => type)).toEqual(WORKFLOW_STEP_ORDER) + expect(revision.steps.map(({ status }) => status)).toEqual([ + 'active', + 'locked', + 'locked', + 'locked', + 'locked', + 'locked', + 'locked', + 'locked', + ]) + expect(step(run, 'character-setup').input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + + const allIds = [run.id, revision.id, ...revision.steps.map(({ id }) => id)] + expect(new Set(allIds).size).toBe(allIds.length) + expect(harness.store.save).toHaveBeenCalledWith(run) + }) + + it('persists the task id before subscribing and never submits the active generation twice', async () => { + const harness = createHarness() + + await startCharacterTemplate(harness) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(harness.generationApis.create).toHaveBeenCalledWith({ + type: 'character_template', + projectId: 'project-1', + prompt: 'pixel knight', + referenceMedia: [], + }) + expect(harness.subscribeTask).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + + const taskSaveIndex = harness.store.save.mock.calls.findIndex(([savedRun]) => { + return step(savedRun, 'character-template').taskId === 'task-1' + }) + expect(taskSaveIndex).toBeGreaterThanOrEqual(0) + expect(harness.store.save.mock.invocationCallOrder[taskSaveIndex]).toBeLessThan( + harness.subscribeTask.mock.invocationCallOrder[0], + ) + + const createdRun = harness.store.save.mock.calls[0]?.[0] + if (!createdRun) throw new Error('Expected the created WorkflowRun to be saved') + const activeRun = harness.controller.getWorkflow(createdRun.id) + if (!activeRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(activeRun, 'character-setup').status).toBe('passed') + expect(step(activeRun, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + }) + + await harness.controller.nextStep(activeRun.id) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('shares one submission when nextStep is called concurrently', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const first = harness.controller.nextStep(run.id) + const second = harness.controller.nextStep(run.id) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + deferred.resolve(pendingCharacterTemplateGeneration()) + await Promise.all([first, second]) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('keeps an active submission alive when resume uses the same controller', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id) + const resumed = await harness.controller.resume(run.id) + + expect(resumed?.status).toBe('active') + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'active', + taskId: null, + submissionId: expect.any(String), + }) + + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('handles a terminal snapshot emitted synchronously when subscribing', async () => { + const harness = createHarness() + const stop = vi.fn() + harness.subscribeTask.mockImplementationOnce((_projectId, _taskId, onEvent) => { + onEvent({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/synchronous.png' }], + }, + }) + return stop + }) + + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id) + + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(harness.controller.getWorkflow(run.id)!, 'template-candidate').status).toBe( + 'active', + ) + expect(stop).toHaveBeenCalledOnce() + }) + + it('ignores another task result and advances only when the matching task completes', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'another-task', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/wrong.png' }], + }, + }) + + const unchangedRun = harness.controller.getWorkflow(run.id) + if (!unchangedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(unchangedRun, 'character-template')).toMatchObject({ + status: 'active', + output: null, + taskId: 'task-1', + }) + expect(step(unchangedRun, 'template-candidate').status).toBe('locked') + + const result = { + type: 'character_template' as const, + images: [{ url: 'https://example.com/knight.png' }], + } + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result, + }) + + const completedRun = harness.controller.getWorkflow(run.id) + if (!completedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(completedRun, 'character-template')).toMatchObject({ + status: 'passed', + output: result, + taskId: null, + }) + expect(step(completedRun, 'template-candidate').status).toBe('active') + }) + + it('marks the step, revision, generation, and run as failed when the task fails', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'model unavailable', + result: null, + }) + + const failedRun = harness.controller.getWorkflow(run.id) + if (!failedRun) throw new Error('Expected the WorkflowRun to remain available') + const revision = currentRevision(failedRun) + expect(step(failedRun, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: 'model unavailable', + }) + expect(revision.status).toBe('failed') + expect(revision.generationStatus).toBe('failed') + expect(failedRun.status).toBe('failed') + }) + + it('resumes a persisted in-flight task without creating another generation', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.taskApis.get).mockResolvedValueOnce({ + id: 'task-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: { + get: harness.taskApis.get, + subscribe: resumeSubscribe, + }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(resumed?.id).toBe(run.id) + expect(harness.taskApis.get).toHaveBeenCalledWith('project-1', 'task-1') + expect(resumeSubscribe).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('applies a completed task found during refresh before subscribing again', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.taskApis.get).mockResolvedValueOnce({ + id: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/recovered.png' }], + }, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: { + get: harness.taskApis.get, + subscribe: resumeSubscribe, + }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(resumed!, 'template-candidate').status).toBe('active') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('does not subscribe after interrupting while resume waits for the task query', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + let resolveTask!: (task: Task) => void + const pendingTask = new Promise((resolve) => { + resolveTask = resolve + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: { + get: vi.fn(() => pendingTask), + subscribe: resumeSubscribe, + }, + }) + + const resuming = resumedController.resume(run.id) + resumedController.interrupt(run.id) + resolveTask({ + id: 'task-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumed = await resuming + + expect(resumed?.status).toBe('interrupted') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('fails safely after refresh when the request was sent before taskId arrived', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const uncertainSnapshot = harness.store.save.mock.calls + .map(([savedRun]) => savedRun) + .find((savedRun) => { + const template = step(savedRun, 'character-template') + return template.submissionId !== null && template.taskId === null + }) + if (!uncertainSnapshot) throw new Error('expected the submitting snapshot to be persisted') + harness.store.save(uncertainSnapshot) + vi.mocked(harness.generationApis.create).mockClear() + + const restoredController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: harness.taskApis, + }) + + const restored = await restoredController.resume(run.id) + + expect(restored?.status).toBe('failed') + expect(step(restored!, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + }) + expect(harness.generationApis.create).not.toHaveBeenCalled() + }) + + it('records a task id that returns after the run was interrupted', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id) + await harness.controller.interrupt(run.id) + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + const interrupted = harness.controller.getWorkflow(run.id) + expect(interrupted?.status).toBe('interrupted') + expect(step(interrupted!, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + submissionId: null, + }) + expect(harness.subscribeTask).not.toHaveBeenCalled() + }) + + it('keeps an interrupted run interrupted when a queued failure arrives late', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const queuedListener = harness.getTaskListener('project-1', 'task-1') + if (!queuedListener) throw new Error('expected an active task subscription') + + await harness.controller.interrupt(run.id) + queuedListener({ + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'late failure', + result: null, + }) + + expect(harness.controller.getWorkflow(run.id)?.status).toBe('interrupted') + }) + + it('publishes saved updates and can interrupt the current run', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const listener = vi.fn() + const unsubscribe = harness.controller.subscribe(run.id, listener) + + const updated = await harness.controller.updateCharacterSetup(run.id, { + description: 'revised knight', + referenceMedia: [], + }) + expect(step(updated, 'character-setup').input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + + const interrupted = await harness.controller.interrupt(run.id) + + expect(interrupted.status).toBe('interrupted') + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ id: run.id, status: 'interrupted' }), + ) + + unsubscribe() + }) +}) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 0000000..58194dc --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,168 @@ +import type { + CharacterSetupStepInput, + GenerationApis, + TaskApis, + WorkflowRun, + WorkflowRunStore, +} from '@/entities' +import { createCharacterTemplateTask } from './character-template-task' +import { + advanceCharacterSetupState, + createWorkflowRunState, + getActiveStep, + getCurrentRevision, + interruptWorkflowRunState, + requireActiveWorkflow, + updateCharacterSetupState, + type CreateWorkflowRunStateInput, +} from './workflow-state' + +/** 首个纵切只开放创建角色;增加动作进入对应步骤实现时再加入 Controller。 */ +export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput + +export interface WorkflowController { + /** 创建并保存一条纯前端运行记录。 */ + create(input: CreateWorkflowControllerInput): WorkflowRun + + /** 按路由中的 runId 读取快照;不存在时返回 null。 */ + getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null + + /** 订阅指定运行记录的本地变化。 */ + subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void): () => void + + /** 修改当前角色资料步骤,页面无需知道步骤内部 ID。 */ + updateCharacterSetup(runId: WorkflowRun['id'], input: CharacterSetupStepInput): WorkflowRun + + /** + * 推进一个步骤。当前纵切只实现角色资料到角色图生成; + * 后续步骤进入各自实现 PR 后再扩展,不在这里伪造完成。 + */ + nextStep(runId: WorkflowRun['id']): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 只停止前端自动推进和任务订阅;后端当前没有取消任务能力。 */ + interrupt(runId: WorkflowRun['id']): WorkflowRun +} + +export interface CreateWorkflowControllerOptions { + store: WorkflowRunStore + generationApis: Pick + taskApis: TaskApis + /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ + createId?: (scope: 'run' | 'revision' | 'submission') => string + /** 测试可注入确定性时间。 */ + now?: () => string +} + +/** + * Quick Start 与手动工作流共用的流程协调器。 + * + * Controller 只负责读取当前步骤、保存状态并委派角色图任务;纯状态转换和异步任务 + * 生命周期分别留在本 Feature 的内部模块。生产接入必须复用同一个 Controller 实例, + * 不能在组件渲染期间重复创建。 + */ +export function createWorkflowController({ + store, + generationApis, + taskApis, + createId = createRuntimeId, + now = () => new Date().toISOString(), +}: CreateWorkflowControllerOptions): WorkflowController { + const characterTemplateTask = createCharacterTemplateTask({ + store, + generationApis, + taskApis, + createSubmissionId: () => createId('submission'), + }) + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function create(input: CreateWorkflowControllerInput): WorkflowRun { + return save( + createWorkflowRunState(input, { + runId: createId('run'), + revisionId: createId('revision'), + createdAt: now(), + }), + ) + } + + function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { + return store.subscribe(runId, listener) + } + + function updateCharacterSetup( + runId: WorkflowRun['id'], + input: CharacterSetupStepInput, + ): WorkflowRun { + return save(updateCharacterSetupState(requireWorkflow(runId), input)) + } + + async function nextStep(runId: WorkflowRun['id']): Promise { + const run = requireActiveWorkflow(requireWorkflow(runId)) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + + if (activeStep.type === 'character-template') { + return characterTemplateTask.start(runId, { + revisionId: revision.id, + stepId: activeStep.id, + }) + } + if (activeStep.type !== 'character-setup') { + throw new Error(`步骤 ${activeStep.type} 尚未进入本轮实现`) + } + + const transitioned = advanceCharacterSetupState(run) + save(transitioned.run) + return characterTemplateTask.start(runId, transitioned.target) + } + + function resume(runId: WorkflowRun['id']) { + return characterTemplateTask.resume(runId) + } + + function interrupt(runId: WorkflowRun['id']): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') return run + + characterTemplateTask.stop(runId) + const latest = requireWorkflow(runId) + if (latest.status !== 'active') return latest + return save(interruptWorkflowRunState(latest)) + } + + return { + create, + getWorkflow, + subscribe, + updateCharacterSetup, + nextStep, + resume, + interrupt, + } +} + +function createRuntimeId(scope: 'run' | 'revision' | 'submission') { + const suffix = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + return `${scope}-${suffix}` +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts new file mode 100644 index 0000000..fcb6978 --- /dev/null +++ b/frontend/src/features/workflow-controller/index.ts @@ -0,0 +1,6 @@ +export { createWorkflowController } from './controller' +export type { + CreateWorkflowControllerInput, + CreateWorkflowControllerOptions, + WorkflowController, +} from './controller' diff --git a/frontend/src/features/workflow-controller/workflow-run.integration.test.ts b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts new file mode 100644 index 0000000..ca4cc9a --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createWorkflowRunStore, + type Generation, + type GenerationApis, + type GenerationInput, + type TaskApis, + type TaskEvent, +} from '@/entities' +import { createWorkflowController } from '.' + +describe('WorkflowRun first vertical slice', () => { + it('runs character setup through a completed character-template task', async () => { + const store = createWorkflowRunStore({ storage: null }) + const taskChannel: { listener?: (event: TaskEvent) => void } = {} + + const createGeneration: GenerationApis['create'] = async ( + input: T, + ) => + ({ + id: 'task-character-template-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const generationApis: Pick = { + create: vi.fn(createGeneration), + } + const taskApis: TaskApis = { + get: vi.fn(async () => { + throw new Error('not used in this slice') + }), + subscribe: vi.fn((_projectId, taskId, onEvent) => { + taskChannel.listener = onEvent + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + delete taskChannel.listener + } + }), + } + const ids = ['run-1', 'revision-1'] + const controller = createWorkflowController({ + store, + generationApis, + taskApis, + createId: () => ids.shift() ?? 'unexpected-id', + now: () => '2026-07-30T12:00:00.000Z', + }) + + const created = await controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + + await controller.nextStep(created.id) + + const inFlight = store.get(created.id) + expect( + inFlight?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'active', + taskId: 'task-character-template-1', + }) + + const taskListener = taskChannel.listener + if (!taskListener) throw new Error('expected the task subscription to be active') + taskListener({ + taskId: 'task-character-template-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + await Promise.resolve() + + const completed = store.get(created.id) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'passed', + taskId: null, + output: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'template-candidate'), + ).toMatchObject({ status: 'active' }) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-state.test.ts b/frontend/src/features/workflow-controller/workflow-state.test.ts new file mode 100644 index 0000000..fc68371 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { + advanceCharacterSetupState, + createWorkflowRunState, + updateCharacterSetupState, +} from './workflow-state' + +const CREATED_AT = '2026-07-31T02:40:00.000Z' + +function createRun() { + return createWorkflowRunState( + { + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }, + { + runId: 'run-1', + revisionId: 'revision-1', + createdAt: CREATED_AT, + }, + ) +} + +describe('workflow state transitions', () => { + it('creates the fixed workflow with normalized Quick Start input', () => { + const run = createRun() + + expect(run).toMatchObject({ + id: 'run-1', + projectId: 'project-1', + status: 'active', + prompt: 'pixel knight', + currentRevisionId: 'revision-1', + }) + expect(run.revisions[0]?.createdAt).toBe(CREATED_AT) + expect(run.revisions[0]?.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'active' }, + { type: 'character-template', status: 'locked' }, + { type: 'template-candidate', status: 'locked' }, + { type: 'action-setup', status: 'locked' }, + { type: 'first-frame', status: 'locked' }, + { type: 'complete-animation', status: 'locked' }, + { type: 'review', status: 'locked' }, + { type: 'export', status: 'locked' }, + ]) + expect(run.revisions[0]?.steps[0]?.input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + }) + + it('normalizes character setup input before storing it', () => { + const updated = updateCharacterSetupState(createRun(), { + description: ' revised knight ', + referenceMedia: [], + }) + + expect(updated.revisions[0]?.steps[0]?.input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + }) + + it('activates character-template with its generation input snapshot', () => { + const run = updateCharacterSetupState(createRun(), { + description: 'revised knight', + referenceMedia: [], + }) + + const transitioned = advanceCharacterSetupState(run) + + expect(transitioned.target).toEqual({ + revisionId: 'revision-1', + stepId: 'revision-1:character-template', + }) + expect(transitioned.run.revisions[0]?.steps.slice(0, 3)).toMatchObject([ + { type: 'character-setup', status: 'passed' }, + { + type: 'character-template', + status: 'active', + input: { + type: 'character_template', + projectId: 'project-1', + prompt: 'revised knight', + referenceMedia: [], + }, + }, + { type: 'template-candidate', status: 'locked' }, + ]) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-state.ts b/frontend/src/features/workflow-controller/workflow-state.ts new file mode 100644 index 0000000..47c608a --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -0,0 +1,217 @@ +import { + WORKFLOW_STEP_ORDER, + type CharacterSetupStepInput, + type CharacterTemplateGenerationInput, + type CreateWorkflowRunInput, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepStatus, + type WorkflowStepType, +} from '@/entities' + +export type CreateWorkflowRunStateInput = Extract< + CreateWorkflowRunInput, + { purpose: 'create_character' } +> + +export interface CreateWorkflowRunStateOptions { + runId: WorkflowRun['id'] + revisionId: WorkflowRevision['id'] + createdAt: string +} + +export interface WorkflowStepTarget { + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] +} + +export function createWorkflowRunState( + input: CreateWorkflowRunStateInput, + { runId, revisionId, createdAt }: CreateWorkflowRunStateOptions, +): WorkflowRun { + const prompt = input.prompt?.trim() || null + + return { + id: runId, + projectId: input.projectId, + characterId: null, + outfitId: null, + purpose: input.purpose, + driver: input.driver, + status: 'active', + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: WORKFLOW_STEP_ORDER.map((type, index) => + createInitialStep(type, revisionId, index, prompt), + ), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt, + }, + ], + prompt, + } +} + +export function getCurrentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) + return revision +} + +export function getActiveStep(revision: WorkflowRevision): WorkflowStep | null { + return revision.steps.find((step) => step.status === 'active') ?? null +} + +export function requireActiveWorkflow(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + return run +} + +export function replaceWorkflowStep( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + update: (step: WorkflowStep) => WorkflowStep, + revisionUpdate?: (revision: WorkflowRevision) => WorkflowRevision, +): WorkflowRun { + return { + ...run, + revisions: run.revisions.map((revision) => { + if (revision.id !== revisionId) return revision + const nextRevision = { + ...revision, + steps: revision.steps.map((step) => (step.id === stepId ? update(step) : step)), + } + return revisionUpdate ? revisionUpdate(nextRevision) : nextRevision + }), + } +} + +export function updateCharacterSetupState( + workflow: WorkflowRun, + input: CharacterSetupStepInput, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.type === 'character-setup') + if (!step || step.type !== 'character-setup' || step.status !== 'active') { + throw new Error('当前只能更新处于 active 状态的角色资料步骤') + } + + const description = input.description.trim() + if (!description) throw new Error('角色描述不能为空') + + return replaceWorkflowStep(run, revision.id, step.id, (current) => { + if (current.type !== 'character-setup') return current + return { + ...current, + input: { + description, + referenceMedia: [...input.referenceMedia], + }, + } + }) +} + +export function advanceCharacterSetupState(workflow: WorkflowRun): { + run: WorkflowRun + target: WorkflowStepTarget +} { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + if (activeStep.type !== 'character-setup') { + throw new Error(`当前步骤不是角色资料:${activeStep.type}`) + } + if (!activeStep.input) throw new Error('请先填写角色资料') + + const templateStep = revision.steps.find((step) => step.type === 'character-template') + if (!templateStep) throw new Error('WorkflowRun 缺少 character-template 步骤') + + const generationInput: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: activeStep.input.description, + referenceMedia: activeStep.input.referenceMedia, + } + + return { + run: { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + generationStatus: 'in_progress' as const, + steps: item.steps.map((step) => { + if (step.id === activeStep.id) return { ...step, status: 'passed' as const } + if (step.id !== templateStep.id || step.type !== 'character-template') return step + return { + ...step, + status: 'active' as const, + input: generationInput, + } + }), + } + }), + }, + target: { + revisionId: revision.id, + stepId: templateStep.id, + }, + } +} + +export function interruptWorkflowRunState(run: WorkflowRun): WorkflowRun { + return run.status === 'active' ? { ...run, status: 'interrupted' } : run +} + +function createInitialStep( + type: WorkflowStepType, + revisionId: string, + index: number, + prompt: string | null, +): WorkflowStep { + const status: WorkflowStepStatus = index === 0 ? 'active' : 'locked' + const base: { + id: string + status: WorkflowStepStatus + taskId: null + submissionId: null + error: null + referenceStepIds: string[] + } = { + id: `${revisionId}:${type}`, + status, + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + + if (type === 'character-setup') { + return { + ...base, + type, + input: prompt ? { description: prompt, referenceMedia: [] } : null, + output: null, + } + } + if (type === 'character-template') { + return { + ...base, + type, + input: null, + output: null, + } + } + return { ...base, type, input: null, output: null } as WorkflowStep +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f80172a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,8 @@ +@import 'tailwindcss'; + +/* 全局只放这一点点:其余样式一律走 Tailwind 工具类,避免样式散落各处。 */ +html, +body, +#root { + height: 100%; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ff3b910 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { App } from '@/app' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx new file mode 100644 index 0000000..016328b --- /dev/null +++ b/frontend/src/pages/asset-library/index.tsx @@ -0,0 +1,9 @@ +/** 资产库。 */ +export function AssetLibraryPage() { + return ( +
+

资产库

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx new file mode 100644 index 0000000..7a153ad --- /dev/null +++ b/frontend/src/pages/home/index.tsx @@ -0,0 +1,13 @@ +/** 首页:入口与项目概览。 */ +export function HomePage() { + return +} + +function PagePlaceholder({ title }: { title: string }) { + return ( +
+

{title}

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/not-found/index.tsx b/frontend/src/pages/not-found/index.tsx new file mode 100644 index 0000000..d35181b --- /dev/null +++ b/frontend/src/pages/not-found/index.tsx @@ -0,0 +1,9 @@ +/** 页面不存在。 */ +export function NotFoundPage() { + return ( +
+

页面不存在

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx new file mode 100644 index 0000000..9796e86 --- /dev/null +++ b/frontend/src/pages/playtest/index.tsx @@ -0,0 +1,9 @@ +/** 核验台。 */ +export function PlaytestPage() { + return ( +
+

核验台

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx new file mode 100644 index 0000000..cc6ff5c --- /dev/null +++ b/frontend/src/pages/project-detail/index.tsx @@ -0,0 +1,9 @@ +/** 项目详情。 */ +export function ProjectDetailPage() { + return ( +
+

项目详情

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx new file mode 100644 index 0000000..8f807f7 --- /dev/null +++ b/frontend/src/pages/projects/index.tsx @@ -0,0 +1,9 @@ +/** 项目列表。 */ +export function ProjectsPage() { + return ( +
+

项目列表

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx new file mode 100644 index 0000000..1ffe9fe --- /dev/null +++ b/frontend/src/pages/quick-start/index.tsx @@ -0,0 +1,9 @@ +/** 快速开始。 */ +export function QuickStartPage() { + return ( +
+

快速开始

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx new file mode 100644 index 0000000..59df779 --- /dev/null +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -0,0 +1,9 @@ +/** 工作流画布。 */ +export function WorkflowEditorPage() { + return ( +
+

工作流画布

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/shared/README.md b/frontend/src/shared/README.md new file mode 100644 index 0000000..b35fb60 --- /dev/null +++ b/frontend/src/shared/README.md @@ -0,0 +1,28 @@ +# shared + +与 Windup 业务无关、可被任意上层模块复用的基础代码。它是依赖方向的最底层,**不能反向依赖 `entities`、`features`、`pages` 或 `app`**。 + +## 判断标准 + +**如果一段代码需要理解 Windup 的业务词汇,它就不属于 shared。** + +## 现有内容 + +- `pagination/` —— 与传输协议无关的分页请求与结果形状。 + +## 后续允许放入 + +- `ui/` —— 按钮、弹窗、加载状态等不含业务含义的展示组件 +- `hooks/` —— 通用浏览器或 React 行为,例如媒体查询、键盘快捷键 +- `utils/` —— 纯函数工具,例如日期格式化、文件大小显示 +- `config/` —— 前端通用常量与运行时配置读取 + +**这些目录只在出现真实代码时创建,不为占位提前建空文件。** + +## 不允许放入 + +- Project、Character、Generation、Task、WorkflowRun 等业务数据 +- `ProjectApis`、`CharacterApis` 这类业务接口集合 +- 流程的推进、重启、中断和 Revision 规则 +- 为开发与生产各维护一套实现的切换机制 +- 只被单个页面或模块使用、却以「复用」名义提前抽出的代码 diff --git a/frontend/src/shared/pagination/index.ts b/frontend/src/shared/pagination/index.ts new file mode 100644 index 0000000..d5e2c27 --- /dev/null +++ b/frontend/src/shared/pagination/index.ts @@ -0,0 +1,13 @@ +/** 与传输协议无关的分页请求形状。 */ +export interface PageQuery { + page?: number + pageSize?: number +} + +/** 与传输协议无关的分页结果形状。 */ +export interface Paged { + items: T[] + total: number + page: number + pageSize: number +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..474b9e6 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* 路径别名:@/xxx -> src/xxx,需与 vite.config.ts 的 resolve.alias 保持一致 */ + "paths": { + "@/*": ["./src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3daab6 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..c99f398 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "installCommand": "npm ci", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b928223 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath, URL } from 'node:url' + +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + // 全项目统一用 @/xxx 引用 src 下的模块,避免 ../../.. 相对路径 + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..3a40db6 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + // 纯逻辑测试跑 node;需要 DOM 的用文件顶部 @vitest-environment jsdom 单独声明 + environment: 'node', + include: [ + 'src/**/*.test.ts', + 'src/**/*.test.tsx', + '../tests/**/*.test.ts', + '../tests/**/*.test.tsx', + ], + }, +})