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/.gitignore b/.gitignore
new file mode 100644
index 0000000..2603d1a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+# macOS
+.DS_Store
+
+# 依赖与构建产物由各子项目的 .gitignore 负责
diff --git a/docs/superpowers/plans/2026-07-28-frontend-workflow-orchestration.md b/docs/superpowers/plans/2026-07-28-frontend-workflow-orchestration.md
new file mode 100644
index 0000000..b7b24f1
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-28-frontend-workflow-orchestration.md
@@ -0,0 +1,419 @@
+# Frontend Workflow Orchestration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make WorkflowRun an explicitly frontend-owned orchestration model while preserving its public API and preparing separate backend capability adapters without inventing unfrozen endpoints.
+
+**Architecture:** Pages keep calling the existing WorkflowRun entity facade. The facade delegates to a `WorkflowRunRepository` port backed by a localStorage adapter and a frontend-only MS2 state transition module; `shared/api` no longer registers or sends `/workflows/*`. Backend Task data is separated from the frontend mapping that associates a task with a run, revision, and node.
+
+**Tech Stack:** React 19, TypeScript 6, Vitest 4, browser localStorage, Oxlint, Oxfmt.
+
+## Global Constraints
+
+- PR #62 `docs/architecture.md` is the backend architecture source of truth.
+- Keep `createWorkflowRun`, `fetchWorkflowRun`, and `submitWorkflowCommand` signatures unchanged.
+- Do not add guessed Generation, Asset, Review, Playtest, Export URLs or DTOs.
+- Do not change routes, page layouts, or user-visible workflow behavior.
+- Production code must not send `/workflows` or `/workflow-runs` requests.
+- Mock generation, quality, review, or export state must not be documented as a real backend capability.
+- Preserve existing unrelated untracked files, especially `docs/frontend-backend-misalignment.md`.
+
+---
+
+## File Structure
+
+- Create `frontend/src/entities/workflow-run/model/repository.ts`: repository port used by orchestration functions.
+- Create `frontend/src/entities/workflow-run/local/store.ts`: localStorage and memory fallback for domain `WorkflowRun` objects.
+- Create `frontend/src/entities/workflow-run/local/machine.ts`: frontend-only MS2 command transitions using camelCase domain types.
+- Create `frontend/src/entities/workflow-run/local/repository.ts`: local adapter implementing the repository port.
+- Create `frontend/src/entities/workflow-run/orchestration/create-workflow-run.ts`: stable async public facade.
+- Create `frontend/src/entities/workflow-run/orchestration/get-workflow-run.ts`: stable async public facade.
+- Create `frontend/src/entities/workflow-run/orchestration/submit-workflow-command.ts`: stable async public facade.
+- Modify `frontend/src/entities/workflow-run/index.ts`: export orchestration functions and new task-link type.
+- Delete `frontend/src/entities/workflow-run/api/*`: remove fake HTTP DTO and requests.
+- Delete `frontend/src/shared/api/client/mock/workflow-run/*`: remove the fake backend resource.
+- Modify `frontend/src/shared/api/client/mock/index.ts`: keep only backend-capability mocks such as Project.
+- Modify `frontend/src/entities/workflow-run/model/task.ts`: separate backend Task from frontend WorkflowTaskLink.
+- Modify `frontend/src/entities/public-contracts.test.ts`: compile-time contract assertions.
+- Modify `tests/integration/architecture.test.ts`: enforce the no-WorkflowRun-HTTP boundary.
+- Modify `tests/integration/workflow-run-store.test.ts`: retain behavior coverage and add storage-shape coverage.
+- Modify `frontend/API_CONTRACT.md`, `frontend/MODULES.md`, `frontend/README.md`, and `frontend-architecture-v3.md`: align documentation with PR #62.
+
+---
+
+### Task 1: Decouple backend Task from frontend workflow correlation
+
+**Files:**
+- Modify: `frontend/src/entities/public-contracts.test.ts`
+- Modify: `frontend/src/entities/workflow-run/model/task.ts`
+- Modify: `frontend/src/entities/workflow-run/index.ts`
+- Modify: `frontend/src/entities/index.ts`
+
+**Interfaces:**
+- Consumes: `WorkflowRun['id']`, `WorkflowRevision['id']`, `WorkflowNode['id']`.
+- Produces: `Task`, `TaskEvent`, and `WorkflowTaskLink` exported from `@/entities`.
+
+- [ ] **Step 1: Write the failing type test**
+
+Replace the Task assertions in `public-contracts.test.ts` with:
+
+```ts
+import type { WorkflowTaskLink } from './index'
+
+expectTypeOf().not.toHaveProperty('runId')
+expectTypeOf().not.toHaveProperty('revisionId')
+expectTypeOf().toEqualTypeOf<{
+ taskId: string
+ runId: string
+ revisionId: string
+ nodeId: string
+}>()
+```
+
+- [ ] **Step 2: Run the type test to verify RED**
+
+Run: `npm test -- src/entities/public-contracts.test.ts`
+
+Expected: FAIL because `Task` still has `runId/revisionId` and `WorkflowTaskLink` is not exported.
+
+- [ ] **Step 3: Implement the minimal contracts**
+
+Change `model/task.ts` to:
+
+```ts
+import type { WorkflowNode, WorkflowRevision, WorkflowRun } from './types'
+
+export type TaskStatus = 'queued' | 'running' | 'succeeded' | 'failed'
+
+export interface Task {
+ id: string
+ status: TaskStatus
+ progress: number | null
+ error: string | null
+ result: unknown
+}
+
+export interface TaskEvent extends Omit {
+ taskId: Task['id']
+}
+
+export interface WorkflowTaskLink {
+ taskId: Task['id']
+ runId: WorkflowRun['id']
+ revisionId: WorkflowRevision['id']
+ nodeId: WorkflowNode['id']
+}
+```
+
+Export `WorkflowTaskLink` through both WorkflowRun and aggregate Entity public indexes.
+
+- [ ] **Step 4: Run the focused test to verify GREEN**
+
+Run: `npm test -- src/entities/public-contracts.test.ts`
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit the task**
+
+```text
+git add frontend/src/entities/public-contracts.test.ts frontend/src/entities/workflow-run/model/task.ts frontend/src/entities/workflow-run/index.ts frontend/src/entities/index.ts
+git commit -m "refactor(frontend): decouple tasks from workflow runs"
+```
+
+---
+
+### Task 2: Replace fake WorkflowRun HTTP routes with a local repository adapter
+
+**Files:**
+- Modify: `tests/integration/architecture.test.ts`
+- Modify: `tests/integration/workflow-run-store.test.ts`
+- Create: `frontend/src/entities/workflow-run/model/repository.ts`
+- Create: `frontend/src/entities/workflow-run/local/store.ts`
+- Create: `frontend/src/entities/workflow-run/local/machine.ts`
+- Create: `frontend/src/entities/workflow-run/local/repository.ts`
+- Create: `frontend/src/entities/workflow-run/orchestration/create-workflow-run.ts`
+- Create: `frontend/src/entities/workflow-run/orchestration/get-workflow-run.ts`
+- Create: `frontend/src/entities/workflow-run/orchestration/submit-workflow-command.ts`
+- Modify: `frontend/src/entities/workflow-run/index.ts`
+- Modify: `frontend/src/shared/api/client/mock/index.ts`
+- Delete: `frontend/src/entities/workflow-run/api/create-workflow-run.ts`
+- Delete: `frontend/src/entities/workflow-run/api/get-workflow-run.ts`
+- Delete: `frontend/src/entities/workflow-run/api/submit-workflow-command.ts`
+- Delete: `frontend/src/entities/workflow-run/api/dto.ts`
+- Delete: `frontend/src/shared/api/client/mock/workflow-run/index.ts`
+- Delete: `frontend/src/shared/api/client/mock/workflow-run/machine.ts`
+- Delete: `frontend/src/shared/api/client/mock/workflow-run/store.ts`
+- Delete: `frontend/src/shared/api/client/mock/workflow-run/types.ts`
+
+**Interfaces:**
+- Consumes: `CreateWorkflowRunInput`, `WorkflowCommand`, and `WorkflowRun` domain types.
+- Produces: the unchanged async facade signatures and a local `WorkflowRunRepository` implementation.
+
+- [ ] **Step 1: Write the failing architecture boundary test**
+
+Add to `tests/integration/architecture.test.ts`:
+
+```ts
+it('WorkflowRun 是前端编排模型,不通过 HTTP transport', () => {
+ const workflowRoot = join(SRC, 'entities/workflow-run')
+ const offenders: string[] = []
+
+ for (const file of walk(workflowRoot)) {
+ if (/\.test\.tsx?$/.test(file)) continue
+ const source = readFileSync(file, 'utf8')
+ const rel = relativeSrc(file)
+ if (moduleSpecifiers(file, source).includes('@/shared/api')) {
+ offenders.push(`${rel}: imports @/shared/api`)
+ }
+ if (/['"`]\/workflow-runs?(?:\/|['"`])/.test(source)) {
+ offenders.push(`${rel}: runtime workflow HTTP path`)
+ }
+ }
+
+ const mockWorkflowDir = join(SRC, 'shared/api/client/mock/workflow-run')
+ if (existsSync(mockWorkflowDir)) offenders.push('shared/api/client/mock/workflow-run exists')
+
+ expect(offenders).toEqual([])
+})
+```
+
+Add this assertion to the persistence test after creating a run:
+
+```ts
+const stored = JSON.parse(localStorage.getItem('windup.workflow-runs.v1') ?? '{}')
+expect(stored[created.id]).toEqual(created)
+```
+
+- [ ] **Step 2: Run the focused tests to verify RED**
+
+Run: `npm test -- ../tests/integration/architecture.test.ts ../tests/integration/workflow-run-store.test.ts`
+
+Expected: FAIL listing the three `@/shared/api` imports, runtime `/workflows` paths, existing mock directory, and missing new storage key.
+
+- [ ] **Step 3: Define the repository port**
+
+Create `model/repository.ts`:
+
+```ts
+import type { CreateWorkflowRunInput, WorkflowCommand, WorkflowRun } from './types'
+
+export interface WorkflowRunRepository {
+ create(input: CreateWorkflowRunInput): WorkflowRun
+ get(runId: WorkflowRun['id']): WorkflowRun | null
+ submit(runId: WorkflowRun['id'], command: WorkflowCommand): WorkflowRun
+}
+```
+
+- [ ] **Step 4: Implement domain-shaped local storage**
+
+Create `local/store.ts` using storage key `windup.workflow-runs.v1`. Save and load `WorkflowRun`
+objects directly, validate that every record has a string `id` and a `revisions` array, and fall back to a
+module-level map when localStorage throws. Keep ID generation as:
+
+```ts
+export function newId(prefix: 'run' | 'revision' | 'node'): string {
+ return `${prefix}-${globalThis.crypto.randomUUID().slice(0, 8)}`
+}
+```
+
+The exact exported storage operations are:
+
+```ts
+export function saveRun(run: WorkflowRun): WorkflowRun
+export function loadRun(runId: WorkflowRun['id']): WorkflowRun | null
+```
+
+- [ ] **Step 5: Convert the MS2 state machine to domain types**
+
+Move the existing transition algorithm to `local/machine.ts` and apply these exact field conversions:
+
+```text
+current_revision_id -> currentRevisionId
+based_on_revision_id -> basedOnRevisionId
+restart_node_id -> restartNodeId
+generation_status -> generationStatus
+export_status -> exportStatus
+playtest_status -> playtestStatus
+reference_node_ids -> referenceNodeIds
+quality_failure_count -> qualityFailureCount
+node_id -> nodeId
+source_revision_id -> sourceRevisionId
+revision_id -> revisionId
+```
+
+Use `WORKFLOW_NODE_ORDER` from `model/types.ts` instead of defining a DTO order. Export:
+
+```ts
+export function createLocalRun(input: CreateWorkflowRunInput): WorkflowRun
+export function advanceLocalRun(runId: WorkflowRun['id'], command: WorkflowCommand): WorkflowRun
+```
+
+The initial AI revision contains passed `asset` and active `generation` nodes; the manual revision contains
+an active `asset` node. Keep all existing quality failure, restart, Playtest, and export transition behavior.
+
+- [ ] **Step 6: Implement the local adapter and async facade**
+
+Create `local/repository.ts`:
+
+```ts
+import type { WorkflowRunRepository } from '../model/repository'
+import { advanceLocalRun, createLocalRun } from './machine'
+import { loadRun } from './store'
+
+export const localWorkflowRunRepository: WorkflowRunRepository = {
+ create: createLocalRun,
+ get: loadRun,
+ submit: advanceLocalRun,
+}
+```
+
+Create the three orchestration files with the stable signatures. Each delegates to
+`localWorkflowRunRepository`; `fetchWorkflowRun` throws `工作流 ${runId} 不存在` when `get` returns null.
+No orchestration file imports `@/shared/api`.
+
+- [ ] **Step 7: Remove fake HTTP wiring**
+
+Update `workflow-run/index.ts` to export from `./orchestration/*`. Remove
+`workflowRunMockHandlers` from the route list in `shared/api/client/mock/index.ts`. Delete the old fake HTTP
+API, DTO, and mock WorkflowRun directories listed above.
+
+- [ ] **Step 8: Run focused tests to verify GREEN**
+
+Run: `npm test -- ../tests/integration/architecture.test.ts ../tests/integration/workflow-run-store.test.ts src/app/quick-start-flow.test.tsx src/pages/workflow-editor/editor/index.test.tsx`
+
+Expected: PASS with unchanged page behavior and no WorkflowRun HTTP boundary violations.
+
+- [ ] **Step 9: Commit the task**
+
+```text
+git add frontend/src/entities/workflow-run frontend/src/shared/api/client/mock/index.ts tests/integration/architecture.test.ts tests/integration/workflow-run-store.test.ts
+git commit -m "refactor(frontend): keep workflow orchestration local"
+```
+
+---
+
+### Task 3: Align frontend documentation with PR #62
+
+**Files:**
+- Modify: `frontend/API_CONTRACT.md`
+- Modify: `frontend/MODULES.md`
+- Modify: `frontend/README.md`
+- Modify: `frontend-architecture-v3.md`
+
+**Interfaces:**
+- Consumes: the implemented repository/orchestration boundary and Task/WorkflowTaskLink types.
+- Produces: one consistent description of current frontend orchestration and future backend capability clients.
+
+- [ ] **Step 1: Update API contract status**
+
+Replace the `/workflows/*` pending-backend section with these facts:
+
+```text
+- WorkflowRun/Revision/node routes are not backend endpoints.
+- Current MS2 orchestration is persisted by the frontend local repository.
+- Real backend integration happens through independent Project, Media, Character, Generation, Asset,
+ Review, Playtest, and Export contracts.
+- Backend Task has no runId/revisionId; WorkflowTaskLink is frontend-only correlation data.
+```
+
+Keep all unfrozen capability endpoints explicitly marked unaligned.
+
+- [ ] **Step 2: Update module and setup documentation**
+
+In `MODULES.md`, assign WorkflowRun orchestration to the frontend Entity and describe future capability
+Adapters as internal dependencies of the orchestrator. In `README.md`, state that WorkflowRun does not use
+the global Mock/Real HTTP switch; capability APIs still do.
+
+- [ ] **Step 3: Update the architecture document**
+
+In `frontend-architecture-v3.md`:
+
+```text
+- Remove the claim that the backend will persist WorkflowRun.
+- Remove “Python WorkflowRun API adapter” from pending work.
+- State that backend workflow definition and execution are separate future domains.
+- State that WorkflowTaskLink correlates backend task IDs to frontend run/revision/node IDs.
+```
+
+- [ ] **Step 4: Check for stale contract language**
+
+Run:
+
+```text
+rg -n "/workflows|Python WorkflowRun|后端.*WorkflowRun|WorkflowRun.*后端" frontend frontend-architecture-v3.md
+```
+
+Expected: only explanatory statements saying that WorkflowRun is not a backend endpoint; no pending real
+WorkflowRun adapter or route remains.
+
+- [ ] **Step 5: Commit the task**
+
+```text
+git add frontend/API_CONTRACT.md frontend/MODULES.md frontend/README.md frontend-architecture-v3.md
+git commit -m "docs(frontend): align orchestration with backend domains"
+```
+
+---
+
+### Task 4: Full verification and final scope audit
+
+**Files:**
+- Verify only; modify implementation files only if a check exposes a defect.
+
+**Interfaces:**
+- Consumes: Tasks 1–3.
+- Produces: fresh evidence that the repository meets the design acceptance criteria.
+
+- [ ] **Step 1: Format and verify formatting**
+
+Run: `npm run format`
+
+Run: `npm run format:check`
+
+Expected: both exit 0.
+
+- [ ] **Step 2: Run static and behavioral checks**
+
+Run: `npm run lint`
+
+Run: `npm run test`
+
+Run: `npm run typecheck`
+
+Run: `npm run build`
+
+Expected: every command exits 0 and all Vitest files pass.
+
+- [ ] **Step 3: Verify the diff and forbidden paths**
+
+Run:
+
+```text
+git diff --check
+rg -n "request<.*WorkflowRun|/workflow-runs|/workflows" frontend/src
+git status --short
+```
+
+Expected: no WorkflowRun HTTP request/path matches, no whitespace errors, and the pre-existing untracked
+`docs/frontend-backend-misalignment.md` remains unmodified and unstaged.
+
+- [ ] **Step 4: Review requirements line by line**
+
+Confirm:
+
+```text
+[ ] WorkflowRun public signatures are unchanged.
+[ ] shared/api has no WorkflowRun route.
+[ ] local storage saves camelCase domain records.
+[ ] Task and WorkflowTaskLink are separated.
+[ ] no capability endpoint was guessed.
+[ ] all four frontend documents agree with PR #62.
+```
+
+- [ ] **Step 5: Commit verification fixes only if needed**
+
+If verification required code changes, stage only those exact files and commit:
+
+```text
+git commit -m "fix(frontend): complete orchestration boundary"
+```
diff --git a/docs/superpowers/plans/2026-07-28-workflow-run-repository-hardening.md b/docs/superpowers/plans/2026-07-28-workflow-run-repository-hardening.md
new file mode 100644
index 0000000..e6407f0
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-28-workflow-run-repository-hardening.md
@@ -0,0 +1,558 @@
+# WorkflowRun Repository Hardening Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the frontend-owned WorkflowRun repository network-shaped and replaceable while preventing local persistence loss, unsupported-ID crashes, incorrect initial generation state, and malformed hydration.
+
+**Architecture:** Keep the existing Promise-based public facade and PR #62 frontend ownership. Introduce one composition entry that selects an asynchronous repository port, currently backed by the local state machine. Harden the local adapter with a memory-wins overlay, layered ID generation, and complete runtime validation without inventing a backend WorkflowRun route.
+
+**Tech Stack:** React 19, TypeScript 6, Vitest 4, browser localStorage, Oxlint, Oxfmt.
+
+## Global Constraints
+
+- Keep `createWorkflowRun`, `fetchWorkflowRun`, and `submitWorkflowCommand` public signatures unchanged.
+- Do not add `/workflows`, `/workflow-runs`, or guessed backend DTOs.
+- PR #62 remains the backend boundary source of truth: WorkflowRun is frontend page orchestration.
+- All repository operations return Promise values even when the current adapter is local.
+- The three orchestration files select their implementation through exactly one composition entry.
+- Current-session memory data overrides stale localStorage data after any failed persistence attempt.
+- Manual creation starts with generation status `not_started`; Quick Start starts with `in_progress`.
+- Malformed persisted records never enter the domain layer; valid siblings remain readable.
+- Preserve the unrelated untracked `docs/frontend-backend-misalignment.md` file without editing or staging it.
+- Follow TDD: add and observe each focused failure before editing production code.
+
+---
+
+## File Structure
+
+- Create `frontend/src/entities/workflow-run/repository.ts`: the only runtime composition entry; exports `workflowRunRepository`.
+- Modify `frontend/src/entities/workflow-run/model/repository.ts`: asynchronous repository port.
+- Create `frontend/src/entities/workflow-run/model/repository.test.ts`: compile-time Promise contract test.
+- Modify `frontend/src/entities/workflow-run/local/repository.ts`: asynchronous local adapter.
+- Modify `frontend/src/entities/workflow-run/orchestration/*.ts`: depend on the composition entry.
+- Modify `frontend/src/entities/workflow-run/local/store.ts`: memory overlay, persistence reads, and layered ID generation.
+- Create `frontend/src/entities/workflow-run/local/validation.ts`: complete persisted WorkflowRun validation and per-record filtering.
+- Modify `frontend/src/entities/workflow-run/local/machine.ts`: `not_started` lifecycle transitions.
+- Modify `frontend/src/entities/workflow-run/model/types.ts`: add `GenerationStatus` member.
+- Modify `tests/integration/architecture.test.ts`: enforce one repository binding point.
+- Modify `tests/integration/workflow-run-store.test.ts`: behavioral regression coverage for all reported failures.
+- Modify `frontend/API_CONTRACT.md`, `frontend/MODULES.md`, `frontend/README.md`, and `frontend-architecture-v3.md`: synchronize the implemented contract.
+
+---
+
+### Task 1: Async repository port and single composition entry
+
+**Files:**
+- Create: `frontend/src/entities/workflow-run/model/repository.test.ts`
+- Create: `frontend/src/entities/workflow-run/repository.ts`
+- Modify: `frontend/src/entities/workflow-run/model/repository.ts`
+- Modify: `frontend/src/entities/workflow-run/local/repository.ts`
+- Modify: `frontend/src/entities/workflow-run/orchestration/create-workflow-run.ts`
+- Modify: `frontend/src/entities/workflow-run/orchestration/get-workflow-run.ts`
+- Modify: `frontend/src/entities/workflow-run/orchestration/submit-workflow-command.ts`
+- Modify: `tests/integration/architecture.test.ts`
+
+**Interfaces:**
+- Consumes: synchronous `createLocalRun`, `loadRun`, and `advanceLocalRun` functions.
+- Produces: `WorkflowRunRepository` methods returning Promise values and `workflowRunRepository: WorkflowRunRepository` as the only selected implementation.
+
+- [ ] **Step 1: Write failing Promise type tests**
+
+Create `model/repository.test.ts`:
+
+```ts
+import { describe, expectTypeOf, it } from 'vitest'
+
+import type { WorkflowRunRepository } from './repository'
+import type { WorkflowRun } from './types'
+
+describe('WorkflowRunRepository 契约', () => {
+ it('所有操作都使用可等待的网络形状', () => {
+ expectTypeOf>().toEqualTypeOf<
+ Promise
+ >()
+ expectTypeOf>().toEqualTypeOf<
+ Promise
+ >()
+ expectTypeOf>().toEqualTypeOf<
+ Promise
+ >()
+ })
+})
+```
+
+- [ ] **Step 2: Extend the architecture test to reject direct local bindings**
+
+Inside the existing WorkflowRun architecture test, inspect files below `orchestration/`. Add an offender when
+an import resolves below `../local/`, and require `entities/workflow-run/repository.ts` to exist. The current
+three direct imports must make the focused test fail.
+
+- [ ] **Step 3: Run RED checks**
+
+Run from `frontend/`:
+
+```text
+npm test -- src/entities/workflow-run/model/repository.test.ts ../tests/integration/architecture.test.ts
+```
+
+Expected: type assertions report synchronous return types, and the architecture test lists all three direct
+`local/repository` imports.
+
+- [ ] **Step 4: Implement the asynchronous port**
+
+Change the port signatures to:
+
+```ts
+export interface WorkflowRunRepository {
+ create(input: CreateWorkflowRunInput): Promise
+ get(runId: WorkflowRun['id']): Promise
+ submit(runId: WorkflowRun['id'], command: WorkflowCommand): Promise
+}
+```
+
+Wrap the synchronous local implementation at the adapter boundary:
+
+```ts
+export const localWorkflowRunRepository: WorkflowRunRepository = {
+ async create(input) {
+ return createLocalRun(input)
+ },
+ async get(runId) {
+ return loadRun(runId)
+ },
+ async submit(runId, command) {
+ return advanceLocalRun(runId, command)
+ },
+}
+```
+
+- [ ] **Step 5: Add and use the one composition entry**
+
+Create `entities/workflow-run/repository.ts`:
+
+```ts
+import { localWorkflowRunRepository } from './local/repository'
+import type { WorkflowRunRepository } from './model/repository'
+
+/** 当前实现选择点;真实契约冻结后只在这里替换或组合 Adapter。 */
+export const workflowRunRepository: WorkflowRunRepository = localWorkflowRunRepository
+```
+
+Import `workflowRunRepository` from `../repository` in all three orchestration files. Await `get` before its
+null check; return the Promise directly for create and submit.
+
+- [ ] **Step 6: Run GREEN checks**
+
+Run:
+
+```text
+npm test -- src/entities/workflow-run/model/repository.test.ts ../tests/integration/architecture.test.ts ../tests/integration/workflow-run-store.test.ts
+```
+
+Expected: all selected test files pass.
+
+- [ ] **Step 7: Commit Task 1**
+
+Stage only the Task 1 files and commit:
+
+```text
+refactor(frontend): make workflow repository replaceable
+```
+
+---
+
+### Task 2: Preserve latest session data when persistence fails
+
+**Files:**
+- Modify: `tests/integration/workflow-run-store.test.ts`
+- Modify: `frontend/src/entities/workflow-run/local/store.ts`
+
+**Interfaces:**
+- Consumes: existing `saveRun` and `loadRun` calls from the local state machine.
+- Produces: a memory overlay whose entries win over the persisted snapshot for the same run ID.
+
+- [ ] **Step 1: Add a storage double that rejects writes**
+
+Add a test helper which accepts an existing map, returns it from `getItem`, and throws `QuotaExceededError`
+from `setItem`. It must implement the complete `Storage` shape and preserve the serialized old snapshot.
+
+- [ ] **Step 2: Write the failing data-loss regression test**
+
+The test performs these real facade operations:
+
+1. Create an old run in writable storage and capture the serialized snapshot.
+2. Replace `localStorage` with the write-rejecting storage containing only that old snapshot.
+3. Create a new run; creation returns successfully because memory is the fallback.
+4. Fetch the new run and assert its ID and revision ID match the just-created object.
+
+The current implementation must fail at step 4 because it re-reads the old disk snapshot first.
+
+- [ ] **Step 3: Run the focused test to verify RED**
+
+Run:
+
+```text
+npm test -- ../tests/integration/workflow-run-store.test.ts -t "写入失败后仍读取本次会话的最新工作流"
+```
+
+Expected: FAIL with `工作流 不存在`.
+
+- [ ] **Step 4: Implement memory-wins merge semantics**
+
+Split disk parsing from the merged read:
+
+```ts
+function readPersisted(): RunMap {
+ // Return only a parsed, validated disk map; return {} on absence or failure.
+}
+
+function readAll(): RunMap {
+ return { ...readPersisted(), ...memory }
+}
+
+export function saveRun(run: WorkflowRun): WorkflowRun {
+ memory = { ...memory, [run.id]: run }
+ const snapshot = readAll()
+ try {
+ storage()?.setItem(STORAGE_KEY, JSON.stringify(snapshot))
+ } catch {
+ // The memory overlay remains authoritative for this session.
+ }
+ return run
+}
+```
+
+Do not clear `memory` after a successful write; it remains the session's latest-authority overlay.
+
+- [ ] **Step 5: Run RED test and the full store suite to verify GREEN**
+
+Run:
+
+```text
+npm test -- ../tests/integration/workflow-run-store.test.ts
+```
+
+Expected: all WorkflowRun integration tests pass.
+
+- [ ] **Step 6: Commit Task 2**
+
+Stage the store and integration test, then commit:
+
+```text
+fix(frontend): preserve workflow memory fallback
+```
+
+---
+
+### Task 3: Generate IDs without requiring randomUUID
+
+**Files:**
+- Modify: `tests/integration/workflow-run-store.test.ts`
+- Modify: `frontend/src/entities/workflow-run/local/store.ts`
+
+**Interfaces:**
+- Consumes: ID prefixes `run`, `revision`, and `node`.
+- Produces: `newId(prefix): string` that survives missing `randomUUID` and missing Web Crypto.
+
+- [ ] **Step 1: Write two failing runtime tests**
+
+Add one test with `crypto` stubbed to an object that exposes only `getRandomValues`; creating a run must return
+IDs beginning with `run-`, `revision-`, and `node-`. Add another test with `crypto` set to `undefined`; create
+two runs and assert both succeed and their run IDs differ.
+
+- [ ] **Step 2: Run tests to verify RED**
+
+Run:
+
+```text
+npm test -- ../tests/integration/workflow-run-store.test.ts -t "randomUUID|Web Crypto"
+```
+
+Expected: current `globalThis.crypto.randomUUID()` call throws a TypeError.
+
+- [ ] **Step 3: Implement layered ID generation**
+
+Add a module-level counter and a suffix function:
+
+```ts
+let fallbackSequence = 0
+
+function randomSuffix(): string {
+ const cryptoApi = globalThis.crypto
+ if (typeof cryptoApi?.randomUUID === 'function') {
+ return cryptoApi.randomUUID().replaceAll('-', '').slice(0, 12)
+ }
+ if (typeof cryptoApi?.getRandomValues === 'function') {
+ const bytes = cryptoApi.getRandomValues(new Uint8Array(8))
+ return [...bytes].map((value) => value.toString(16).padStart(2, '0')).join('')
+ }
+ fallbackSequence += 1
+ return `${Date.now().toString(36)}${fallbackSequence.toString(36)}${Math.random()
+ .toString(36)
+ .slice(2, 8)}`
+}
+
+export function newId(prefix: 'run' | 'revision' | 'node'): string {
+ return `${prefix}-${randomSuffix()}`
+}
+```
+
+- [ ] **Step 4: Run the full store suite to verify GREEN**
+
+Run:
+
+```text
+npm test -- ../tests/integration/workflow-run-store.test.ts
+```
+
+Expected: all store tests pass under default crypto and both degraded environments.
+
+- [ ] **Step 5: Commit Task 3**
+
+Stage the two files and commit:
+
+```text
+fix(frontend): fall back when randomUUID is unavailable
+```
+
+---
+
+### Task 4: Represent generation that has not started
+
+**Files:**
+- Modify: `tests/integration/workflow-run-store.test.ts`
+- Modify: `frontend/src/entities/workflow-run/model/types.ts`
+- Modify: `frontend/src/entities/workflow-run/local/machine.ts`
+
+**Interfaces:**
+- Consumes: `WorkflowRevision.generationStatus` and node progression.
+- Produces: `GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed'` with lifecycle transitions.
+
+- [ ] **Step 1: Write the failing lifecycle test**
+
+Create a manual run and assert its initial revision is `not_started`. Complete its active asset node, then assert
+the new current node is generation and the revision is `in_progress`. In the same test create a Quick Start run
+and assert it starts `in_progress`.
+
+- [ ] **Step 2: Write the restart boundary test**
+
+After a manual run advances to generation, restart from its passed asset node. Assert the new revision's active
+node is asset and its generation status is `not_started`. Restarting from generation must remain
+`in_progress`.
+
+- [ ] **Step 3: Run tests to verify RED**
+
+Run:
+
+```text
+npm test -- ../tests/integration/workflow-run-store.test.ts -t "尚未开始|素材节点重启"
+```
+
+Expected: manual initial and asset-restart assertions receive `in_progress` instead of `not_started`.
+
+- [ ] **Step 4: Implement the state and transitions**
+
+Add `not_started` to `GenerationStatus`. In `initialRevision`, select by driver:
+
+```ts
+generationStatus: driver === 'ai' ? 'in_progress' : 'not_started'
+```
+
+When `appendNextNode` appends `generation`, return the revision with `generationStatus: 'in_progress'`.
+When `restartRevision` activates asset, return `not_started`; when it activates generation or candidate, return
+`in_progress`; for later nodes keep the source generation status.
+
+- [ ] **Step 5: Run the store and selector suites to verify GREEN**
+
+Run:
+
+```text
+npm test -- ../tests/integration/workflow-run-store.test.ts src/entities/workflow-run/model/selectors.test.ts
+```
+
+Expected: all tests pass and completed/failed gates remain unchanged.
+
+- [ ] **Step 6: Commit Task 4**
+
+Stage the three files and commit:
+
+```text
+fix(frontend): distinguish unstarted generation
+```
+
+---
+
+### Task 5: Reject malformed persisted WorkflowRuns completely
+
+**Files:**
+- Create: `frontend/src/entities/workflow-run/local/validation.ts`
+- Modify: `frontend/src/entities/workflow-run/local/store.ts`
+- Modify: `tests/integration/workflow-run-store.test.ts`
+
+**Interfaces:**
+- Consumes: the full `WorkflowRun`, `WorkflowRevision`, and `WorkflowNode` domain shapes.
+- Produces: `parseWorkflowRunMap(value: unknown): Record` that filters invalid entries.
+
+- [ ] **Step 1: Add a complete literal persisted-run fixture**
+
+In the integration test, define a complete manual-run JSON object with all required run, revision, and node
+fields. Do not generate expected fields through production helpers. Use distinct fixed IDs such as
+`run-valid-storage`, `revision-valid-storage`, and `node-valid-storage`.
+
+- [ ] **Step 2: Write malformed-record table tests**
+
+For separate IDs, store records containing each defect and assert `fetchWorkflowRun(id)` rejects:
+
+- missing `projectId`;
+- invalid `driver`;
+- missing revision `nodes`;
+- invalid node `status`;
+- `currentRevisionId` not found in `revisions`;
+- storage map key different from `run.id`.
+
+Add a valid sibling beside one invalid record and assert the valid sibling still loads.
+
+- [ ] **Step 3: Run focused tests to verify RED**
+
+Run:
+
+```text
+npm test -- ../tests/integration/workflow-run-store.test.ts -t "损坏记录|合法同级记录"
+```
+
+Expected: at least the record with only `id` and `revisions`-compatible fields is returned instead of rejected.
+
+- [ ] **Step 4: Implement full structural validation**
+
+Create `local/validation.ts` with these exact validator groups:
+
+```ts
+const DRIVERS = ['ai', 'manual'] as const
+const RUN_STATUSES = ['active', 'completed', 'failed'] as const
+const REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const
+const NODE_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const
+const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const
+const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const
+const PLAYTEST_STATUSES = ['not_tested', 'passed', 'issues_found'] as const
+```
+
+Use object, own-property, string-or-null, integer, enum, and string-array helpers. A node must own `input` and
+`output`, have a node type in `WORKFLOW_NODE_ORDER`, and contain every other `WorkflowNode` field. A revision
+must contain all fields and only valid nodes. A run must contain all fields, at least one valid revision, and a
+`currentRevisionId` matching one of them.
+
+Export only:
+
+```ts
+export function parseWorkflowRunMap(value: unknown): Record {
+ if (!isRecord(value)) return {}
+ return Object.fromEntries(
+ Object.entries(value).filter(
+ (entry): entry is [string, WorkflowRun] => isWorkflowRun(entry[1]) && entry[0] === entry[1].id,
+ ),
+ )
+}
+```
+
+Replace `isRunMap` in `store.ts` with `parseWorkflowRunMap(JSON.parse(raw))`.
+
+- [ ] **Step 5: Run store tests to verify GREEN**
+
+Run:
+
+```text
+npm test -- ../tests/integration/workflow-run-store.test.ts
+```
+
+Expected: every malformed case rejects, the valid sibling loads, and all existing workflow behavior passes.
+
+- [ ] **Step 6: Commit Task 5**
+
+Stage the validation, store, and integration test files and commit:
+
+```text
+fix(frontend): validate persisted workflow runs
+```
+
+---
+
+### Task 6: Synchronize documentation and run complete verification
+
+**Files:**
+- Modify: `frontend/API_CONTRACT.md`
+- Modify: `frontend/MODULES.md`
+- Modify: `frontend/README.md`
+- Modify: `frontend-architecture-v3.md`
+- Verify: all Task 1–5 source and test files
+
+**Interfaces:**
+- Consumes: implemented async port, composition entry, storage guarantees, ID fallback, status lifecycle, and validator.
+- Produces: one consistent written contract and fresh verification evidence.
+
+- [ ] **Step 1: Update API and module documentation**
+
+State that repository methods are Promise-based, all orchestration code uses the one composition entry, the
+current adapter remains local, and no backend WorkflowRun endpoint exists. Document `not_started`, memory-wins
+storage fallback, degraded ID support, and full hydration rejection.
+
+- [ ] **Step 2: Update README and architecture status**
+
+Describe future replacement as adding a remote/hybrid Adapter and changing only the composition entry. Keep
+PR #62 domain ownership and the prohibition on invented WorkflowRun routes. Add the new regression guarantees
+to the testing section.
+
+- [ ] **Step 3: Run formatting and static checks**
+
+Run from `frontend/`:
+
+```text
+npm run format
+npm run format:check
+npm run lint
+npm run typecheck
+```
+
+Expected: every command exits 0.
+
+- [ ] **Step 4: Run complete behavioral and build verification**
+
+Run:
+
+```text
+npm run test
+npm run build
+git diff --check
+```
+
+Expected: all Vitest files pass, the production build succeeds, and no whitespace errors are reported.
+
+- [ ] **Step 5: Audit the final boundary and scope**
+
+Run from the worktree root:
+
+```text
+rg -n "localWorkflowRunRepository" frontend/src/entities/workflow-run/orchestration
+rg -n "['\"`]\/workflow-runs?|['\"`]\/workflows" frontend/src
+git status --short
+```
+
+Expected: no direct local-repository imports in orchestration, no runtime WorkflowRun HTTP path, and
+`docs/frontend-backend-misalignment.md` remains unmodified and unstaged.
+
+- [ ] **Step 6: Commit Task 6**
+
+Stage only the four synchronized documents plus formatting changes belonging to Tasks 1–5, then commit:
+
+```text
+docs(frontend): document workflow repository guarantees
+```
+
+## Self-Review
+
+- Spec coverage: async shape and one binding are Task 1; fallback data loss is Task 2; unsupported UUID is
+ Task 3; missing lifecycle state is Task 4; hydration validation is Task 5; all documentation and verification
+ requirements are Task 6.
+- Placeholder scan: the plan contains no deferred implementation markers; every production change has an
+ exact file, interface, focused failing test, implementation rule, and verification command.
+- Type consistency: every layer uses the same Promise repository methods, the same `not_started` spelling, and
+ `parseWorkflowRunMap(value: unknown): Record`.
diff --git a/docs/superpowers/specs/2026-07-28-frontend-workflow-orchestration-design.md b/docs/superpowers/specs/2026-07-28-frontend-workflow-orchestration-design.md
new file mode 100644
index 0000000..02c079b
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-28-frontend-workflow-orchestration-design.md
@@ -0,0 +1,194 @@
+# 前端 WorkflowRun 编排边界设计
+
+## 背景与决策
+
+PR #62 的 `docs/architecture.md` 是后端目标架构的唯一依据。MS2 阶段的
+`WorkflowRun`、`WorkflowRevision` 和页面节点属于前端编排模型;后端不提供
+`/workflows` 或 `/workflow-runs` 资源,而是分别提供 Project、Media、Character、
+Generation、Asset、Review、Playtest 和 Export 等独立业务能力。
+
+当前前端已经通过 `createWorkflowRun`、`fetchWorkflowRun` 和
+`submitWorkflowCommand` 隔离了页面调用,但它们仍经过通用 HTTP transport,并把
+`/workflows/*` 描述成未来真实后端接口。开发环境的 Mock handler 会拦截这些路径,生产
+环境则会把请求发给不存在的后端路由。这是本次需要消除的错误边界。
+
+本次采用“前端编排服务 + 本地仓库 Port/Adapter”的方案。保留已经被页面依赖的公开函数和
+领域模型,不把后端业务逻辑写进页面;后续 OpenAPI 落地时,在编排服务内部增加独立能力
+Adapter,而不是重写页面。
+
+## 目标
+
+- 保持 Quick Start、Workflow Editor、Playtest 等页面的公开调用方式不变。
+- 明确 WorkflowRun 是前端模型,任何环境都不会向后端发送 `/workflows/*`。
+- 保留当前 MS2 的节点、Revision、命令和浏览器持久化行为。
+- 将临时状态机和 localStorage 实现从通用 HTTP Mock 中移到 WorkflowRun Entity 内部。
+- 将后端 Task 与前端 run/revision 关联拆成两个类型。
+- 为未来 Generation、Asset、Review、Export 的 OpenAPI Adapter 保留单一接入点,但不提前猜测
+ 路径、字段或状态机。
+- 同步 API、模块和架构文档,删除“未来 Python WorkflowRun API”的表述。
+
+## 非目标
+
+- 不实现或猜测 Generation、Asset、Review、Playtest、Export 的真实 URL 和 DTO。
+- 不改变当前页面布局、路由或交互。
+- 不把前端 WorkflowRun 变成后端 `workflow` 或 `execution` 的数据模型。
+- 不将 Mock 的质量门禁或生成结果描述为真实后端能力。
+- 不修改 PR #62 的后端架构文档。
+
+## 架构
+
+```text
+Pages / Features
+ |
+ v
+entities/workflow-run public API
+ createWorkflowRun()
+ fetchWorkflowRun()
+ submitWorkflowCommand()
+ |
+ v
+WorkflowOrchestrator
+ - 维护前端页面推进语义
+ - 调用 WorkflowRunRepository
+ - 未来组合独立后端能力 Adapter
+ |
+ v
+WorkflowRunRepository (Port)
+ |
+ v
+LocalWorkflowRunRepository (当前 Adapter)
+ - localStorage,失败时退回内存
+ - MS2 Mock 状态转换
+
+未来:
+WorkflowOrchestrator
+ -> GenerationClient / AssetClient / ReviewClient / ExportClient
+ -> OpenAPI 生成的真实 Adapter
+```
+
+`shared/api` 继续只负责真实后端能力的 HTTP、上传、响应壳和 Mock/Real 切换,不再注册或认识
+WorkflowRun 路由。
+
+## 组件与文件职责
+
+### WorkflowOrchestrator
+
+位于 `entities/workflow-run` 内部,承接现有三个公开函数。调用方继续从 Entity 公共入口导入,
+不感知本地仓库或未来后端能力 Adapter。
+
+公开签名保持不变:
+
+```ts
+createWorkflowRun(input: CreateWorkflowRunInput): Promise
+fetchWorkflowRun(runId: string): Promise
+submitWorkflowCommand(runId: string, command: WorkflowCommand): Promise
+```
+
+### WorkflowRunRepository
+
+Repository Port 只表达前端编排需要的持久化和命令操作,不表达 HTTP:
+
+```ts
+interface WorkflowRunRepository {
+ create(input: CreateWorkflowRunInput): WorkflowRun
+ get(runId: string): WorkflowRun | null
+ submit(runId: string, command: WorkflowCommand): WorkflowRun
+}
+```
+
+当前 Adapter 复用已有状态机、ID 生成和 localStorage 回退行为。原先的 snake_case
+`WorkflowRunDto` 只服务于伪 HTTP 协议;移除该协议后,本地仓库直接保存前端领域形状,避免
+继续伪装成后端 DTO。
+
+### 后端能力 Adapter
+
+本次不创建空的 Generation/Asset/Review/Export 实现。后端 OpenAPI 冻结后,按照真实 Schema
+逐个增加 Client 和 Adapter,并由 WorkflowOrchestrator 组合。这样不会因为提前猜测接口而再次
+返工。
+
+## Task 与关联数据
+
+后端 Task 不依赖前端 WorkflowRun:
+
+```ts
+interface Task {
+ id: string
+ status: TaskStatus
+ progress: number | null
+ error: string | null
+ result: unknown
+}
+```
+
+前端单独保存关联:
+
+```ts
+interface WorkflowTaskLink {
+ taskId: Task['id']
+ runId: WorkflowRun['id']
+ revisionId: WorkflowRevision['id']
+ nodeId: WorkflowNode['id']
+}
+```
+
+Task SSE 事件也只携带后端 Task 字段。断线恢复、事件名和 payload 在 OpenAPI/SSE 契约冻结前
+继续明确失败,不返回假成功。
+
+## 数据流
+
+### 当前 MS2
+
+1. 页面调用 `createWorkflowRun`。
+2. WorkflowOrchestrator 在本地仓库创建 run 和初始 revision。
+3. 页面提交命令时,Orchestrator 调用本地 MS2 状态转换并保存结果。
+4. 页面通过 `fetchWorkflowRun` 或现有 hook 读取最新前端状态。
+5. 全流程不经过通用 HTTP transport,也不产生 `/workflows/*` 网络请求。
+
+### 后端能力接入后
+
+1. Orchestrator 调用真实 Generation Client 创建 Task。
+2. 前端保存 `WorkflowTaskLink`,把 `taskId` 关联到当前节点。
+3. 查询或 SSE 返回 Task 更新,Orchestrator 据此更新前端 WorkflowRun。
+4. Generation 返回 `assetId` 后,后续 Review、Playtest、Export 只使用后端 Asset 引用。
+5. 页面仍使用原有 WorkflowRun 公共接口,不感知后端模块拆分。
+
+## 错误处理
+
+- 本地 run 不存在时,公开函数继续显式抛错,不返回空对象或假成功。
+- localStorage 不可用时退回进程内存,维持当前会话可用。
+- 非法命令由本地 MS2 状态转换拒绝。
+- 真实后端能力未接入时,对应功能必须明确显示未实现或失败;生产环境不得用 Mock 伪造生成、
+ 审核或导出成功。
+- 后端 Task 失败只更新关联节点的展示状态,不篡改其他 Revision。
+
+## 测试设计
+
+实施遵循测试先行:
+
+1. 新增边界测试,证明调用三个 WorkflowRun 公开函数不会触发通用 HTTP `fetch`,也不依赖
+ `VITE_USE_MOCK`。
+2. 新增/调整 Repository 测试,覆盖创建、读取、命令推进和未知 run。
+3. 先增加类型测试,要求 `Task` 不再包含 `runId/revisionId`,并要求
+ `WorkflowTaskLink` 精确包含 task/run/revision/node 四个 ID。
+4. 更新架构测试,禁止 WorkflowRun 编排实现导入 `shared/api` 请求函数,并确认
+ `shared/api/client/mock` 不再包含 WorkflowRun 路由。
+5. 保留现有 Quick Start、Workflow Editor、Revision、门禁和 Playtest 测试,确保页面行为不回归。
+6. 最后运行 format check、lint、全部测试、typecheck、build 和 `git diff --check`。
+
+## 文档同步
+
+- `frontend/API_CONTRACT.md`:删除 `/workflows/*` 待后端实现列表,说明它们不是后端契约;记录
+ Task/WorkflowTaskLink 分离。
+- `frontend/MODULES.md`:将 WorkflowRun 描述为前端编排 Entity,真实后端能力按独立域接入。
+- `frontend-architecture-v3.md`:删除“后端最终保存 WorkflowRun”和“Python WorkflowRun API
+ adapter”,与 PR #62 的 workflow/execution 分层一致。
+- `frontend/README.md`:说明开发 Mock 与真实独立能力 Adapter 的切换边界。
+
+## 验收条件
+
+- 仓库中不存在运行时 `/workflows` 或 `/workflow-runs` 请求与 Mock route。
+- 页面和 Feature 不需要修改其 WorkflowRun 公共调用签名。
+- `Task` 与 `WorkflowTaskLink` 类型边界通过编译期测试。
+- WorkflowRun 的本地编排测试和原有前端测试全部通过。
+- 文档不再暗示后端需要实现 WorkflowRun 资源。
+- 未冻结的独立后端接口没有被凭空添加。
diff --git a/docs/superpowers/specs/2026-07-28-workflow-run-repository-hardening-design.md b/docs/superpowers/specs/2026-07-28-workflow-run-repository-hardening-design.md
new file mode 100644
index 0000000..52a6bc0
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-28-workflow-run-repository-hardening-design.md
@@ -0,0 +1,193 @@
+# WorkflowRun Repository 异步化与本地存储加固设计
+
+## 背景与架构决策
+
+当前页面通过 `createWorkflowRun`、`fetchWorkflowRun` 和
+`submitWorkflowCommand` 使用 WorkflowRun,三个公开函数已经返回 Promise;但它们内部依赖的
+`WorkflowRunRepository` 仍是同步接口,而且每个编排文件都直接导入本地实现。这会造成两个问题:
+
+1. 未来接入网络实现时,Repository 契约及其调用链仍需整体重写。
+2. 实现选择散落在三个调用点,无法像通用 API 层一样从一个位置切换。
+
+本次采用已经确认的方案 A:**异步 Repository Port + 唯一组合入口,当前仍绑定本地
+Adapter**。该方案同时遵守两项既有边界:
+
+- Issue #58 要求前端从一开始就使用可等待的网络形状,并让未来实现替换集中在一处。
+- PR #62 规定当前 WorkflowRun 是前端页面编排模型,后端没有 `/workflows` 或
+ `/workflow-runs` 资源。
+
+因此,本次不会虚构一个远程 WorkflowRun API,也不会把本地状态机包装成假 HTTP。以后真实后端
+能力契约冻结时,可以新增远程或混合 Adapter,并且只修改唯一组合入口;页面、Feature 和三个
+编排函数不需要改变。
+
+## 目标
+
+- 将 Repository 的创建、读取和提交命令全部改成 Promise 契约。
+- 让三个编排函数只依赖同一个 Repository 组合入口。
+- 保持当前本地 WorkflowRun 行为和页面公开调用方式不变。
+- 修复 localStorage 写入失败后旧磁盘数据覆盖最新内存数据的问题。
+- 为不支持 `crypto.randomUUID` 的运行环境提供 ID 生成兜底。
+- 增加明确的“尚未开始生成”状态,并正确维护状态转换。
+- 对持久化数据执行完整的运行时结构校验,拒绝损坏记录。
+- 用回归测试覆盖每一条评审问题,并同步接口及架构文档。
+
+## 非目标
+
+- 不新增或猜测 `/workflows`、`/workflow-runs` 等后端路由。
+- 不实现尚未冻结的 Generation、Asset、Review、Playtest 或 Export DTO。
+- 不修改页面路由、布局或交互设计。
+- 不把前端 WorkflowRun 归属改回后端。
+- 不引入新的状态管理或运行时 Schema 依赖。
+
+## 架构
+
+```text
+Pages / Features
+ |
+ v
+WorkflowRun public facade(保持现有 Promise 签名)
+ |
+ v
+create / get / submit orchestration
+ |
+ v
+workflowRunRepository(唯一组合入口)
+ |
+ v
+WorkflowRunRepository(异步 Port)
+ |
+ v
+LocalWorkflowRunRepository(当前 Adapter)
+ |-- 本地状态机
+ `-- localStorage + 内存覆盖层
+
+未来真实契约冻结后:
+workflowRunRepository(唯一组合入口)
+ `-- Remote/Hybrid Adapter
+```
+
+组合入口只负责选择实现,不承载业务逻辑。当前它固定导出本地 Adapter;由于不存在真实的
+WorkflowRun 后端资源,本次不增加无效的环境开关或必然失败的远程分支。
+
+## Repository 契约
+
+Repository Port 调整为:
+
+```ts
+export interface WorkflowRunRepository {
+ create(input: CreateWorkflowRunInput): Promise
+ get(runId: WorkflowRun['id']): Promise
+ submit(
+ runId: WorkflowRun['id'],
+ command: WorkflowCommand,
+ ): Promise
+}
+```
+
+本地状态机内部仍可保持同步计算;本地 Adapter 负责把同步结果放进 Promise 契约。这样既不增加
+无意义的异步逻辑,也能保证调用方从现在起就按照网络延迟、失败和等待的形状工作。
+
+三个编排文件禁止直接导入 `localWorkflowRunRepository`,只能导入唯一组合入口导出的
+`workflowRunRepository`。架构测试会固定这一约束。
+
+## 本地存储一致性
+
+### 问题
+
+当前保存流程先更新内存,再尝试写 localStorage;一旦写入失败,后续读取又优先采用
+localStorage 中的旧快照。这样会让刚创建或刚推进的工作流在下一次读取时消失。
+
+### 设计
+
+内存改为当前会话的覆盖层:
+
+1. 每次读取都先解析并校验磁盘快照。
+2. 将磁盘中的有效数据与内存覆盖层合并。
+3. 同一 run ID 同时存在时,内存版本永远优先。
+4. 保存时先更新内存覆盖层,再尝试持久化合并后的完整快照。
+5. 写入失败只影响跨会话持久化,不影响当前会话读取到最新数据。
+
+这既覆盖无痕模式、配额耗尽和安全策略异常,也保留读取其他有效磁盘记录的能力。
+
+## 持久化数据校验
+
+读取 JSON 后不再仅检查 `id` 和 `revisions`。校验器将验证完整的领域结构:
+
+- Run:ID、项目与角色归属、driver、run 状态、当前 revision ID、prompt 和 revisions。
+- Revision:ID、来源关系、生成/导出/试玩状态、节点数组、创建时间。
+- Node:ID、节点类型、顺序、节点状态、引用节点 ID、失败次数,以及对象形状。
+- 枚举字段必须属于当前定义的合法值。
+- `currentRevisionId` 必须能在当前 run 的 revisions 中找到。
+- 存储 Map 的键必须与记录自身的 run ID 一致。
+
+损坏记录按条忽略,不能进入领域层;同一存储对象中的其他合法记录仍可使用。校验失败不会抛出到
+页面,也不会覆盖会话内已经存在的最新合法数据。
+
+## ID 生成兜底
+
+ID 保持 `run-*`、`revision-*` 和 `node-*` 前缀。随机部分依次使用:
+
+1. `crypto.randomUUID`(首选)。
+2. `crypto.getRandomValues` 生成随机字节。
+3. 时间戳、会话递增计数和 `Math.random` 的组合作为最后兜底。
+
+ID 只用于前端本地编排标识,不作为安全令牌。测试将覆盖 `randomUUID` 缺失以及 Web Crypto 整体
+不可用的情况,并验证连续生成不会立即重复。
+
+## 生成状态
+
+`GenerationStatus` 新增 `not_started`:
+
+- 手动创建工作流时只有素材节点处于 active,生成状态为 `not_started`。
+- 手动流程完成素材节点、进入 generation 节点时,转为 `in_progress`。
+- Quick Start 创建后已位于 generation 节点,仍为 `in_progress`。
+- 生成完成或失败后继续使用 `completed` / `failed`。
+- 从 generation 或其后续节点重启时,回到 `in_progress`;仅停留在素材准备阶段时保持
+ `not_started`。
+
+页面现有的完成和失败判断继续有效;需要显示进行中时必须显式判断 `in_progress`,不得把
+`not_started` 当成正在生成。
+
+## 错误处理
+
+- Repository Promise 会正常传播本地状态机抛出的非法命令错误。
+- `fetchWorkflowRun` 对不存在或被判定为损坏的 run 继续抛出明确的“不存在”错误。
+- localStorage 读取、解析或写入异常均退回有效内存数据,不返回假成功对象。
+- ID 生成能力降级时自动使用下一级方案,不因浏览器运行环境直接崩溃。
+- 不捕获并吞掉与存储无关的领域错误。
+
+## 测试策略
+
+实施遵循测试先行,每项先增加失败测试,再做最小实现:
+
+1. 类型测试证明 Repository 三个方法返回 Promise。
+2. 架构测试证明三个编排文件只依赖唯一组合入口,不直接引用本地 Adapter。
+3. 行为测试证明现有创建、读取、推进调用仍可等待并保持页面行为。
+4. 存储测试模拟 `setItem` 抛错,证明最新内存 run 不会被旧磁盘快照覆盖。
+5. ID 测试分别移除 `randomUUID` 和整个 Web Crypto,证明生成仍成功且不重复。
+6. 状态测试证明手动流程从 `not_started` 转到 `in_progress`,Quick Start 初始仍在生成中。
+7. 校验测试放入缺字段、非法枚举、损坏节点和错误 revision 引用,证明它们不会进入领域层;合法
+ 同级记录仍可读取。
+8. 最后运行格式、lint、完整测试、类型检查、构建和差异检查。
+
+## 文档同步
+
+- `frontend/API_CONTRACT.md`:说明 WorkflowRun Repository 是可等待的前端 Port,目前由本地
+ Adapter 实现,未来只在组合入口替换。
+- `frontend/MODULES.md`:记录唯一实现入口以及本地存储边界。
+- `frontend/README.md`:说明当前本地实现与未来真实能力 Adapter 的切换方式。
+- `frontend-architecture-v3.md`:保持 PR #62 的前端编排归属,并补充异步 Port 与存储保护。
+
+文档不会把本地 Repository 描述成后端已提供的 WorkflowRun API。
+
+## 验收条件
+
+- Repository Port 的三个方法全部返回 Promise。
+- 三个编排文件不存在本地 Adapter 的直接导入。
+- 当前运行时不发送 `/workflows` 或 `/workflow-runs` 请求。
+- localStorage 写入失败后,当前会话仍能读取刚保存的最新工作流。
+- `randomUUID` 不存在时仍可创建工作流。
+- 手动工作流初始不是“生成中”,进入生成节点后状态正确变化。
+- 任意缺失必需字段或包含非法状态的持久化记录都不会被返回。
+- 六类回归测试和完整前端验证全部通过。
+- 用户原有未跟踪文件保持未修改、未暂存。
diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md
new file mode 100644
index 0000000..d1566b6
--- /dev/null
+++ b/frontend-architecture-v3.md
@@ -0,0 +1,250 @@
+# Windup MS2 前端架构定义(最终冻结版)
+
+本文记录当前已确认的前端目标架构,并区分已落地代码与尚待后端/业务实现的接口。不存在的能力只保留接口和说明,不伪造成功结果。
+
+## 1. 技术边界
+
+- 前端:React + Vite + TypeScript + Tailwind CSS。
+- 工具链:Oxlint 负责代码检查,Oxfmt 负责格式化;Tailwind 与 Vite 插件属于构建期开发依赖。
+- 后端:Python;按 PR #62 分为 Project、Media、Character、Generation、Asset、Review、Playtest、Export,以及未来的 workflow definition / execution 等独立能力。后端 Job、质量门禁和导出任务由对应业务域保存或执行。
+- 分层:app -> pages -> features -> entities -> shared。
+- Quick Start 与手动 Workflow 是两种输入入口,最终进入同一套 WorkflowRun、Revision、生成、质检、历史、Playtest 和导出流程。
+- WorkflowRun、Revision 和页面节点由前端本地 Repository 编排,不要求后端提供同名资源。
+
+## 2. 分层职责
+
+| 层 | 职责 | 当前状态 |
+|---|---|---|
+| app | 启动、Router、Provider、全局布局、错误边界 | 已有基础实现 |
+| pages | 完整路由页面、URL、页面临时状态和模块组合 | 已有部分页面,Workflow steps 待实现 |
+| features | 用户对业务对象执行的完整操作 | 已有占位 Feature,按真实实现增量拆分 |
+| entities | 业务对象、查询、命令、选择器和领域规则 | Project 已接后端;WorkflowRun Revision/门禁由前端 Repository Port/Adapter 承载 |
+| shared | 通用 API transport、UI、工具和测试辅助 | 已有基础 API/UI,upload/stream 边界待补 |
+
+Account、Billing 和资产库复用 Feature 当前只在本文中预留,不创建代码入口。
+
+## 3. 依赖规则
+
+1. 代码只能依赖更低层:app -> pages -> features -> entities -> shared。
+2. 同一层不同 Slice 默认不能互相 import。
+3. 对外统一从 Slice 根 index.ts 进入。
+4. entities 对外使用统一门面 @/entities;Entity 内部默认不产生其他 Entity 的运行时依赖。
+5. Entity 之间通过 ID、类型契约或输入对象传递关系,不直接调用另一个 Entity 的内部 API。
+6. shared 不得依赖任何 Windup 业务层。
+7. Page、Feature、Entity 不直接调用 fetch;后端网络访问只能经 shared/api,WorkflowRun 本地编排不经过 HTTP。
+8. 生产代码不得导入 tests 或 shared/testing。
+9. 不允许深层路径绕过公开入口,不允许循环依赖。
+10. 未实现能力不得返回伪造成功结果。
+
+当前仓库已有 AST 架构检查;新增规则在当前代码可验证时加入,依赖未来后端/generated client 的规则先作为文档验收项。
+
+## 4. 路由与页面
+
+当前确认的路由:
+
+~~~
+/ Home(目标入口)
+/quick-start Quick Start 输入页
+/quick-start/:runId Quick Start 简化创作台
+/projects 项目列表
+/projects/:projectId 项目详情
+/projects/:projectId/assets 项目资产库
+/workflow-editor/:runId 当前 Revision 的工作流入口
+/workflow-editor/:runId/:stage 当前 Revision 的指定节点
+/playtest/:characterId 独立核验台
+~~~
+
+/ 不再承担 Quick Start 具体业务;Quick Start 使用 /quick-start。项目详情保留当前的 /projects/:projectId,不改为 /project/:projectId。
+
+Home 只提供 Quick Start 和从项目开始两个入口,不保存业务状态。Quick Start 负责自然语言输入和初始计划解析,创建与手动入口完全相同的 WorkflowRun 后停留在独立的简化创作台(/quick-start/:runId)。该页面使用自然语言展示生成、检查和结果,不展示五个节点、Revision、WorkflowRun 或 Workflow Editor;后台仍复用同一套领域状态。需要精细控制时才进入 Workflow Editor。
+
+ProjectsPage、ProjectDetailPage 和 AssetLibraryPage 当前直接使用 Entity;不提前创建 features/project 或 features/asset-library。Asset Library 以项目为上下文,展示项目 Character、项目 ActionTemplate、Wearable 以及系统内置 ActionTemplate。出现复杂复用后再提取 Feature。
+
+## 5. Workflow Editor
+
+目录边界:
+
+~~~
+pages/workflow-editor/
+├─ index.tsx
+├─ canvas/
+├─ editor/ 编辑器组件与交互测试
+└─ steps/
+ ├─ asset-step/
+ ├─ generation-step/
+ ├─ candidate-step/
+ ├─ review-step/
+ └─ export-step/
+~~~
+
+五个节点当前先写死,但通过有序 nodes 数组表达,后续可扩展节点类型。步骤页面负责 URL、布局、Feature 组合和页面临时状态;生成、质检、审核、修复和导出操作归对应 Feature。
+
+未解锁的后续节点访问时重定向到当前可执行节点;已执行历史节点允许只读查看;已通过节点允许重新开始。
+
+## 6. WorkflowRun、Revision 与节点
+
+前端领域层所有业务 ID 使用 string;独立后端能力的 DTO 保留真实类型,由对应 Entity mapper 转换。
+
+同一个 runId 下可以有多个 Revision:
+
+~~~
+run-1
+├─ revision-1:历史完整流程
+└─ revision-2:从某节点重新开始的当前流程
+~~~
+
+已经跑通的 Revision 永久保留、可查看;重新开始不会覆盖旧 Revision。
+
+当前节点类型:
+
+~~~
+asset | generation | candidate | review | export
+~~~
+
+节点状态:
+
+~~~
+locked | available | active | passed | failed
+~~~
+
+用户从节点 N3 重新开始时:
+
+1. N1、N2 的结果和输入可以作为新 Revision 的参考。
+2. N3 的旧结果可以作为重新执行的参考输入,但新 N3 必须重新通过。
+3. N4 及之后从新 Revision 的当前执行线上移除,不得作为新生成参考。
+4. 旧 Revision 的 N4 及之后仍保留,只能历史查看。
+5. 新 Revision 必须从 N3 重新跑到末尾,才能形成新的完整结果。
+
+流程门禁统一由 entities/workflow-run 的 selector/command 负责。Page 和 Feature 不复制门禁逻辑。
+
+## 7. 生成、质检、历史、Playtest 与导出
+
+当前需要真实联通两个 Provider,后续可扩展。前后端都可以持有凭据:
+
+~~~
+client | server
+~~~
+
+API Key 不写入 localStorage、WorkflowRun、Revision、Job 或历史记录。后端持有时前端只使用短期 sessionId;前端持有时只存于内存,刷新后重新建立 session。
+
+生成候选必须先经过系统质检:
+
+~ ~ ~
+generation
+ -> quality-gate
+ -> 第 1 次失败:自动重试
+ -> 第 2 次失败:阻断并请求重新生成
+ -> 通过:交付人工审核
+~ ~ ~
+
+以上是当前 MS2 的前端展示门禁,不是后端业务事实来源。真实生成与质检结果由后端
+Generation、Asset 和 Review 能力返回,前端只据此更新对应节点。
+
+质检通过后立即将 Revision 标记为生成完成并进入历史。人工审核和 Playtest 可以发现问题并发起新的 Revision,但不是逐帧强制通过门槛。
+
+状态拆分:
+
+~ ~ ~
+generationStatus: not_started | in_progress | completed | failed
+exportStatus: not_exported | exporting | exported | failed
+playtestStatus: not_tested | passed | issues_found
+~ ~ ~
+
+手动流程在素材准备阶段使用 `not_started`,进入 generation 节点后才切换为 `in_progress`;
+Quick Start 创建后已经进入 generation,因此初始为 `in_progress`。
+
+Playtest 可从 Quick Start、Workflow 或历史 Revision 导入。URL 形式:
+
+~ ~ ~
+/playtest/:characterId?runId=:runId&revision=:revisionId
+~ ~ ~
+
+Playtest 保存独立核验记录,可回流到对应 Revision 的 Review,但不修改历史结果。Playtest 未通过不阻断导出,只在导出时给出重新生成建议。
+
+## 8. API 与数据边界
+
+~ ~ ~
+shared/api/
+├─ index.ts JSON 请求的公开门面,以及 Mock/Real transport 切换
+├─ upload.ts 文件上传
+├─ stream.ts SSE/流式任务预留
+├─ generated/ 预留,不伪造生成代码
+└─ client/
+ ├─ real/
+ ├─ mock/
+ └─ mappers/
+~ ~ ~
+
+- shared/api 负责 HTTP、响应壳、分页、通用错误和 transport。
+- entities 负责业务 DTO 到领域模型的转换和非法状态校验。
+- WorkflowRun 由 Entity 内部返回 Promise 的 Repository Port 持久化,不注册 shared/api Mock route。
+- 三个编排函数只依赖唯一组合入口;当前入口绑定本地 Adapter,未来实现只在该入口替换或组合。
+- 本地存储以内存覆盖层保护写入失败后的最新状态,恢复时逐条校验完整领域形状;本地 ID 不强制依赖 `randomUUID`。
+- 非法节点、状态、Revision 或 ID 必须抛出契约错误,不能用默认值伪造成功。
+- JSON、上传、SSE 分别走 request/upload/stream,业务层禁止直接 fetch。
+- 独立后端能力的 Mock 只在开发/测试显式启用;生产只能使用真实 API,失败不得回退 Mock。
+- generated 只作为未来 OpenAPI 客户端接入点,不创建不存在的代码。
+
+## 9. 状态归属和查询抽象
+
+- WorkflowRun、Revision、节点、命令和门禁归 entities/workflow-run。
+- Project、Character、ActionTemplate、Wearable 归各自 Entity。
+- 后端 Task 快照只包含任务自身的状态、进度、错误和未冻结的 result;前端用 `WorkflowTaskLink` 将 taskId 关联到 run、revision 和 node。
+- Character 保留 outfits 层;造型通过 candidateCharacterTemplates 保存候选母版,通过 characterTemplateUrl 保存用户选定母版,并拥有各自的 Action;MVP UI 只展示第一套造型。
+- Action 自身携带 fps;Frame 顺序由 Action.frames 数组表达,不重复保存 index。
+- Action 的 sourceWorkflowRunId 是前端定位信息,不要求后端资产依赖 WorkflowRun;前端领域 ID 和枚举统一使用语义明确的字符串。
+- ActionTemplate 使用 system/project 作用域并携带动作提示词;addAction 只通过 actionTemplateId 引用它。角色母版统一使用 characterTemplate 前缀,多方向基准帧保持命名为 baseFrames。
+- URL、画布缩放、节点选中、资产筛选和当前审核位置归对应 Page。
+- Generation、Review、Playtest 的局部交互状态归对应 Feature 或 Playtest Page。
+- 不建立 Redux、Zustand 等全局业务 Store。
+- 先保留 query key、query function、mutation 和 data/loading/error/refresh 语义,暂不绑定 React Query。
+- 跨 Entity 复用的异步 React 状态放在 shared/hooks,不使用含义宽泛的 shared/lib。
+
+## 10. 目录增量规则
+
+计划中的 Feature 子目录可以现在创建,但不写伪实现:
+
+- 有公开职责的目录使用 index.ts/index.tsx,只包含类型、Props、签名和注释。
+- 没有可定义接口的目录使用 README.md 说明职责、输入输出和禁止事项。
+- shared/ui 不提前创建 Button、Modal、Toast 等空组件;只维护真实存在的组件,并用 README 说明未来规范。
+- Account/Billing 只在本文预留,不创建页面、Feature 或 Entity。
+- 资产库复用 Feature 只在本文预留,不创建 features/asset-library。
+
+## 11. 测试策略
+
+优先覆盖:
+
+1. Entity 状态机、Revision、节点重启、历史只读和门禁。
+2. Quick Start 与手动 Workflow 共用同一个 WorkflowRun。
+3. 质检连续失败 2 次、生成完成、导出状态和 Playtest 非阻断规则。
+4. Repository 异步契约、唯一实现入口、存储失败回退、ID 降级、`not_started` 与损坏数据过滤。
+5. 页面路由参数、历史模式和 Playtest 导入。
+6. 独立后端能力接通后补真实 API Adapter、Provider、SSE 和跨入口 E2E。
+
+架构测试立即检查当前可验证的 import、fetch、测试依赖和循环依赖;generated client、真实 Python API 和 Mock/Real 完整能力一致性在对应代码出现后启用。
+
+## 12. 当前实现状态
+
+已实现:
+
+- WorkflowRun 的前端编排门面、异步 Repository Port、唯一组合入口、本地 Adapter、Revision、有序五节点和字符串领域 ID。
+- localStorage 写入失败的内存优先回退、完整恢复校验,以及缺少 `randomUUID` 时的 ID 生成兜底。
+- 手动流程的 `not_started` 到 `in_progress` 状态转换,以及从素材节点重启后的状态恢复。
+- 节点门禁、历史只读、从节点重启和后续执行线移除。
+- 前端演示门禁连续失败两次的页面规则,以及对应的生成状态展示。
+- Quick Start 创建统一 WorkflowRun 并进入独立的简化创作台;后台进入 generation,但页面不展示工作流内部结构。
+- Workflow Editor 节点路由、历史 Revision URL 和重启交互。
+- Playtest 的完整 Revision 导入门禁、核验结论记录和非阻断导出提示。
+- 项目资产库路由及系统/项目 ActionTemplate 作用域契约。
+- Task/WorkflowTaskLink 分离、Outfit、Action fps、Frame 数组顺序,以及 ActionTemplate / characterTemplate / baseFrames 三类概念的明确前端命名。
+- 生产构建强制使用真实 API transport,业务层禁止直接 fetch。
+
+仍待真实后端或业务实现:
+
+- Generation、Asset、Review、Playtest、Export 等独立后端能力的 OpenAPI Adapter。
+- 两个 Provider 的真实 Session、模型验证、Job runtime 和 SSE。
+- 后端 quality-gate 报告和生成产物。
+- Character/Action/Frame 正式接口、Review 修复任务和真实播放器。
+- ExportJob、文件生成和下载。
+
+未实现部分只能保留类型和公开边界,不得返回假成功或伪造后端结果。
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
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..e8139e2
--- /dev/null
+++ b/frontend/API_CONTRACT.md
@@ -0,0 +1,208 @@
+# 前端 API 契约状态
+
+本文是前端当前调用和需求签名的清单,不替代未来由后端 OpenAPI 生成的客户端。
+在 OpenAPI 落地前,只有标为“已核对”的接口可以按真实后端接口联调;其余接口
+不能被描述为已接通后端。独立业务能力在开发或测试中可以由 Mock transport 承载,但生产请求
+仍需等待明确的后端契约。WorkflowRun 是前端编排模型,不属于后端 API 契约。
+
+## 通用约定
+
+- 开发环境以 `/api` 为基址;Vite 将其代理到 `http://127.0.0.1:8000`。
+- 单项响应:`{ code, message, data, timestamp? }`;只有 `code === 200` 才是成功。
+- 列表响应额外包含 `total`、`page` 和 `page_size`。
+- 生产构建只使用真实 transport;不会回退到 Mock 数据。
+
+## 已核对:项目与上传
+
+以下接口的路径、方法、字段和响应壳已与后端 PR #57 对照。该 PR 尚未合并或部署,
+因此它们是“可联调契约”,不是当前已验证可访问的服务。
+
+### 项目
+
+- `GET /projects?page=1&page_size=20&user_id=1`
+- `GET /projects/{id}`
+- `POST /projects`
+- `DELETE /projects/{id}`
+
+创建项目示例:
+
+```json
+{
+ "user_id": 1,
+ "workflow_id": null,
+ "project_name": "Knight",
+ "character_perspective": 1,
+ "directional_movement": 1,
+ "sprite_width": 64,
+ "sprite_height": 64,
+ "game_style": null,
+ "sprite_sample_url": null
+}
+```
+
+`user_id` 目前是演示值;接入认证后应由后端从令牌推导,前端不再传递它。
+
+后端当前用数字表达视角和移动方向,前端领域层统一使用字符串枚举,数字只在 Project mapper
+内转换:
+
+```ts
+type CharacterPerspective = 'side' | 'top-down' | 'isometric'
+type DirectionalMovement = 'single' | 'four-way' | 'eight-way'
+```
+
+`gameStyle` 是项目级画风约束,会进入本项目的生成上下文;`sampleImageUrl` 是项目级画风
+参考图,不是生成结果或角色母版。
+
+### 参考图片上传
+
+- `POST /upload/image`
+- `multipart/form-data`,字段名为 `file`
+- 允许 `image/jpeg`、`image/png`、`image/webp`、`image/gif`
+- 最大 10 MiB;前端会在发起网络请求前拒绝超限文件。
+
+```ts
+const url = await uploadImage(file)
+// 成功响应:{ code: 200, message: 'success', data: { url } }
+```
+
+## 前端本地编排:WorkflowRun
+
+`WorkflowRun`、`WorkflowRevision` 和五个页面节点用于组织 Quick Start、Workflow Editor、
+Playtest 与历史查看。它们由前端 `entities/workflow-run` 的异步 Repository Port 持久化,不经过
+`shared/api`,也不存在对应的 `/workflows` 或 `/workflow-runs` 后端路径。当前唯一组合入口
+`entities/workflow-run/repository.ts` 选择本地 Adapter,三个编排函数不直接依赖具体实现。
+
+页面继续使用稳定的前端门面:
+
+```ts
+createWorkflowRun(input): Promise
+fetchWorkflowRun(runId): Promise
+submitWorkflowCommand(runId, command): Promise
+```
+
+Repository 内部的 `create/get/submit` 同样全部返回 Promise。未来真实能力契约冻结后,只在组合入口
+新增或切换远程/混合 Adapter,不需要重写页面或三个编排调用点。当前本地 Adapter 还保证:
+
+- localStorage 写入失败时,本次会话的最新内存数据优先于磁盘旧快照。
+- `crypto.randomUUID` 不可用时依次使用随机字节和本地序列生成 ID。
+- 手动流程在素材准备期间使用 `not_started`,进入 generation 后才是 `in_progress`。
+- 从 localStorage 恢复时完整校验 run、revision、node、枚举和当前版本引用,逐条忽略损坏记录。
+
+真实后端集成按照 PR #62 拆为 Project、Media、Character、Generation、Asset、Review、
+Playtest 和 Export 等独立能力。其 OpenAPI 冻结后,由 WorkflowRun 编排层组合对应 Adapter;
+页面不需要知道后端模块拆分,也不提前猜测未确定的路径和 DTO。
+
+## 仅有需求签名:角色与资产
+
+以下函数当前会明确抛出 `not implemented`,不存在真实网络调用:
+
+- `GET /characters/{id}`、`POST /characters`
+- `GET /projects/{id}/characters`
+- `confirmCharacterTemplate`、`addAction`、`POST /actions/{id}/confirm`(前两项路径待定)
+- `GET /projects/{id}/action-templates`
+- `GET /projects/{id}/wearables`
+
+前端禁止单独使用 `template` 表达不同业务概念:动作模板统一使用 `ActionTemplate` 和
+`actionTemplateId`;角色母版统一使用 `candidateCharacterTemplates`、`characterTemplateUrl`
+和 `confirmCharacterTemplate`。母版确认的后端路径同样必须包含 `character-template` 前缀,
+具体资源层级仍以后端 OpenAPI 为准。母版确认后展开的多方向基准帧继续称为 `baseFrames`,
+不使用 template。
+
+```ts
+interface Frame {
+ imageUrl: string
+ qc: 'pending' | 'passed' | 'failed'
+ rejected: boolean
+}
+
+interface Action {
+ id: string
+ outfitId: string
+ fps: number
+ frames: Frame[]
+ sourceWorkflowRunId: string | null
+}
+
+interface Outfit {
+ id: string
+ characterId: string
+ name: string
+ candidateCharacterTemplates: string[]
+ characterTemplateUrl: string | null
+ actions: Action[]
+}
+
+interface Character {
+ id: string
+ projectId: string
+ name: string
+ outfits: Outfit[]
+}
+
+interface ActionTemplateBase {
+ id: string
+ name: string
+ prompt: string
+}
+
+type ActionTemplate = ActionTemplateBase &
+ (
+ | { scope: 'system'; projectId: null }
+ | { scope: 'project'; projectId: string }
+ )
+
+declare function confirmCharacterTemplate(outfitId: string): Promise
+
+declare function addAction(
+ outfitId: string,
+ input: {
+ name: string
+ kind: 'preset' | 'custom'
+ actionTemplateId?: string
+ },
+): Promise
+```
+
+即使 MVP UI 只展示一个造型,Character 也通过 `outfits` 保留造型层,候选母版、选定母版与
+动作都归属具体 Outfit。`sourceWorkflowRunId` 是前端编排定位信息,不要求后端 Action 认识
+WorkflowRun;后端可返回自己的 Task、Execution 或 Asset 引用,再由前端建立关联。前端全部
+业务 ID 都是 string,后端数字 ID 只在 DTO mapper 中转换。
+
+`Action.frames` 的数组顺序就是播放顺序,因此 Frame 不重复携带 `index`。审核和页面定位可以
+临时使用 `frameIndex`;如果后端以后支持独立 Frame 资源,应另行提供稳定 ID。Action 的 `fps`
+由后端返回,预览和导出不得依赖前端全局常量。
+
+项目模板查询应返回“系统内置模板 + 当前项目自定义模板”的合集,并通过 `scope` 与
+`projectId` 区分归属。系统模板不虚构项目 ID。
+
+## 未对齐:异步任务与 SSE
+
+任务创建、查询和断线恢复需要完整快照;流式事件使用同一组状态字段:
+
+```ts
+type TaskStatus = 'queued' | 'running' | 'succeeded' | 'failed'
+
+interface Task {
+ id: string
+ status: TaskStatus
+ progress: number | null
+ error: string | null
+ result: unknown
+}
+
+interface TaskEvent extends Omit {
+ taskId: Task['id']
+}
+
+interface WorkflowTaskLink {
+ taskId: Task['id']
+ runId: string
+ revisionId: string
+ nodeId: string
+}
+```
+
+后端 Task 不依赖前端 WorkflowRun。`WorkflowTaskLink` 只保存在前端,用于把后端 `taskId`
+映射回当前 run、revision 和 node。`result` 在具体生成产物契约冻结前保持 `unknown`。SSE 当前
+只有 transport 预留,订阅地址、事件名称、断线重连、补发策略以及 Task 创建/查询接口均未与
+后端对齐,前端不得把它描述为已接通能力。
diff --git a/frontend/MODULES.md b/frontend/MODULES.md
new file mode 100644
index 0000000..2965208
--- /dev/null
+++ b/frontend/MODULES.md
@@ -0,0 +1,96 @@
+# Windup 前端模块契约
+
+完整架构以根目录 frontend-architecture-v3.md 为准;本文只记录代码模块的公开边界。
+
+## 分层
+
+~~~text
+app -> pages -> features -> entities -> shared
+~~~
+
+代码只能向下依赖。Page、Feature、Entity 不直接调用 fetch,所有后端网络能力经
+shared/api 的 request、upload 或 stream 边界访问;前端 WorkflowRun 编排使用自己的本地
+Repository,不伪装成 HTTP 资源。
+
+## entities
+
+entities 是对外统一的数据门面,调用方使用:
+
+~~~ts
+import { createWorkflowRun, getCurrentRevision } from '@/entities'
+~~~
+
+Project、Character、ActionTemplate、Wearable 和 WorkflowRun 是内部业务分区。外部不得绕过
+@/entities 访问内部文件;Entity 之间默认不产生运行时依赖,关系通过 ID、类型契约或输入对象表达。
+
+WorkflowRun 的领域模型包含:
+
+- 一个 runId。
+- 多个只读/当前 Revision。
+- 当前先固定五个有序节点:asset、generation、candidate、review、export。
+- 节点门禁、Revision 重启、历史查看和质量门禁 selector/command。
+- 返回 Promise 的 Repository Port,以及唯一实现组合入口;页面和编排函数不依赖本地存储实现。
+- 当前本地 Adapter 使用 localStorage + 内存覆盖层;写入失败以内存最新值为准,读取时逐条校验完整领域形状。
+- `GenerationStatus` 区分素材准备的 `not_started` 与实际生成的 `in_progress`。
+- 不含 run/revision 的后端 Task 快照,以及前端专用的 `WorkflowTaskLink` 关联。
+
+WorkflowRun 只负责前端页面编排。后端 OpenAPI 落地后,Generation、Asset、Review、Playtest、
+Export 等独立能力 Adapter 由该 Entity 内部组合,且只在 `entities/workflow-run/repository.ts` 选择
+实现,页面公开调用方式不变。本地 ID 在 `randomUUID` 缺失时有随机字节和会话序列两级兜底。
+
+Character 与动作资产的当前前端契约:
+
+- Character 始终包含 `outfits`;MVP UI 可以只展示第一套造型,但不把造型层折叠掉。
+- 动作模板使用 `ActionTemplate` / `actionTemplateId`;角色母版使用
+ `candidateCharacterTemplates` / `characterTemplateUrl` / `confirmCharacterTemplate`,不使用裸的 template。
+- 母版确认后展开的多方向基准帧保持命名为 `baseFrames`。
+- Action 自身携带 `fps`;预览和导出不使用全局帧率常量。
+- `Action.frames` 的数组顺序就是帧顺序,Frame 不重复保存 `index`。
+- `sourceWorkflowRunId` 是前端定位信息,不要求后端资产依赖 WorkflowRun;前端领域 ID 统一使用 string。
+- `ActionTemplate` 分为 `system` 和 `project` 作用域;系统模板的 `projectId` 为 `null`。
+
+## 页面内模块
+
+### Workflow Editor
+
+入口:pages/workflow-editor/editor/index.tsx。
+
+它只接收已解析的 run、revision 和节点类型,不读取 Router。外层 Page 负责:
+
+- 读取 runId、节点路径和 revision query。
+- 未解锁节点的重定向。
+- 当前/历史只读模式。
+- 跨页跳转到 Playtest。
+
+### Playtest
+
+入口:pages/playtest/inspection-preview/index.tsx。
+
+Playtest 是独立核验台 Page,不是通用 Feature。它接收完整生成 Revision,保存独立核验结论,
+问题可以回流 Review,但不会阻断导出。
+
+### Asset Library
+
+入口:`/projects/:projectId/assets`。
+
+页面以项目为上下文,展示当前项目的 Character、项目自定义 ActionTemplate 和 Wearable,
+同时展示可供该项目使用的系统内置 ActionTemplate。
+
+## Features
+
+Feature 表示用户操作,Feature 之间不互相 import。当前真实实现仍按功能增量推进;规划子目录使用
+README 说明职责,未实现能力不得返回假成功。
+
+## Shared
+
+- shared/api:独立后端能力的传输、响应壳、错误、上传和流式任务;不承载 WorkflowRun。
+- shared/api/generated:未来 OpenAPI 生成代码的接入位置,当前不放伪代码。
+- shared/hooks:跨 Entity 复用的 React hooks 和对应状态类型。
+- shared/ui:业务无关 UI;只维护已经存在的组件。
+- shared/testing:仅测试代码使用,生产代码不得导入。
+
+## 测试
+
+架构测试检查分层、公开入口、Router 隔离、直接网络请求、测试依赖和循环依赖;
+WorkflowRun 单元/集成测试检查异步 Port、单点实现选择、存储失败回退、ID 降级、完整数据校验、
+`not_started` 状态、Revision、节点重启、质量门禁、历史和 Playtest 导入规则。
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..f64bbb9
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,70 @@
+# Windup 前端
+
+React + Vite + TypeScript + Tailwind CSS。当前架构定义以仓库根目录的
+frontend-architecture-v3.md 为准。
+
+## 运行
+
+~~~bash
+cd frontend
+npm install
+npm run dev
+npm run typecheck
+npm run build
+npm run test
+npm run lint
+npm run format
+npm run format:check
+~~~
+
+接口联调状态、请求形式与未接通能力见 [API_CONTRACT.md](API_CONTRACT.md)。
+
+默认页面是 Home:
+
+- /:选择 Quick Start 或从项目开始
+- /quick-start:自然语言输入;创建 WorkflowRun 后进入独立的简化创作台,隐藏工作流节点与版本术语
+- /quick-start/:runId:Quick Start 的持续创作页;以自然语言展示生成、检查和结果状态
+- /projects:项目列表
+- /projects/:projectId:项目详情
+- /projects/:projectId/assets:项目资产库;展示项目资产及系统内置动作模板
+- /workflow-editor/:runId:当前 Revision 的工作流入口
+- /workflow-editor/:runId/:stage:当前 Revision 的工作流节点
+- /playtest/:characterId?runId=:runId&revision=:revisionId:独立核验台
+
+## 分层
+
+~~~text
+app -> pages -> features -> entities -> shared
+~~~
+
+- app:启动、Router、全局布局和错误边界。
+- pages:路由、URL、页面临时状态和模块组合。
+- features:生成、角色设置、审核和导出等用户操作。
+- entities:Project、Character、WorkflowRun、Revision 和领域规则。
+- shared:通用 API transport、React hooks、UI 和测试辅助。
+
+跨模块只能走公开 index.ts;页面、Feature 和 Entity 不直接调用 fetch。
+
+## WorkflowRun
+
+Quick Start 与手动 Workflow 共用同一个 WorkflowRun。一个 run 可以有多个 Revision;
+从某节点重新开始会保留节点及以前的参考,移除之后的当前执行线,旧 Revision 仍只读保留。
+
+Quick Start 不展示节点、Revision 或 Workflow Editor。它以简化创作台呈现自然语言进度;
+完成后满足条件时可导入核验台,导出入口待后端任务接入。Workflow Editor 则保留完整的人工控制能力。
+
+WorkflowRun 是前端编排模型,通过 Entity 内返回 Promise 的 Repository Port 访问。三个编排函数
+只依赖唯一组合入口;当前入口选择 localStorage Adapter,不经过 shared/api,也不对应后端
+`/workflows` 资源。未来增加真实 Adapter 时只更换该入口,页面调用方式不变。
+
+本地存储以当前会话内存覆盖磁盘旧快照,写入失败不会让刚创建的流程消失;读取磁盘记录时会完整
+校验 run、revision、node 和状态。ID 在 `randomUUID` 缺失的环境也能生成。手动流程先处于
+`not_started`,进入 generation 节点后才切换为 `in_progress`。数据模型使用 Revision + 有序五节点:
+asset、generation、candidate、review、export。
+
+Project、Media、Generation、Asset、Review、Playtest、Export 等独立后端能力仍经 shared/api 的
+Mock/Real transport 切换。OpenAPI 落地后只替换这些能力 Adapter,页面继续使用现有 WorkflowRun
+门面。
+
+Provider、系统质量门禁、SSE、正式角色资产和导出任务尚待后端契约;未实现能力不会返回伪造成功。
+本地 WorkflowRun 的节点变化只表达页面编排状态,不代表后端已经生成、审核或导出产物。
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..30b1852
--- /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",
+ "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/asset-library-flow.test.tsx b/frontend/src/app/asset-library-flow.test.tsx
new file mode 100644
index 0000000..be9b056
--- /dev/null
+++ b/frontend/src/app/asset-library-flow.test.tsx
@@ -0,0 +1,23 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+import { App } from './index'
+
+describe('AssetLibraryPage', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ window.history.replaceState(null, '', '/projects/project-1/assets')
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('从项目路径读取资产库所属项目', () => {
+ render()
+
+ expect(screen.getByRole('heading', { name: '资产库' })).toBeTruthy()
+ expect(screen.getByText('项目 project-1 内可复用的角色、动作与穿戴')).toBeTruthy()
+ })
+})
diff --git a/frontend/src/app/error-boundary.test.tsx b/frontend/src/app/error-boundary.test.tsx
new file mode 100644
index 0000000..a3325fb
--- /dev/null
+++ b/frontend/src/app/error-boundary.test.tsx
@@ -0,0 +1,85 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { Link, MemoryRouter, Route, Routes } from 'react-router'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { ErrorBoundary, RouteErrorBoundary } from './error-boundary'
+
+/**
+ * 错误边界得真的兜得住才算数:这里让子组件抛异常,验证应用没有白屏、
+ * 而是显示了兜底界面。React 会把错误往控制台打一遍,测试里静音掉。
+ */
+function Boom(): never {
+ throw new Error('组件炸了')
+}
+
+describe('全局错误边界', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ // 没有这一行,上一个用例的 DOM 会留到下一个用例里,查询串味
+ cleanup()
+ vi.restoreAllMocks()
+ })
+
+ it('子组件抛异常时显示兜底界面,而不是整页空白', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('这个页面出错了')).toBeTruthy()
+ expect(screen.getByText('组件炸了')).toBeTruthy()
+ expect(screen.getByRole('button', { name: '重试' })).toBeTruthy()
+ })
+
+ it('没有异常时原样渲染子组件,不多加任何东西', () => {
+ render(
+
+ 一切正常
+ ,
+ )
+ expect(screen.getByText('一切正常')).toBeTruthy()
+ expect(screen.queryByText('这个页面出错了')).toBeNull()
+ })
+
+ it('路由位置变化后清除上一页的错误状态', () => {
+ const view = render(
+
+
+ ,
+ )
+ expect(screen.getByText('这个页面出错了')).toBeTruthy()
+
+ view.rerender(
+
+ 新页面正常
+ ,
+ )
+
+ expect(screen.getByText('新页面正常')).toBeTruthy()
+ expect(screen.queryByText('这个页面出错了')).toBeNull()
+ })
+
+ it('页面抛错后可以通过导航进入正常页面', async () => {
+ render(
+
+ 离开错误页
+
+
+ } />
+ 目标页面正常
} />
+
+
+ ,
+ )
+
+ expect(screen.getByText('这个页面出错了')).toBeTruthy()
+ fireEvent.click(screen.getByRole('link', { name: '离开错误页' }))
+
+ expect(await screen.findByText('目标页面正常')).toBeTruthy()
+ expect(screen.queryByText('这个页面出错了')).toBeNull()
+ })
+})
diff --git a/frontend/src/app/error-boundary.tsx b/frontend/src/app/error-boundary.tsx
new file mode 100644
index 0000000..99ee0f4
--- /dev/null
+++ b/frontend/src/app/error-boundary.tsx
@@ -0,0 +1,66 @@
+import { Component } from 'react'
+import type { ErrorInfo, ReactNode } from 'react'
+import { useLocation } from 'react-router'
+
+/**
+ * 全局错误边界。任何一页渲染时抛出的异常都在这里兜住,
+ * 不让整个应用白屏。放在 AppShell 内部包住路由,出错时顶部导航仍可用,
+ * 用户能自己走开,不必刷新。
+ *
+ * 只能用 class 写:React 至今没有等价的函数组件写法。
+ */
+interface ErrorBoundaryProps {
+ children: ReactNode
+ /** 路由位置变化时清除上一页的错误,避免 fallback 挡住新页面。 */
+ resetKey?: string
+}
+
+interface ErrorBoundaryState {
+ error: Error | null
+}
+
+export class ErrorBoundary extends Component {
+ state: ErrorBoundaryState = { error: null }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { error }
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo): void {
+ // 骨架阶段只打控制台;接监控是生产阶段的事,不在本次范围
+ console.error('[Windup] 未捕获的渲染错误', error, info.componentStack)
+ }
+
+ componentDidUpdate(previous: ErrorBoundaryProps): void {
+ if (this.state.error && previous.resetKey !== this.props.resetKey) this.reset()
+ }
+
+ reset = (): void => {
+ this.setState({ error: null })
+ }
+
+ render(): ReactNode {
+ const { error } = this.state
+ if (!error) return this.props.children
+
+ return (
+
+ 这个页面出错了
+ {error.message}
+
+
+ )
+ }
+}
+
+/** 把 Router 位置适配为错误边界的重置键;导航栏放在此组件外部。 */
+export function RouteErrorBoundary({ children }: { children: ReactNode }) {
+ const location = useLocation()
+ return {children}
+}
diff --git a/frontend/src/app/index.tsx b/frontend/src/app/index.tsx
new file mode 100644
index 0000000..e12d84e
--- /dev/null
+++ b/frontend/src/app/index.tsx
@@ -0,0 +1,42 @@
+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 { RouteErrorBoundary } from './error-boundary'
+import { AppShell } from './layout'
+
+/** 路由表与全局外壳。app 层只做启动与装配。 */
+export function App() {
+ return (
+
+
+
+
+
+ )
+}
+
+function AppRoutes() {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ )
+}
diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx
new file mode 100644
index 0000000..c69669a
--- /dev/null
+++ b/frontend/src/app/layout/index.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from 'react'
+import { Link } from 'react-router'
+
+/**
+ * 模块五:跨页面常驻导航。不属于任何单个页面,故上提到 app 层。
+ * 页内头部 PageHeader 在 shared/ui —— pages 不能 import app 层。
+ */
+
+export interface AppShellProps {
+ /** 渲染在全局导航下方的当前路由页面。 */
+ children: ReactNode
+}
+
+/** 全站外壳,全局导航常驻。 */
+export function AppShell({ children }: AppShellProps) {
+ return (
+
+
+
{children}
+
+ )
+}
diff --git a/frontend/src/app/quick-start-flow.test.tsx b/frontend/src/app/quick-start-flow.test.tsx
new file mode 100644
index 0000000..e745f4b
--- /dev/null
+++ b/frontend/src/app/quick-start-flow.test.tsx
@@ -0,0 +1,34 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+import { App } from './index'
+
+describe('QuickStartPage', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ window.history.replaceState(null, '', '/')
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('从 Home 进入 Quick Start 后创建统一运行并保留在简化创作台', async () => {
+ render()
+
+ fireEvent.click(screen.getAllByRole('link', { name: /快速开始/ })[0])
+
+ fireEvent.change(screen.getByPlaceholderText(/一个戴斗篷的像素小骑士/), {
+ target: { value: '像素小骑士,要走路' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: '开始创作' }))
+
+ expect(await screen.findByRole('heading', { name: '正在生成角色' })).toBeTruthy()
+ expect(screen.getByLabelText('创作进度')).toBeTruthy()
+ expect(screen.getByText('生成服务暂未连接')).toBeTruthy()
+ expect(window.location.pathname).toMatch(/^\/quick-start\/run-[^/]+$/)
+ expect(screen.queryByRole('heading', { name: '工作流' })).toBeNull()
+ expect(screen.queryByText(/Provider Session/)).toBeNull()
+ })
+})
diff --git a/frontend/src/entities/action-template/index.ts b/frontend/src/entities/action-template/index.ts
new file mode 100644
index 0000000..2734d81
--- /dev/null
+++ b/frontend/src/entities/action-template/index.ts
@@ -0,0 +1,42 @@
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
+
+/** 可复用的动作模板;查询项目可用模板时合并系统内置与项目自定义数据。 */
+
+interface ActionTemplateBase {
+ /** 动作模板 ID;具体生成方和格式等待 ActionTemplate OpenAPI 确定。 */
+ id: string
+ /** 面向用户展示的模板名称,如“行走”或“待机”。 */
+ name: string
+ /** 创建该动作时提交给生成能力的提示词。 */
+ prompt: string
+}
+
+/**
+ * 项目可用的动作模板。
+ * 系统内置模板没有项目归属;项目自定义模板必须携带所属 Project ID。
+ */
+export type ActionTemplate = ActionTemplateBase &
+ (
+ | {
+ /** 系统内置模板,对所有项目可见。 */
+ scope: 'system'
+ /** 系统模板不属于任何项目,固定为 null。 */
+ projectId: null
+ }
+ | {
+ /** 由某个项目创建和维护的自定义模板。 */
+ scope: 'project'
+ /** 自定义模板所属的 Project ID。 */
+ projectId: string
+ }
+ )
+
+export async function fetchActionTemplates(_projectId: string): Promise {
+ throw new Error('not implemented:等待后端 GET /projects/{id}/action-templates')
+}
+
+/** 订阅系统内置模板与当前项目自定义模板的合集。 */
+export function useActionTemplates(projectId: string): AsyncState {
+ return useAsync(() => fetchActionTemplates(projectId), [projectId])
+}
diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts
new file mode 100644
index 0000000..ca4f86a
--- /dev/null
+++ b/frontend/src/entities/character/index.ts
@@ -0,0 +1,83 @@
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
+import type { ActionTemplate } from '../action-template'
+import type { Action, Character, CreateCharacterInput, Outfit } from './types'
+
+/** 角色、动作、帧。后端接口未提供,下面的签名即我们提给后端的需求。 */
+
+export type {
+ Action,
+ ActionKind,
+ ActionStatus,
+ Character,
+ CreateCharacterInput,
+ Frame,
+ FrameQcResult,
+ Outfit,
+} from './types'
+
+/** 带全部动作与帧。审核台进入只需这一个调用。 */
+export async function fetchCharacter(_id: string): Promise {
+ throw new Error('not implemented:等待后端 GET /characters/{id}')
+}
+
+/** 资产库与项目详情页用。 */
+export async function fetchCharactersByProject(_projectId: string): Promise {
+ throw new Error('not implemented:等待后端 GET /projects/{id}/characters')
+}
+
+/** 建角色及第一套造型并生成候选母版。异步任务返回前候选列表可以为空。 */
+export async function createCharacter(_input: CreateCharacterInput): Promise {
+ throw new Error('not implemented:等待后端 POST /characters')
+}
+
+/** 确认某套造型的母版后才能为该造型添加动作。 */
+export async function confirmCharacterTemplate(_outfitId: string): Promise {
+ throw new Error('not implemented:等待后端角色造型母版确认接口')
+}
+
+/**
+ * 加动作的入参。
+ *
+ * 写成可辨识联合而不是「kind 加一个可选 id」,是因为后者允许 kind: 'preset' 却不给
+ * actionTemplateId——声明用了预设,却说不出用的是哪一份。那种组合没有意义,
+ * 这里让它在类型上就不成立。
+ *
+ * TODO(待定):自定义动作的提示词从哪来还没定。一种走法是自定义动作也先落成一份
+ * project 作用域的 ActionTemplate,那样这里只剩模板 id,kind 可以取消。
+ */
+export type AddActionInput =
+ | {
+ /** 面向用户展示的动作名称。 */
+ name: string
+ /** 来自系统内置或项目自定义的动作模板。 */
+ kind: 'preset'
+ /** 选用的动作模板;预设动作必须指明。 */
+ actionTemplateId: ActionTemplate['id']
+ }
+ | {
+ /** 面向用户展示的动作名称。 */
+ name: string
+ /** 用户临时描述的动作,不走模板。 */
+ kind: 'custom'
+ }
+
+/** 加一个动作,此时还未生成。 */
+export async function addAction(_outfitId: string, _input: AddActionInput): Promise {
+ throw new Error('not implemented:等待后端角色造型动作接口')
+}
+
+/** 候选转正式。新候选不覆盖正式资产。 */
+export async function confirmAction(_actionId: string): Promise {
+ throw new Error('not implemented:等待后端 POST /actions/{id}/confirm')
+}
+
+/** 订阅一个角色。 */
+export function useCharacter(id: string): AsyncState {
+ return useAsync(() => fetchCharacter(id), [id])
+}
+
+/** 订阅项目下的角色列表。 */
+export function useCharacters(projectId: string): AsyncState {
+ return useAsync(() => fetchCharactersByProject(projectId), [projectId])
+}
diff --git a/frontend/src/entities/character/types.ts b/frontend/src/entities/character/types.ts
new file mode 100644
index 0000000..2d60e9d
--- /dev/null
+++ b/frontend/src/entities/character/types.ts
@@ -0,0 +1,102 @@
+import type { WorkflowRun } from '../workflow-run'
+
+/** 角色、动作、帧。后端接口尚未提供,形状按界面需要先定,待与后端对齐。 */
+
+/** 预设动作(行走/奔跑/跳跃/待机等 6–10 个)或自定义。 */
+export type ActionKind = 'preset' | 'custom'
+
+/** 动作在生成流水线上的位置。 */
+export type ActionStatus =
+ /** 已加入工作流但还没开始生成 */
+ | 'planned'
+ /** 后端任务进行中 */
+ | 'generating'
+ /** 生成完成,是候选,还没被用户确认 */
+ | 'candidate'
+ /** 用户确认后成为正式资产 */
+ | 'confirmed'
+ /** 生成失败 */
+ | 'failed'
+
+/** 系统质检结论。系统通过不等于人工通过,分开记。 */
+export type FrameQcResult = 'pending' | 'passed' | 'failed'
+
+/** 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 */
+export interface Frame {
+ /** 帧图片的可访问 URL;候选与正式资产 URL 的具体来源等待 Asset 契约确定。 */
+ imageUrl: string
+
+ /** 后端自动质检结论,与人工 rejected 状态相互独立。 */
+ qc: FrameQcResult
+ /** 人工退回。不设「通过此帧」,故是布尔量而非三态。 */
+ rejected: boolean
+}
+
+/** 某个角色造型下的一段动画动作。 */
+export interface Action {
+ /** 动作领域 ID;由 Character/Asset 接口返回,具体格式等待 OpenAPI 确定。 */
+ id: string
+ /** 拥有该动作的 Outfit ID。 */
+ outfitId: Outfit['id']
+
+ /** 面向用户展示的动作名称。 */
+ name: string
+ /** 动作来自系统预设还是用户自定义。 */
+ kind: ActionKind
+ /** 动作在生成、候选确认流程中的当前状态。 */
+ status: ActionStatus
+ /** 每秒播放帧数(frames per second);预览和导出不得使用前端全局常量。 */
+ fps: number
+ /** 按播放顺序排列的帧;数组下标就是零基帧序号。 */
+ frames: Frame[]
+ /**
+ * 生成它的 WorkflowRun id,审核台退回单帧后据此跳回编辑器定位。
+ * 类型必须与 WorkflowRun.id 一致,否则这条恢复链在类型上就是断的。
+ */
+ sourceWorkflowRunId: WorkflowRun['id'] | null
+}
+
+/** 同一角色的一套独立造型;MVP UI 只展示第一套,但数据结构不折叠该层。 */
+export interface Outfit {
+ /** 造型领域 ID;具体来源和格式等待 Character OpenAPI 确定。 */
+ id: string
+ /** 该造型所属的 Character ID。 */
+ characterId: string
+ /** 面向用户展示的造型名称。 */
+ name: string
+ /** 母版生成阶段返回的三张候选图 URL;生成完成前可以为空数组。 */
+ candidateCharacterTemplates: string[]
+ /** 用户从候选图中选定的角色母版 URL;尚未选定时为 null。 */
+ characterTemplateUrl: string | null
+ /** 该造型拥有的动作集合;每个 Action.outfitId 必须等于本造型 ID。 */
+ actions: Action[]
+}
+
+/** 项目下的角色资产;造型拥有各自的母版和动作帧。 */
+export interface Character {
+ /** 后端 Character ID;具体格式等待 OpenAPI 确定。 */
+ id: string
+ /** 角色所属的 Project ID。 */
+ projectId: string
+ /** 面向用户展示的角色名称。 */
+ name: string
+ /** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */
+ outfits: Outfit[]
+ /** 角色创建时间,预期使用后端返回的 ISO 8601 字符串。 */
+ createdAt: string
+ /** 角色最后更新时间,预期使用后端返回的 ISO 8601 字符串。 */
+ updatedAt: string
+}
+
+/** 创建角色并发起母版生成所需的前端领域入参。 */
+export interface CreateCharacterInput {
+ /** 新角色所属的 Project ID。 */
+ projectId: string
+ /** 面向用户展示的角色名称。 */
+ name: string
+ /** 交给模型生成母版。 */
+ description: string
+
+ /** 可选的用户参考图 URL;未提供或已明确清空时为 null/undefined。 */
+ referenceImageUrl?: string | null
+}
diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts
new file mode 100644
index 0000000..3005638
--- /dev/null
+++ b/frontend/src/entities/index.ts
@@ -0,0 +1,98 @@
+/**
+ * entities 唯一公开门面。外部不得绕过本文件访问内部 Entity 文件。
+ * WorkflowRun 是前端编排模型;Project 已有后端实现,其他独立能力等待 OpenAPI 接入。
+ */
+
+/* 项目 —— 已对接后端 PR #57 */
+export {
+ CHARACTER_PERSPECTIVE,
+ DIRECTIONAL_MOVEMENT,
+ SPRITE_SIZES,
+ createProject,
+ deleteProject,
+ fetchProject,
+ fetchProjects,
+ uploadImage,
+ useProject,
+ useProjects,
+} from './project'
+export type {
+ CharacterPerspective,
+ CreateProjectInput,
+ DirectionalMovement,
+ Project,
+} from './project'
+
+/* 角色 / 动作 / 帧 —— 提案,待与后端 review */
+export {
+ addAction,
+ confirmAction,
+ confirmCharacterTemplate,
+ createCharacter,
+ fetchCharacter,
+ fetchCharactersByProject,
+ useCharacter,
+ useCharacters,
+} from './character'
+export type {
+ Action,
+ ActionKind,
+ ActionStatus,
+ AddActionInput,
+ Character,
+ CreateCharacterInput,
+ Frame,
+ FrameQcResult,
+ Outfit,
+} from './character'
+
+/* 工作流数据 —— Revision、五节点和领域门禁 */
+export {
+ WORKFLOW_NODE_ORDER,
+ canEnterNode,
+ canImportToPlaytest,
+ canRestartFromNode,
+ canSubmitCommand,
+ createWorkflowRun,
+ fetchWorkflowRun,
+ getCurrentNode,
+ getCurrentRevision,
+ getFirstAccessibleNodeType,
+ getNode,
+ getNodeByType,
+ getRevision,
+ isWorkflowNodeType,
+ listRevisionHistory,
+ nextNodeType,
+ subscribeTask,
+ submitWorkflowCommand,
+ useWorkflowRun,
+ workflowRunKeys,
+} from './workflow-run'
+export type {
+ CreateWorkflowRunInput,
+ ExportStatus,
+ GenerationStatus,
+ PlaytestStatus,
+ Task,
+ TaskEvent,
+ TaskStatus,
+ WorkflowTaskLink,
+ WorkflowCommand,
+ WorkflowCommandKind,
+ WorkflowDriver,
+ WorkflowLocation,
+ WorkflowNode,
+ WorkflowNodeStatus,
+ WorkflowNodeType,
+ WorkflowRevision,
+ WorkflowRevisionStatus,
+ WorkflowRun,
+ WorkflowRunStatus,
+} from './workflow-run'
+
+/* 资产库 —— 提案,待与后端 review */
+export { fetchActionTemplates, useActionTemplates } from './action-template'
+export type { ActionTemplate } from './action-template'
+export { fetchWearables, useWearables } from './wearable'
+export type { Wearable } from './wearable'
diff --git a/frontend/src/entities/project/api.test.ts b/frontend/src/entities/project/api.test.ts
new file mode 100644
index 0000000..cea752b
--- /dev/null
+++ b/frontend/src/entities/project/api.test.ts
@@ -0,0 +1,30 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { fetchProject, uploadImage } from './api'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('uploadImage', () => {
+ it('rejects images larger than the backend upload limit before making a request', async () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ const file = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'large.png', {
+ type: 'image/png',
+ })
+
+ await expect(uploadImage(file)).rejects.toThrow('文件超过大小上限(10485760 字节)')
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('Project DTO mapper', () => {
+ it('把后端数字枚举转换成前端字符串领域值', async () => {
+ const project = await fetchProject('1')
+
+ expect(project.perspective).toBe('side')
+ expect(project.directionalMovement).toBe('four-way')
+ })
+})
diff --git a/frontend/src/entities/project/api.ts b/frontend/src/entities/project/api.ts
new file mode 100644
index 0000000..931e7f3
--- /dev/null
+++ b/frontend/src/entities/project/api.ts
@@ -0,0 +1,136 @@
+import { ApiError, request, requestList, uploadFile } from '@/shared/api'
+import type { Paged, PageQuery } from '@/shared/api'
+import type {
+ CharacterPerspective,
+ CreateProjectInput,
+ DirectionalMovement,
+ Project,
+} from './types'
+
+const SUPPORTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])
+const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024
+
+/** 后端原始形状,只在本文件出现;出了这里全项目只认 Project。 */
+interface ProjectDto {
+ id: number
+ user_id: number
+ workflow_id: number | null
+ project_name: string
+ character_perspective: number
+ directional_movement: number
+ sprite_width: number
+ sprite_height: number
+ game_style: string | null
+ sprite_sample_url: string | null
+ create_at: string
+ update_at: string
+}
+
+/** TODO(对后端):user_id 应由后端从 token 推出,不该前端传。暂时写死。 */
+const CURRENT_USER_ID = 1
+
+const PERSPECTIVE_FROM_DTO: Record = {
+ 1: 'side',
+ 2: 'top-down',
+ 3: 'isometric',
+}
+const PERSPECTIVE_TO_DTO: Record = {
+ side: 1,
+ 'top-down': 2,
+ isometric: 3,
+}
+const MOVEMENT_FROM_DTO: Record = {
+ 1: 'single',
+ 2: 'four-way',
+ 3: 'eight-way',
+}
+const MOVEMENT_TO_DTO: Record = {
+ single: 1,
+ 'four-way': 2,
+ 'eight-way': 3,
+}
+
+/** 后端形状 → 业务对象。 */
+function toProject(dto: ProjectDto): Project {
+ const perspective = PERSPECTIVE_FROM_DTO[dto.character_perspective]
+ const directionalMovement = MOVEMENT_FROM_DTO[dto.directional_movement]
+ if (!perspective || !directionalMovement) {
+ throw new Error(`项目 ${dto.id} 返回了未知的视角或移动方向`)
+ }
+ return {
+ id: String(dto.id),
+ ownerId: String(dto.user_id),
+ workflowId: dto.workflow_id === null ? null : String(dto.workflow_id),
+ name: dto.project_name,
+ perspective,
+ directionalMovement,
+ spriteSize: { width: dto.sprite_width, height: dto.sprite_height },
+ gameStyle: dto.game_style,
+ sampleImageUrl: dto.sprite_sample_url,
+ createdAt: dto.create_at,
+ updatedAt: dto.update_at,
+ }
+}
+
+/** GET /projects */
+export async function fetchProjects(query: PageQuery = {}): Promise> {
+ const paged = await requestList('/projects', {
+ query: {
+ page: query.page ?? 1,
+ page_size: query.pageSize ?? 20,
+ user_id: CURRENT_USER_ID,
+ },
+ })
+ return { ...paged, items: paged.items.map(toProject) }
+}
+
+/** GET /projects/{id},不存在时抛 ApiError(404)。 */
+export async function fetchProject(id: string): Promise {
+ const dto = await request(`/projects/${id}`)
+ if (!dto) throw new Error(`项目 ${id} 返回为空`)
+ return toProject(dto)
+}
+
+/** POST /projects,重名时抛 ApiError(400)。 */
+export async function createProject(input: CreateProjectInput): Promise {
+ const dto = await request('/projects', {
+ method: 'POST',
+ body: {
+ user_id: CURRENT_USER_ID,
+ workflow_id: null,
+ project_name: input.name,
+ character_perspective: PERSPECTIVE_TO_DTO[input.perspective],
+ directional_movement: MOVEMENT_TO_DTO[input.directionalMovement],
+ sprite_width: input.spriteSize.width,
+ sprite_height: input.spriteSize.height,
+ game_style: input.gameStyle ?? null,
+ sprite_sample_url: input.sampleImageUrl ?? null,
+ },
+ })
+ if (!dto) throw new Error('创建项目未返回数据')
+ return toProject(dto)
+}
+
+/** DELETE /projects/{id} */
+export async function deleteProject(id: string): Promise {
+ await request(`/projects/${id}`, { method: 'DELETE' })
+}
+
+/**
+ * POST /upload/image —— 上传参考图,返回可直接填进 sampleImageUrl 的 URL。
+ *
+ * 走 multipart 而不是 JSON,所以不经 shared/api 的 request():那条通道只处理
+ * JSON 请求体。不要手动设 Content-Type,浏览器会自动补 multipart boundary。
+ * 后端白名单为 jpeg/png/webp/gif;前端先做同一检查,最终仍以后端校验为准。
+ */
+export async function uploadImage(file: File): Promise {
+ if (!SUPPORTED_IMAGE_TYPES.has(file.type)) {
+ throw new ApiError('仅支持 jpg/png/webp/gif 图片', 400)
+ }
+ if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
+ throw new ApiError(`文件超过大小上限(${MAX_IMAGE_UPLOAD_BYTES} 字节)`, 400)
+ }
+
+ const payload = await uploadFile<{ url: string }>('/upload/image', file)
+ return payload.url
+}
diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts
new file mode 100644
index 0000000..5a4b052
--- /dev/null
+++ b/frontend/src/entities/project/index.ts
@@ -0,0 +1,26 @@
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
+import type { Paged, PageQuery } from '@/shared/api'
+import { fetchProject, fetchProjects } from './api'
+import type { Project } from './types'
+
+/** 项目。后端 GET/POST /projects、GET/DELETE /projects/{id} 已实现(PR #57)。 */
+
+export { createProject, deleteProject, fetchProject, fetchProjects, uploadImage } from './api'
+export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './types'
+export type {
+ CharacterPerspective,
+ CreateProjectInput,
+ DirectionalMovement,
+ Project,
+} from './types'
+
+/** 订阅项目列表。 */
+export function useProjects(query: PageQuery = {}): AsyncState> {
+ return useAsync(() => fetchProjects(query), [query.page, query.pageSize])
+}
+
+/** 订阅单个项目。 */
+export function useProject(id: string): AsyncState {
+ return useAsync(() => fetchProject(id), [id])
+}
diff --git a/frontend/src/entities/project/types.ts b/frontend/src/entities/project/types.ts
new file mode 100644
index 0000000..586b4fc
--- /dev/null
+++ b/frontend/src/entities/project/types.ts
@@ -0,0 +1,86 @@
+/**
+ * 项目:角色生成的容器,保存全局约束(视角、尺寸、画风)。
+ * 字段对照后端 ProjectOut(PR #57),命名转换在 ./api.ts。
+ */
+export interface Project {
+ /** 后端 Project ID;领域层统一转为字符串。 */
+ id: string
+ /** 后端目前要求前端显式传 user_id,无登录态前写死。 */
+ ownerId: string
+ /** 未开始生成时为 null。 */
+ workflowId: string | null
+ /** 后端限制 1–20 字符,同一用户下不可重名。 */
+ name: string
+ /** 游戏视角,见 CHARACTER_PERSPECTIVE。 */
+ perspective: CharacterPerspective
+ /** 移动方向,见 DIRECTIONAL_MOVEMENT。 */
+ directionalMovement: DirectionalMovement
+ /** 后端校验 32–2048,实际取 SPRITE_SIZES 里的档位。 */
+ spriteSize: {
+ /** 精灵图宽度,单位为像素。 */
+ width: number
+ /** 精灵图高度,单位为像素。 */
+ height: number
+ }
+ /** 项目级画风描述,会作为本项目所有角色与动作生成的视觉约束。 */
+ gameStyle: string | null
+ /**
+ * 项目级画风参考图,本项目所有角色都照它的风格生成;URL 来自 POST /upload/image。
+ * 别跟角色自己的参考图(CreateCharacterInput.referenceImageUrl)混:
+ * 那张定的是单个角色长什么样,这张定的是整个项目的统一风格。
+ */
+ sampleImageUrl: string | null
+ /** 项目创建时间,保留后端返回的时间字符串。 */
+ createdAt: string
+ /** 项目最后更新时间,保留后端返回的时间字符串。 */
+ updatedAt: string
+}
+
+/** 新建项目的入参。 */
+export interface CreateProjectInput {
+ /** 项目名称;后端当前限制 1–20 字符,同一用户下不可重名。 */
+ name: string
+ /** 游戏视角;传输层会映射成后端数字枚举。 */
+ perspective: CharacterPerspective
+ /** 移动方向;传输层会映射成后端数字枚举。 */
+ directionalMovement: DirectionalMovement
+ /** 精灵图宽高,单位为像素;当前页面从 SPRITE_SIZES 中选择。 */
+ spriteSize: {
+ /** 目标精灵图宽度。 */
+ width: number
+ /** 目标精灵图高度。 */
+ height: number
+ }
+ /** 可选的项目级画风描述;不设置或明确清空时发送 null。 */
+ gameStyle?: string | null
+ /** 可选的项目级参考图 URL;由图片上传接口返回。 */
+ sampleImageUrl?: string | null
+}
+
+/** 前端使用的游戏视角枚举;仅在 mapper 中转换为后端数字。 */
+export type CharacterPerspective = 'side' | 'top-down' | 'isometric'
+
+/** 前端使用的移动方向枚举;仅在 mapper 中转换为后端数字。 */
+export type DirectionalMovement = 'single' | 'four-way' | 'eight-way'
+
+/** 游戏视角。DTO 数字值只在 api mapper 内存在。 */
+export const CHARACTER_PERSPECTIVE: Record = {
+ side: '横版视角',
+ 'top-down': '俯视',
+ isometric: '2.5D',
+}
+
+/** 移动方向,决定一个动作要生成几套朝向的帧。 */
+export const DIRECTIONAL_MOVEMENT: Record = {
+ single: '单向',
+ 'four-way': '四向',
+ 'eight-way': '八向',
+}
+
+/**
+ * 精灵图尺寸档位,来自 windup_project 表注释。
+ *
+ * 注意:接口文档与 pydantic 校验写的是「32~2048 任意整数」,表注释列的却是这七个档。
+ * 前端按档位做选择器,避免用户填出后端不打算支持的尺寸。TODO(对后端):以哪个为准。
+ */
+export const SPRITE_SIZES = [32, 64, 128, 256, 512, 1024, 2048] as const
diff --git a/frontend/src/entities/public-contracts.test.ts b/frontend/src/entities/public-contracts.test.ts
new file mode 100644
index 0000000..fa384ee
--- /dev/null
+++ b/frontend/src/entities/public-contracts.test.ts
@@ -0,0 +1,85 @@
+import { describe, expectTypeOf, it } from 'vitest'
+
+import { addAction, confirmCharacterTemplate } from './index'
+import type {
+ Action,
+ ActionTemplate,
+ AddActionInput,
+ Character,
+ CharacterPerspective,
+ DirectionalMovement,
+ Frame,
+ Outfit,
+ Project,
+ Task,
+ WorkflowRun,
+ WorkflowTaskLink,
+} from './index'
+
+describe('entities 公开契约', () => {
+ it('动作携带播放帧率,Frame 的顺序由数组表达', () => {
+ expectTypeOf().toHaveProperty('fps').toBeNumber()
+ expectTypeOf().not.toHaveProperty('hasDisplacement')
+ expectTypeOf().not.toHaveProperty('index')
+ })
+
+ it('角色母版与动作模板使用不同概念', () => {
+ expectTypeOf().toHaveProperty('outfits').toEqualTypeOf()
+ expectTypeOf().toHaveProperty('candidateCharacterTemplates').toEqualTypeOf()
+ expectTypeOf().toHaveProperty('characterTemplateUrl').toEqualTypeOf()
+ expectTypeOf().not.toHaveProperty('baseImageUrl')
+ expectTypeOf(confirmCharacterTemplate).toEqualTypeOf<(outfitId: string) => Promise>()
+ expectTypeOf(addAction).toEqualTypeOf<
+ (outfitId: string, input: AddActionInput) => Promise
+ >()
+ expectTypeOf().toHaveProperty('outfitId').toBeString()
+ expectTypeOf()
+ .toHaveProperty('sourceWorkflowRunId')
+ .toEqualTypeOf()
+ expectTypeOf().not.toHaveProperty('sourceWorkflowId')
+ })
+
+ it('预设动作必须指明用哪份模板', () => {
+ type Preset = Extract
+ type Custom = Extract
+
+ expectTypeOf().toHaveProperty('actionTemplateId').toEqualTypeOf()
+ expectTypeOf().not.toHaveProperty('actionTemplateId')
+ })
+
+ it('系统模板与项目模板通过作用域区分归属', () => {
+ type SystemTemplate = Extract
+ type ProjectTemplate = Extract
+
+ expectTypeOf().toHaveProperty('projectId').toEqualTypeOf()
+ expectTypeOf().toHaveProperty('projectId').toBeString()
+ expectTypeOf().toHaveProperty('prompt').toBeString()
+ expectTypeOf().not.toHaveProperty('previewImageUrl')
+ expectTypeOf().not.toHaveProperty('frameCount')
+ expectTypeOf().not.toHaveProperty('fps')
+ expectTypeOf().not.toHaveProperty('hasDisplacement')
+ })
+
+ it('异步任务提供可查询和恢复的完整快照', () => {
+ expectTypeOf().toHaveProperty('id').toBeString()
+ expectTypeOf().not.toHaveProperty('runId')
+ expectTypeOf().not.toHaveProperty('revisionId')
+ expectTypeOf().toHaveProperty('progress').toEqualTypeOf()
+ expectTypeOf().toHaveProperty('result').toBeUnknown()
+ expectTypeOf().toEqualTypeOf<{
+ taskId: string
+ runId: string
+ revisionId: string
+ nodeId: string
+ }>()
+ })
+
+ it('Project 在领域层使用字符串枚举,数字只保留在 DTO', () => {
+ expectTypeOf().toEqualTypeOf<'side' | 'top-down' | 'isometric'>()
+ expectTypeOf().toEqualTypeOf<'single' | 'four-way' | 'eight-way'>()
+ expectTypeOf().toHaveProperty('perspective').toEqualTypeOf()
+ expectTypeOf()
+ .toHaveProperty('directionalMovement')
+ .toEqualTypeOf()
+ })
+})
diff --git a/frontend/src/entities/wearable/index.ts b/frontend/src/entities/wearable/index.ts
new file mode 100644
index 0000000..9fe6f5f
--- /dev/null
+++ b/frontend/src/entities/wearable/index.ts
@@ -0,0 +1,24 @@
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
+
+/** 可复用的穿戴资产,与 action-template 同属项目资产库,先占好入口。 */
+
+export interface Wearable {
+ /** 穿戴资产 ID;具体生成方和格式等待 Wearable OpenAPI 确定。 */
+ id: string
+ /** 穿戴资产所属的 Project ID;是否支持系统内置资产仍待后端契约确定。 */
+ projectId: string
+ /** 面向用户展示的穿戴资产名称。 */
+ name: string
+ /** 穿戴资产预览图 URL;没有可展示预览时为 null。 */
+ previewImageUrl: string | null
+}
+
+export async function fetchWearables(_projectId: string): Promise {
+ throw new Error('not implemented:等待后端 GET /projects/{id}/wearables')
+}
+
+/** 订阅穿戴资产列表。 */
+export function useWearables(projectId: string): AsyncState {
+ return useAsync(() => fetchWearables(projectId), [projectId])
+}
diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts
new file mode 100644
index 0000000..4bb57a5
--- /dev/null
+++ b/frontend/src/entities/workflow-run/index.ts
@@ -0,0 +1,53 @@
+import { useAsync } from '@/shared/hooks'
+import type { AsyncState } from '@/shared/hooks'
+import { fetchWorkflowRun } from './orchestration/get-workflow-run'
+import type { TaskEvent } from './model/task'
+import type { WorkflowRun } from './model/types'
+
+export { createWorkflowRun } from './orchestration/create-workflow-run'
+export { fetchWorkflowRun } from './orchestration/get-workflow-run'
+export { submitWorkflowCommand } from './orchestration/submit-workflow-command'
+export { workflowRunKeys } from './model/queries'
+export {
+ canEnterNode,
+ canImportToPlaytest,
+ canRestartFromNode,
+ canSubmitCommand,
+ getCurrentNode,
+ getCurrentRevision,
+ getFirstAccessibleNodeType,
+ getNode,
+ getNodeByType,
+ getRevision,
+ isWorkflowNodeType,
+ listRevisionHistory,
+ nextNodeType,
+} from './model/selectors'
+export { WORKFLOW_NODE_ORDER } from './model/types'
+export type { Task, TaskEvent, TaskStatus, WorkflowTaskLink } from './model/task'
+export type {
+ CreateWorkflowRunInput,
+ ExportStatus,
+ GenerationStatus,
+ PlaytestStatus,
+ WorkflowCommand,
+ WorkflowCommandKind,
+ WorkflowDriver,
+ WorkflowLocation,
+ WorkflowNode,
+ WorkflowNodeStatus,
+ WorkflowNodeType,
+ WorkflowRevision,
+ WorkflowRevisionStatus,
+ WorkflowRun,
+ WorkflowRunStatus,
+} from './model/types'
+
+/** 任务流协议尚未冻结,调用会明确失败而不是伪造进度。 */
+export function subscribeTask(_taskId: string, _onEvent: (event: TaskEvent) => void): () => void {
+ throw new Error('subscribeTask 的 SSE 协议尚未与后端确定,暂不可调用')
+}
+
+export function useWorkflowRun(runId: string): AsyncState {
+ return useAsync(() => fetchWorkflowRun(runId), [runId])
+}
diff --git a/frontend/src/entities/workflow-run/local/machine.ts b/frontend/src/entities/workflow-run/local/machine.ts
new file mode 100644
index 0000000..094d77a
--- /dev/null
+++ b/frontend/src/entities/workflow-run/local/machine.ts
@@ -0,0 +1,296 @@
+import {
+ WORKFLOW_NODE_ORDER,
+ type CreateWorkflowRunInput,
+ type WorkflowCommand,
+ type WorkflowNode,
+ type WorkflowRevision,
+ type WorkflowRun,
+} from '../model/types'
+import { loadRun, newId, saveRun } from './store'
+
+function node(
+ type: WorkflowNode['type'],
+ status: WorkflowNode['status'],
+ input: unknown,
+): WorkflowNode {
+ return {
+ id: newId('node'),
+ type,
+ order: 0,
+ status,
+ input,
+ output: null,
+ referenceNodeIds: [],
+ qualityFailureCount: 0,
+ }
+}
+
+/** AI 入口跳过资产设置直接进生成,手动入口从资产设置开始。 */
+function initialRevision(driver: WorkflowRun['driver'], prompt: string | null): WorkflowRevision {
+ const asset = node('asset', driver === 'ai' ? 'passed' : 'active', { prompt })
+ const nodes =
+ driver === 'ai'
+ ? [
+ asset,
+ {
+ ...node('generation', 'active', { prompt }),
+ order: 1,
+ referenceNodeIds: [asset.id],
+ },
+ ]
+ : [asset]
+
+ return {
+ id: newId('revision'),
+ basedOnRevisionId: null,
+ restartNodeId: null,
+ status: 'active',
+ nodes,
+ generationStatus: driver === 'ai' ? 'in_progress' : 'not_started',
+ exportStatus: 'not_exported',
+ playtestStatus: 'not_tested',
+ createdAt: new Date().toISOString(),
+ }
+}
+
+export function createLocalRun(input: CreateWorkflowRunInput): WorkflowRun {
+ const prompt = input.prompt?.trim() || null
+ const revision = initialRevision(input.driver, prompt)
+ return saveRun({
+ id: newId('run'),
+ projectId: input.projectId,
+ characterId: null,
+ driver: input.driver,
+ status: 'active',
+ currentRevisionId: revision.id,
+ revisions: [revision],
+ prompt,
+ })
+}
+
+/** MS2 页面推进状态机;真实生成、审核和导出结果未来由独立能力 Adapter 驱动。 */
+export function advanceLocalRun(runId: WorkflowRun['id'], command: WorkflowCommand): WorkflowRun {
+ const run = loadRun(runId)
+ if (!run) throw new Error(`工作流 ${runId} 不存在`)
+ if (!canSubmit(run, command)) throw new Error(`工作流 ${runId} 当前不允许命令 ${command.kind}`)
+
+ if (command.kind === 'record-playtest') {
+ return saveRun({
+ ...run,
+ revisions: run.revisions.map((item) =>
+ item.id === command.revisionId ? { ...item, playtestStatus: command.status } : item,
+ ),
+ })
+ }
+
+ if (command.kind === 'restart-from-node') {
+ const revision = restartRevision(revisionOf(run, command.sourceRevisionId)!, command.nodeId)
+ return saveRun({
+ ...run,
+ status: 'active',
+ currentRevisionId: revision.id,
+ revisions: [...run.revisions, revision],
+ })
+ }
+
+ const current = currentRevision(run)
+
+ if (command.kind === 'set-export-status') {
+ const exportNode = current.nodes.find((item) => item.type === 'export')!
+ const updated = replaceNode(current, exportNode.id, (item) => ({
+ ...item,
+ status:
+ command.status === 'exported'
+ ? 'passed'
+ : command.status === 'failed'
+ ? 'failed'
+ : item.status,
+ }))
+ const exported = command.status === 'exported'
+ return saveRun(
+ replaceRevision(
+ run,
+ {
+ ...updated,
+ exportStatus: command.status,
+ status: exported ? 'completed' : updated.status,
+ },
+ exported ? 'completed' : run.status,
+ ),
+ )
+ }
+
+ if (command.kind === 'record-quality-result') {
+ return saveRun(applyQualityResult(run, current, command.nodeId, command.passed, command.report))
+ }
+
+ if (command.kind === 'fail-node') {
+ const failed = replaceNode(current, command.nodeId, (item) => ({
+ ...item,
+ status: 'failed',
+ output: { error: command.error },
+ }))
+ return saveRun(replaceRevision(run, { ...failed, status: 'failed' }, 'failed'))
+ }
+
+ const completed = replaceNode(current, command.nodeId, (item) => ({
+ ...item,
+ status: 'passed',
+ output: command.output ?? null,
+ }))
+ return saveRun(replaceRevision(run, appendNextNode(completed, nodeOf(completed, command.nodeId))))
+}
+
+function canSubmit(run: WorkflowRun, command: WorkflowCommand): boolean {
+ if (command.kind === 'record-playtest') {
+ return revisionOf(run, command.revisionId)?.generationStatus === 'completed'
+ }
+
+ if (command.kind === 'restart-from-node') {
+ const selected = revisionOf(run, command.sourceRevisionId)?.nodes.find(
+ (item) => item.id === command.nodeId,
+ )
+ return Boolean(selected && selected.status !== 'locked' && selected.status !== 'available')
+ }
+
+ const revision = currentRevision(run)
+ if (revision.status === 'abandoned') return false
+
+ if (command.kind === 'set-export-status') {
+ const exportNode = revision.nodes.find((item) => item.type === 'export')
+ return revision.generationStatus === 'completed' && exportNode?.status === 'active'
+ }
+
+ const selected = revision.nodes.find((item) => item.id === command.nodeId)
+ if (!selected || selected.status !== 'active') return false
+ if (command.kind === 'record-quality-result') return selected.type === 'candidate'
+ if (command.kind === 'complete-node') return selected.type !== 'candidate'
+ return true
+}
+
+function applyQualityResult(
+ run: WorkflowRun,
+ revision: WorkflowRevision,
+ nodeId: string,
+ passed: boolean,
+ report: unknown,
+): WorkflowRun {
+ if (passed) {
+ const completed = replaceNode(revision, nodeId, (item) => ({
+ ...item,
+ status: 'passed',
+ output: report ?? null,
+ }))
+ return replaceRevision(run, {
+ ...appendNextNode(completed, nodeOf(completed, nodeId)),
+ generationStatus: 'completed',
+ })
+ }
+
+ let failureCount = 0
+ const failed = replaceNode(revision, nodeId, (item) => {
+ failureCount = item.qualityFailureCount + 1
+ return {
+ ...item,
+ qualityFailureCount: failureCount,
+ status: failureCount >= 2 ? 'failed' : 'active',
+ output: report ?? null,
+ }
+ })
+ if (failureCount < 2) return replaceRevision(run, failed)
+ return replaceRevision(run, { ...failed, generationStatus: 'failed', status: 'failed' }, 'failed')
+}
+
+/** 从某节点重开:保留该节点及之前的前缀作为参考,后续执行线不带过来。 */
+function restartRevision(source: WorkflowRevision, nodeId: string): WorkflowRevision {
+ const selectedIndex = source.nodes.findIndex((item) => item.id === nodeId)
+ if (selectedIndex < 0) throw new Error(`版本 ${source.id} 不包含节点 ${nodeId}`)
+
+ const nodes = source.nodes.slice(0, selectedIndex + 1).map((item, index) => ({
+ ...item,
+ id: newId('node'),
+ order: index,
+ status: index === selectedIndex ? ('active' as const) : ('passed' as const),
+ output: index === selectedIndex ? null : item.output,
+ referenceNodeIds: [...item.referenceNodeIds, item.id],
+ qualityFailureCount: index === selectedIndex ? 0 : item.qualityFailureCount,
+ }))
+ const selectedOrder = WORKFLOW_NODE_ORDER.indexOf(nodes.at(-1)!.type)
+ const selectedType = nodes.at(-1)!.type
+
+ return {
+ id: newId('revision'),
+ basedOnRevisionId: source.id,
+ restartNodeId: nodeId,
+ status: 'active',
+ nodes,
+ generationStatus:
+ selectedType === 'asset'
+ ? 'not_started'
+ : selectedOrder <= WORKFLOW_NODE_ORDER.indexOf('candidate')
+ ? 'in_progress'
+ : source.generationStatus,
+ exportStatus: 'not_exported',
+ playtestStatus: 'not_tested',
+ createdAt: new Date().toISOString(),
+ }
+}
+
+function appendNextNode(revision: WorkflowRevision, source: WorkflowNode): WorkflowRevision {
+ const type = WORKFLOW_NODE_ORDER[WORKFLOW_NODE_ORDER.indexOf(source.type) + 1]
+ if (!type) return revision
+ const nextRevision: WorkflowRevision = {
+ ...revision,
+ nodes: [
+ ...revision.nodes,
+ {
+ id: newId('node'),
+ type,
+ order: source.order + 1,
+ status: 'active',
+ input: null,
+ output: null,
+ referenceNodeIds: [source.id],
+ qualityFailureCount: 0,
+ },
+ ],
+ }
+ return type === 'generation' ? { ...nextRevision, generationStatus: 'in_progress' } : nextRevision
+}
+
+function revisionOf(run: WorkflowRun, revisionId: string): WorkflowRevision | null {
+ return run.revisions.find((item) => item.id === revisionId) ?? null
+}
+
+function currentRevision(run: WorkflowRun): WorkflowRevision {
+ const revision = revisionOf(run, run.currentRevisionId)
+ if (!revision) throw new Error(`工作流 ${run.id} 缺少当前版本 ${run.currentRevisionId}`)
+ return revision
+}
+
+function nodeOf(revision: WorkflowRevision, nodeId: string): WorkflowNode {
+ return revision.nodes.find((item) => item.id === nodeId)!
+}
+
+function replaceNode(
+ revision: WorkflowRevision,
+ nodeId: string,
+ update: (node: WorkflowNode) => WorkflowNode,
+): WorkflowRevision {
+ return {
+ ...revision,
+ nodes: revision.nodes.map((item) => (item.id === nodeId ? update(item) : item)),
+ }
+}
+
+function replaceRevision(
+ run: WorkflowRun,
+ revision: WorkflowRevision,
+ status: WorkflowRun['status'] = run.status,
+): WorkflowRun {
+ return {
+ ...run,
+ status,
+ revisions: run.revisions.map((item) => (item.id === revision.id ? revision : item)),
+ }
+}
diff --git a/frontend/src/entities/workflow-run/local/repository.ts b/frontend/src/entities/workflow-run/local/repository.ts
new file mode 100644
index 0000000..44b60c6
--- /dev/null
+++ b/frontend/src/entities/workflow-run/local/repository.ts
@@ -0,0 +1,15 @@
+import type { WorkflowRunRepository } from '../model/repository'
+import { advanceLocalRun, createLocalRun } from './machine'
+import { loadRun } from './store'
+
+export const localWorkflowRunRepository: WorkflowRunRepository = {
+ async create(input) {
+ return createLocalRun(input)
+ },
+ async get(runId) {
+ return loadRun(runId)
+ },
+ async submit(runId, command) {
+ return advanceLocalRun(runId, command)
+ },
+}
diff --git a/frontend/src/entities/workflow-run/local/store.ts b/frontend/src/entities/workflow-run/local/store.ts
new file mode 100644
index 0000000..57be372
--- /dev/null
+++ b/frontend/src/entities/workflow-run/local/store.ts
@@ -0,0 +1,67 @@
+import type { WorkflowRun } from '../model/types'
+import { parseWorkflowRunMap } from './validation'
+
+const STORAGE_KEY = 'windup.workflow-runs.v1'
+
+type RunMap = Record
+
+let memory: RunMap = {}
+let fallbackSequence = 0
+
+function storage(): Storage | null {
+ try {
+ return globalThis.localStorage ?? null
+ } catch {
+ return null
+ }
+}
+
+function readPersisted(): RunMap {
+ try {
+ const raw = storage()?.getItem(STORAGE_KEY)
+ if (!raw) return {}
+ const parsed: unknown = JSON.parse(raw)
+ return parseWorkflowRunMap(parsed)
+ } catch {
+ return {}
+ }
+}
+
+function readAll(): RunMap {
+ return { ...readPersisted(), ...memory }
+}
+
+export function saveRun(run: WorkflowRun): WorkflowRun {
+ memory = { ...memory, [run.id]: run }
+ try {
+ storage()?.setItem(STORAGE_KEY, JSON.stringify(readAll()))
+ } catch {
+ // 无痕模式等场景无法落盘时,本次会话仍使用内存仓库。
+ }
+ return run
+}
+
+export function loadRun(runId: WorkflowRun['id']): WorkflowRun | null {
+ return readAll()[runId] ?? null
+}
+
+function randomSuffix(): string {
+ const cryptoApi = globalThis.crypto
+ if (typeof cryptoApi?.randomUUID === 'function') {
+ return cryptoApi.randomUUID().replaceAll('-', '').slice(0, 12)
+ }
+ if (typeof cryptoApi?.getRandomValues === 'function') {
+ const bytes = new Uint8Array(8)
+ cryptoApi.getRandomValues(bytes)
+ return [...bytes].map((value) => value.toString(16).padStart(2, '0')).join('')
+ }
+
+ fallbackSequence += 1
+ return `${Date.now().toString(36)}${fallbackSequence.toString(36)}${Math.random()
+ .toString(36)
+ .slice(2, 8)}`
+}
+
+export function newId(prefix: 'run' | 'revision' | 'node'): string {
+ return `${prefix}-${randomSuffix()}`
+}
diff --git a/frontend/src/entities/workflow-run/local/validation.ts b/frontend/src/entities/workflow-run/local/validation.ts
new file mode 100644
index 0000000..64ea5d8
--- /dev/null
+++ b/frontend/src/entities/workflow-run/local/validation.ts
@@ -0,0 +1,108 @@
+import {
+ WORKFLOW_NODE_ORDER,
+ type WorkflowNode,
+ type WorkflowRevision,
+ type WorkflowRun,
+} from '../model/types'
+
+type UnknownRecord = Record
+
+const DRIVERS = ['ai', 'manual'] as const
+const RUN_STATUSES = ['active', 'completed', 'failed'] as const
+const REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const
+const NODE_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const
+const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const
+const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const
+const PLAYTEST_STATUSES = ['not_tested', 'passed', 'issues_found'] as const
+
+function isRecord(value: unknown): value is UnknownRecord {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+}
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === 'string' && value.length > 0
+}
+
+function isNullableString(value: unknown): value is string | null {
+ return value === null || isNonEmptyString(value)
+}
+
+function isEnum(value: unknown, allowed: T): value is T[number] {
+ return typeof value === 'string' && allowed.includes(value as T[number])
+}
+
+function isNonNegativeInteger(value: unknown): value is number {
+ return Number.isInteger(value) && Number(value) >= 0
+}
+
+function isStringArray(value: unknown): value is string[] {
+ return Array.isArray(value) && value.every(isNonEmptyString)
+}
+
+function hasUniqueIds(items: Array<{ id: string }>): boolean {
+ return new Set(items.map((item) => item.id)).size === items.length
+}
+
+function isWorkflowNode(value: unknown, index: number): value is WorkflowNode {
+ return (
+ isRecord(value) &&
+ isNonEmptyString(value.id) &&
+ isEnum(value.type, WORKFLOW_NODE_ORDER) &&
+ value.order === index &&
+ isEnum(value.status, NODE_STATUSES) &&
+ Object.hasOwn(value, 'input') &&
+ Object.hasOwn(value, 'output') &&
+ isStringArray(value.referenceNodeIds) &&
+ isNonNegativeInteger(value.qualityFailureCount)
+ )
+}
+
+function isWorkflowRevision(value: unknown): value is WorkflowRevision {
+ if (!isRecord(value) || !Array.isArray(value.nodes) || value.nodes.length === 0) return false
+ if (!value.nodes.every((node, index) => isWorkflowNode(node, index))) return false
+ const nodes = value.nodes as WorkflowNode[]
+
+ return (
+ isNonEmptyString(value.id) &&
+ isNullableString(value.basedOnRevisionId) &&
+ isNullableString(value.restartNodeId) &&
+ isEnum(value.status, REVISION_STATUSES) &&
+ hasUniqueIds(nodes) &&
+ isEnum(value.generationStatus, GENERATION_STATUSES) &&
+ isEnum(value.exportStatus, EXPORT_STATUSES) &&
+ isEnum(value.playtestStatus, PLAYTEST_STATUSES) &&
+ isNonEmptyString(value.createdAt) &&
+ !Number.isNaN(Date.parse(value.createdAt))
+ )
+}
+
+function isWorkflowRun(value: unknown): value is WorkflowRun {
+ if (!isRecord(value) || !Array.isArray(value.revisions) || value.revisions.length === 0) {
+ return false
+ }
+ if (!value.revisions.every(isWorkflowRevision)) return false
+ const revisions = value.revisions as WorkflowRevision[]
+
+ return (
+ isNonEmptyString(value.id) &&
+ isNonEmptyString(value.projectId) &&
+ isNullableString(value.characterId) &&
+ isEnum(value.driver, DRIVERS) &&
+ isEnum(value.status, RUN_STATUSES) &&
+ isNonEmptyString(value.currentRevisionId) &&
+ hasUniqueIds(revisions) &&
+ revisions.some((revision) => revision.id === value.currentRevisionId) &&
+ isNullableString(value.prompt)
+ )
+}
+
+/** 逐条过滤浏览器存储数据,损坏记录不会进入 WorkflowRun 领域层。 */
+export function parseWorkflowRunMap(value: unknown): Record {
+ if (!isRecord(value)) return {}
+
+ const entries: Array<[string, WorkflowRun]> = []
+ for (const [key, candidate] of Object.entries(value)) {
+ if (isWorkflowRun(candidate) && key === candidate.id) entries.push([key, candidate])
+ }
+ return Object.fromEntries(entries)
+}
diff --git a/frontend/src/entities/workflow-run/model/queries.ts b/frontend/src/entities/workflow-run/model/queries.ts
new file mode 100644
index 0000000..6b2d77b
--- /dev/null
+++ b/frontend/src/entities/workflow-run/model/queries.ts
@@ -0,0 +1,7 @@
+/** 查询缓存键。同一个 runId 只能对应同一份 WorkflowRun 查询键。 */
+export const workflowRunKeys = {
+ all: ['workflow-run'] as const,
+ detail: (runId: string) => ['workflow-run', runId] as const,
+ revision: (runId: string, revisionId: string) =>
+ ['workflow-run', runId, 'revision', revisionId] as const,
+}
diff --git a/frontend/src/entities/workflow-run/model/repository.test.ts b/frontend/src/entities/workflow-run/model/repository.test.ts
new file mode 100644
index 0000000..7e048d4
--- /dev/null
+++ b/frontend/src/entities/workflow-run/model/repository.test.ts
@@ -0,0 +1,18 @@
+import { describe, expectTypeOf, it } from 'vitest'
+
+import type { WorkflowRunRepository } from './repository'
+import type { WorkflowRun } from './types'
+
+describe('WorkflowRunRepository 契约', () => {
+ it('所有操作都使用可等待的网络形状', () => {
+ expectTypeOf>().toEqualTypeOf<
+ Promise
+ >()
+ expectTypeOf>().toEqualTypeOf<
+ Promise
+ >()
+ expectTypeOf>().toEqualTypeOf<
+ Promise
+ >()
+ })
+})
diff --git a/frontend/src/entities/workflow-run/model/repository.ts b/frontend/src/entities/workflow-run/model/repository.ts
new file mode 100644
index 0000000..bdbe46c
--- /dev/null
+++ b/frontend/src/entities/workflow-run/model/repository.ts
@@ -0,0 +1,11 @@
+import type { CreateWorkflowRunInput, WorkflowCommand, WorkflowRun } from './types'
+
+/** WorkflowRun 是前端编排模型;仓库端口不表达 HTTP 或后端资源。 */
+export interface WorkflowRunRepository {
+ /** 创建并持久化一个新的前端流程。 */
+ create(input: CreateWorkflowRunInput): Promise
+ /** 按前端运行 ID 读取流程;不存在时返回 null。 */
+ get(runId: WorkflowRun['id']): Promise
+ /** 校验并应用本地编排命令,然后返回更新后的完整流程。 */
+ submit(runId: WorkflowRun['id'], command: WorkflowCommand): Promise
+}
diff --git a/frontend/src/entities/workflow-run/model/revision.ts b/frontend/src/entities/workflow-run/model/revision.ts
new file mode 100644
index 0000000..810025b
--- /dev/null
+++ b/frontend/src/entities/workflow-run/model/revision.ts
@@ -0,0 +1,11 @@
+/** 兼容明确的 Revision 类型入口;真实定义集中在 types.ts。 */
+export type {
+ ExportStatus,
+ GenerationStatus,
+ PlaytestStatus,
+ WorkflowNode,
+ WorkflowNodeStatus,
+ WorkflowNodeType,
+ WorkflowRevision,
+ WorkflowRevisionStatus,
+} from './types'
diff --git a/frontend/src/entities/workflow-run/model/selectors.test.ts b/frontend/src/entities/workflow-run/model/selectors.test.ts
new file mode 100644
index 0000000..ce4aca5
--- /dev/null
+++ b/frontend/src/entities/workflow-run/model/selectors.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ canEnterNode,
+ canImportToPlaytest,
+ canRestartFromNode,
+ canSubmitCommand,
+ getCurrentNode,
+ getCurrentRevision,
+ getFirstAccessibleNodeType,
+ getNodeByType,
+ isWorkflowNodeType,
+ nextNodeType,
+} from './selectors'
+import type { WorkflowNode, WorkflowRevision, WorkflowRun } from './types'
+
+function node(
+ id: string,
+ type: WorkflowNode['type'],
+ status: WorkflowNode['status'],
+ order: number,
+): WorkflowNode {
+ return {
+ id,
+ type,
+ status,
+ order,
+ input: null,
+ output: null,
+ referenceNodeIds: [],
+ qualityFailureCount: 0,
+ }
+}
+
+function revision(overrides: Partial = {}): WorkflowRevision {
+ return {
+ id: 'revision-1',
+ basedOnRevisionId: null,
+ restartNodeId: null,
+ status: 'active',
+ nodes: [node('asset-1', 'asset', 'passed', 0), node('generation-1', 'generation', 'active', 1)],
+ generationStatus: 'in_progress',
+ exportStatus: 'not_exported',
+ playtestStatus: 'not_tested',
+ createdAt: '2026-07-27T00:00:00.000Z',
+ ...overrides,
+ }
+}
+
+function run(
+ current: WorkflowRevision = revision(),
+ history: WorkflowRevision[] = [],
+): WorkflowRun {
+ return {
+ id: 'run-1',
+ projectId: 'project-1',
+ characterId: null,
+ driver: 'ai',
+ status: 'active',
+ currentRevisionId: current.id,
+ revisions: [...history, current],
+ prompt: '像素小骑士',
+ }
+}
+
+describe('WorkflowRun selectors', () => {
+ it('读取当前版本、当前节点和首个可访问节点', () => {
+ const value = run()
+ expect(getCurrentRevision(value).id).toBe('revision-1')
+ expect(getCurrentNode(value)?.id).toBe('generation-1')
+ expect(getFirstAccessibleNodeType(getCurrentRevision(value))).toBe('generation')
+ expect(getNodeByType(getCurrentRevision(value), 'asset')?.status).toBe('passed')
+ })
+
+ it('只允许进入当前执行线上已经存在的节点', () => {
+ const value = run()
+ expect(canEnterNode(value, 'revision-1', 'asset')).toBe(true)
+ expect(canEnterNode(value, 'revision-1', 'generation')).toBe(true)
+ expect(canEnterNode(value, 'revision-1', 'candidate')).toBe(false)
+ })
+
+ it('已经存在的节点允许作为重新开始位置', () => {
+ const value = run()
+ expect(canRestartFromNode(value, 'revision-1', 'asset-1')).toBe(true)
+ expect(canRestartFromNode(value, 'revision-1', 'missing')).toBe(false)
+ })
+
+ it('候选节点只能提交质量门禁结果,不能直接标记完成', () => {
+ const candidateRevision = revision({
+ nodes: [
+ node('asset-1', 'asset', 'passed', 0),
+ node('generation-1', 'generation', 'passed', 1),
+ node('candidate-1', 'candidate', 'active', 2),
+ ],
+ })
+ const value = run(candidateRevision)
+ expect(
+ canSubmitCommand(value, {
+ kind: 'record-quality-result',
+ nodeId: 'candidate-1',
+ passed: true,
+ }),
+ ).toBe(true)
+ expect(canSubmitCommand(value, { kind: 'complete-node', nodeId: 'candidate-1' })).toBe(false)
+ })
+
+ it('只有系统质检通过的版本可以导入核验台', () => {
+ const value = run(revision({ generationStatus: 'completed' }))
+ expect(canImportToPlaytest(value, 'revision-1')).toBe(true)
+ expect(canImportToPlaytest(run(), 'revision-1')).toBe(false)
+ })
+
+ it('节点顺序和类型判断保持稳定', () => {
+ expect(nextNodeType('asset')).toBe('generation')
+ expect(nextNodeType('export')).toBeNull()
+ expect(isWorkflowNodeType('review')).toBe(true)
+ expect(isWorkflowNodeType('unknown')).toBe(false)
+ })
+})
diff --git a/frontend/src/entities/workflow-run/model/selectors.ts b/frontend/src/entities/workflow-run/model/selectors.ts
new file mode 100644
index 0000000..9250172
--- /dev/null
+++ b/frontend/src/entities/workflow-run/model/selectors.ts
@@ -0,0 +1,97 @@
+import {
+ WORKFLOW_NODE_ORDER,
+ type WorkflowCommand,
+ type WorkflowNode,
+ type WorkflowNodeType,
+ type WorkflowRevision,
+ type WorkflowRun,
+} from './types'
+
+export function getRevision(run: WorkflowRun, revisionId: string): WorkflowRevision | null {
+ return run.revisions.find((revision) => revision.id === revisionId) ?? null
+}
+
+export function getCurrentRevision(run: WorkflowRun): WorkflowRevision {
+ const revision = getRevision(run, run.currentRevisionId)
+ if (!revision) throw new Error(`工作流 ${run.id} 缺少当前版本 ${run.currentRevisionId}`)
+ return revision
+}
+
+export function listRevisionHistory(run: WorkflowRun): WorkflowRevision[] {
+ return [...run.revisions].sort((left, right) => right.createdAt.localeCompare(left.createdAt))
+}
+
+export function getNode(run: WorkflowRun, revisionId: string, nodeId: string): WorkflowNode | null {
+ return getRevision(run, revisionId)?.nodes.find((node) => node.id === nodeId) ?? null
+}
+
+export function getNodeByType(
+ revision: WorkflowRevision,
+ type: WorkflowNodeType,
+): WorkflowNode | null {
+ return revision.nodes.find((node) => node.type === type) ?? null
+}
+
+export function getCurrentNode(run: WorkflowRun): WorkflowNode | null {
+ return getCurrentRevision(run).nodes.find((node) => node.status === 'active') ?? null
+}
+
+export function getFirstAccessibleNodeType(revision: WorkflowRevision): WorkflowNodeType {
+ return (
+ revision.nodes.find((node) => node.status === 'active')?.type ??
+ revision.nodes.at(-1)?.type ??
+ WORKFLOW_NODE_ORDER[0]
+ )
+}
+
+export function canEnterNode(
+ run: WorkflowRun,
+ revisionId: string,
+ type: WorkflowNodeType,
+): boolean {
+ const node = getRevision(run, revisionId)?.nodes.find((item) => item.type === type)
+ return node?.status === 'active' || node?.status === 'passed' || node?.status === 'failed'
+}
+
+export function canRestartFromNode(run: WorkflowRun, revisionId: string, nodeId: string): boolean {
+ const revision = getRevision(run, revisionId)
+ const node = revision?.nodes.find((item) => item.id === nodeId)
+ return Boolean(revision && node && node.status !== 'locked' && node.status !== 'available')
+}
+
+export function canSubmitCommand(run: WorkflowRun, command: WorkflowCommand): boolean {
+ if (command.kind === 'record-playtest') {
+ return getRevision(run, command.revisionId)?.generationStatus === 'completed'
+ }
+
+ if (command.kind === 'restart-from-node') {
+ return canRestartFromNode(run, command.sourceRevisionId, command.nodeId)
+ }
+
+ const revision = getCurrentRevision(run)
+ if (revision.status === 'abandoned') return false
+
+ if (command.kind === 'set-export-status') {
+ const exportNode = getNodeByType(revision, 'export')
+ return revision.generationStatus === 'completed' && exportNode?.status === 'active'
+ }
+
+ const node = revision.nodes.find((item) => item.id === command.nodeId)
+ if (!node || node.status !== 'active') return false
+ if (command.kind === 'record-quality-result') return node.type === 'candidate'
+ if (command.kind === 'complete-node') return node.type !== 'candidate'
+ return true
+}
+
+export function canImportToPlaytest(run: WorkflowRun, revisionId: string): boolean {
+ return getRevision(run, revisionId)?.generationStatus === 'completed'
+}
+
+export function nextNodeType(type: WorkflowNodeType): WorkflowNodeType | null {
+ const index = WORKFLOW_NODE_ORDER.indexOf(type)
+ return WORKFLOW_NODE_ORDER[index + 1] ?? null
+}
+
+export function isWorkflowNodeType(value: string | undefined): value is WorkflowNodeType {
+ return WORKFLOW_NODE_ORDER.includes(value as WorkflowNodeType)
+}
diff --git a/frontend/src/entities/workflow-run/model/task.ts b/frontend/src/entities/workflow-run/model/task.ts
new file mode 100644
index 0000000..c1240b0
--- /dev/null
+++ b/frontend/src/entities/workflow-run/model/task.ts
@@ -0,0 +1,53 @@
+import type { WorkflowNode, WorkflowRevision, WorkflowRun } from './types'
+
+/**
+ * 异步任务契约,与 WorkflowRun 节点是两回事。
+ *
+ * 任务粒度 = 一次耗时较久的大模型调用:生成母版、生成一个动作(含 8 帧,算 1 个
+ * 任务而非 1+8)、重生成某一帧,各是一个任务。一个 workflow 关联多个任务,
+ * 任务进度不等于工作流节点本身,所以进度回调给的是 TaskEvent。
+ *
+ * 下面只是事件形状的候选,**传输协议尚未与后端确定**,四项缺口:
+ * SSE 端点路径、事件名与分帧格式、`result` 的具体结构、断线重连与补发策略。
+ * 协议定下来之前 subscribeTask 不可调用,见其函数注释。
+ */
+/**
+ * 后端异步任务的候选状态集合。
+ * queued 等待执行,running 正在执行,succeeded/failed 是成功或失败终态;最终取值以后端契约为准。
+ */
+export type TaskStatus = 'queued' | 'running' | 'succeeded' | 'failed'
+
+/** 创建、查询和断线恢复都使用的完整任务快照。 */
+export interface Task {
+ /** 后端生成的任务 ID,是查询状态和订阅事件的唯一标识。 */
+ id: string
+ /** 后端任务当前状态;它不会直接改变 WorkflowNodeStatus。 */
+ status: TaskStatus
+ /** 0–100。后端给不出就是 null,界面显示不确定进度。 */
+ progress: number | null
+ /** 失败原因,status 为 failed 时有值。 */
+ error: string | null
+ /**
+ * 任务产出(如生成出的图 URL)。
+ * 保持 unknown 而不是先猜一个形状:猜错会让调用方写出依赖假结构的代码。
+ */
+ result: unknown
+}
+
+/** SSE 每条事件携带完整状态,taskId 对应 Task.id。 */
+export interface TaskEvent extends Omit {
+ /** 发生变化的后端任务 ID。 */
+ taskId: Task['id']
+}
+
+/** 前端编排关联;后端 Task 不需要认识 WorkflowRun、Revision 或页面节点。 */
+export interface WorkflowTaskLink {
+ /** 被关联的后端任务 ID。 */
+ taskId: Task['id']
+ /** 接收任务结果的前端 WorkflowRun ID。 */
+ runId: WorkflowRun['id']
+ /** 发起任务时所在的前端版本 ID,避免结果写入后来创建的新版本。 */
+ revisionId: WorkflowRevision['id']
+ /** 发起任务的前端节点 ID,用于把结果映射回正确页面阶段。 */
+ nodeId: WorkflowNode['id']
+}
diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts
new file mode 100644
index 0000000..2b49d1f
--- /dev/null
+++ b/frontend/src/entities/workflow-run/model/types.ts
@@ -0,0 +1,175 @@
+/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */
+export type WorkflowDriver = 'ai' | 'manual'
+
+/** MS2 页面节点的固定展示顺序;它不是后端 Workflow 或 Execution 定义。 */
+export const WORKFLOW_NODE_ORDER = ['asset', 'generation', 'candidate', 'review', 'export'] as const
+
+/** 前端页面节点类型,与 WORKFLOW_NODE_ORDER 的成员保持一致。 */
+export type WorkflowNodeType = (typeof WORKFLOW_NODE_ORDER)[number]
+
+/**
+ * 前端节点的可用性和执行结果;不直接复用后端任务状态。
+ * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示本地结果。
+ */
+export type WorkflowNodeStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed'
+
+/**
+ * 单个前端版本的生命周期。
+ * active 表示仍在推进,completed/failed 表示终态,abandoned 表示停止沿用但仍保留为历史。
+ */
+export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned'
+
+/** 整次前端页面流程的汇总状态:进行中、已完成或失败。 */
+export type WorkflowRunStatus = 'active' | 'completed' | 'failed'
+
+/** 当前版本在生成阶段的页面汇总状态;素材准备期间为 not_started。 */
+export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed'
+
+/** 当前版本在导出阶段的页面汇总状态。 */
+export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed'
+
+/** 当前版本的人工核验结论;发现问题不会自动阻断导出。 */
+export type PlaytestStatus = 'not_tested' | 'passed' | 'issues_found'
+
+/** 一个 WorkflowRevision 中已经进入执行线的前端页面节点。 */
+export interface WorkflowNode {
+ /** 前端生成的节点 ID;只用于本地编排和页面定位,不发送给后端作为业务 ID。 */
+ id: string
+ /** 节点所代表的页面阶段。 */
+ type: WorkflowNodeType
+ /** 节点在当前版本中的零基顺序,正常情况下与 nodes 数组位置一致。 */
+ order: number
+ /** 前端对该节点可用性或结果的记录。 */
+ status: WorkflowNodeStatus
+ /** 进入节点时保存的输入快照;具体结构由对应业务能力 Adapter 定义。 */
+ input: unknown
+ /** 节点完成后的结果或引用;尚无结果时为 null,具体结构不在编排层猜测。 */
+ output: unknown
+ /** 该节点沿用或依赖的前端节点 ID,用于版本来源追踪,不代表后端执行依赖。 */
+ referenceNodeIds: string[]
+ /** 系统质检连续失败次数;只对 candidate 节点有业务意义,其他节点保持 0。 */
+ qualityFailureCount: number
+}
+
+/** WorkflowRun 的一次页面执行版本;当前版本会推进,从旧节点重开则追加新版本。 */
+export interface WorkflowRevision {
+ /** 前端生成的版本 ID。 */
+ id: string
+ /** 来源版本 ID;首次创建的版本没有来源,因此为 null。 */
+ basedOnRevisionId: string | null
+ /** 在来源版本中选择的重启节点 ID;非重启创建的版本为 null。 */
+ restartNodeId: string | null
+ /** 该版本的前端生命周期状态。 */
+ status: WorkflowRevisionStatus
+ /** 已进入当前执行线的节点;尚未推进到的后续节点可以不存在。 */
+ nodes: WorkflowNode[]
+ /** 供页面展示和门禁判断使用的生成汇总状态。 */
+ generationStatus: GenerationStatus
+ /** 供页面展示和门禁判断使用的导出汇总状态。 */
+ exportStatus: ExportStatus
+ /** 核验台针对该版本保存的人工结论。 */
+ playtestStatus: PlaytestStatus
+ /** 版本创建时间,使用 ISO 8601 字符串。 */
+ createdAt: string
+}
+
+/** 一次由前端维护的 MS2 页面流程;后端不存在同名资源。 */
+export interface WorkflowRun {
+ /** 前端生成的运行 ID,用于路由和本地持久化。 */
+ id: string
+ /** 本流程所属的后端 Project ID。 */
+ projectId: string
+ /** 已关联的后端 Character ID;角色尚未创建或确认时为 null。 */
+ characterId: string | null
+ /** 启动流程的交互入口:自然语言 Quick Start 或手动编辑器。 */
+ driver: WorkflowDriver
+ /** 当前运行的前端汇总状态。 */
+ status: WorkflowRunStatus
+ /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */
+ currentRevisionId: string
+ /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */
+ revisions: WorkflowRevision[]
+ /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */
+ prompt: string | null
+}
+
+/** 创建前端 WorkflowRun 所需的最小输入。 */
+export interface CreateWorkflowRunInput {
+ /** 新流程所属的后端 Project ID。 */
+ projectId: string
+ /** 选择 Quick Start 或手动编辑入口。 */
+ driver: WorkflowDriver
+ /** Quick Start 的自然语言需求;提交时会去除首尾空白,空字符串按 null 保存。 */
+ prompt?: string
+}
+
+/**
+ * 驱动前端页面编排状态变化的命令。
+ * 真实生成、审核和导出结果应先由对应后端能力返回,再转换成这里的本地命令。
+ */
+export type WorkflowCommand =
+ | {
+ /** 将当前活动节点标记为完成并推进到下一页面节点。 */
+ kind: 'complete-node'
+ /** 当前版本中要完成的节点 ID。 */
+ nodeId: string
+ /** 对应业务能力返回的结果或引用;编排层不限定具体结构。 */
+ output?: unknown
+ }
+ | {
+ /** 将当前活动节点和所在版本标记为失败。 */
+ kind: 'fail-node'
+ /** 当前版本中失败的节点 ID。 */
+ nodeId: string
+ /** 可展示或记录的失败原因,不承载结构化后端错误对象。 */
+ error: string
+ }
+ | {
+ /** 记录 candidate 节点的一次系统质检结果。 */
+ kind: 'record-quality-result'
+ /** 当前版本中的 candidate 节点 ID。 */
+ nodeId: string
+ /** 是否通过本次系统质检。 */
+ passed: boolean
+ /** 后端 Review 能力返回的质检报告;契约冻结前保持 unknown。 */
+ report?: unknown
+ }
+ | {
+ /** 从历史版本的某个节点创建一个新版本。 */
+ kind: 'restart-from-node'
+ /** 被选作来源的历史版本 ID。 */
+ sourceRevisionId: string
+ /** 来源版本中作为新执行起点的节点 ID。 */
+ nodeId: string
+ }
+ | {
+ /** 同步当前版本的页面导出状态。 */
+ kind: 'set-export-status'
+ /** 由后端 Export 能力结果映射得到的状态。 */
+ status: ExportStatus
+ }
+ | {
+ /** 保存核验台针对指定版本的人工结论。 */
+ kind: 'record-playtest'
+ /** 被核验的版本 ID,不要求是当前可编辑版本。 */
+ revisionId: string
+ /** 已作出的核验结论;未核验状态不能作为提交值。 */
+ status: Exclude
+ }
+
+/** WorkflowCommand 判别字段的联合类型。 */
+export type WorkflowCommandKind = WorkflowCommand['kind']
+
+/** 跨页面恢复编辑位置时使用的前端定位信息。 */
+export interface WorkflowLocation {
+ /** 要打开的 WorkflowRun ID。 */
+ runId: string
+ /** 要查看或编辑的版本 ID。 */
+ revisionId: string
+ /** 要聚焦的页面节点 ID。 */
+ nodeId: string
+ /** 可选的 Action ID;只有需要定位到具体动作时提供。 */
+ actionId?: string
+ /** Action.frames 中的零基索引;只有需要定位到具体帧时提供。 */
+ frameIndex?: number
+}
diff --git a/frontend/src/entities/workflow-run/orchestration/create-workflow-run.ts b/frontend/src/entities/workflow-run/orchestration/create-workflow-run.ts
new file mode 100644
index 0000000..b584bf5
--- /dev/null
+++ b/frontend/src/entities/workflow-run/orchestration/create-workflow-run.ts
@@ -0,0 +1,7 @@
+import type { CreateWorkflowRunInput, WorkflowRun } from '../model/types'
+import { workflowRunRepository } from '../repository'
+
+/** 创建前端编排状态;真实后端能力由后续独立 Adapter 接入。 */
+export async function createWorkflowRun(input: CreateWorkflowRunInput): Promise {
+ return workflowRunRepository.create(input)
+}
diff --git a/frontend/src/entities/workflow-run/orchestration/get-workflow-run.ts b/frontend/src/entities/workflow-run/orchestration/get-workflow-run.ts
new file mode 100644
index 0000000..6f29f2a
--- /dev/null
+++ b/frontend/src/entities/workflow-run/orchestration/get-workflow-run.ts
@@ -0,0 +1,9 @@
+import type { WorkflowRun } from '../model/types'
+import { workflowRunRepository } from '../repository'
+
+/** 读取前端编排状态;WorkflowRun 不是后端资源。 */
+export async function fetchWorkflowRun(runId: WorkflowRun['id']): Promise {
+ const run = await workflowRunRepository.get(runId)
+ if (!run) throw new Error(`工作流 ${runId} 不存在`)
+ return run
+}
diff --git a/frontend/src/entities/workflow-run/orchestration/submit-workflow-command.ts b/frontend/src/entities/workflow-run/orchestration/submit-workflow-command.ts
new file mode 100644
index 0000000..c334645
--- /dev/null
+++ b/frontend/src/entities/workflow-run/orchestration/submit-workflow-command.ts
@@ -0,0 +1,10 @@
+import type { WorkflowCommand, WorkflowRun } from '../model/types'
+import { workflowRunRepository } from '../repository'
+
+/** 推进前端页面编排;不把命令发送到不存在的 WorkflowRun 后端接口。 */
+export async function submitWorkflowCommand(
+ runId: WorkflowRun['id'],
+ command: WorkflowCommand,
+): Promise {
+ return workflowRunRepository.submit(runId, command)
+}
diff --git a/frontend/src/entities/workflow-run/repository.ts b/frontend/src/entities/workflow-run/repository.ts
new file mode 100644
index 0000000..77b4011
--- /dev/null
+++ b/frontend/src/entities/workflow-run/repository.ts
@@ -0,0 +1,5 @@
+import { localWorkflowRunRepository } from './local/repository'
+import type { WorkflowRunRepository } from './model/repository'
+
+/** 当前实现的唯一选择点;真实契约冻结后只在这里替换或组合 Adapter。 */
+export const workflowRunRepository: WorkflowRunRepository = localWorkflowRunRepository
diff --git a/frontend/src/features/character-setup/action-definition/README.md b/frontend/src/features/character-setup/action-definition/README.md
new file mode 100644
index 0000000..4b501ba
--- /dev/null
+++ b/frontend/src/features/character-setup/action-definition/README.md
@@ -0,0 +1,4 @@
+# Action Definition
+
+Idle、Walk 和扩展动作规格的定义边界。
+
diff --git a/frontend/src/features/character-setup/base-frame/README.md b/frontend/src/features/character-setup/base-frame/README.md
new file mode 100644
index 0000000..ace5a94
--- /dev/null
+++ b/frontend/src/features/character-setup/base-frame/README.md
@@ -0,0 +1,4 @@
+# Base Frame
+
+同一造型多方向基准帧的采集和确认边界。
+
diff --git a/frontend/src/features/character-setup/index.tsx b/frontend/src/features/character-setup/index.tsx
new file mode 100644
index 0000000..136f57c
--- /dev/null
+++ b/frontend/src/features/character-setup/index.tsx
@@ -0,0 +1,23 @@
+import type { Character } from '@/entities'
+
+/**
+ * 创建/确认角色与母版,选择动作模板应用到角色。
+ * 宿主:quick-start、workflow-editor、projects、asset-library 的「继续补充动作」。
+ */
+export interface CharacterSetupProps {
+ /** 要创建或编辑角色的后端 Project ID。 */
+ projectId: string
+ /** 已有角色时传入,用于补充动作;不传表示新建。 */
+ characterId?: string
+ /** 角色建好或母版确认后通知宿主。 */
+ onCharacterReady?: (character: Character) => void
+}
+
+export function CharacterSetup({ projectId, characterId }: CharacterSetupProps) {
+ return (
+
+ 角色与母版设置待实现(项目 {projectId}
+ {characterId ? ` · 角色 ${characterId}` : ' · 新建'})。
+
+ )
+}
diff --git a/frontend/src/features/character-setup/outfit/README.md b/frontend/src/features/character-setup/outfit/README.md
new file mode 100644
index 0000000..7d47a24
--- /dev/null
+++ b/frontend/src/features/character-setup/outfit/README.md
@@ -0,0 +1,4 @@
+# Outfit
+
+单造型及固定外观特征的编辑边界。
+
diff --git a/frontend/src/features/character-setup/profile/README.md b/frontend/src/features/character-setup/profile/README.md
new file mode 100644
index 0000000..7682a1b
--- /dev/null
+++ b/frontend/src/features/character-setup/profile/README.md
@@ -0,0 +1,4 @@
+# Character Profile
+
+角色身份、名称、定位和外观描述的交互边界。
+
diff --git a/frontend/src/features/character-setup/source/README.md b/frontend/src/features/character-setup/source/README.md
new file mode 100644
index 0000000..cf499e2
--- /dev/null
+++ b/frontend/src/features/character-setup/source/README.md
@@ -0,0 +1,4 @@
+# Character Source
+
+从零创建、上传参考或复用当前项目角色的来源选择边界。
+
diff --git a/frontend/src/features/export/configuration/README.md b/frontend/src/features/export/configuration/README.md
new file mode 100644
index 0000000..5780d7c
--- /dev/null
+++ b/frontend/src/features/export/configuration/README.md
@@ -0,0 +1,4 @@
+# Export Configuration
+
+GIF、SpriteSheet PNG 和 JSON 的导出规格配置边界。
+
diff --git a/frontend/src/features/export/downloads/README.md b/frontend/src/features/export/downloads/README.md
new file mode 100644
index 0000000..10da0b6
--- /dev/null
+++ b/frontend/src/features/export/downloads/README.md
@@ -0,0 +1,4 @@
+# Downloads
+
+导出完成文件和下载入口边界。
+
diff --git a/frontend/src/features/export/eligibility/README.md b/frontend/src/features/export/eligibility/README.md
new file mode 100644
index 0000000..771c6c6
--- /dev/null
+++ b/frontend/src/features/export/eligibility/README.md
@@ -0,0 +1,4 @@
+# Export Eligibility
+
+导出资格检查边界。Playtest 未通过只产生建议,不阻断导出。
+
diff --git a/frontend/src/features/export/index.tsx b/frontend/src/features/export/index.tsx
new file mode 100644
index 0000000..eb9fabc
--- /dev/null
+++ b/frontend/src/features/export/index.tsx
@@ -0,0 +1,17 @@
+/**
+ * 选择内容、发起导出和下载。下载属于本 feature,不放 shared/hooks。
+ */
+export interface ExportProps {
+ /** 要导出的后端 Character ID。 */
+ characterId: string
+ /** 后端导出完成后触发;参数是本次导出产物的下载 URL。 */
+ onExported?: (downloadUrl: string) => void
+}
+
+export function Export({ characterId }: ExportProps) {
+ return (
+
+ 导出待实现(角色 {characterId})。
+
+ )
+}
diff --git a/frontend/src/features/export/job/README.md b/frontend/src/features/export/job/README.md
new file mode 100644
index 0000000..48c401d
--- /dev/null
+++ b/frontend/src/features/export/job/README.md
@@ -0,0 +1,4 @@
+# Export Job
+
+导出任务状态、失败和重试边界。
+
diff --git a/frontend/src/features/generation/asset-intake/README.md b/frontend/src/features/generation/asset-intake/README.md
new file mode 100644
index 0000000..3977108
--- /dev/null
+++ b/frontend/src/features/generation/asset-intake/README.md
@@ -0,0 +1,4 @@
+# Asset Intake
+
+候选进入正式资产的唯一入库边界。当前只预留,不伪造入库成功。
+
diff --git a/frontend/src/features/generation/candidate-selection/README.md b/frontend/src/features/generation/candidate-selection/README.md
new file mode 100644
index 0000000..6062db5
--- /dev/null
+++ b/frontend/src/features/generation/candidate-selection/README.md
@@ -0,0 +1,4 @@
+# Candidate Selection
+
+候选展示和人工选择边界;候选通过质量门禁后才可交付。
+
diff --git a/frontend/src/features/generation/index.tsx b/frontend/src/features/generation/index.tsx
new file mode 100644
index 0000000..c304717
--- /dev/null
+++ b/frontend/src/features/generation/index.tsx
@@ -0,0 +1,43 @@
+import type { ProviderSessionStatus } from './provider-session'
+
+export type {
+ ProviderCredentialMode,
+ ProviderDescriptor,
+ ProviderSession,
+ ProviderSessionStatus,
+} from './provider-session'
+
+/** Generation 只展示任务和 Provider 状态;真实连接由后端契约接入。 */
+export interface GenerationProps {
+ /** 当前前端 WorkflowRun ID,仅用于页面编排和任务关联。 */
+ runId: string
+ /** 要生成的 Action ID;生成母版等非动作任务可以省略。 */
+ actionId?: string
+ /** 当前 Provider 会话状态;省略时按尚未配置展示。 */
+ providerStatus?: ProviderSessionStatus
+ /** 后端确认动作生成完成后触发,并返回对应 Action ID。 */
+ onGenerated?: (actionId: string) => void
+}
+
+export function Generation({ runId, actionId, providerStatus = 'unconfigured' }: GenerationProps) {
+ const message = {
+ unconfigured: 'Provider Session 尚未连接。',
+ connecting: '正在验证 Provider Session…',
+ ready: 'Provider Session 已连接,等待生成任务。',
+ failed: 'Provider Session 连接失败,请检查后端错误。',
+ }[providerStatus]
+
+ return (
+
+ AI 生成
+
+ 工作流 {runId}
+ {actionId ? ` · 动作 ${actionId}` : ''}
+
+ {message}
+
+ 真实 Provider、Job 和增量产物由后端接口接入;当前不会模拟生成成功。
+
+
+ )
+}
diff --git a/frontend/src/features/generation/job-runtime/README.md b/frontend/src/features/generation/job-runtime/README.md
new file mode 100644
index 0000000..f92f244
--- /dev/null
+++ b/frontend/src/features/generation/job-runtime/README.md
@@ -0,0 +1,4 @@
+# Job Runtime
+
+生成 Job 状态、重试、增量产物和连续两次质检失败门禁的交互边界。
+
diff --git a/frontend/src/features/generation/provider-session/README.md b/frontend/src/features/generation/provider-session/README.md
new file mode 100644
index 0000000..dffae21
--- /dev/null
+++ b/frontend/src/features/generation/provider-session/README.md
@@ -0,0 +1,4 @@
+# Provider Session
+
+两个 Provider 的连接验证、模型选择和 client/server credential mode 边界。API Key 不进入 WorkflowRun 或 localStorage。
+
diff --git a/frontend/src/features/generation/provider-session/index.ts b/frontend/src/features/generation/provider-session/index.ts
new file mode 100644
index 0000000..7cd6c5c
--- /dev/null
+++ b/frontend/src/features/generation/provider-session/index.ts
@@ -0,0 +1,39 @@
+/**
+ * Provider Session 的凭据来源。
+ * client 表示浏览器临时提交,server 表示服务端预先配置;两者都不在前端持久化明文 API Key。
+ */
+export type ProviderCredentialMode = 'client' | 'server'
+
+/**
+ * 前端展示的 Provider Session 连接状态。
+ * unconfigured 尚未配置,connecting 正在连接,ready 可用,failed 连接失败;最终值以后端契约为准。
+ */
+export type ProviderSessionStatus = 'unconfigured' | 'connecting' | 'ready' | 'failed'
+
+/** 后端声明的一个可用 AI Provider 及其能力。 */
+export interface ProviderDescriptor {
+ /** 后端稳定的 Provider 标识,用于创建会话。 */
+ id: string
+ /** 面向用户展示的 Provider 名称。 */
+ name: string
+ /** 该 Provider 允许使用的凭据提供方式。 */
+ credentialModes: ProviderCredentialMode[]
+ /** 后端允许选择的模型标识列表。 */
+ models: string[]
+}
+
+/** 后端创建的临时 Provider 连接会话;不包含 API Key。 */
+export interface ProviderSession {
+ /** 后端生成的会话 ID,供 Generation 请求引用。 */
+ id: string
+ /** 会话连接的 ProviderDescriptor.id。 */
+ providerId: string
+ /** 本次会话选定的模型标识。 */
+ model: string
+ /** 本次会话采用的凭据提供方式。 */
+ credentialMode: ProviderCredentialMode
+ /** 会话当前连接状态。 */
+ status: ProviderSessionStatus
+ /** 会话过期时间,预期为 ISO 8601;不会过期或后端未提供时为 null。 */
+ expiresAt: string | null
+}
diff --git a/frontend/src/features/generation/request-confirmation/README.md b/frontend/src/features/generation/request-confirmation/README.md
new file mode 100644
index 0000000..87b27b0
--- /dev/null
+++ b/frontend/src/features/generation/request-confirmation/README.md
@@ -0,0 +1,4 @@
+# Request Confirmation
+
+汇总生成输入并由用户确认后提交生成命令。
+
diff --git a/frontend/src/features/review/decision/README.md b/frontend/src/features/review/decision/README.md
new file mode 100644
index 0000000..da3b0a5
--- /dev/null
+++ b/frontend/src/features/review/decision/README.md
@@ -0,0 +1,4 @@
+# Review Decision
+
+人工审核问题记录和回退命令边界;不要求逐帧全部通过才能导出。
+
diff --git a/frontend/src/features/review/index.tsx b/frontend/src/features/review/index.tsx
new file mode 100644
index 0000000..76675e5
--- /dev/null
+++ b/frontend/src/features/review/index.tsx
@@ -0,0 +1,27 @@
+/**
+ * 人工审核、查看自动质检结果与退回修复。
+ * 逐帧审核的 Canvas 与 Worker 归本 feature。
+ * 不提供「通过此帧」:用户只在有问题时点退回。
+ */
+export interface ReviewProps {
+ /** 要审核的后端 Character ID。 */
+ characterId: string
+ /** 受控选择中的 Action ID;省略表示尚未选中动作。 */
+ actionId?: string
+ /** 受控选择中的零基帧索引;省略表示尚未选中具体帧。 */
+ frameIndex?: number
+ /** 用户切换帧时通知宿主;Review 不保存第二份选择状态。 */
+ onSelectFrame?: (actionId: string, frameIndex: number) => void
+ /** 退回某帧。只报告是哪一帧,跳去哪由宿主决定。 */
+ onRejectFrame?: (actionId: string, frameIndex: number) => void
+}
+
+export function Review({ characterId, actionId, frameIndex }: ReviewProps) {
+ return (
+
+ 逐帧审核待实现(角色 {characterId}
+ {actionId ? ` · 动作 ${actionId}` : ''}
+ {frameIndex !== undefined ? ` · 第 ${frameIndex + 1} 帧` : ''})。
+
+ )
+}
diff --git a/frontend/src/features/review/player/README.md b/frontend/src/features/review/player/README.md
new file mode 100644
index 0000000..477c527
--- /dev/null
+++ b/frontend/src/features/review/player/README.md
@@ -0,0 +1,4 @@
+# Review Player
+
+审核阶段的播放、暂停和逐帧查看边界。
+
diff --git a/frontend/src/features/review/quality/checks/README.md b/frontend/src/features/review/quality/checks/README.md
new file mode 100644
index 0000000..bd61175
--- /dev/null
+++ b/frontend/src/features/review/quality/checks/README.md
@@ -0,0 +1,4 @@
+# Quality Checks
+
+系统自动质检项的展示边界。连续两次失败后阻断生成并请求重新生成。
+
diff --git a/frontend/src/features/review/quality/report/README.md b/frontend/src/features/review/quality/report/README.md
new file mode 100644
index 0000000..fe359c3
--- /dev/null
+++ b/frontend/src/features/review/quality/report/README.md
@@ -0,0 +1,4 @@
+# Quality Report
+
+系统质量门禁总体结果、阻断原因和建议展示边界。
+
diff --git a/frontend/src/features/review/repair/README.md b/frontend/src/features/review/repair/README.md
new file mode 100644
index 0000000..b673be3
--- /dev/null
+++ b/frontend/src/features/review/repair/README.md
@@ -0,0 +1,4 @@
+# Review Repair
+
+修复要求和新 Revision 创建边界。
+
diff --git a/frontend/src/features/review/session/README.md b/frontend/src/features/review/session/README.md
new file mode 100644
index 0000000..acb24a0
--- /dev/null
+++ b/frontend/src/features/review/session/README.md
@@ -0,0 +1,4 @@
+# Review Session
+
+审核位置和临时标注恢复边界。
+
diff --git a/frontend/src/features/review/timeline/README.md b/frontend/src/features/review/timeline/README.md
new file mode 100644
index 0000000..2e9c59a
--- /dev/null
+++ b/frontend/src/features/review/timeline/README.md
@@ -0,0 +1,4 @@
+# Review Timeline
+
+帧序列和审核位置展示边界。
+
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..78374a3
--- /dev/null
+++ b/frontend/src/pages/asset-library/index.tsx
@@ -0,0 +1,22 @@
+import { useParams } from 'react-router'
+
+import { CharacterSetup } from '@/features/character-setup'
+import { PageHeader } from '@/shared/ui'
+
+/**
+ * 资产库以项目为上下文;系统内置动作模板作为当前项目可用资源一并展示。
+ * 按角色/视角/动作浏览,「继续补充动作」复用 CharacterSetup。
+ */
+export function AssetLibraryPage() {
+ const { projectId = '' } = useParams()
+
+ return (
+ <>
+
+
+ 待实现:读取当前项目的 Character、ActionTemplate、Wearable,以及系统内置 ActionTemplate。
+
+
+ >
+ )
+}
diff --git a/frontend/src/pages/home/README.md b/frontend/src/pages/home/README.md
new file mode 100644
index 0000000..77eb34d
--- /dev/null
+++ b/frontend/src/pages/home/README.md
@@ -0,0 +1,4 @@
+# Home 页面预留
+
+根路由目标入口,提供 Quick Start 与从项目开始两个选择。当前仅保留架构位置,不放置伪业务实现。
+
diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx
new file mode 100644
index 0000000..bdde83a
--- /dev/null
+++ b/frontend/src/pages/home/index.tsx
@@ -0,0 +1,28 @@
+import { Link } from 'react-router'
+
+import { PageHeader } from '@/shared/ui'
+
+/** 根入口只负责提供两种制作入口,不持有工作流业务状态。 */
+export function HomePage() {
+ return (
+ <>
+
+
+
+ 快速开始
+ 用一句话描述你想制作的角色。
+
+
+ 从项目开始
+ 从已有项目进入工作流。
+
+
+ >
+ )
+}
diff --git a/frontend/src/pages/not-found/index.tsx b/frontend/src/pages/not-found/index.tsx
new file mode 100644
index 0000000..0ea96a2
--- /dev/null
+++ b/frontend/src/pages/not-found/index.tsx
@@ -0,0 +1,21 @@
+import { useNavigate } from 'react-router'
+
+import { PageHeader } from '@/shared/ui'
+
+/** 路由兜底:地址不存在时不留白屏,给一条回去的路。 */
+export function NotFoundPage() {
+ const navigate = useNavigate()
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/frontend/src/pages/playtest/acceptance/README.md b/frontend/src/pages/playtest/acceptance/README.md
new file mode 100644
index 0000000..c676997
--- /dev/null
+++ b/frontend/src/pages/playtest/acceptance/README.md
@@ -0,0 +1,4 @@
+# Playtest Acceptance
+
+保存核验结论并回流对应 Revision 的 Review。核验结果不改变生成完成状态。
+
diff --git a/frontend/src/pages/playtest/action-selector/README.md b/frontend/src/pages/playtest/action-selector/README.md
new file mode 100644
index 0000000..7fa4a0b
--- /dev/null
+++ b/frontend/src/pages/playtest/action-selector/README.md
@@ -0,0 +1,4 @@
+# Playtest Action Selector
+
+动作切换和可用动作选择边界。
+
diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx
new file mode 100644
index 0000000..3df50fe
--- /dev/null
+++ b/frontend/src/pages/playtest/index.tsx
@@ -0,0 +1,78 @@
+import { useNavigate, useParams, useSearchParams } from 'react-router'
+
+import { canImportToPlaytest, getRevision, submitWorkflowCommand, useWorkflowRun } from '@/entities'
+import { PageHeader } from '@/shared/ui'
+import { InspectionPreview } from './inspection-preview'
+
+export function PlaytestPage() {
+ const navigate = useNavigate()
+ const { characterId = '' } = useParams()
+ const [search] = useSearchParams()
+ const runId = search.get('runId')
+ const revisionId = search.get('revision')
+
+ return (
+ <>
+ navigate(-1)}
+ />
+ {!characterId || !runId || !revisionId ? (
+ 缺少角色、工作流或版本参数,无法确定核验来源。
+ ) : (
+
+ navigate(
+ `/workflow-editor/${encodeURIComponent(runId)}/review?revision=${encodeURIComponent(revisionId)}`,
+ )
+ }
+ />
+ )}
+ >
+ )
+}
+
+function PlaytestSession({
+ characterId,
+ runId,
+ revisionId,
+ onOpenReview,
+}: {
+ characterId: string
+ runId: string
+ revisionId: string
+ onOpenReview: () => void
+}) {
+ const query = useWorkflowRun(runId)
+ const revision = query.data ? getRevision(query.data, revisionId) : null
+
+ if (query.loading) return 加载核验版本…
+ if (query.error) return 加载失败:{query.error.message}
+ if (!query.data || !revision)
+ return 指定的历史版本不存在。
+ if (!canImportToPlaytest(query.data, revision.id)) {
+ return 该版本尚未通过系统质检,不能导入核验台。
+ }
+
+ return (
+ {
+ await submitWorkflowCommand(runId, {
+ kind: 'record-playtest',
+ revisionId,
+ status,
+ })
+ query.refresh()
+ }}
+ onOpenReview={onOpenReview}
+ />
+ )
+}
diff --git a/frontend/src/pages/playtest/input/README.md b/frontend/src/pages/playtest/input/README.md
new file mode 100644
index 0000000..9373799
--- /dev/null
+++ b/frontend/src/pages/playtest/input/README.md
@@ -0,0 +1,4 @@
+# Playtest Input
+
+独立核验台的按键输入和绑定边界。
+
diff --git a/frontend/src/pages/playtest/inspection-preview/index.test.tsx b/frontend/src/pages/playtest/inspection-preview/index.test.tsx
new file mode 100644
index 0000000..3138dfc
--- /dev/null
+++ b/frontend/src/pages/playtest/inspection-preview/index.test.tsx
@@ -0,0 +1,27 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { InspectionPreview } from './index'
+
+describe('InspectionPreview 模块契约', () => {
+ afterEach(cleanup)
+
+ it('不依赖 Router 即可记录非阻断核验结论', () => {
+ const onRecordStatus = vi.fn(async () => undefined)
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/来源 run-1 \/ revision-1/)).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: '记录发现问题' }))
+ expect(onRecordStatus).toHaveBeenCalledWith('issues_found')
+ })
+})
diff --git a/frontend/src/pages/playtest/inspection-preview/index.tsx b/frontend/src/pages/playtest/inspection-preview/index.tsx
new file mode 100644
index 0000000..33aa360
--- /dev/null
+++ b/frontend/src/pages/playtest/inspection-preview/index.tsx
@@ -0,0 +1,72 @@
+import type { PlaytestStatus } from '@/entities'
+
+export interface InspectionPreviewProps {
+ /** 要在核验台加载的后端 Character ID。 */
+ characterId: string
+ /** 该核验记录所属的前端 WorkflowRun ID。 */
+ runId: string
+ /** 被核验的前端版本 ID;核验结果写回这个版本。 */
+ revisionId: string
+ /** 当前已保存的核验结论。 */
+ status: PlaytestStatus
+ /** 保存明确的通过或发现问题结论;未核验状态不能由用户提交。 */
+ onRecordStatus: (status: Exclude) => Promise
+ /** 返回审核页面;具体路由和定位由外层 Page 决定。 */
+ onOpenReview: () => void
+}
+
+/** 独立核验台外壳;真实播放器未接入时不伪造播放或通过结果。 */
+export function InspectionPreview({
+ characterId,
+ runId,
+ revisionId,
+ status,
+ onRecordStatus,
+ onOpenReview,
+}: InspectionPreviewProps) {
+ return (
+
+
+ 角色 {characterId}
+
+ 来源 {runId} / {revisionId}
+
+
+ 播放器、按键绑定和动作状态机等待真实角色资产与渲染实现接入。
+
+
+
+
+ 当前核验结论:{status}
+
+
+
+
+
+ {status === 'issues_found' ? (
+
+ 建议从对应节点重新生成;该结论不会阻断当前版本导出。
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/src/pages/playtest/player/README.md b/frontend/src/pages/playtest/player/README.md
new file mode 100644
index 0000000..b2a2f8e
--- /dev/null
+++ b/frontend/src/pages/playtest/player/README.md
@@ -0,0 +1,4 @@
+# Playtest Player
+
+独立核验台的播放渲染边界。
+
diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx
new file mode 100644
index 0000000..f57fe19
--- /dev/null
+++ b/frontend/src/pages/project-detail/index.tsx
@@ -0,0 +1,73 @@
+import { useNavigate, useParams } from 'react-router'
+
+import {
+ CHARACTER_PERSPECTIVE,
+ DIRECTIONAL_MOVEMENT,
+ createWorkflowRun,
+ useProject,
+} from '@/entities'
+import { PageHeader } from '@/shared/ui'
+
+/**
+ * 单个项目的内容浏览:项目约束 + 项目下的全部内容(一期只角色,后续加动作模板、穿戴)。
+ * 与项目列表是两页,07-22 会议要求两页都要有。
+ */
+export function ProjectDetailPage() {
+ const navigate = useNavigate()
+ const { projectId = '' } = useParams()
+ const { data: project, loading, error } = useProject(projectId)
+
+ async function start() {
+ const run = await createWorkflowRun({ projectId, driver: 'manual' })
+ navigate(`/workflow-editor/${run.id}/asset`)
+ }
+
+ return (
+ <>
+ navigate('/projects')}
+ actions={
+ project ? (
+
+ ) : null
+ }
+ />
+ {loading ? 加载中…
: null}
+ {error ? 加载失败:{error.message}
: null}
+ {project ? (
+ <>
+
+ - 精灵尺寸
+ -
+ {project.spriteSize.width}×{project.spriteSize.height}
+
+ - 视角
+ - {CHARACTER_PERSPECTIVE[project.perspective]}
+ - 移动方向
+ - {DIRECTIONAL_MOVEMENT[project.directionalMovement]}
+ - 画风
+ - {project.gameStyle ?? '未设置'}
+
+
+ 待实现:本项目下的角色列表,以及跳转到项目资产库。
+
+
+ >
+ ) : null}
+ >
+ )
+}
diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx
new file mode 100644
index 0000000..def9504
--- /dev/null
+++ b/frontend/src/pages/projects/index.tsx
@@ -0,0 +1,36 @@
+import { useNavigate } from 'react-router'
+
+import { CHARACTER_PERSPECTIVE, useProjects } from '@/entities'
+import { PageHeader } from '@/shared/ui'
+
+/** 项目列表。点进去是项目内容页,「开始工作流」在那一页上。 */
+export function ProjectsPage() {
+ const navigate = useNavigate()
+ const { data, loading, error } = useProjects({ page: 1, pageSize: 20 })
+
+ return (
+ <>
+
+ {loading ? 加载中…
: null}
+ {error ? 加载失败:{error.message}
: null}
+
+ {data?.items.map((project) => (
+ -
+
+
+ ))}
+
+ {data ? 共 {data.total} 个项目
: null}
+ >
+ )
+}
diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx
new file mode 100644
index 0000000..2c13b49
--- /dev/null
+++ b/frontend/src/pages/quick-start/index.tsx
@@ -0,0 +1,185 @@
+import { useState } from 'react'
+import { useNavigate, useParams } from 'react-router'
+
+import {
+ canImportToPlaytest,
+ createWorkflowRun,
+ getCurrentRevision,
+ useWorkflowRun,
+} from '@/entities'
+import { PageHeader } from '@/shared/ui'
+
+const CREATION_COPY = {
+ asset: ['正在理解你的设定', '已准备好角色创作所需的基础信息。'],
+ generation: ['正在生成角色', '正在连接生成服务并准备创作素材。'],
+ candidate: ['正在检查结果', '系统正在检查生成内容是否符合交付要求。'],
+ review: ['正在整理交付版本', '生成结果已准备好,正在完成最终整理。'],
+ export: ['正在准备文件', '正在整理可导出和可核验的角色文件。'],
+} as const
+
+/**
+ * Quick Start 是面向创作的独立体验。它复用 WorkflowRun 的领域状态,
+ * 但不向用户暴露节点、版本或编辑器术语。
+ */
+export function QuickStartPage() {
+ const navigate = useNavigate()
+ const { runId } = useParams()
+ const [description, setDescription] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ if (runId)
+ return navigate('/quick-start')} />
+
+ async function start() {
+ const prompt = description.trim()
+ if (!prompt) {
+ setError('请先描述要制作的角色。')
+ return
+ }
+
+ setSubmitting(true)
+ setError(null)
+ try {
+ const run = await createWorkflowRun({
+ projectId: 'quick-start',
+ driver: 'ai',
+ prompt,
+ })
+ navigate(`/quick-start/${run.id}`)
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : String(cause))
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+function QuickStartCreation({
+ runId,
+ onCreateAnother,
+}: {
+ runId: string
+ onCreateAnother: () => void
+}) {
+ const navigate = useNavigate()
+ const query = useWorkflowRun(runId)
+
+ if (query.loading) return 正在准备创作…
+ if (query.error)
+ return 无法继续这次创作:{query.error.message}
+ if (!query.data) return 未找到这次创作记录。
+
+ const run = query.data
+ const revision = getCurrentRevision(run)
+ const activeNode = revision.nodes.find((node) => node.status === 'active')
+ const [title, detail] = CREATION_COPY[activeNode?.type ?? 'export']
+ const completed = revision.generationStatus === 'completed'
+ const canOpenPlaytest = Boolean(run.characterId && canImportToPlaytest(run, revision.id))
+
+ return (
+
+
+ 新建创作
+
+ }
+ />
+
+
+
+
{completed ? '已经为你准备好结果。' : title}
+
+ {completed ? '你可以继续导出或导入核验台。' : detail}
+
+
+
+ {['理解设定', '生成角色', '检查交付'].map((label, index) => {
+ const currentIndex = activeNode ? Math.min(activeNode.order, 2) : 2
+ const state =
+ completed || index < currentIndex
+ ? '已完成'
+ : index === currentIndex
+ ? '进行中'
+ : '等待中'
+ return (
+ -
+
{label}
+ {state}
+
+ )
+ })}
+
+
+
+ {completed ? (
+
+ 下一步
+
+ 导出服务会在后端任务接入后显示下载入口。核验台可独立记录试用结果,不会影响当前完成状态。
+
+ {canOpenPlaytest ? (
+
+ ) : null}
+
+ ) : (
+
+ 生成服务暂未连接
+
+ 服务接入后,这里会持续更新创作进度和结果;当前不会显示虚假的完成结果。
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/workflow-editor/canvas/README.md b/frontend/src/pages/workflow-editor/canvas/README.md
new file mode 100644
index 0000000..c8ec053
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/canvas/README.md
@@ -0,0 +1,4 @@
+# Workflow Canvas 预留
+
+负责节点、连线、缩放、拖拽和节点选中。业务门禁由 entities/workflow-run 提供,本目录不复制状态机。
+
diff --git a/frontend/src/pages/workflow-editor/editor/index.test.tsx b/frontend/src/pages/workflow-editor/editor/index.test.tsx
new file mode 100644
index 0000000..9aeeda3
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/editor/index.test.tsx
@@ -0,0 +1,62 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { createWorkflowRun, getCurrentRevision } from '@/entities'
+import { WorkflowEditor } from './index'
+
+afterEach(cleanup)
+
+describe('WorkflowEditor', () => {
+ it('稳定渲染五个节点,未进入执行线的节点保持锁定', async () => {
+ const run = await createWorkflowRun({ projectId: 'project-1', driver: 'manual' })
+ const revision = getCurrentRevision(run)
+
+ render(
+ ,
+ )
+
+ expect((screen.getByRole('button', { name: /资产设置/ }) as HTMLButtonElement).disabled).toBe(
+ false,
+ )
+ expect((screen.getByRole('button', { name: /AI 生成/ }) as HTMLButtonElement).disabled).toBe(
+ true,
+ )
+ expect((screen.getByRole('button', { name: /导出/ }) as HTMLButtonElement).disabled).toBe(true)
+ expect(screen.getByText(/当前版本/)).toBeTruthy()
+ })
+
+ it('历史版本显示只读状态并允许从已有节点重新开始', async () => {
+ const run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt: '骑士' })
+ const revision = getCurrentRevision(run)
+ const onRestartNode = vi.fn(async () => undefined)
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/历史只读/)).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: '从此节点重新开始' }))
+ expect(onRestartNode).toHaveBeenCalledWith(
+ revision.nodes.find((node) => node.type === 'generation')!.id,
+ )
+ })
+})
diff --git a/frontend/src/pages/workflow-editor/editor/index.tsx b/frontend/src/pages/workflow-editor/editor/index.tsx
new file mode 100644
index 0000000..d1f0d23
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/editor/index.tsx
@@ -0,0 +1,212 @@
+import { useState } from 'react'
+
+import {
+ WORKFLOW_NODE_ORDER,
+ canImportToPlaytest,
+ canRestartFromNode,
+ getNodeByType,
+} from '@/entities'
+import type { WorkflowNodeType, WorkflowRevision, WorkflowRun } from '@/entities'
+import { CharacterSetup } from '@/features/character-setup'
+import { Export } from '@/features/export'
+import { Generation } from '@/features/generation'
+import { Review } from '@/features/review'
+
+const NODE_LABELS: Record = {
+ asset: '资产设置',
+ generation: 'AI 生成',
+ candidate: '系统质检',
+ review: '人工审核',
+ export: '导出',
+}
+
+export interface WorkflowEditorProps {
+ /** 当前完整的前端 WorkflowRun,用于版本列表和跨阶段门禁判断。 */
+ run: WorkflowRun
+ /** 当前打开的版本;可以是当前可编辑版本,也可以是历史只读版本。 */
+ revision: WorkflowRevision
+ /** 当前显示的页面节点类型,由外层路由状态控制。 */
+ activeType: WorkflowNodeType
+ /** 是否禁止修改;打开非 currentRevisionId 的历史版本时应为 true。 */
+ readOnly: boolean
+ /** 用户切换页面节点时通知外层更新路由。 */
+ onSelectNode: (type: WorkflowNodeType) => void
+ /** 用户选择历史版本时通知外层打开对应版本。 */
+ onOpenRevision: (revisionId: string) => void
+ /** 从指定节点创建新版本;Promise 完成前编辑器保持重启中状态。 */
+ onRestartNode: (nodeId: string) => Promise
+ /** 打开指定 Character 的独立核验台。 */
+ onOpenPreview: (characterId: string) => void
+}
+
+/** 不读取 Router 的页面内编辑器;URL 和跨页导航全部由外层 Page 适配。 */
+export function WorkflowEditor({
+ run,
+ revision,
+ activeType,
+ readOnly,
+ onSelectNode,
+ onOpenRevision,
+ onRestartNode,
+ onOpenPreview,
+}: WorkflowEditorProps) {
+ const [restarting, setRestarting] = useState(false)
+ const activeNode = getNodeByType(revision, activeType)
+ const canRestart = activeNode ? canRestartFromNode(run, revision.id, activeNode.id) : false
+
+ async function restart() {
+ if (!activeNode || restarting) return
+ setRestarting(true)
+ try {
+ await onRestartNode(activeNode.id)
+ } finally {
+ setRestarting(false)
+ }
+ }
+
+ return (
+
+
+
+ {WORKFLOW_NODE_ORDER.map((type, index) => {
+ const node = getNodeByType(revision, type)
+ const selected = type === activeType
+ return (
+ -
+
+
+ )
+ })}
+
+
+
+
+
+ {readOnly ? '历史只读' : '当前版本'} · {revision.id} · 生成 {revision.generationStatus}
+
+ {canRestart ? (
+
+ ) : null}
+
+
+ {activeNode ? (
+
+ ) : (
+
该节点不在当前执行线上。
+ )}
+
+
+ 版本历史
+
+ {[...run.revisions].reverse().map((item) => (
+ -
+
+
+ ))}
+
+
+
+ )
+}
+
+function StageContent({
+ run,
+ revision,
+ type,
+ readOnly,
+ onOpenPreview,
+}: {
+ run: WorkflowRun
+ revision: WorkflowRevision
+ type: WorkflowNodeType
+ readOnly: boolean
+ onOpenPreview: (characterId: string) => void
+}) {
+ if (type === 'asset') {
+ return readOnly ? (
+
+ ) : (
+
+ )
+ }
+ if (type === 'generation') return
+ if (type === 'candidate') {
+ return (
+
+ )
+ }
+ if (type === 'review') {
+ return run.characterId ? (
+
+ ) : (
+
+ )
+ }
+
+ return (
+
+ {run.characterId ? (
+
+ ) : (
+
+ )}
+ {run.characterId && canImportToPlaytest(run, revision.id) ? (
+
+ ) : null}
+ {revision.playtestStatus === 'issues_found' ? (
+
核验台发现问题,建议重新生成;当前结果仍允许导出。
+ ) : null}
+
+ )
+}
+
+function ReadOnlyNotice({ text }: { text: string }) {
+ return {text}
+}
diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx
new file mode 100644
index 0000000..4bf1eca
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/index.tsx
@@ -0,0 +1,95 @@
+import { useEffect } from 'react'
+import { useNavigate, useParams, useSearchParams } from 'react-router'
+
+import {
+ canEnterNode,
+ getFirstAccessibleNodeType,
+ getNodeByType,
+ getRevision,
+ isWorkflowNodeType,
+ submitWorkflowCommand,
+ useWorkflowRun,
+} from '@/entities'
+import { PageHeader } from '@/shared/ui'
+import { WorkflowEditor } from './editor'
+
+/** Workflow Editor 的 Router 适配器;编辑器本体不读取 URL。 */
+export function WorkflowEditorPage() {
+ const navigate = useNavigate()
+ const { runId = '', stage } = useParams()
+ const [search] = useSearchParams()
+ const query = useWorkflowRun(runId)
+ const requestedRevisionId = search.get('revision')
+ const run = query.data
+ const revision = run ? getRevision(run, requestedRevisionId ?? run.currentRevisionId) : null
+ const requestedType = isWorkflowNodeType(stage) ? stage : null
+ const fallbackType = revision ? getFirstAccessibleNodeType(revision) : null
+ const activeType =
+ run && revision && requestedType && canEnterNode(run, revision.id, requestedType)
+ ? requestedType
+ : fallbackType
+
+ useEffect(() => {
+ if (!run || !revision || !activeType) return
+ if (stage === activeType && requestedType) return
+ const suffix = requestedRevisionId ? `?revision=${encodeURIComponent(requestedRevisionId)}` : ''
+ navigate(`/workflow-editor/${run.id}/${activeType}${suffix}`, { replace: true })
+ }, [activeType, navigate, requestedRevisionId, requestedType, revision, run, stage])
+
+ if (query.loading) return 加载工作流…
+ if (query.error) return 加载失败:{query.error.message}
+ if (!run) return null
+ if (!revision) return 指定的历史版本不存在。
+ if (!activeType) return 工作流没有可访问节点。
+
+ const readOnly = revision.id !== run.currentRevisionId
+
+ return (
+ <>
+ navigate(-1)}
+ />
+ {
+ const suffix = readOnly ? `?revision=${encodeURIComponent(revision.id)}` : ''
+ navigate(`/workflow-editor/${run.id}/${type}${suffix}`)
+ }}
+ onOpenRevision={(revisionId) => {
+ const target = getRevision(run, revisionId)
+ if (!target) return
+ const type = getFirstAccessibleNodeType(target)
+ const suffix =
+ revisionId === run.currentRevisionId
+ ? ''
+ : `?revision=${encodeURIComponent(revisionId)}`
+ navigate(`/workflow-editor/${run.id}/${type}${suffix}`)
+ }}
+ onRestartNode={async (nodeId) => {
+ const updated = await submitWorkflowCommand(run.id, {
+ kind: 'restart-from-node',
+ sourceRevisionId: revision.id,
+ nodeId,
+ })
+ const nextRevision = getRevision(updated, updated.currentRevisionId)!
+ const nextNode = getNodeByType(nextRevision, activeType)
+ navigate(
+ `/workflow-editor/${updated.id}/${nextNode?.type ?? getFirstAccessibleNodeType(nextRevision)}`,
+ )
+ query.refresh()
+ }}
+ onOpenPreview={(characterId) =>
+ navigate(
+ `/playtest/${encodeURIComponent(characterId)}?runId=${encodeURIComponent(run.id)}` +
+ `&revision=${encodeURIComponent(revision.id)}`,
+ )
+ }
+ />
+ >
+ )
+}
diff --git a/frontend/src/pages/workflow-editor/steps/asset-step/README.md b/frontend/src/pages/workflow-editor/steps/asset-step/README.md
new file mode 100644
index 0000000..ba7e367
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/asset-step/README.md
@@ -0,0 +1,4 @@
+# Asset Step
+
+资产设置阶段页面:项目、角色、造型、基准帧和动作输入的页面组合入口。
+
diff --git a/frontend/src/pages/workflow-editor/steps/candidate-step/README.md b/frontend/src/pages/workflow-editor/steps/candidate-step/README.md
new file mode 100644
index 0000000..a00ae8a
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/candidate-step/README.md
@@ -0,0 +1,4 @@
+# Candidate Step
+
+候选交付阶段页面:展示通过系统质检的候选并提供人工处理入口。
+
diff --git a/frontend/src/pages/workflow-editor/steps/export-step/README.md b/frontend/src/pages/workflow-editor/steps/export-step/README.md
new file mode 100644
index 0000000..66c7378
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/export-step/README.md
@@ -0,0 +1,4 @@
+# Export Step
+
+导出阶段页面:组合导出配置、任务状态和下载入口。Playtest 未通过只提供建议,不阻断导出。
+
diff --git a/frontend/src/pages/workflow-editor/steps/generation-step/README.md b/frontend/src/pages/workflow-editor/steps/generation-step/README.md
new file mode 100644
index 0000000..f8d3aef
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/generation-step/README.md
@@ -0,0 +1,4 @@
+# Generation Step
+
+生成阶段页面:Provider Session、确认、Job 进度和质量门禁的页面组合入口。
+
diff --git a/frontend/src/pages/workflow-editor/steps/review-step/README.md b/frontend/src/pages/workflow-editor/steps/review-step/README.md
new file mode 100644
index 0000000..02007d3
--- /dev/null
+++ b/frontend/src/pages/workflow-editor/steps/review-step/README.md
@@ -0,0 +1,4 @@
+# Review Step
+
+审核与修复阶段页面:组合 Review Feature,回退时创建新的 WorkflowRun Revision。
+
diff --git a/frontend/src/shared/api/client/mappers/index.ts b/frontend/src/shared/api/client/mappers/index.ts
new file mode 100644
index 0000000..130890d
--- /dev/null
+++ b/frontend/src/shared/api/client/mappers/index.ts
@@ -0,0 +1,80 @@
+/** 传输格式的通用转换与错误映射。不认识任何业务概念。 */
+
+/** 后端统一响应,见 windup_common/result/response.py(PR #57)。成功 code = 200。 */
+export interface ApiResponse {
+ /** 业务状态码;当前约定 200 表示成功,非 200 由 unwrap 转成 ApiError。 */
+ code: number
+ /** 后端返回的可读结果或失败说明。 */
+ message: string
+ /** 成功响应的数据主体;无返回对象的成功操作可以为 null。 */
+ data: T | null
+ /** 后端可选的响应生成时间;格式需以后端 OpenAPI 为准。 */
+ timestamp?: string
+}
+
+/** 列表响应,多三个分页字段,data 恒为数组。 */
+export interface ApiListResponse {
+ /** 业务状态码;当前约定 200 表示成功。 */
+ code: number
+ /** 后端返回的可读结果或失败说明。 */
+ message: string
+ /** 当前页的数据项,空页返回空数组。 */
+ data: T[]
+ /** 满足本次查询条件的数据总条数。 */
+ total: number
+ /** 后端返回的当前页码;当前约定从 1 开始。 */
+ page: number
+ /** 后端实际采用的每页条数。 */
+ page_size: number
+ /** 后端可选的响应生成时间;格式需以后端 OpenAPI 为准。 */
+ timestamp?: string
+}
+
+/** 分页入参,各处列表统一用这份。 */
+export interface PageQuery {
+ /** 请求页码,当前约定从 1 开始;省略时由具体 Entity 提供默认值。 */
+ page?: number
+ /** 每页条数;传输层会映射为后端的 page_size。 */
+ pageSize?: number
+}
+
+/** 去除后端响应壳并转换为前端命名后的分页结果。 */
+export interface Paged {
+ /** 当前页的数据项。 */
+ items: T[]
+ /** 满足查询条件的数据总条数。 */
+ total: number
+ /** 当前页码,当前约定从 1 开始。 */
+ page: number
+ /** 当前页容量。 */
+ pageSize: number
+}
+
+/** code !== 200 时抛出,用于区分业务失败与网络失败。 */
+export class ApiError extends Error {
+ /** 后端业务状态码;用于区分具体业务失败。 */
+ readonly code: number
+
+ constructor(message: string, code: number) {
+ super(message)
+ this.name = 'ApiError'
+ this.code = code
+ }
+}
+
+/** 拆掉响应壳,业务失败翻成 ApiError。 */
+export function unwrap(payload: ApiResponse): T | null {
+ if (payload.code !== 200) throw new ApiError(payload.message, payload.code)
+ return payload.data
+}
+
+/** 拆列表响应,顺带把 page_size 翻成前端命名。 */
+export function unwrapList(payload: ApiListResponse): Paged {
+ if (payload.code !== 200) throw new ApiError(payload.message, payload.code)
+ return {
+ items: payload.data,
+ total: payload.total,
+ page: payload.page,
+ pageSize: payload.page_size,
+ }
+}
diff --git a/frontend/src/shared/api/client/mock/index.ts b/frontend/src/shared/api/client/mock/index.ts
new file mode 100644
index 0000000..6c7cdbe
--- /dev/null
+++ b/frontend/src/shared/api/client/mock/index.ts
@@ -0,0 +1,26 @@
+import type { RequestOptions } from '../types'
+import { projectMockHandlers } from './project-handlers'
+import type { MockRoute } from './types'
+
+export type { MockHandler, MockRoute } from './types'
+
+/**
+ * mock 分发。与真实现形状一致,业务代码换实现不用改。
+ * 后端接口到位后删掉对应 handler 即可,不必整体切换。
+ */
+const routes: MockRoute[] = [...projectMockHandlers]
+
+/** 让加载态在开发时看得见;测试里不必等。 */
+const MOCK_LATENCY_MS = import.meta.env.MODE === 'test' ? 0 : 200
+
+export async function mockRequest(path: string, options: RequestOptions): Promise {
+ const method = options.method ?? 'GET'
+ await new Promise((resolve) => setTimeout(resolve, MOCK_LATENCY_MS))
+
+ for (const route of routes) {
+ if (route.method !== method) continue
+ const matched = route.pattern.exec(path)
+ if (matched) return route.handler(options, matched.slice(1)) as R
+ }
+ throw new Error(`[mock] 尚未实现的接口:${method} ${path}`)
+}
diff --git a/frontend/src/shared/api/client/mock/project-handlers.test.ts b/frontend/src/shared/api/client/mock/project-handlers.test.ts
new file mode 100644
index 0000000..7aa7724
--- /dev/null
+++ b/frontend/src/shared/api/client/mock/project-handlers.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+
+import type { RequestOptions } from '../types'
+import { projectMockHandlers } from './project-handlers'
+
+function dispatch(method: string, path: string, options: RequestOptions = {}): unknown {
+ for (const route of projectMockHandlers) {
+ if (route.method !== method) continue
+ const matched = route.pattern.exec(path)
+ if (matched) return route.handler(options, matched.slice(1))
+ }
+ throw new Error(`未找到 Mock 路由:${method} ${path}`)
+}
+
+describe('project mock contract', () => {
+ it('列表按 user_id 过滤,并返回过滤后的 total', () => {
+ const result = dispatch('GET', '/projects', {
+ query: { page: 1, page_size: 20, user_id: 999_001 },
+ }) as { data: unknown[]; total: number }
+
+ expect(result.data).toEqual([])
+ expect(result.total).toBe(0)
+ })
+
+ it('项目名称只在同一用户下判重', () => {
+ const name = '用户级唯一名称测试'
+ const create = (userId: number) =>
+ dispatch('POST', '/projects', {
+ method: 'POST',
+ body: {
+ user_id: userId,
+ project_name: name,
+ character_perspective: 1,
+ directional_movement: 1,
+ sprite_width: 64,
+ sprite_height: 64,
+ },
+ }) as { code: number }
+
+ expect(create(999_001).code).toBe(200)
+ expect(create(999_002).code).toBe(200)
+ expect(create(999_001).code).toBe(400)
+ })
+})
diff --git a/frontend/src/shared/api/client/mock/project-handlers.ts b/frontend/src/shared/api/client/mock/project-handlers.ts
new file mode 100644
index 0000000..a76c164
--- /dev/null
+++ b/frontend/src/shared/api/client/mock/project-handlers.ts
@@ -0,0 +1,128 @@
+import type { MockRoute } from './types'
+
+/**
+ * mock 扮演后端,字段与 ProjectOut 严格一致(含 snake_case),不写成 camelCase。
+ * 对照 web/api/project.py(PR #57)。
+ */
+interface ProjectDto {
+ id: number
+ user_id: number
+ workflow_id: number | null
+ project_name: string
+ character_perspective: number
+ directional_movement: number
+ sprite_width: number
+ sprite_height: number
+ game_style: string | null
+ sprite_sample_url: string | null
+ create_at: string
+ update_at: string
+}
+
+let nextId = 3
+const store: ProjectDto[] = [
+ {
+ id: 1,
+ user_id: 1,
+ workflow_id: null,
+ project_name: '像素小骑士',
+ character_perspective: 1,
+ directional_movement: 2,
+ sprite_width: 64,
+ sprite_height: 64,
+ game_style: '16-bit 像素、暖色调',
+ sprite_sample_url: null,
+ create_at: '2026-07-25T10:00:00Z',
+ update_at: '2026-07-25T10:00:00Z',
+ },
+ {
+ id: 2,
+ user_id: 1,
+ workflow_id: null,
+ project_name: '森林精灵',
+ character_perspective: 2,
+ directional_movement: 1,
+ sprite_width: 64,
+ sprite_height: 64,
+ game_style: null,
+ sprite_sample_url: null,
+ create_at: '2026-07-26T09:30:00Z',
+ update_at: '2026-07-26T09:30:00Z',
+ },
+]
+
+export const projectMockHandlers: MockRoute[] = [
+ {
+ method: 'GET',
+ pattern: /^\/projects$/,
+ handler: (options) => {
+ const page = Number(options.query?.page ?? 1)
+ const pageSize = Number(options.query?.page_size ?? 20)
+ const rawUserId = options.query?.user_id
+ const userId = rawUserId === undefined ? null : Number(rawUserId)
+ const visible = (userId === null ? store : store.filter((item) => item.user_id === userId))
+ // 接口文档:列表按项目 ID 倒序,最新创建的在前
+ .slice()
+ .sort((a, b) => b.id - a.id)
+ const start = (page - 1) * pageSize
+ return {
+ code: 200,
+ message: 'success',
+ data: visible.slice(start, start + pageSize),
+ total: visible.length,
+ page,
+ page_size: pageSize,
+ }
+ },
+ },
+ {
+ method: 'GET',
+ pattern: /^\/projects\/(\d+)$/,
+ handler: (_options, params) => {
+ const found = store.find((item) => item.id === Number(params[0]))
+ return found
+ ? { code: 200, message: 'success', data: found }
+ : { code: 404, message: '项目不存在', data: null }
+ },
+ },
+ {
+ method: 'POST',
+ pattern: /^\/projects$/,
+ handler: (options) => {
+ const body = options.body as Partial
+ const userId = body.user_id ?? 1
+ if (
+ store.some((item) => item.user_id === userId && item.project_name === body.project_name)
+ ) {
+ return { code: 400, message: '项目名称已存在', data: null }
+ }
+ const now = new Date().toISOString()
+ const created: ProjectDto = {
+ id: nextId++,
+ user_id: userId,
+ workflow_id: body.workflow_id ?? null,
+ project_name: body.project_name ?? '未命名项目',
+ character_perspective: body.character_perspective ?? 1,
+ directional_movement: body.directional_movement ?? 1,
+ sprite_width: body.sprite_width ?? 64,
+ sprite_height: body.sprite_height ?? 64,
+ game_style: body.game_style ?? null,
+ sprite_sample_url: body.sprite_sample_url ?? null,
+ create_at: now,
+ update_at: now,
+ }
+ store.push(created)
+ return { code: 200, message: '创建成功', data: created }
+ },
+ },
+ {
+ method: 'DELETE',
+ pattern: /^\/projects\/(\d+)$/,
+ handler: (_options, params) => {
+ const index = store.findIndex((item) => item.id === Number(params[0]))
+ if (index === -1) return { code: 404, message: '项目不存在', data: null }
+ store.splice(index, 1)
+ return { code: 200, message: '删除成功', data: null }
+ },
+ },
+]
diff --git a/frontend/src/shared/api/client/mock/types.ts b/frontend/src/shared/api/client/mock/types.ts
new file mode 100644
index 0000000..c46ae7b
--- /dev/null
+++ b/frontend/src/shared/api/client/mock/types.ts
@@ -0,0 +1,12 @@
+import type { RequestOptions } from '../types'
+
+export type MockHandler = (options: RequestOptions, params: string[]) => unknown
+
+export interface MockRoute {
+ /** 要匹配的 HTTP 方法,使用大写字符串。 */
+ method: string
+ /** 捕获组作为 params 传给 handler。 */
+ pattern: RegExp
+ /** 匹配成功后生成后端原始响应数据的处理函数。 */
+ handler: MockHandler
+}
diff --git a/frontend/src/shared/api/client/real/index.ts b/frontend/src/shared/api/client/real/index.ts
new file mode 100644
index 0000000..42ae013
--- /dev/null
+++ b/frontend/src/shared/api/client/real/index.ts
@@ -0,0 +1,29 @@
+import { ApiError } from '../mappers'
+import type { RequestOptions } from '../types'
+
+/** 开发时经 vite proxy 转发到 8000。 */
+const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'
+
+function buildUrl(path: string, query?: RequestOptions['query']): string {
+ if (!query) return `${BASE_URL}${path}`
+ const search = new URLSearchParams()
+ for (const [key, value] of Object.entries(query)) {
+ if (value !== undefined) search.set(key, String(value))
+ }
+ const qs = search.toString()
+ return qs ? `${BASE_URL}${path}?${qs}` : `${BASE_URL}${path}`
+}
+
+/** 发一次真实请求,返回后端原始响应体(含 code/message/data 壳)。 */
+export async function realRequest(path: string, options: RequestOptions): Promise {
+ const { method = 'GET', body, query } = options
+ const response = await fetch(buildUrl(path, query), {
+ method,
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ })
+ if (!response.ok) {
+ throw new ApiError(`请求失败:HTTP ${response.status}`, response.status)
+ }
+ return (await response.json()) as R
+}
diff --git a/frontend/src/shared/api/client/types.ts b/frontend/src/shared/api/client/types.ts
new file mode 100644
index 0000000..ada6aec
--- /dev/null
+++ b/frontend/src/shared/api/client/types.ts
@@ -0,0 +1,9 @@
+/** 真实现与 mock 共同遵守的请求形状。 */
+export interface RequestOptions {
+ /** HTTP 方法;省略时 request 使用 GET。 */
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
+ /** 自动序列化并带 Content-Type。 */
+ body?: unknown
+ /** 值为 undefined 的键会被丢掉。 */
+ query?: Record
+}
diff --git a/frontend/src/shared/api/generated/README.md b/frontend/src/shared/api/generated/README.md
new file mode 100644
index 0000000..eff1766
--- /dev/null
+++ b/frontend/src/shared/api/generated/README.md
@@ -0,0 +1,4 @@
+# Generated API 预留
+
+这里预留未来由 OpenAPI/后端契约生成的客户端代码。当前不放置不存在的生成文件,业务代码不得直接依赖本目录。
+
diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts
new file mode 100644
index 0000000..61e941a
--- /dev/null
+++ b/frontend/src/shared/api/index.ts
@@ -0,0 +1,33 @@
+/**
+ * 只管传输:拼 URL、发请求、拆响应壳、切换 mock,不认识业务概念。
+ * 唯一消费者是 entities,pages / features 不应直接用。
+ */
+import { mockRequest } from './client/mock'
+import { unwrap, unwrapList } from './client/mappers'
+import type { ApiListResponse, ApiResponse, Paged } from './client/mappers'
+import { realRequest } from './client/real'
+import type { RequestOptions } from './client/types'
+
+/** 开发默认 Mock;生产构建永远使用真实 transport,不能回退演示数据。 */
+const USE_MOCK = !import.meta.env.PROD && import.meta.env.VITE_USE_MOCK !== 'false'
+
+const send = (path: string, options: RequestOptions): Promise =>
+ USE_MOCK ? mockRequest(path, options) : realRequest(path, options)
+
+/** 取单个对象。code !== 200 时抛 ApiError,调用方不必判断 code。 */
+export async function request(path: string, options: RequestOptions = {}): Promise {
+ return unwrap(await send>(path, options))
+}
+
+/** 取列表。 */
+export async function requestList(
+ path: string,
+ options: RequestOptions = {},
+): Promise> {
+ return unwrapList(await send>(path, options))
+}
+
+export { ApiError } from './client/mappers'
+export type { ApiListResponse, ApiResponse, Paged, PageQuery } from './client/mappers'
+export type { RequestOptions } from './client/types'
+export { uploadFile } from './upload'
diff --git a/frontend/src/shared/api/request.ts b/frontend/src/shared/api/request.ts
new file mode 100644
index 0000000..0fb0f51
--- /dev/null
+++ b/frontend/src/shared/api/request.ts
@@ -0,0 +1,4 @@
+/**
+ * JSON transport 公开边界。
+ * 真实实现待后端契约稳定后补入;业务层不得直接调用 fetch。
+ */
diff --git a/frontend/src/shared/api/stream.ts b/frontend/src/shared/api/stream.ts
new file mode 100644
index 0000000..5a7b724
--- /dev/null
+++ b/frontend/src/shared/api/stream.ts
@@ -0,0 +1,4 @@
+/**
+ * SSE/流式任务 transport 公开边界。
+ * 真实实现待 Provider Job 事件协议稳定后补入。
+ */
diff --git a/frontend/src/shared/api/upload.ts b/frontend/src/shared/api/upload.ts
new file mode 100644
index 0000000..2d57907
--- /dev/null
+++ b/frontend/src/shared/api/upload.ts
@@ -0,0 +1,27 @@
+import { ApiError } from './client/mappers'
+
+/**
+ * 文件上传 transport。业务层只负责校验业务允许的文件类型,
+ * URL、multipart 和响应错误统一由 shared 处理。
+ */
+export async function uploadFile(path: string, file: File): Promise {
+ const form = new FormData()
+ form.append('file', file)
+
+ const response = await fetch(`${import.meta.env.VITE_API_BASE_URL ?? '/api'}${path}`, {
+ method: 'POST',
+ body: form,
+ })
+ const payload = (await response.json()) as {
+ code: number
+ message: string
+ data: T | null
+ }
+ if (!response.ok || payload.code !== 200 || payload.data === null) {
+ throw new ApiError(
+ payload.message || `上传失败:HTTP ${response.status}`,
+ payload.code || response.status,
+ )
+ }
+ return payload.data
+}
diff --git a/frontend/src/shared/hooks/async-state.ts b/frontend/src/shared/hooks/async-state.ts
new file mode 100644
index 0000000..97cd3a6
--- /dev/null
+++ b/frontend/src/shared/hooks/async-state.ts
@@ -0,0 +1,47 @@
+import { useEffect, useState } from 'react'
+
+/**
+ * entities 层所有 useXxx 的返回形状。
+ * 自己定义而不直接暴露某个请求库的类型,是为了以后换实现时页面不用改。
+ */
+export interface AsyncState {
+ /** 最近一次成功取得的数据;首次完成前或尚无数据时为 null。 */
+ data: T | null
+ /** 首次加载或 refresh 触发的请求进行中时为 true。 */
+ loading: boolean
+ /** 最近一次请求失败的 Error;加载开始和成功后为 null。 */
+ error: Error | null
+ /** 失败重试或外部操作后刷新。 */
+ refresh: () => void
+}
+
+/** deps 变化时重拉;旧请求结果会被丢弃,避免慢请求覆盖新数据。 */
+export function useAsync(factory: () => Promise, deps: unknown[]): AsyncState {
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [tick, setTick] = useState(0)
+
+ useEffect(() => {
+ let stale = false
+ setLoading(true)
+ setError(null)
+ factory()
+ .then((result) => {
+ if (!stale) setData(result)
+ })
+ .catch((cause: unknown) => {
+ if (!stale) setError(cause instanceof Error ? cause : new Error(String(cause)))
+ })
+ .finally(() => {
+ if (!stale) setLoading(false)
+ })
+ return () => {
+ stale = true
+ }
+ // factory 每次渲染都是新函数,依赖由调用方经 deps 声明
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [...deps, tick])
+
+ return { data, loading, error, refresh: () => setTick((n) => n + 1) }
+}
diff --git a/frontend/src/shared/hooks/index.ts b/frontend/src/shared/hooks/index.ts
new file mode 100644
index 0000000..3e915c5
--- /dev/null
+++ b/frontend/src/shared/hooks/index.ts
@@ -0,0 +1,2 @@
+export { useAsync } from './async-state'
+export type { AsyncState } from './async-state'
diff --git a/frontend/src/shared/testing/index.ts b/frontend/src/shared/testing/index.ts
new file mode 100644
index 0000000..9163d12
--- /dev/null
+++ b/frontend/src/shared/testing/index.ts
@@ -0,0 +1,6 @@
+/** 通用测试辅助工具。业务无关,各层测试共用。 */
+
+/** 等一个 tick,让 mock 的延迟与 React 状态更新落地。 */
+export function flush(ms = 0): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/frontend/src/shared/ui/README.md b/frontend/src/shared/ui/README.md
new file mode 100644
index 0000000..762041e
--- /dev/null
+++ b/frontend/src/shared/ui/README.md
@@ -0,0 +1,4 @@
+# Shared UI 预留
+
+这里记录业务无关基础组件的约定。当前只维护已经存在的 PageHeader;Button、Modal、Toast、Skeleton 等组件在真实使用场景出现后再实现。
+
diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts
new file mode 100644
index 0000000..1dc5067
--- /dev/null
+++ b/frontend/src/shared/ui/index.ts
@@ -0,0 +1,3 @@
+/** 业务无关的 UI 基础件。 */
+export { PageHeader } from './page-header'
+export type { PageHeaderProps } from './page-header'
diff --git a/frontend/src/shared/ui/page-header.tsx b/frontend/src/shared/ui/page-header.tsx
new file mode 100644
index 0000000..dcc57cf
--- /dev/null
+++ b/frontend/src/shared/ui/page-header.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from 'react'
+
+export interface PageHeaderProps {
+ /** 页面标题。 */
+ title: string
+ /** 补充说明,如「项目 3 · 生成动作」。 */
+ subtitle?: string
+ /** 不传则不显示返回。 */
+ onBack?: () => void
+ /** 本页特有操作。 */
+ actions?: ReactNode
+}
+
+/** 页内头部。 */
+export function PageHeader({ title, subtitle, onBack, actions }: PageHeaderProps) {
+ return (
+
+
+ {onBack ? (
+
+ ) : null}
+
{title}
+ {subtitle ?
{subtitle}
: null}
+
+ {actions ? {actions}
: null}
+
+ )
+}
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..8455dcb
--- /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"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..ce82f7a
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,26 @@
+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)),
+ },
+ },
+ server: {
+ proxy: {
+ // 开发时把 /api 转给本地后端(backend/ 默认跑在 8000)
+ '/api': {
+ target: 'http://127.0.0.1:8000',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, ''),
+ },
+ },
+ },
+})
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',
+ ],
+ },
+})
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
new file mode 100644
index 0000000..e5c3e95
--- /dev/null
+++ b/tests/e2e/README.md
@@ -0,0 +1 @@
+完整用户流程测试。
diff --git a/tests/integration/README.md b/tests/integration/README.md
new file mode 100644
index 0000000..c7c74de
--- /dev/null
+++ b/tests/integration/README.md
@@ -0,0 +1 @@
+多 Slice 组合测试。
diff --git a/tests/integration/architecture.test.ts b/tests/integration/architecture.test.ts
new file mode 100644
index 0000000..19cbc6d
--- /dev/null
+++ b/tests/integration/architecture.test.ts
@@ -0,0 +1,370 @@
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
+import { dirname, join, relative, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import ts from 'typescript'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * 依赖边界自动检查。
+ *
+ * 用 TypeScript AST 而不是正则:同时覆盖单/双引号、import、export-from 和
+ * 字符串形式的 dynamic import;别名与相对路径都按真实目标判断。
+ */
+
+const SRC = join(dirname(fileURLToPath(import.meta.url)), '../../frontend/src')
+
+/** 每层允许 import 的层。 */
+const ALLOWED: Record = {
+ app: ['pages', 'features', 'entities', 'shared'],
+ pages: ['features', 'entities', 'shared'],
+ features: ['entities', 'shared'],
+ entities: ['shared'],
+ shared: [],
+}
+
+/** 这两层的不同 Slice 不得互相引用。entities 已选择作为一个整体模块。 */
+const ISOLATED_SLICE_LAYERS = new Set(['pages', 'features'])
+
+/** 跨层引用时,这些层以第二段目录作为公开入口根。 */
+const PUBLIC_SLICE_LAYERS = new Set(['pages', 'features', 'entities', 'shared'])
+
+const PAGE_MODULE_ROOTS = [
+ 'pages/workflow-editor/editor',
+ 'pages/playtest/inspection-preview',
+]
+
+function relativeSrc(file: string): string {
+ return relative(SRC, file).replaceAll('\\', '/')
+}
+
+function walk(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const full = join(dir, name)
+ if (statSync(full).isDirectory()) return walk(full)
+ return /\.tsx?$/.test(name) ? [full] : []
+ })
+}
+
+interface SourceLocation {
+ layer: string | null
+ slice: string | null
+}
+
+interface TargetLocation {
+ layer: string
+ slice: string
+ rest: string[]
+}
+
+interface Violation {
+ file: string
+ specifier: string
+ reason: string
+}
+
+function sourceLocation(file: string): SourceLocation {
+ const parts = relativeSrc(file).split('/')
+ const layer = ALLOWED[parts[0]] ? parts[0] : null
+ if (!layer) return { layer: null, slice: null }
+ return {
+ layer,
+ slice: ISOLATED_SLICE_LAYERS.has(layer) ? (parts[1] ?? null) : layer,
+ }
+}
+
+function targetLocation(file: string, specifier: string): TargetLocation | null {
+ let parts: string[]
+ if (specifier.startsWith('@/')) {
+ parts = specifier.slice(2).split('/')
+ } else if (specifier.startsWith('.')) {
+ const target = resolve(dirname(file), specifier)
+ const rel = relativeSrc(target)
+ if (rel.startsWith('..')) return null
+ parts = rel.split('/')
+ } else {
+ return null
+ }
+
+ const layer = parts[0]
+ if (!ALLOWED[layer]) return null
+ return {
+ layer,
+ slice: PUBLIC_SLICE_LAYERS.has(layer) ? (parts[1] ?? '') : layer,
+ rest: PUBLIC_SLICE_LAYERS.has(layer) ? parts.slice(2) : parts.slice(1),
+ }
+}
+
+function targetRelativePath(file: string, specifier: string): string | null {
+ if (specifier.startsWith('@/')) return specifier.slice(2)
+ if (!specifier.startsWith('.')) return null
+ const rel = relativeSrc(resolve(dirname(file), specifier))
+ return rel.startsWith('..') ? null : rel
+}
+
+function moduleSpecifiers(file: string, source: string): string[] {
+ const sourceFile = ts.createSourceFile(
+ file,
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
+ )
+ const found: string[] = []
+
+ function visit(node: ts.Node): void {
+ if (
+ (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
+ node.moduleSpecifier &&
+ ts.isStringLiteralLike(node.moduleSpecifier)
+ ) {
+ found.push(node.moduleSpecifier.text)
+ }
+ if (
+ ts.isCallExpression(node) &&
+ node.expression.kind === ts.SyntaxKind.ImportKeyword &&
+ node.arguments.length === 1 &&
+ ts.isStringLiteralLike(node.arguments[0])
+ ) {
+ found.push(node.arguments[0].text)
+ }
+ ts.forEachChild(node, visit)
+ }
+
+ visit(sourceFile)
+ return found
+}
+
+function violationFor(file: string, specifier: string): Violation | null {
+ const rel = relativeSrc(file)
+ const source = sourceLocation(file)
+
+ if (
+ (rel.startsWith('pages/workflow-editor/editor/') ||
+ rel.startsWith('pages/playtest/inspection-preview/')) &&
+ (specifier === 'react-router' || specifier === 'react-router-dom')
+ ) {
+ return { file: rel, specifier, reason: '页面内模块不得读 Router' }
+ }
+
+ const target = targetLocation(file, specifier)
+ if (!target) return null
+
+ const targetRel = targetRelativePath(file, specifier)
+ for (const moduleRoot of PAGE_MODULE_ROOTS) {
+ const sourceInsideModule = rel.startsWith(`${moduleRoot}/`)
+ const targetInsideModule = targetRel?.startsWith(`${moduleRoot}/`) ?? false
+ if (!sourceInsideModule && targetInsideModule) {
+ return { file: rel, specifier, reason: '页面内模块只能通过目录根 index 进入' }
+ }
+ }
+
+ // src 根的 main.tsx 只能经 app 公开入口启动。
+ if (!source.layer) {
+ if (rel === 'main.tsx' && specifier === '@/app') return null
+ return { file: rel, specifier, reason: 'src 根入口只能 import @/app' }
+ }
+
+ // entities 被定义为一个数据模块:上层只走唯一门面。
+ if (target.layer === 'entities' && source.layer !== 'entities' && specifier !== '@/entities') {
+ return { file: rel, specifier, reason: '上层只能通过 @/entities 使用数据模块' }
+ }
+
+ if (source.layer === 'shared' && target.layer !== 'shared') {
+ return { file: rel, specifier, reason: 'shared 不得依赖业务层' }
+ }
+
+ if (target.layer === source.layer) {
+ if (ISOLATED_SLICE_LAYERS.has(source.layer) && target.slice !== source.slice) {
+ return { file: rel, specifier, reason: '同层 Slice 不得互相 import' }
+ }
+ return null
+ }
+
+ if (!ALLOWED[source.layer]?.includes(target.layer)) {
+ return { file: rel, specifier, reason: `${source.layer} 不得依赖 ${target.layer}` }
+ }
+
+ // 跨层只能引到目标 Slice 根,再深就是绕过 index.ts。
+ if (target.rest.length > 0) {
+ return { file: rel, specifier, reason: '跨 Slice 只能走公开接口 index.ts' }
+ }
+
+ return null
+}
+
+function collectViolations(): Violation[] {
+ const found: Violation[] = []
+ for (const file of walk(SRC)) {
+ const source = readFileSync(file, 'utf8')
+ for (const specifier of moduleSpecifiers(file, source)) {
+ const violation = violationFor(file, specifier)
+ if (violation) found.push(violation)
+ }
+ }
+ return found
+}
+
+function resolveInternalFile(file: string, specifier: string): string | null {
+ let base: string
+ if (specifier.startsWith('@/')) base = join(SRC, specifier.slice(2))
+ else if (specifier.startsWith('.')) base = resolve(dirname(file), specifier)
+ else return null
+
+ const candidates = [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts'), join(base, 'index.tsx')]
+ return candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile()) ?? null
+}
+
+function collectCycles(): string[][] {
+ const files = walk(SRC)
+ const graph = new Map()
+ for (const file of files) {
+ const source = readFileSync(file, 'utf8')
+ const targets = moduleSpecifiers(file, source)
+ .map((specifier) => resolveInternalFile(file, specifier))
+ .filter((target): target is string => target !== null)
+ graph.set(file, targets)
+ }
+
+ const visiting = new Set()
+ const visited = new Set()
+ const stack: string[] = []
+ const cycles: string[][] = []
+
+ function visit(file: string): void {
+ if (visiting.has(file)) {
+ const start = stack.indexOf(file)
+ cycles.push([...stack.slice(start), file].map((item) => relativeSrc(item)))
+ return
+ }
+ if (visited.has(file)) return
+
+ visiting.add(file)
+ stack.push(file)
+ for (const target of graph.get(file) ?? []) visit(target)
+ stack.pop()
+ visiting.delete(file)
+ visited.add(file)
+ }
+
+ for (const file of files) visit(file)
+ return cycles
+}
+
+describe('依赖边界', () => {
+ it('检查器能识别双引号、export-from 和动态 import', () => {
+ const source = `
+ import type { Project } from "@/entities"
+ export { Review } from '@/features/review'
+ const load = () => import("@/pages/workflow-editor")
+ `
+ expect(moduleSpecifiers('fixture.ts', source)).toEqual([
+ '@/entities',
+ '@/features/review',
+ '@/pages/workflow-editor',
+ ])
+ })
+
+ it.each([
+ ['pages/workflow-editor/index.tsx', './editor', './editor/internal/canvas'],
+ [
+ 'pages/playtest/index.tsx',
+ './inspection-preview',
+ './inspection-preview/internal/player',
+ ],
+ ])('%s 不能绕过页面内模块的目录根 index', (pagePath, root, deep) => {
+ const page = join(SRC, pagePath)
+ expect(violationFor(page, root)).toBeNull()
+ expect(violationFor(page, deep)?.reason).toBe(
+ '页面内模块只能通过目录根 index 进入',
+ )
+ })
+
+ it('没有任何一条 import 违反分层、Slice 或模块入口规则', () => {
+ const violations = collectViolations()
+ expect(
+ violations.map((violation) =>
+ `${violation.file}: ${violation.specifier} —— ${violation.reason}`,
+ ),
+ ).toEqual([])
+ })
+
+ it('两个页面内模块不依赖 Router,脱离路由也能渲染与测试', () => {
+ const offenders: string[] = []
+ for (const moduleRoot of PAGE_MODULE_ROOTS) {
+ const dir = join(SRC, moduleRoot)
+ if (!existsSync(dir)) continue
+ for (const file of walk(dir)) {
+ if (/\.test\.tsx?$/.test(file)) continue
+ if (/from\s+'react-router'|import\s+'react-router'/.test(readFileSync(file, 'utf8'))) {
+ offenders.push(relative(SRC, file))
+ }
+ }
+ }
+ expect(offenders).toEqual([])
+ })
+
+ it('源码依赖图没有循环', () => {
+ expect(collectCycles()).toEqual([])
+ })
+
+ it('业务层不得直接发网络请求或依赖测试辅助', () => {
+ const offenders: string[] = []
+ for (const file of walk(SRC)) {
+ const rel = relative(SRC, file).replaceAll('\\', '/')
+ const source = readFileSync(file, 'utf8')
+ const isTransport =
+ rel.startsWith('shared/api/client/real/') ||
+ rel === 'shared/api/upload.ts' ||
+ rel === 'shared/api/stream.ts'
+ if (!isTransport && /\bfetch\s*\(/.test(source)) {
+ offenders.push(`${rel}: direct fetch`)
+ }
+ for (const specifier of moduleSpecifiers(file, source)) {
+ if (specifier === '@/shared/testing' || specifier.startsWith('@/shared/testing/')) {
+ offenders.push(`${rel}: ${specifier}`)
+ }
+ if (specifier === '@/tests' || specifier.startsWith('@/tests/')) {
+ offenders.push(`${rel}: ${specifier}`)
+ }
+ }
+ }
+ expect(offenders).toEqual([])
+ })
+
+ it('WorkflowRun 是前端编排模型,不通过 HTTP transport', () => {
+ const workflowRoot = join(SRC, 'entities/workflow-run')
+ const orchestrationRoot = join(workflowRoot, 'orchestration')
+ const offenders: string[] = []
+
+ for (const file of walk(workflowRoot)) {
+ if (/\.test\.tsx?$/.test(file)) continue
+ const source = readFileSync(file, 'utf8')
+ const rel = relativeSrc(file)
+ if (moduleSpecifiers(file, source).includes('@/shared/api')) {
+ offenders.push(`${rel}: imports @/shared/api`)
+ }
+ if (/['"`]\/workflow-runs?(?:\/|['"`])/.test(source)) {
+ offenders.push(`${rel}: runtime workflow HTTP path`)
+ }
+ if (file.startsWith(orchestrationRoot)) {
+ for (const specifier of moduleSpecifiers(file, source)) {
+ const target = targetRelativePath(file, specifier)
+ if (target?.startsWith('entities/workflow-run/local/')) {
+ offenders.push(`${rel}: binds local repository directly`)
+ }
+ }
+ }
+ }
+
+ if (!existsSync(join(workflowRoot, 'repository.ts'))) {
+ offenders.push('entities/workflow-run/repository.ts composition entry is missing')
+ }
+
+ const mockWorkflowDir = join(SRC, 'shared/api/client/mock/workflow-run')
+ if (existsSync(mockWorkflowDir) && walk(mockWorkflowDir).length > 0) {
+ offenders.push('shared/api/client/mock/workflow-run contains runtime code')
+ }
+
+ expect(offenders).toEqual([])
+ })
+})
diff --git a/tests/integration/workflow-run-store.test.ts b/tests/integration/workflow-run-store.test.ts
new file mode 100644
index 0000000..d859598
--- /dev/null
+++ b/tests/integration/workflow-run-store.test.ts
@@ -0,0 +1,386 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ canImportToPlaytest,
+ createWorkflowRun,
+ fetchWorkflowRun,
+ getCurrentNode,
+ getCurrentRevision,
+ getNodeByType,
+ getRevision,
+ submitWorkflowCommand,
+} from '@/entities'
+
+function createMemoryStorage(): Storage {
+ const values = new Map()
+ return {
+ get length() {
+ return values.size
+ },
+ clear: () => values.clear(),
+ getItem: (key) => values.get(key) ?? null,
+ key: (index) => [...values.keys()][index] ?? null,
+ removeItem: (key) => values.delete(key),
+ setItem: (key, value) => values.set(key, value),
+ }
+}
+
+function createWriteRejectingStorage(snapshot: string): Storage {
+ const values = new Map([['windup.workflow-runs.v1', snapshot]])
+ return {
+ get length() {
+ return values.size
+ },
+ clear: () => values.clear(),
+ getItem: (key) => values.get(key) ?? null,
+ key: (index) => [...values.keys()][index] ?? null,
+ removeItem: (key) => values.delete(key),
+ setItem: () => {
+ throw new Error('QuotaExceededError')
+ },
+ }
+}
+
+function createStorageWithRuns(runs: Record): Storage {
+ const storage = createMemoryStorage()
+ storage.setItem('windup.workflow-runs.v1', JSON.stringify(runs))
+ return storage
+}
+
+function persistedRun(id: string) {
+ const revisionId = `revision-${id}`
+ return {
+ id,
+ projectId: 'project-from-storage',
+ characterId: null,
+ driver: 'manual',
+ status: 'active',
+ currentRevisionId: revisionId,
+ revisions: [
+ {
+ id: revisionId,
+ basedOnRevisionId: null,
+ restartNodeId: null,
+ status: 'active',
+ nodes: [
+ {
+ id: `node-${id}`,
+ type: 'asset',
+ order: 0,
+ status: 'active',
+ input: { prompt: null },
+ output: null,
+ referenceNodeIds: [],
+ qualityFailureCount: 0,
+ },
+ ],
+ generationStatus: 'not_started',
+ exportStatus: 'not_exported',
+ playtestStatus: 'not_tested',
+ createdAt: '2026-07-28T00:00:00.000Z',
+ },
+ ],
+ prompt: null,
+ }
+}
+
+function withoutProjectId(id: string): Record {
+ const run: Record = { ...persistedRun(id) }
+ delete run.projectId
+ return run
+}
+
+function withoutRevisionNodes(id: string) {
+ const run = persistedRun(id)
+ const revision: Record = { ...run.revisions[0] }
+ delete revision.nodes
+ return { ...run, revisions: [revision] }
+}
+
+describe('entities/workflow-run Revision 契约', () => {
+ beforeEach(() => {
+ vi.stubGlobal('localStorage', createMemoryStorage())
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('Quick Start 和手动入口创建同一种 WorkflowRun', async () => {
+ const ai = await createWorkflowRun({
+ projectId: 'quick-start',
+ driver: 'ai',
+ prompt: '像素小骑士',
+ })
+ const manual = await createWorkflowRun({ projectId: 'project-1', driver: 'manual' })
+
+ expect(getCurrentNode(ai)?.type).toBe('generation')
+ expect(getNodeByType(getCurrentRevision(ai), 'asset')?.status).toBe('passed')
+ expect(getCurrentNode(manual)?.type).toBe('asset')
+ expect(ai.revisions).toHaveLength(1)
+ expect(manual.revisions).toHaveLength(1)
+ })
+
+ it('手动流程在进入生成节点前明确标记为尚未开始', async () => {
+ let manual = await createWorkflowRun({ projectId: 'manual-not-started', driver: 'manual' })
+
+ expect(getCurrentRevision(manual).generationStatus).toBe('not_started')
+ manual = await submitWorkflowCommand(manual.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(manual)!.id,
+ })
+ expect(getCurrentNode(manual)?.type).toBe('generation')
+ expect(getCurrentRevision(manual).generationStatus).toBe('in_progress')
+
+ const quickStart = await createWorkflowRun({
+ projectId: 'quick-start-in-progress',
+ driver: 'ai',
+ prompt: '骑士',
+ })
+ expect(getCurrentRevision(quickStart).generationStatus).toBe('in_progress')
+ })
+
+ it('从素材节点重启时生成状态回到尚未开始', async () => {
+ let run = await createWorkflowRun({ projectId: 'manual-restart', driver: 'manual' })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(run)!.id,
+ })
+ const source = getCurrentRevision(run)
+ const asset = getNodeByType(source, 'asset')!
+ const generation = getNodeByType(source, 'generation')!
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'restart-from-node',
+ sourceRevisionId: source.id,
+ nodeId: asset.id,
+ })
+ expect(getCurrentNode(run)?.type).toBe('asset')
+ expect(getCurrentRevision(run).generationStatus).toBe('not_started')
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'restart-from-node',
+ sourceRevisionId: source.id,
+ nodeId: generation.id,
+ })
+ expect(getCurrentNode(run)?.type).toBe('generation')
+ expect(getCurrentRevision(run).generationStatus).toBe('in_progress')
+ })
+
+ it('用同一个 runId 持久化并取回同一版本', async () => {
+ const created = await createWorkflowRun({ projectId: 'project-1', driver: 'manual' })
+ const loaded = await fetchWorkflowRun(created.id)
+ expect(loaded.id).toBe(created.id)
+ expect(loaded.currentRevisionId).toBe(created.currentRevisionId)
+
+ const stored = JSON.parse(localStorage.getItem('windup.workflow-runs.v1') ?? '{}')
+ expect(stored[created.id]).toEqual(created)
+ })
+
+ it('localStorage 写入失败后仍读取本次会话的最新工作流', async () => {
+ await createWorkflowRun({ projectId: 'old-project', driver: 'manual' })
+ const oldSnapshot = localStorage.getItem('windup.workflow-runs.v1')!
+ vi.stubGlobal('localStorage', createWriteRejectingStorage(oldSnapshot))
+
+ const created = await createWorkflowRun({ projectId: 'new-project', driver: 'manual' })
+ const loaded = await fetchWorkflowRun(created.id)
+
+ expect(loaded.id).toBe(created.id)
+ expect(loaded.currentRevisionId).toBe(created.currentRevisionId)
+ })
+
+ it('randomUUID 缺失时使用 getRandomValues 创建工作流 ID', async () => {
+ vi.stubGlobal('crypto', {
+ getRandomValues: (values: Uint8Array) => {
+ values.fill(7)
+ return values
+ },
+ })
+
+ const run = await createWorkflowRun({ projectId: 'project-random-values', driver: 'manual' })
+
+ expect(run.id).toMatch(/^run-/)
+ expect(getCurrentRevision(run).id).toMatch(/^revision-/)
+ expect(getCurrentNode(run)?.id).toMatch(/^node-/)
+ })
+
+ it('Web Crypto 不可用时仍能创建不重复的工作流 ID', async () => {
+ vi.stubGlobal('crypto', undefined)
+
+ const first = await createWorkflowRun({ projectId: 'project-fallback-1', driver: 'manual' })
+ const second = await createWorkflowRun({ projectId: 'project-fallback-2', driver: 'manual' })
+
+ expect(first.id).toMatch(/^run-/)
+ expect(second.id).toMatch(/^run-/)
+ expect(second.id).not.toBe(first.id)
+ })
+
+ it('缺失字段、非法枚举或错误引用的损坏记录不会被读取', async () => {
+ const invalidDriver = persistedRun('run-invalid-driver')
+ const missingNodes = withoutRevisionNodes('run-missing-nodes')
+ const invalidNodeStatus = persistedRun('run-invalid-node-status')
+ const missingCurrentRevision = persistedRun('run-missing-current-revision')
+ const mismatchedMapValue = persistedRun('run-map-value')
+
+ const cases: Array<[lookupId: string, record: unknown]> = [
+ ['run-missing-project', withoutProjectId('run-missing-project')],
+ ['run-invalid-driver', { ...invalidDriver, driver: 'robot' }],
+ ['run-missing-nodes', missingNodes],
+ [
+ 'run-invalid-node-status',
+ {
+ ...invalidNodeStatus,
+ revisions: [
+ {
+ ...invalidNodeStatus.revisions[0],
+ nodes: [{ ...invalidNodeStatus.revisions[0].nodes[0], status: 'unknown' }],
+ },
+ ],
+ },
+ ],
+ [
+ 'run-missing-current-revision',
+ { ...missingCurrentRevision, currentRevisionId: 'revision-does-not-exist' },
+ ],
+ ['run-map-key', mismatchedMapValue],
+ ]
+
+ for (const [lookupId, record] of cases) {
+ vi.stubGlobal('localStorage', createStorageWithRuns({ [lookupId]: record }))
+ await expect(fetchWorkflowRun(lookupId)).rejects.toThrow(/不存在/)
+ }
+ })
+
+ it('过滤损坏记录时仍保留同一快照中的合法记录', async () => {
+ const valid = persistedRun('run-valid-sibling')
+ vi.stubGlobal(
+ 'localStorage',
+ createStorageWithRuns({
+ 'run-invalid-sibling': withoutProjectId('run-invalid-sibling'),
+ [valid.id]: valid,
+ }),
+ )
+
+ await expect(fetchWorkflowRun('run-invalid-sibling')).rejects.toThrow(/不存在/)
+ await expect(fetchWorkflowRun(valid.id)).resolves.toMatchObject({
+ id: valid.id,
+ currentRevisionId: valid.currentRevisionId,
+ })
+ })
+
+ it('生成完成后进入质量门禁,连续失败两次才阻断', async () => {
+ let run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt: '骑士' })
+ const generation = getCurrentNode(run)!
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: generation.id,
+ output: { jobId: 'job-1' },
+ })
+ const candidate = getCurrentNode(run)!
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: candidate.id,
+ passed: false,
+ report: { reason: '抖动' },
+ })
+ expect(getCurrentNode(run)?.qualityFailureCount).toBe(1)
+ expect(getCurrentNode(run)?.status).toBe('active')
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: candidate.id,
+ passed: false,
+ report: { reason: '轮廓断裂' },
+ })
+ expect(getNodeByType(getCurrentRevision(run), 'candidate')?.status).toBe('failed')
+ expect(getCurrentRevision(run).generationStatus).toBe('failed')
+ expect(run.status).toBe('failed')
+ })
+
+ it('质量门禁通过后形成可导入 Playtest 的历史版本', async () => {
+ let run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt: '骑士' })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(run)!.id,
+ })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: getCurrentNode(run)!.id,
+ passed: true,
+ report: { passed: true },
+ })
+
+ expect(getCurrentRevision(run).generationStatus).toBe('completed')
+ expect(getCurrentNode(run)?.type).toBe('review')
+ expect(canImportToPlaytest(run, run.currentRevisionId)).toBe(true)
+ })
+
+ it('导出必须进入 export 节点,不能绕过 review', async () => {
+ let run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt: '骑士' })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(run)!.id,
+ })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: getCurrentNode(run)!.id,
+ passed: true,
+ })
+
+ await expect(
+ submitWorkflowCommand(run.id, { kind: 'set-export-status', status: 'exported' }),
+ ).rejects.toThrow(/不允许命令/)
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(run)!.id,
+ })
+ expect(getCurrentNode(run)?.type).toBe('export')
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'set-export-status',
+ status: 'exported',
+ })
+ expect(getCurrentRevision(run).exportStatus).toBe('exported')
+ expect(getNodeByType(getCurrentRevision(run), 'export')?.status).toBe('passed')
+ })
+
+ it('从历史节点重启会保留前缀引用并移除后续执行线', async () => {
+ let run = await createWorkflowRun({ projectId: 'quick-start', driver: 'ai', prompt: '骑士' })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'complete-node',
+ nodeId: getCurrentNode(run)!.id,
+ })
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'record-quality-result',
+ nodeId: getCurrentNode(run)!.id,
+ passed: true,
+ })
+ const sourceRevision = getCurrentRevision(run)
+ const generationNode = getNodeByType(sourceRevision, 'generation')!
+
+ run = await submitWorkflowCommand(run.id, {
+ kind: 'restart-from-node',
+ sourceRevisionId: sourceRevision.id,
+ nodeId: generationNode.id,
+ })
+ const restarted = getCurrentRevision(run)
+
+ expect(run.revisions).toHaveLength(2)
+ expect(restarted.basedOnRevisionId).toBe(sourceRevision.id)
+ expect(restarted.nodes.map((node) => node.type)).toEqual(['asset', 'generation'])
+ expect(getCurrentNode(run)?.type).toBe('generation')
+ expect(getNodeByType(restarted, 'generation')?.referenceNodeIds).toContain(generationNode.id)
+ expect(getRevision(run, sourceRevision.id)?.nodes.map((node) => node.type)).toEqual([
+ 'asset',
+ 'generation',
+ 'candidate',
+ 'review',
+ ])
+ })
+
+ it('不存在的工作流明确报错', async () => {
+ await expect(fetchWorkflowRun('run-does-not-exist')).rejects.toThrow()
+ })
+})