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 ( + <> + +
{ + event.preventDefault() + void start() + }} + > +