diff --git a/.github/actions/install-frontend-deps/action.yml b/.github/actions/install-frontend-deps/action.yml index 1e6d3e6be80..530ab0a722c 100644 --- a/.github/actions/install-frontend-deps/action.yml +++ b/.github/actions/install-frontend-deps/action.yml @@ -1,5 +1,10 @@ name: install frontend dependencies description: Installs frontend dependencies with pnpm, with caching +inputs: + working-directory: + description: Directory to install dependencies in + required: false + default: invokeai/frontend/web runs: using: 'composite' steps: @@ -30,4 +35,4 @@ runs: - name: install frontend dependencies run: pnpm install --prefer-frozen-lockfile shell: bash - working-directory: invokeai/frontend/web + working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml index b36fbeb650b..c287d45529d 100644 --- a/.github/workflows/frontend-checks.yml +++ b/.github/workflows/frontend-checks.yml @@ -30,14 +30,13 @@ on: type: boolean default: true -defaults: - run: - working-directory: invokeai/frontend/web - jobs: frontend-checks: runs-on: ubuntu-latest timeout-minutes: 10 # expected run time: <2 min + defaults: + run: + working-directory: invokeai/frontend/web steps: - uses: actions/checkout@v6 @@ -94,3 +93,37 @@ jobs: if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} run: 'pnpm lint:knip' shell: bash + + frontend-webv2-checks: + runs-on: ubuntu-latest + timeout-minutes: 10 # expected run time: <2 min + defaults: + run: + working-directory: invokeai/frontend/webv2 + steps: + - uses: actions/checkout@v6 + + - name: check for changed webv2 files + if: ${{ inputs.always_run != true }} + id: changed-files + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 + with: + files_yaml: | + webv2: + - 'invokeai/frontend/webv2/**' + + - name: install dependencies + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + uses: ./.github/actions/install-frontend-deps + with: + working-directory: invokeai/frontend/webv2 + + - name: lint + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm run lint' + shell: bash + + - name: build + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm run build' + shell: bash diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index abb1fb8419f..824ec08203a 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -30,14 +30,13 @@ on: type: boolean default: true -defaults: - run: - working-directory: invokeai/frontend/web - jobs: frontend-tests: runs-on: ubuntu-latest timeout-minutes: 10 # expected run time: <2 min + defaults: + run: + working-directory: invokeai/frontend/web steps: - uses: actions/checkout@v6 @@ -63,3 +62,32 @@ jobs: if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} run: 'pnpm test:no-watch' shell: bash + + frontend-webv2-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 # expected run time: <2 min + defaults: + run: + working-directory: invokeai/frontend/webv2 + steps: + - uses: actions/checkout@v6 + + - name: check for changed webv2 files + if: ${{ inputs.always_run != true }} + id: changed-files + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 + with: + files_yaml: | + webv2: + - 'invokeai/frontend/webv2/**' + + - name: install dependencies + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + uses: ./.github/actions/install-frontend-deps + with: + working-directory: invokeai/frontend/webv2 + + - name: vitest + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm test' + shell: bash diff --git a/.gitignore b/.gitignore index cc037f09abd..cbb63f66c37 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,16 @@ env/ venv/ ENV/ +# Generated stuff +invokeai.yaml +invokeai.example.yaml + +/models/ +/databases/ +/configs/ +/nodes/ +/outputs/ + # Spyder project settings .spyderproject .spyproject diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index e7468c1bca4..2957bd70d97 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -41,6 +41,7 @@ from invokeai.app.services.names.names_default import SimpleNameService from invokeai.app.services.object_serializer.object_serializer_disk import ObjectSerializerDisk from invokeai.app.services.object_serializer.object_serializer_forward_cache import ObjectSerializerForwardCache +from invokeai.app.services.project_records.project_records_sqlite import ProjectRecordsSqlite from invokeai.app.services.session_processor.session_processor_default import ( DefaultSessionProcessor, DefaultSessionRunner, @@ -187,6 +188,7 @@ def initialize( style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images") workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder) client_state_persistence = ClientStatePersistenceSqlite(db=db) + project_records = ProjectRecordsSqlite(db=db) users = UserService(db=db) services = InvocationServices( @@ -220,6 +222,7 @@ def initialize( style_preset_image_files=style_preset_image_files, workflow_thumbnails=workflow_thumbnails, client_state_persistence=client_state_persistence, + project_records=project_records, users=users, ) diff --git a/invokeai/app/api/routers/_access.py b/invokeai/app/api/routers/_access.py index fae3971a144..9e442c6dc29 100644 --- a/invokeai/app/api/routers/_access.py +++ b/invokeai/app/api/routers/_access.py @@ -22,6 +22,8 @@ def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> N """ if current_user.is_admin: return + if not ApiDependencies.invoker.services.image_records.exists(image_name): + raise HTTPException(status_code=404, detail="Image not found") owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name) if owner is not None and owner == current_user.user_id: return @@ -50,6 +52,8 @@ def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault """ if current_user.is_admin: return + if not ApiDependencies.invoker.services.image_records.exists(image_name): + raise HTTPException(status_code=404, detail="Image not found") owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name) if owner is not None and owner == current_user.user_id: diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py index 832e58f5e24..1ea8cbe494b 100644 --- a/invokeai/app/api/routers/app_info.py +++ b/invokeai/app/api/routers/app_info.py @@ -23,6 +23,7 @@ load_external_api_keys, ) from invokeai.app.services.external_generation.external_generation_common import ExternalProviderStatus +from invokeai.app.services.external_generation.startup import sync_configured_external_starter_models from invokeai.app.services.invocation_cache.invocation_cache_common import InvocationCacheStatus from invokeai.app.services.model_records.model_records_base import UnknownModelException from invokeai.backend.image_util.infill_methods.patchmatch import PatchMatch @@ -178,7 +179,7 @@ async def update_runtime_config( status_code=200, response_model=list[ExternalProviderStatusModel], ) -async def get_external_provider_statuses() -> list[ExternalProviderStatusModel]: +async def get_external_provider_statuses(_: AdminUserOrDefault) -> list[ExternalProviderStatusModel]: statuses = ApiDependencies.invoker.services.external_generation.get_provider_statuses() return [status_to_model(status) for status in statuses.values()] @@ -189,7 +190,7 @@ async def get_external_provider_statuses() -> list[ExternalProviderStatusModel]: status_code=200, response_model=list[ExternalProviderConfigModel], ) -async def get_external_provider_configs() -> list[ExternalProviderConfigModel]: +async def get_external_provider_configs(_: AdminUserOrDefault) -> list[ExternalProviderConfigModel]: config = get_config() return [_build_external_provider_config(provider_id, config) for provider_id in EXTERNAL_PROVIDER_FIELDS] @@ -219,9 +220,14 @@ async def set_external_provider_config( raise HTTPException(status_code=400, detail="No external provider config fields provided") api_key_removed = update.api_key is not None and updates.get(api_key_field) is None + api_key_set = update.api_key is not None and updates.get(api_key_field) is not None _apply_external_provider_update(updates) if api_key_removed: _remove_external_models_for_provider(provider_id) + elif api_key_set: + # Configuring a key should make the provider's models usable without a + # restart; queue its external starter models the same way startup does. + _sync_external_starter_models_for_provider(provider_id) return _build_external_provider_config(provider_id, get_config()) @@ -316,6 +322,19 @@ def _build_external_provider_config(provider_id: str, config: InvokeAIAppConfig) ) +def _sync_external_starter_models_for_provider(provider_id: str) -> None: + invoker = ApiDependencies.invoker + try: + sync_configured_external_starter_models( + configured_provider_ids={provider_id}, + model_manager=invoker.services.model_manager, + logger=invoker.services.logger, + ) + except Exception as error: + # Queuing installs must never fail the config save; surface and move on. + invoker.services.logger.warning(f"Failed queueing external starter models for '{provider_id}': {error}") + + def _remove_external_models_for_provider(provider_id: str) -> None: model_manager = ApiDependencies.invoker.services.model_manager external_models = model_manager.store.search_by_attr( diff --git a/invokeai/app/api/routers/custom_nodes.py b/invokeai/app/api/routers/custom_nodes.py index 3ee8c0ec99c..473d4c55f3b 100644 --- a/invokeai/app/api/routers/custom_nodes.py +++ b/invokeai/app/api/routers/custom_nodes.py @@ -1,8 +1,8 @@ """FastAPI routes for custom node management.""" +import asyncio import json import shutil -import subprocess import sys import traceback from importlib.util import module_from_spec, spec_from_file_location @@ -28,6 +28,7 @@ # were imported by that pack. Used on uninstall to delete only pack-imported workflows # — deleting by tag alone is unsafe because users can edit tags on their own workflows. PACK_MANIFEST_FILENAME = ".invokeai_pack_manifest.json" +GIT_CLONE_TIMEOUT_SECONDS = 120 class NodePackInfo(BaseModel): @@ -126,6 +127,26 @@ def _get_installed_packs() -> list[NodePackInfo]: return packs +async def _clone_node_pack(source: str, target_dir: Path) -> tuple[int, str]: + process = await asyncio.create_subprocess_exec( + "git", + "clone", + source, + str(target_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + _stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=GIT_CLONE_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + process.kill() + await process.communicate() + raise + + return process.returncode or 0, stderr.decode(errors="replace").strip() + + @custom_nodes_router.get( "/", operation_id="list_custom_node_packs", @@ -172,21 +193,16 @@ async def install_custom_node_pack( try: # Clone the repository - result = subprocess.run( - ["git", "clone", source, str(target_dir)], - capture_output=True, - text=True, - timeout=120, - ) + returncode, stderr = await _clone_node_pack(source, target_dir) - if result.returncode != 0: + if returncode != 0: # Clean up on failure if target_dir.exists(): shutil.rmtree(target_dir) return InstallNodePackResponse( name=pack_name, success=False, - message=f"Git clone failed: {result.stderr.strip()}", + message=f"Git clone failed: {stderr}", ) # Detect dependency manifests but do NOT install them automatically. @@ -232,7 +248,7 @@ async def install_custom_node_pack( dependency_file=dependency_file, ) - except subprocess.TimeoutExpired: + except asyncio.TimeoutError: if target_dir.exists(): shutil.rmtree(target_dir) return InstallNodePackResponse( diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index bdd2e406444..a1c47b4e722 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -11,7 +11,7 @@ from typing import List, Optional, Type import huggingface_hub -from fastapi import Body, Path, Query, Response, UploadFile +from fastapi import Body, Header, Path, Query, Response, UploadFile from fastapi.responses import FileResponse, HTMLResponse from fastapi.routing import APIRouter from PIL import Image @@ -68,6 +68,11 @@ class ModelsList(BaseModel): model_config = ConfigDict(use_enum_values=True) +class EmptyModelCacheResponse(BaseModel): + models_cleared: int + bytes_freed: int + + class CacheType(str, Enum): """Cache type - one of vram or ram.""" @@ -218,6 +223,20 @@ async def list_missing_models() -> ModelsList: return ModelsList(models=missing_models) +@model_manager_router.get( + "/models_dir", + operation_id="get_models_dir", + responses={200: {"description": "The absolute path of the models directory"}}, +) +async def get_models_dir() -> str: + """Get the absolute path of the directory managed models are stored in. + + Model config `path` values are relative to this directory unless they are + absolute (in-place installs from outside it). + """ + return ApiDependencies.invoker.services.configuration.models_path.resolve().as_posix() + + @model_manager_router.get( "/get_by_attrs", operation_id="get_model_records_by_attrs", @@ -747,6 +766,11 @@ async def install_model( source: str = Query(description="Model source to install, can be a local path, repo_id, or remote URL"), inplace: Optional[bool] = Query(description="Whether or not to install a local model in place", default=False), access_token: Optional[str] = Query(description="access token for the remote resource", default=None), + source_access_token: Optional[str] = Header( + alias="X-Model-Source-Access-Token", + description="access token for the remote resource", + default=None, + ), config: ModelRecordChanges = Body( description="Object containing fields that override auto-probed values in the model config record, such as name, description and prediction_type ", examples=[{"name": "string", "description": "string"}], @@ -786,7 +810,7 @@ async def install_model( result: ModelInstallJob = installer.heuristic_import( source=source, config=config, - access_token=access_token, + access_token=source_access_token or access_token, inplace=bool(inplace), ) logger.info(f"Started installation of {source}") @@ -1301,12 +1325,14 @@ async def get_stats() -> Optional[CacheStats]: "/empty_model_cache", operation_id="empty_model_cache", status_code=200, + response_model=EmptyModelCacheResponse, ) -async def empty_model_cache(current_admin: AdminUserOrDefault) -> None: +async def empty_model_cache(current_admin: AdminUserOrDefault) -> EmptyModelCacheResponse: """Drop all models from the model cache to free RAM/VRAM. 'Locked' models that are in active use will not be dropped.""" # Request 1000GB of room in order to force the cache to drop all models. ApiDependencies.invoker.services.logger.info("Emptying model cache.") - ApiDependencies.invoker.services.model_manager.load.ram_cache.make_room(1000 * 2**30) + result = ApiDependencies.invoker.services.model_manager.load.ram_cache.make_room(1000 * 2**30) + return EmptyModelCacheResponse(models_cleared=result.models_cleared, bytes_freed=result.bytes_freed) class HFTokenStatus(str, Enum): @@ -1341,7 +1367,7 @@ def reset_token(cls) -> HFTokenStatus: @model_manager_router.get("/hf_login", operation_id="get_hf_login_status", response_model=HFTokenStatus) -async def get_hf_login_status() -> HFTokenStatus: +async def get_hf_login_status(_: AdminUserOrDefault) -> HFTokenStatus: token_status = HFTokenHelper.get_status() if token_status is HFTokenStatus.UNKNOWN: diff --git a/invokeai/app/api/routers/projects.py b/invokeai/app/api/routers/projects.py new file mode 100644 index 00000000000..6609c091235 --- /dev/null +++ b/invokeai/app/api/routers/projects.py @@ -0,0 +1,105 @@ +from typing import Any + +from fastapi import Body, HTTPException, Path, status +from fastapi.routing import APIRouter +from pydantic import BaseModel, Field + +from invokeai.app.api.auth_dependencies import CurrentUserOrDefault +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.project_records.project_records_common import ( + ProjectRecordConflictError, + ProjectRecordDTO, + ProjectRecordExistsError, + ProjectRecordNotFoundError, + ProjectSummaryDTO, +) + +projects_router = APIRouter(prefix="/v1/projects", tags=["projects"]) + + +class ProjectCreateRequest(BaseModel): + """Request body for creating a project.""" + + project_id: str | None = Field( + default=None, description="Client-generated project id (e.g. for imports); generated when omitted" + ) + name: str = Field(description="The project's display name") + data: dict[str, Any] = Field(description="The opaque client-owned project document") + + +class ProjectUpdateRequest(BaseModel): + """Request body for saving a project with optimistic concurrency.""" + + name: str = Field(description="The project's display name") + data: dict[str, Any] = Field(description="The opaque client-owned project document") + expected_revision: int = Field(description="The revision this save is based on; mismatch returns 409") + + +@projects_router.get("/", operation_id="list_projects", response_model=list[ProjectSummaryDTO]) +async def list_projects(current_user: CurrentUserOrDefault) -> list[ProjectSummaryDTO]: + """Lists the current user's projects as lightweight summaries (no documents).""" + return ApiDependencies.invoker.services.project_records.list(current_user.user_id) + + +@projects_router.post( + "/", operation_id="create_project", response_model=ProjectRecordDTO, status_code=status.HTTP_201_CREATED +) +async def create_project( + current_user: CurrentUserOrDefault, + request: ProjectCreateRequest = Body(description="The project to create"), +) -> ProjectRecordDTO: + """Creates a project for the current user.""" + try: + return ApiDependencies.invoker.services.project_records.create( + user_id=current_user.user_id, + name=request.name, + data=request.data, + project_id=request.project_id, + ) + except ProjectRecordExistsError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + + +@projects_router.get("/{project_id}", operation_id="get_project", response_model=ProjectRecordDTO) +async def get_project( + current_user: CurrentUserOrDefault, + project_id: str = Path(description="The id of the project to get"), +) -> ProjectRecordDTO: + """Gets one of the current user's projects, including its document.""" + try: + return ApiDependencies.invoker.services.project_records.get(current_user.user_id, project_id) + except ProjectRecordNotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@projects_router.put("/{project_id}", operation_id="update_project", response_model=ProjectRecordDTO) +async def update_project( + current_user: CurrentUserOrDefault, + project_id: str = Path(description="The id of the project to save"), + request: ProjectUpdateRequest = Body(description="The project document and the revision it is based on"), +) -> ProjectRecordDTO: + """Saves a project. Returns 409 with the current revision when the save is based on a stale revision.""" + try: + return ApiDependencies.invoker.services.project_records.update( + user_id=current_user.user_id, + project_id=project_id, + expected_revision=request.expected_revision, + name=request.name, + data=request.data, + ) + except ProjectRecordNotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except ProjectRecordConflictError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"message": str(e), "current_revision": e.current_revision}, + ) + + +@projects_router.delete("/{project_id}", operation_id="delete_project", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project( + current_user: CurrentUserOrDefault, + project_id: str = Path(description="The id of the project to delete"), +) -> None: + """Deletes one of the current user's projects. Idempotent.""" + ApiDependencies.invoker.services.project_records.delete(current_user.user_id, project_id) diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index cd2260d2271..02c466d0a17 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Callable, Optional from fastapi import Body, HTTPException, Path, Query from fastapi.routing import APIRouter @@ -6,6 +6,7 @@ from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.invocations.fields import ImageField from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus from invokeai.app.services.session_queue.session_queue_common import ( Batch, @@ -38,6 +39,59 @@ class SessionQueueAndProcessorStatus(BaseModel): processor: SessionProcessorStatus +def _image_record_exists(image_name: str) -> bool: + return ApiDependencies.invoker.services.image_records.exists(image_name) + + +def strip_missing_image_results( + queue_item: SessionQueueItem, image_exists: Callable[[str], bool] | None = None +) -> SessionQueueItem: + """Remove result outputs whose image records have been deleted. + + Completed queue history can outlive its output images. API clients hydrate + images listed in `session.results`; returning stale names makes them loop on + 404s. Keep the queue item/history, but do not advertise impossible outputs. + """ + if not queue_item.session.results: + return queue_item + + image_exists = image_exists or _image_record_exists + filtered_results = {} + did_filter = False + exists_cache: dict[str, bool] = {} + + def cached_exists(image_name: str) -> bool: + if image_name not in exists_cache: + exists_cache[image_name] = image_exists(image_name) + return exists_cache[image_name] + + for node_id, output in queue_item.session.results.items(): + image = getattr(output, "image", None) + if isinstance(image, ImageField) and not cached_exists(image.image_name): + did_filter = True + continue + + collection = getattr(output, "collection", None) + if isinstance(collection, list) and any(isinstance(item, ImageField) for item in collection): + filtered_collection = [ + item for item in collection if not isinstance(item, ImageField) or cached_exists(item.image_name) + ] + if len(filtered_collection) != len(collection): + did_filter = True + if len(filtered_collection) == 0: + continue + output = output.model_copy(update={"collection": filtered_collection}) + + filtered_results[node_id] = output + + if not did_filter: + return queue_item + + sanitized_item = queue_item.model_copy(deep=True) + sanitized_item.session.results = filtered_results + return sanitized_item + + def sanitize_queue_item_for_user( queue_item: SessionQueueItem, current_user_id: str, is_admin: bool ) -> SessionQueueItem: @@ -57,7 +111,7 @@ def sanitize_queue_item_for_user( """ # Admins and item owners can see everything if is_admin or queue_item.user_id == current_user_id: - return queue_item + return strip_missing_image_results(queue_item) # For non-admins viewing other users' items, strip everything except # item_id, queue_id, status, and timestamps @@ -83,6 +137,18 @@ def sanitize_queue_item_for_user( return sanitized_item +def get_queue_item_for_mutation(queue_id: str, item_id: int, current_user: CurrentUserOrDefault) -> SessionQueueItem: + queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) + + if queue_item.queue_id != queue_id: + raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + + if queue_item.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException(status_code=403, detail=f"You do not have permission to mutate queue item {item_id}") + + return queue_item + + @session_queue_router.post( "/{queue_id}/enqueue_batch", operation_id="enqueue_batch", @@ -140,6 +206,9 @@ async def get_queue_item_ids( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + origin_prefix: Optional[str] = Query( + default=None, description="Only include queue items whose origin starts with this prefix" + ), ) -> ItemIdsResult: """Gets all queue item ids that match the given parameters. @@ -152,7 +221,9 @@ async def get_queue_item_ids( current_user is required so the endpoint stays behind authentication in multiuser mode. """ try: - return ApiDependencies.invoker.services.session_queue.get_queue_item_ids(queue_id=queue_id, order_dir=order_dir) + return ApiDependencies.invoker.services.session_queue.get_queue_item_ids( + queue_id=queue_id, order_dir=order_dir, origin_prefix=origin_prefix + ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue item ids: {e}") @@ -320,15 +391,21 @@ async def retry_items_by_id( try: # Check authorization: user must own all items or be an admin if not current_user.is_admin: + for item_id in item_ids: + try: + get_queue_item_for_mutation(queue_id, item_id, current_user) + except SessionQueueItemNotFoundError: + # Skip items that don't exist - they will be handled by retry_items_by_id + continue + else: for item_id in item_ids: try: queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) - if queue_item.user_id != current_user.user_id: + if queue_item.queue_id != queue_id: raise HTTPException( - status_code=403, detail=f"You do not have permission to retry queue item {item_id}" + status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}" ) except SessionQueueItemNotFoundError: - # Skip items that don't exist - they will be handled by retry_items_by_id continue return ApiDependencies.invoker.services.session_queue.retry_items_by_id(queue_id=queue_id, item_ids=item_ids) @@ -399,10 +476,13 @@ async def prune( async def get_current_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), + origin_prefix: Optional[str] = Query( + default=None, description="Only include queue items whose origin starts with this prefix" + ), ) -> Optional[SessionQueueItem]: """Gets the currently execution queue item""" try: - item = ApiDependencies.invoker.services.session_queue.get_current(queue_id) + item = ApiDependencies.invoker.services.session_queue.get_current(queue_id, origin_prefix=origin_prefix) if item is not None: item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) return item @@ -420,10 +500,13 @@ async def get_current_queue_item( async def get_next_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), + origin_prefix: Optional[str] = Query( + default=None, description="Only include queue items whose origin starts with this prefix" + ), ) -> Optional[SessionQueueItem]: """Gets the next queue item, without executing it""" try: - item = ApiDependencies.invoker.services.session_queue.get_next(queue_id) + item = ApiDependencies.invoker.services.session_queue.get_next(queue_id, origin_prefix=origin_prefix) if item is not None: item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) return item @@ -441,11 +524,16 @@ async def get_next_queue_item( async def get_queue_status( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), + origin_prefix: Optional[str] = Query( + default=None, description="Only include queue items whose origin starts with this prefix" + ), ) -> SessionQueueAndProcessorStatus: """Gets the status of the session queue. Non-admin users see only their own counts and cannot see current item details unless they own it.""" try: user_id = None if current_user.is_admin else current_user.user_id - queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id, user_id=user_id) + queue = ApiDependencies.invoker.services.session_queue.get_queue_status( + queue_id, user_id=user_id, origin_prefix=origin_prefix + ) processor = ApiDependencies.invoker.services.session_processor.get_status() return SessionQueueAndProcessorStatus(queue=queue, processor=processor) except Exception as e: @@ -511,12 +599,7 @@ async def delete_queue_item( ) -> None: """Deletes a queue item. Users can only delete their own items unless they are an admin.""" try: - # Get the queue item to check ownership - queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) - - # Check authorization: user must own the item or be an admin - if queue_item.user_id != current_user.user_id and not current_user.is_admin: - raise HTTPException(status_code=403, detail="You do not have permission to delete this queue item") + get_queue_item_for_mutation(queue_id, item_id, current_user) ApiDependencies.invoker.services.session_queue.delete_queue_item(item_id) except SessionQueueItemNotFoundError: @@ -541,12 +624,7 @@ async def cancel_queue_item( ) -> SessionQueueItem: """Cancels a queue item. Users can only cancel their own items unless they are an admin.""" try: - # Get the queue item to check ownership - queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) - - # Check authorization: user must own the item or be an admin - if queue_item.user_id != current_user.user_id and not current_user.is_admin: - raise HTTPException(status_code=403, detail="You do not have permission to cancel this queue item") + get_queue_item_for_mutation(queue_id, item_id, current_user) return ApiDependencies.invoker.services.session_queue.cancel_queue_item(item_id) except SessionQueueItemNotFoundError: diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index 7e93c332d64..acba63f2846 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -29,6 +29,7 @@ ModelInstallCompleteEvent, ModelInstallDownloadProgressEvent, ModelInstallDownloadsCompleteEvent, + ModelInstallDownloadStartedEvent, ModelInstallErrorEvent, ModelInstallStartedEvent, ModelLoadCompleteEvent, @@ -77,6 +78,7 @@ class BulkDownloadSubscriptionEvent(BaseModel): DownloadStartedEvent, ModelLoadStartedEvent, ModelLoadCompleteEvent, + ModelInstallDownloadStartedEvent, ModelInstallDownloadProgressEvent, ModelInstallDownloadsCompleteEvent, ModelInstallStartedEvent, @@ -87,6 +89,16 @@ class BulkDownloadSubscriptionEvent(BaseModel): BULK_DOWNLOAD_EVENTS = {BulkDownloadStartedEvent, BulkDownloadCompleteEvent, BulkDownloadErrorEvent} +MODEL_INSTALL_EVENTS = ( + ModelInstallDownloadStartedEvent, + ModelInstallDownloadProgressEvent, + ModelInstallDownloadsCompleteEvent, + ModelInstallStartedEvent, + ModelInstallCompleteEvent, + ModelInstallCancelledEvent, + ModelInstallErrorEvent, +) + class SocketIO: _sub_queue = "subscribe_queue" @@ -354,7 +366,12 @@ async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]): logger.error(f"Error handling queue event {event[0]}: {e}", exc_info=True) async def _handle_model_event(self, event: FastAPIEvent[ModelEventBase | DownloadEventBase]) -> None: - await self._sio.emit(event=event[0], data=event[1].model_dump(mode="json")) + event_name, event_data = event + if isinstance(event_data, MODEL_INSTALL_EVENTS): + await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin") + return + + await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json")) async def _handle_bulk_image_download_event(self, event: FastAPIEvent[BulkDownloadEventBase]) -> None: event_name, event_data = event diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 4b79e1eeb0c..db9ddb0b02b 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -26,6 +26,7 @@ images, model_manager, model_relationships, + projects, recall_parameters, session_queue, style_presets, @@ -186,6 +187,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): app.include_router(workflows.workflows_router, prefix="/api") app.include_router(style_presets.style_presets_router, prefix="/api") app.include_router(client_state.client_state_router, prefix="/api") +app.include_router(projects.projects_router, prefix="/api") app.include_router(recall_parameters.recall_parameters_router, prefix="/api") app.include_router(custom_nodes.custom_nodes_router, prefix="/api") diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index 8c71dfba9e7..9e263522cd9 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -30,6 +30,11 @@ def get_metadata(self, image_name: str) -> Optional[MetadataField]: """Gets an image's metadata'.""" pass + @abstractmethod + def exists(self, image_name: str) -> bool: + """Returns whether an image record exists.""" + pass + @abstractmethod def update( self, diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index 1eb3857dba6..8a6a7f47c68 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -84,6 +84,18 @@ def get_metadata(self, image_name: str) -> Optional[MetadataField]: metadata_raw = cast(Optional[str], as_dict.get("metadata", None)) return MetadataFieldValidator.validate_json(metadata_raw) if metadata_raw is not None else None + def exists(self, image_name: str) -> bool: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT 1 FROM images + WHERE image_name = ? + LIMIT 1; + """, + (image_name,), + ) + return cursor.fetchone() is not None + def update( self, image_name: str, diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 4a190f37edc..dabc65fb9f6 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -132,6 +132,9 @@ def get_pil_image(self, image_name: str) -> PILImageType: try: record = self.__invoker.services.image_records.get(image_name) return self.__invoker.services.image_files.get(image_name, image_subfolder=record.image_subfolder) + except ImageRecordNotFoundException: + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") + raise except ImageFileNotFoundException: self.__invoker.services.logger.error("Failed to get image file") raise @@ -143,7 +146,7 @@ def get_record(self, image_name: str) -> ImageRecord: try: return self.__invoker.services.image_records.get(image_name) except ImageRecordNotFoundException: - self.__invoker.services.logger.error("Image record not found") + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") raise except Exception as e: self.__invoker.services.logger.error("Problem getting image record") @@ -162,7 +165,7 @@ def get_dto(self, image_name: str) -> ImageDTO: return image_dto except ImageRecordNotFoundException: - self.__invoker.services.logger.error("Image record not found") + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") raise except Exception as e: self.__invoker.services.logger.error("Problem getting image DTO") @@ -172,7 +175,7 @@ def get_metadata(self, image_name: str) -> Optional[MetadataField]: try: return self.__invoker.services.image_records.get_metadata(image_name) except ImageRecordNotFoundException: - self.__invoker.services.logger.error("Image record not found") + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") raise except Exception as e: self.__invoker.services.logger.error("Problem getting image metadata") @@ -182,6 +185,9 @@ def get_workflow(self, image_name: str) -> Optional[str]: try: record = self.__invoker.services.image_records.get(image_name) return self.__invoker.services.image_files.get_workflow(image_name, image_subfolder=record.image_subfolder) + except ImageRecordNotFoundException: + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") + raise except ImageFileNotFoundException: self.__invoker.services.logger.error("Image file not found") raise @@ -193,6 +199,9 @@ def get_graph(self, image_name: str) -> Optional[str]: try: record = self.__invoker.services.image_records.get(image_name) return self.__invoker.services.image_files.get_graph(image_name, image_subfolder=record.image_subfolder) + except ImageRecordNotFoundException: + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") + raise except ImageFileNotFoundException: self.__invoker.services.logger.error("Image file not found") raise @@ -208,6 +217,9 @@ def get_path(self, image_name: str, thumbnail: bool = False) -> str: image_name, thumbnail, image_subfolder=record.image_subfolder ) ) + except ImageRecordNotFoundException: + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") + raise except Exception as e: self.__invoker.services.logger.error("Problem getting image path") raise e diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 2c95f87b41d..923bfdd5905 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -34,6 +34,7 @@ ) from invokeai.app.services.model_relationships.model_relationships_base import ModelRelationshipsServiceABC from invokeai.app.services.names.names_base import NameServiceBase + from invokeai.app.services.project_records.project_records_base import ProjectRecordsStorageBase from invokeai.app.services.session_processor.session_processor_base import SessionProcessorBase from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase from invokeai.app.services.urls.urls_base import UrlServiceBase @@ -78,6 +79,7 @@ def __init__( style_preset_image_files: "StylePresetImageFileStorageBase", workflow_thumbnails: "WorkflowThumbnailServiceBase", client_state_persistence: "ClientStatePersistenceABC", + project_records: "ProjectRecordsStorageBase", users: "UserServiceBase", ): self.board_images = board_images @@ -110,4 +112,5 @@ def __init__( self.style_preset_image_files = style_preset_image_files self.workflow_thumbnails = workflow_thumbnails self.client_state_persistence = client_state_persistence + self.project_records = project_records self.users = users diff --git a/invokeai/app/services/project_records/project_records_base.py b/invokeai/app/services/project_records/project_records_base.py new file mode 100644 index 00000000000..0bc02b74d70 --- /dev/null +++ b/invokeai/app/services/project_records/project_records_base.py @@ -0,0 +1,62 @@ +from abc import ABC, abstractmethod +from typing import Any + +from invokeai.app.services.project_records.project_records_common import ProjectRecordDTO, ProjectSummaryDTO + + +class ProjectRecordsStorageBase(ABC): + """Storage for per-user workbench project documents. + + All operations are scoped by user_id; a user can never read or write + another user's projects. Saves use optimistic concurrency via the + project's monotonic revision. + """ + + @abstractmethod + def create(self, user_id: str, name: str, data: dict[str, Any], project_id: str | None = None) -> ProjectRecordDTO: + """Create a project for the user. + + Args: + user_id: The owning user. + name: The project's display name. + data: The opaque client-owned project document. + project_id: Client-generated id (e.g. for imports); generated when omitted. + + Returns: + The created project record. + + Raises: + ProjectRecordExistsError: The user already has a project with this id. + """ + pass + + @abstractmethod + def get(self, user_id: str, project_id: str) -> ProjectRecordDTO: + """Get one of the user's projects, including its document. + + Raises: + ProjectRecordNotFoundError: No such project for this user. + """ + pass + + @abstractmethod + def list(self, user_id: str) -> list[ProjectSummaryDTO]: + """List the user's projects as lightweight summaries, oldest first.""" + pass + + @abstractmethod + def update( + self, user_id: str, project_id: str, expected_revision: int, name: str, data: dict[str, Any] + ) -> ProjectRecordDTO: + """Save a project if the caller's revision is current. + + Raises: + ProjectRecordNotFoundError: No such project for this user. + ProjectRecordConflictError: The stored revision differs from expected_revision. + """ + pass + + @abstractmethod + def delete(self, user_id: str, project_id: str) -> None: + """Delete one of the user's projects. Idempotent: deleting a missing project is a no-op.""" + pass diff --git a/invokeai/app/services/project_records/project_records_common.py b/invokeai/app/services/project_records/project_records_common.py new file mode 100644 index 00000000000..dc25b170174 --- /dev/null +++ b/invokeai/app/services/project_records/project_records_common.py @@ -0,0 +1,45 @@ +"""Common types and errors for the project records service.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class ProjectRecordNotFoundError(Exception): + """Raised when a project record is not found for the requesting user.""" + + def __init__(self, project_id: str) -> None: + super().__init__(f"Project {project_id} not found") + + +class ProjectRecordExistsError(Exception): + """Raised when creating a project with an id the user already has.""" + + def __init__(self, project_id: str) -> None: + super().__init__(f"Project {project_id} already exists") + + +class ProjectRecordConflictError(Exception): + """Raised when a save carries a stale revision (another client saved first).""" + + def __init__(self, project_id: str, expected_revision: int, current_revision: int) -> None: + self.current_revision = current_revision + super().__init__( + f"Project {project_id} is at revision {current_revision}; the save expected revision {expected_revision}" + ) + + +class ProjectSummaryDTO(BaseModel): + """Lightweight project listing entry; the document payload is omitted.""" + + project_id: str = Field(description="The project's client-generated identifier") + name: str = Field(description="The project's display name") + revision: int = Field(description="Monotonic revision, incremented on every save") + created_at: str = Field(description="When the project was created") + updated_at: str = Field(description="When the project was last saved") + + +class ProjectRecordDTO(ProjectSummaryDTO): + """Full project record including the client-owned document.""" + + data: dict[str, Any] = Field(description="The opaque client-owned project document") diff --git a/invokeai/app/services/project_records/project_records_sqlite.py b/invokeai/app/services/project_records/project_records_sqlite.py new file mode 100644 index 00000000000..0369a626f45 --- /dev/null +++ b/invokeai/app/services/project_records/project_records_sqlite.py @@ -0,0 +1,134 @@ +import json +import sqlite3 +import uuid +from typing import Any + +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.project_records.project_records_base import ProjectRecordsStorageBase +from invokeai.app.services.project_records.project_records_common import ( + ProjectRecordConflictError, + ProjectRecordDTO, + ProjectRecordExistsError, + ProjectRecordNotFoundError, + ProjectSummaryDTO, +) +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + + +class ProjectRecordsSqlite(ProjectRecordsStorageBase): + """SQLite implementation of per-user project document storage.""" + + def __init__(self, db: SqliteDatabase) -> None: + super().__init__() + self._db = db + + def start(self, invoker: Invoker) -> None: + self._invoker = invoker + + def create(self, user_id: str, name: str, data: dict[str, Any], project_id: str | None = None) -> ProjectRecordDTO: + project_id = project_id or uuid.uuid4().hex + + try: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + INSERT INTO projects (project_id, user_id, name, data) + VALUES (?, ?, ?, ?); + """, + (project_id, user_id, name, json.dumps(data)), + ) + except sqlite3.IntegrityError as e: + raise ProjectRecordExistsError(project_id) from e + + return self.get(user_id, project_id) + + def get(self, user_id: str, project_id: str) -> ProjectRecordDTO: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT project_id, name, data, revision, created_at, updated_at + FROM projects + WHERE user_id = ? AND project_id = ?; + """, + (user_id, project_id), + ) + row = cursor.fetchone() + + if row is None: + raise ProjectRecordNotFoundError(project_id) + + return ProjectRecordDTO( + project_id=row[0], + name=row[1], + data=json.loads(row[2]), + revision=row[3], + created_at=row[4], + updated_at=row[5], + ) + + def list(self, user_id: str) -> list[ProjectSummaryDTO]: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT project_id, name, revision, created_at, updated_at + FROM projects + WHERE user_id = ? + -- rowid breaks ties between rows created in the same millisecond, + -- keeping the listing in true insertion order. + ORDER BY created_at ASC, rowid ASC; + """, + (user_id,), + ) + rows = cursor.fetchall() + + return [ + ProjectSummaryDTO( + project_id=row[0], + name=row[1], + revision=row[2], + created_at=row[3], + updated_at=row[4], + ) + for row in rows + ] + + def update( + self, user_id: str, project_id: str, expected_revision: int, name: str, data: dict[str, Any] + ) -> ProjectRecordDTO: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + UPDATE projects + SET name = ?, data = ?, revision = revision + 1 + WHERE user_id = ? AND project_id = ? AND revision = ?; + """, + (name, json.dumps(data), user_id, project_id, expected_revision), + ) + + if cursor.rowcount == 0: + # Distinguish "gone" from "someone else saved first". + cursor.execute( + """--sql + SELECT revision FROM projects + WHERE user_id = ? AND project_id = ?; + """, + (user_id, project_id), + ) + row = cursor.fetchone() + + if row is None: + raise ProjectRecordNotFoundError(project_id) + + raise ProjectRecordConflictError(project_id, expected_revision, row[0]) + + return self.get(user_id, project_id) + + def delete(self, user_id: str, project_id: str) -> None: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + DELETE FROM projects + WHERE user_id = ? AND project_id = ?; + """, + (user_id, project_id), + ) diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index bb7971cf90d..09b521d6812 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -43,12 +43,12 @@ def enqueue_batch( pass @abstractmethod - def get_current(self, queue_id: str) -> Optional[SessionQueueItem]: + def get_current(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: """Gets the currently-executing session queue item""" pass @abstractmethod - def get_next(self, queue_id: str) -> Optional[SessionQueueItem]: + def get_next(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: """Gets the next session queue item (does not dequeue it)""" pass @@ -78,6 +78,7 @@ def get_queue_status( queue_id: str, user_id: Optional[str] = None, acting_user_id: Optional[str] = None, + origin_prefix: Optional[str] = None, ) -> SessionQueueStatus: """Gets the status of the queue. @@ -191,6 +192,7 @@ def get_queue_item_ids( queue_id: str, order_dir: SQLiteDirection = SQLiteDirection.Descending, user_id: Optional[str] = None, + origin_prefix: Optional[str] = None, ) -> ItemIdsResult: """Gets all queue item ids that match the given parameters. If user_id is provided, only returns items for that user.""" pass diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index c29ed9b0038..71eba1eb48d 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -193,7 +193,7 @@ async def enqueue_batch( SELECT item_id FROM session_queue WHERE batch_id = ? - ORDER BY item_id DESC; + ORDER BY item_id ASC; """, (batch.batch_id,), ) @@ -233,10 +233,9 @@ def dequeue(self) -> Optional[SessionQueueItem]: queue_item = self._set_queue_item_status(item_id=queue_item.item_id, status="in_progress") return queue_item - def get_next(self, queue_id: str) -> Optional[SessionQueueItem]: + def get_next(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: with self._db.transaction() as cursor: - cursor.execute( - """--sql + query = """--sql SELECT sq.*, u.display_name as user_display_name, @@ -246,22 +245,28 @@ def get_next(self, queue_id: str) -> Optional[SessionQueueItem]: WHERE sq.queue_id = ? AND sq.status = 'pending' + """ + params = [queue_id] + if origin_prefix is not None: + query += """--sql + AND sq.origin LIKE ? + """ + params.append(f"{origin_prefix}%") + query += """--sql ORDER BY sq.priority DESC, sq.created_at ASC LIMIT 1 - """, - (queue_id,), - ) + """ + cursor.execute(query, params) result = cast(Union[sqlite3.Row, None], cursor.fetchone()) if result is None: return None return SessionQueueItem.queue_item_from_dict(dict(result)) - def get_current(self, queue_id: str) -> Optional[SessionQueueItem]: + def get_current(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: with self._db.transaction() as cursor: - cursor.execute( - """--sql + query = """--sql SELECT sq.*, u.display_name as user_display_name, @@ -271,10 +276,17 @@ def get_current(self, queue_id: str) -> Optional[SessionQueueItem]: WHERE sq.queue_id = ? AND sq.status = 'in_progress' + """ + params = [queue_id] + if origin_prefix is not None: + query += """--sql + AND sq.origin LIKE ? + """ + params.append(f"{origin_prefix}%") + query += """--sql LIMIT 1 - """, - (queue_id,), - ) + """ + cursor.execute(query, params) result = cast(Union[sqlite3.Row, None], cursor.fetchone()) if result is None: return None @@ -832,6 +844,7 @@ def get_queue_item_ids( queue_id: str, order_dir: SQLiteDirection = SQLiteDirection.Descending, user_id: Optional[str] = None, + origin_prefix: Optional[str] = None, ) -> ItemIdsResult: with self._db.transaction() as cursor_: query = """--sql @@ -845,6 +858,10 @@ def get_queue_item_ids( query += " AND user_id = ?" query_params.append(user_id) + if origin_prefix is not None: + query += " AND origin LIKE ?" + query_params.append(f"{origin_prefix}%") + query += f" ORDER BY created_at {order_dir.value}" cursor_.execute(query, query_params) @@ -858,20 +875,23 @@ def get_queue_status( queue_id: str, user_id: Optional[str] = None, acting_user_id: Optional[str] = None, + origin_prefix: Optional[str] = None, ) -> SessionQueueStatus: with self._db.transaction() as cursor: - # Aggregate counts are always global (across all users). This lets a non-admin's - # badge show "own / total" — their share of the whole queue — and lets the queue - # list surface (redacted) entries belonging to other users. - cursor.execute( - """--sql + # Aggregate counts are global across all users within the requested scope. + query = """--sql SELECT status, count(*) FROM session_queue WHERE queue_id = ? - GROUP BY status - """, - (queue_id,), - ) + """ + params: list[str] = [queue_id] + + if origin_prefix is not None: + query += " AND origin LIKE ?" + params.append(f"{origin_prefix}%") + + query += " GROUP BY status" + cursor.execute(query, params) counts_result = cast(list[sqlite3.Row], cursor.fetchall()) # When user_id is provided, additionally compute that user's own counts so the @@ -879,18 +899,24 @@ def get_queue_status( # separate user_pending/user_in_progress fields and never replace the global counts. user_counts_result: list[sqlite3.Row] = [] if user_id is not None: - cursor.execute( - """--sql + user_query = """--sql SELECT status, count(*) FROM session_queue WHERE queue_id = ? AND user_id = ? + """ + user_params = [queue_id, user_id] + + if origin_prefix is not None: + user_query += " AND origin LIKE ?" + user_params.append(f"{origin_prefix}%") + + user_query += """--sql GROUP BY status - """, - (queue_id, user_id), - ) + """ + cursor.execute(user_query, user_params) user_counts_result = cast(list[sqlite3.Row], cursor.fetchall()) - current_item = self.get_current(queue_id=queue_id) + current_item = self.get_current(queue_id=queue_id, origin_prefix=origin_prefix) total = sum(row[1] or 0 for row in counts_result) counts: dict[str, int] = {row[0]: row[1] for row in counts_result} diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index 3e1d5c53f3e..14b6c61a85a 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -35,6 +35,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import build_migration_30 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_31 import build_migration_31 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_32 import build_migration_32 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_33 import build_migration_33 from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator @@ -87,6 +88,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto migrator.register_migration(build_migration_30()) migrator.register_migration(build_migration_31()) migrator.register_migration(build_migration_32()) + migrator.register_migration(build_migration_33()) migrator.run_migrations() return db diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py index 5e06d634e76..edb60d4ac2f 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py @@ -37,7 +37,6 @@ def _repair_model_relationships_fks(self, cursor: sqlite3.Cursor) -> None: # Foreign keys already point at the correct table, nothing to repair. return - # Rebuild the table with the correct foreign keys referencing models(id). cursor.execute("ALTER TABLE model_relationships RENAME TO model_relationships_old;") cursor.execute( """ @@ -55,7 +54,7 @@ def _repair_model_relationships_fks(self, cursor: sqlite3.Cursor) -> None: """ ) - # Copy over the existing links, dropping any orphaned rows whose model keys no + # Copy over existing links, dropping orphaned rows whose model keys no # longer exist -- these would violate the restored foreign keys. cursor.execute( """ diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py new file mode 100644 index 00000000000..44d54803d1f --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py @@ -0,0 +1,63 @@ +"""Migration 33: Add the projects table for server-side workbench project persistence. + +Projects are the v7 workbench's primary unit of work (spec: State Ownership). +Each row stores one user-owned project document as opaque JSON, plus a +monotonic revision used for optimistic concurrency: clients send the revision +they loaded, and a save against a stale revision is rejected so concurrent +tabs/devices cannot silently overwrite each other. +""" + +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration33Callback: + """Migration to add the projects table.""" + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._create_projects_table(cursor) + + def _create_projects_table(self, cursor: sqlite3.Cursor) -> None: + cursor.execute( + """--sql + CREATE TABLE projects ( + -- Client-generated identifier; unique per user, not globally. + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + -- Opaque client-owned project document (JSON). + data TEXT NOT NULL, + -- Incremented on every update; used for optimistic concurrency. + revision INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + PRIMARY KEY (user_id, project_id), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """ + ) + cursor.execute( + """--sql + CREATE TRIGGER tg_projects_updated_at + AFTER UPDATE ON projects + FOR EACH ROW + BEGIN + UPDATE projects + SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE user_id = OLD.user_id AND project_id = OLD.project_id; + END; + """ + ) + + +def build_migration_33() -> Migration: + """Builds the migration object for migrating from version 32 to version 33. This includes: + - Creating the `projects` table for per-user workbench project persistence. + - Adding a trigger to keep `updated_at` current. + """ + return Migration( + from_version=32, + to_version=33, + callback=Migration33Callback(), + ) diff --git a/invokeai/backend/model_manager/load/model_cache/cache_stats.py b/invokeai/backend/model_manager/load/model_cache/cache_stats.py index 4998ac6c77a..c339d3b7ad1 100644 --- a/invokeai/backend/model_manager/load/model_cache/cache_stats.py +++ b/invokeai/backend/model_manager/load/model_cache/cache_stats.py @@ -9,6 +9,7 @@ class CacheStats(object): hits: int = 0 # cache hits misses: int = 0 # cache misses high_watermark: int = 0 # amount of cache used + cache_used: int = 0 # current amount of cache used in_cache: int = 0 # number of models in cache cleared: int = 0 # number of models cleared to make space cache_size: int = 0 # total size of cache diff --git a/invokeai/backend/model_manager/load/model_cache/model_cache.py b/invokeai/backend/model_manager/load/model_cache/model_cache.py index e3a0928e52b..6b5da659848 100644 --- a/invokeai/backend/model_manager/load/model_cache/model_cache.py +++ b/invokeai/backend/model_manager/load/model_cache/model_cache.py @@ -77,6 +77,12 @@ class CacheEntrySnapshot: current_vram_bytes: int +@dataclass(frozen=True) +class CacheClearResult: + models_cleared: int + bytes_freed: int + + class CacheMissCallback(Protocol): def __call__( self, @@ -360,6 +366,7 @@ def put(self, key: str, model: AnyModel, execution_device: Optional[torch.device cache_record = CacheRecord(key=key, cached_model=wrapped_model) self._cached_models[key] = cache_record self._cache_stack.append(key) + self._sync_current_stats() self._logger.debug( f"Added model {key} (Type: {model.__class__.__name__}, Wrap mode: {wrapped_model.__class__.__name__}, Model size: {size / MB:.2f}MB)" ) @@ -378,6 +385,11 @@ def _get_cache_snapshot(self) -> dict[str, CacheEntrySnapshot]: return overview + def _sync_current_stats(self) -> None: + if self.stats: + self.stats.cache_used = self._get_ram_in_use() + self.stats.in_cache = len(self._cached_models) + @synchronized @record_activity def get(self, key: str, stats_name: Optional[str] = None) -> CacheRecord: @@ -405,6 +417,7 @@ def get(self, key: str, stats_name: Optional[str] = None) -> CacheRecord: if self.stats: stats_name = stats_name or key self.stats.high_watermark = max(self.stats.high_watermark, self._get_ram_in_use()) + self.stats.cache_used = self._get_ram_in_use() self.stats.in_cache = len(self._cached_models) self.stats.loaded_model_sizes[stats_name] = max( self.stats.loaded_model_sizes.get(stats_name, 0), cache_entry.cached_model.total_bytes() @@ -485,6 +498,7 @@ def unlock(self, cache_entry: CacheRecord) -> None: self._delete_cache_entry(cache_entry) if self.stats: self.stats.cleared = (self.stats.cleared or 0) + 1 + self._sync_current_stats() snapshot = self._get_cache_snapshot() for cb in self._on_cache_models_cleared_callbacks: cb( @@ -820,16 +834,16 @@ def _log_cache_state(self, title: str = "Model cache state:", include_entry_deta self._logger.debug(log) @synchronized - def make_room(self, bytes_needed: int) -> None: + def make_room(self, bytes_needed: int) -> CacheClearResult: """Make enough room in the cache to accommodate a new model of indicated size. Note: This function deletes all of the cache's internal references to a model in order to free it. If there are external references to the model, there's nothing that the cache can do about it, and those models will not be garbage-collected. """ - self._make_room_internal(bytes_needed) + return self._make_room_internal(bytes_needed) - def _make_room_internal(self, bytes_needed: int) -> None: + def _make_room_internal(self, bytes_needed: int) -> CacheClearResult: """Internal implementation of make_room(). Assumes the lock is already held.""" self._logger.debug(f"Making room for {bytes_needed / MB:.2f}MB of RAM.") self._log_cache_state(title="Before dropping models:") @@ -878,9 +892,12 @@ def _make_room_internal(self, bytes_needed: int) -> None: ) gc.collect() + self._sync_current_stats() + TorchDevice.empty_cache() self._logger.debug(f"Dropped {models_cleared} models to free {ram_bytes_freed / MB:.2f}MB of RAM.") self._log_cache_state(title="After dropping models:") + return CacheClearResult(models_cleared=models_cleared, bytes_freed=ram_bytes_freed) def _delete_cache_entry(self, cache_entry: CacheRecord) -> None: """Delete cache_entry from the cache if it exists. No exception is thrown if it doesn't exist.""" @@ -918,6 +935,7 @@ def drop_model(self, model_key: str) -> int: if dropped: if self.stats: self.stats.cleared = len(dropped) + self._sync_current_stats() snapshot = self._get_cache_snapshot() for cb in self._on_cache_models_cleared_callbacks: cb( diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 7033408b197..16642659763 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -728,6 +728,27 @@ } } }, + "/api/v2/models/models_dir": { + "get": { + "tags": ["model_manager"], + "summary": "Get Models Dir", + "description": "Get the absolute path of the directory managed models are stored in.\n\nModel config `path` values are relative to this directory unless they are\nabsolute (in-place installs from outside it).", + "operationId": "get_models_dir", + "responses": { + "200": { + "description": "The absolute path of the models directory", + "content": { + "application/json": { + "schema": { + "type": "string", + "title": "Response Get Models Dir" + } + } + } + } + } + } + }, "/api/v2/models/get_by_attrs": { "get": { "tags": ["model_manager"], @@ -2890,6 +2911,24 @@ "title": "Access Token" }, "description": "access token for the remote resource" + }, + { + "name": "X-Model-Source-Access-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "access token for the remote resource", + "title": "X-Model-Source-Access-Token" + }, + "description": "access token for the remote resource" } ], "requestBody": { @@ -3777,7 +3816,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/EmptyModelCacheResponse" + } } } } @@ -3805,7 +3846,12 @@ } } } - } + }, + "security": [ + { + "HTTPBearer": [] + } + ] }, "post": { "tags": ["model_manager"], @@ -6531,7 +6577,12 @@ } } } - } + }, + "security": [ + { + "HTTPBearer": [] + } + ] } }, "/api/v1/app/external_providers/config": { @@ -6554,7 +6605,12 @@ } } } - } + }, + "security": [ + { + "HTTPBearer": [] + } + ] } }, "/api/v1/app/external_providers/config/{provider_id}": { @@ -6964,6 +7020,24 @@ "default": "DESC" }, "description": "The order of sort" + }, + { + "name": "origin_prefix", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Only include queue items whose origin starts with this prefix", + "title": "Origin Prefix" + }, + "description": "Only include queue items whose origin starts with this prefix" } ], "responses": { @@ -7542,6 +7616,24 @@ "title": "Queue Id" }, "description": "The queue id to perform this operation on" + }, + { + "name": "origin_prefix", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Only include queue items whose origin starts with this prefix", + "title": "Origin Prefix" + }, + "description": "Only include queue items whose origin starts with this prefix" } ], "responses": { @@ -7604,6 +7696,24 @@ "title": "Queue Id" }, "description": "The queue id to perform this operation on" + }, + { + "name": "origin_prefix", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Only include queue items whose origin starts with this prefix", + "title": "Origin Prefix" + }, + "description": "Only include queue items whose origin starts with this prefix" } ], "responses": { @@ -7666,6 +7776,24 @@ "title": "Queue Id" }, "description": "The queue id to perform this operation on" + }, + { + "name": "origin_prefix", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Only include queue items whose origin starts with this prefix", + "title": "Origin Prefix" + }, + "description": "Only include queue items whose origin starts with this prefix" } ], "responses": { @@ -9589,6 +9717,223 @@ } } }, + "/api/v1/projects/": { + "get": { + "tags": ["projects"], + "summary": "List Projects", + "description": "Lists the current user's projects as lightweight summaries (no documents).", + "operationId": "list_projects", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProjectSummaryDTO" + }, + "type": "array", + "title": "Response List Projects" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": ["projects"], + "summary": "Create Project", + "description": "Creates a project for the current user.", + "operationId": "create_project", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreateRequest", + "description": "The project to create" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecordDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/projects/{project_id}": { + "get": { + "tags": ["projects"], + "summary": "Get Project", + "description": "Gets one of the current user's projects, including its document.", + "operationId": "get_project", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the project to get", + "title": "Project Id" + }, + "description": "The id of the project to get" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecordDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": ["projects"], + "summary": "Update Project", + "description": "Saves a project. Returns 409 with the current revision when the save is based on a stale revision.", + "operationId": "update_project", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the project to save", + "title": "Project Id" + }, + "description": "The id of the project to save" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectUpdateRequest", + "description": "The project document and the revision it is based on" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecordDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": ["projects"], + "summary": "Delete Project", + "description": "Deletes one of the current user's projects. Idempotent.", + "operationId": "delete_project", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the project to delete", + "title": "Project Id" + }, + "description": "The id of the project to delete" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/recall/{queue_id}": { "post": { "tags": ["recall"], @@ -14352,6 +14697,11 @@ "title": "High Watermark", "default": 0 }, + "cache_used": { + "type": "integer", + "title": "Cache Used", + "default": 0 + }, "in_cache": { "type": "integer", "title": "In Cache", @@ -21996,6 +22346,21 @@ "required": ["node_id", "field"], "title": "EdgeConnection" }, + "EmptyModelCacheResponse": { + "properties": { + "models_cleared": { + "type": "integer", + "title": "Models Cleared" + }, + "bytes_freed": { + "type": "integer", + "title": "Bytes Freed" + } + }, + "type": "object", + "required": ["models_cleared", "bytes_freed"], + "title": "EmptyModelCacheResponse" + }, "EnqueueBatchResult": { "properties": { "queue_id": { @@ -58819,6 +59184,133 @@ "title": "ProgressImage", "type": "object" }, + "ProjectCreateRequest": { + "properties": { + "project_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Project Id", + "description": "Client-generated project id (e.g. for imports); generated when omitted" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned project document" + } + }, + "type": "object", + "required": ["name", "data"], + "title": "ProjectCreateRequest", + "description": "Request body for creating a project." + }, + "ProjectRecordDTO": { + "properties": { + "project_id": { + "type": "string", + "title": "Project Id", + "description": "The project's client-generated identifier" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "revision": { + "type": "integer", + "title": "Revision", + "description": "Monotonic revision, incremented on every save" + }, + "created_at": { + "type": "string", + "title": "Created At", + "description": "When the project was created" + }, + "updated_at": { + "type": "string", + "title": "Updated At", + "description": "When the project was last saved" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned project document" + } + }, + "type": "object", + "required": ["project_id", "name", "revision", "created_at", "updated_at", "data"], + "title": "ProjectRecordDTO", + "description": "Full project record including the client-owned document." + }, + "ProjectSummaryDTO": { + "properties": { + "project_id": { + "type": "string", + "title": "Project Id", + "description": "The project's client-generated identifier" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "revision": { + "type": "integer", + "title": "Revision", + "description": "Monotonic revision, incremented on every save" + }, + "created_at": { + "type": "string", + "title": "Created At", + "description": "When the project was created" + }, + "updated_at": { + "type": "string", + "title": "Updated At", + "description": "When the project was last saved" + } + }, + "type": "object", + "required": ["project_id", "name", "revision", "created_at", "updated_at"], + "title": "ProjectSummaryDTO", + "description": "Lightweight project listing entry; the document payload is omitted." + }, + "ProjectUpdateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned project document" + }, + "expected_revision": { + "type": "integer", + "title": "Expected Revision", + "description": "The revision this save is based on; mismatch returns 409" + } + }, + "type": "object", + "required": ["name", "data", "expected_revision"], + "title": "ProjectUpdateRequest", + "description": "Request body for saving a project with optimistic concurrency." + }, "PromptTemplateInvocation": { "category": "prompt", "class": "invocation", diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 7864579706a..d946c1ba125 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -388,6 +388,29 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v2/models/models_dir": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Models Dir + * @description Get the absolute path of the directory managed models are stored in. + * + * Model config `path` values are relative to this directory unless they are + * absolute (in-place installs from outside it). + */ + get: operations["get_models_dir"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/models/get_by_attrs": { parameters: { query?: never; @@ -2648,6 +2671,58 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/projects/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Projects + * @description Lists the current user's projects as lightweight summaries (no documents). + */ + get: operations["list_projects"]; + put?: never; + /** + * Create Project + * @description Creates a project for the current user. + */ + post: operations["create_project"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{project_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Project + * @description Gets one of the current user's projects, including its document. + */ + get: operations["get_project"]; + /** + * Update Project + * @description Saves a project. Returns 409 with the current revision when the save is based on a stale revision. + */ + put: operations["update_project"]; + post?: never; + /** + * Delete Project + * @description Deletes one of the current user's projects. Idempotent. + */ + delete: operations["delete_project"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/recall/{queue_id}": { parameters: { query?: never; @@ -5164,6 +5239,11 @@ export type components = { * @default 0 */ high_watermark?: number; + /** + * Cache Used + * @default 0 + */ + cache_used?: number; /** * In Cache * @default 0 @@ -9006,6 +9086,13 @@ export type components = { */ field: string; }; + /** EmptyModelCacheResponse */ + EmptyModelCacheResponse: { + /** Models Cleared */ + models_cleared: number; + /** Bytes Freed */ + bytes_freed: number; + }; /** EnqueueBatchResult */ EnqueueBatchResult: { /** @@ -24845,6 +24932,121 @@ export type components = { */ dataURL: string; }; + /** + * ProjectCreateRequest + * @description Request body for creating a project. + */ + ProjectCreateRequest: { + /** + * Project Id + * @description Client-generated project id (e.g. for imports); generated when omitted + */ + project_id?: string | null; + /** + * Name + * @description The project's display name + */ + name: string; + /** + * Data + * @description The opaque client-owned project document + */ + data: { + [key: string]: unknown; + }; + }; + /** + * ProjectRecordDTO + * @description Full project record including the client-owned document. + */ + ProjectRecordDTO: { + /** + * Project Id + * @description The project's client-generated identifier + */ + project_id: string; + /** + * Name + * @description The project's display name + */ + name: string; + /** + * Revision + * @description Monotonic revision, incremented on every save + */ + revision: number; + /** + * Created At + * @description When the project was created + */ + created_at: string; + /** + * Updated At + * @description When the project was last saved + */ + updated_at: string; + /** + * Data + * @description The opaque client-owned project document + */ + data: { + [key: string]: unknown; + }; + }; + /** + * ProjectSummaryDTO + * @description Lightweight project listing entry; the document payload is omitted. + */ + ProjectSummaryDTO: { + /** + * Project Id + * @description The project's client-generated identifier + */ + project_id: string; + /** + * Name + * @description The project's display name + */ + name: string; + /** + * Revision + * @description Monotonic revision, incremented on every save + */ + revision: number; + /** + * Created At + * @description When the project was created + */ + created_at: string; + /** + * Updated At + * @description When the project was last saved + */ + updated_at: string; + }; + /** + * ProjectUpdateRequest + * @description Request body for saving a project with optimistic concurrency. + */ + ProjectUpdateRequest: { + /** + * Name + * @description The project's display name + */ + name: string; + /** + * Data + * @description The opaque client-owned project document + */ + data: { + [key: string]: unknown; + }; + /** + * Expected Revision + * @description The revision this save is based on; mismatch returns 409 + */ + expected_revision: number; + }; /** * Prompt Template * @description Applies a Style Preset template to positive and negative prompts. @@ -33601,6 +33803,26 @@ export interface operations { }; }; }; + get_models_dir: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The absolute path of the models directory */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; get_model_records_by_attrs: { parameters: { query: { @@ -34215,7 +34437,10 @@ export interface operations { /** @description access token for the remote resource */ access_token?: string | null; }; - header?: never; + header?: { + /** @description access token for the remote resource */ + "X-Model-Source-Access-Token"?: string | null; + }; path?: never; cookie?: never; }; @@ -34712,7 +34937,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["EmptyModelCacheResponse"]; }; }; }; @@ -36836,6 +37061,8 @@ export interface operations { query?: { /** @description The order of sort */ order_dir?: components["schemas"]["SQLiteDirection"]; + /** @description Only include queue items whose origin starts with this prefix */ + origin_prefix?: string | null; }; header?: never; path: { @@ -37203,7 +37430,10 @@ export interface operations { }; get_current_queue_item: { parameters: { - query?: never; + query?: { + /** @description Only include queue items whose origin starts with this prefix */ + origin_prefix?: string | null; + }; header?: never; path: { /** @description The queue id to perform this operation on */ @@ -37235,7 +37465,10 @@ export interface operations { }; get_next_queue_item: { parameters: { - query?: never; + query?: { + /** @description Only include queue items whose origin starts with this prefix */ + origin_prefix?: string | null; + }; header?: never; path: { /** @description The queue id to perform this operation on */ @@ -37267,7 +37500,10 @@ export interface operations { }; get_queue_status: { parameters: { - query?: never; + query?: { + /** @description Only include queue items whose origin starts with this prefix */ + origin_prefix?: string | null; + }; header?: never; path: { /** @description The queue id to perform this operation on */ @@ -38417,6 +38653,157 @@ export interface operations { }; }; }; + list_projects: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectSummaryDTO"][]; + }; + }; + }; + }; + create_project: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectCreateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecordDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_project: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the project to get */ + project_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecordDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_project: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the project to save */ + project_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecordDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_project: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the project to delete */ + project_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_recall_parameters: { parameters: { query?: never; diff --git a/invokeai/frontend/webv2/.gitignore b/invokeai/frontend/webv2/.gitignore new file mode 100644 index 00000000000..22f1644ccfa --- /dev/null +++ b/invokeai/frontend/webv2/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +stats.html diff --git a/invokeai/frontend/webv2/.oxfmtrc.json b/invokeai/frontend/webv2/.oxfmtrc.json new file mode 100644 index 00000000000..a261d816261 --- /dev/null +++ b/invokeai/frontend/webv2/.oxfmtrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "arrowParens": "always", + "bracketSameLine": false, + "bracketSpacing": true, + "endOfLine": "lf", + "ignorePatterns": ["dist/**", "node_modules/**", "stats.html"], + "printWidth": 120, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "sortImports": { + "groups": [ + "type-import", + ["value-builtin", "value-external"], + ["type-internal", "value-internal"], + ["type-parent", "type-sibling", "type-index"], + ["value-parent", "value-sibling", "value-index"], + "unknown" + ] + }, + "sortPackageJson": { + "sortScripts": true + } +} diff --git a/invokeai/frontend/webv2/.oxlintrc.json b/invokeai/frontend/webv2/.oxlintrc.json new file mode 100644 index 00000000000..aba3b3877a9 --- /dev/null +++ b/invokeai/frontend/webv2/.oxlintrc.json @@ -0,0 +1,137 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "react", "import", "unicorn", "oxc", "react-perf", "eslint"], + "categories": { + "correctness": "error" + }, + "rules": { + "curly": "error", + "eqeqeq": "error", + + "import/no-cycle": "error", + "import/no-duplicates": "error", + "import/no-relative-parent-imports": "error", + "import/no-absolute-path": "error", + "import/no-amd": "error", + "import/no-commonjs": "error", + "import/no-mutable-exports": "error", + + "no-console": "warn", + "no-eval": "error", + "no-extend-native": "error", + "no-implied-eval": "error", + "no-label-var": "error", + "no-promise-executor-return": "error", + "no-return-assign": "error", + "no-sequences": "error", + "no-template-curly-in-string": "error", + "no-throw-literal": "error", + "no-unmodified-loop-condition": "error", + "no-var": "error", + + "prefer-template": "error", + "radix": "error", + "no-restricted-exports": "error", + + // "eslint/complexity": [ + // "error", + // { + // "max": 3 + // } + // ], + + "react/jsx-curly-brace-presence": [ + "error", + { + "props": "never", + "children": "never" + } + ], + "react/no-children-prop": "error", + "react/no-danger": "error", + "react/no-unknown-property": "error", + "react/jsx-no-useless-fragment": "error", + "react/exhaustive-deps": "error", + "react/react-compiler": "error", + + "react-hooks/exhaustive-deps": "error", + "react-hooks/rules-of-hooks": "error", + + "react-perf/jsx-no-new-object-as-prop": [ + "error", + { + "nativeAllowList": "all" + } + ], + "react-perf/jsx-no-new-function-as-prop": [ + "error", + { + "nativeAllowList": "all" + } + ], + "react-perf/jsx-no-new-array-as-prop": [ + "error", + { + "nativeAllowList": "all" + } + ], + "react-perf/jsx-no-jsx-as-prop": [ + "error", + { + "nativeAllowList": "all" + } + ], + + "require-await": "error", + + "typescript/consistent-type-imports": [ + "error", + { + "fixStyle": "separate-type-imports", + "prefer": "type-imports" + } + ], + "typescript/no-empty-interface": [ + "error", + { + "allowSingleExtends": true + } + ], + "typescript/consistent-type-assertions": [ + "error", + { + "assertionStyle": "as" + } + ], + "typescript/ban-ts-comment": "error", + "typescript/no-import-type-side-effects": "error", + + "unicorn/no-abusive-eslint-disable": "error", + "unicorn/error-message": "error", + "unicorn/throw-new-error": "error", + "unicorn/prefer-type-error": "error", + + "vitest/require-mock-type-parameters": "off" + }, + "env": { + "browser": true, + "builtin": true, + "es2022": true, + "node": true + }, + "globals": { + "GlobalCompositeOperation": "readonly", + "RequestInit": "readonly" + }, + "ignorePatterns": [ + "dist/**", + "node_modules/**", + "static/**", + ".husky/**", + "patches/**", + "stats.html", + "index.html", + ".yarn/**", + "**/*.scss" + ] +} diff --git a/invokeai/frontend/webv2/OXC_RULE_COMPATIBILITY.md b/invokeai/frontend/webv2/OXC_RULE_COMPATIBILITY.md new file mode 100644 index 00000000000..2af36ab0b91 --- /dev/null +++ b/invokeai/frontend/webv2/OXC_RULE_COMPATIBILITY.md @@ -0,0 +1,23 @@ +# OXC Rule Compatibility + +`webv2` uses `oxlint` and `oxfmt` instead of ESLint and Prettier. + +The config mirrors the existing Invoke web lint intent where OXC supports equivalent rules: + +- React and hooks checks: `react/jsx-no-bind`, `react/jsx-curly-brace-presence`, `react-hooks/*`. +- TypeScript checks: `typescript/consistent-type-imports`, `typescript/no-empty-interface`, plus TypeScript compile checks through `tsc --noEmit`. +- Import checks: `import/no-duplicates`, `import/no-cycle`. +- General correctness/style checks: `curly`, `no-var`, `prefer-template`, `radix`, `eqeqeq`, `no-eval`, `no-extend-native`, `no-implied-eval`, `no-label-var`, `no-return-assign`, `no-sequences`, `no-template-curly-in-string`, `no-throw-literal`, `no-unmodified-loop-condition`, `no-console`, `no-promise-executor-return`, and `require-await`. +- Formatting: `oxfmt` uses the same print width, tab width, semicolon, single quote, and trailing comma preferences as the existing web formatter. It uses `lf` line endings because `oxfmt` does not support Prettier's `auto` value. + +Known unsupported or intentionally deferred equivalents: + +- `brace-style`, `one-var`, and `react/jsx-no-bind`: oxlint 1.69.0 does not expose these ESLint/plugin rule names. +- `simple-import-sort/*`: oxlint does not currently provide the same configurable import sorting behavior. +- `unused-imports/no-unused-imports`: oxlint reports unused bindings, but it is not the same plugin rule. +- `@typescript-eslint/ban-ts-comment`: no exact oxlint equivalent is configured yet. +- `@typescript-eslint/no-import-type-side-effects`: no exact oxlint equivalent is configured yet. +- `@typescript-eslint/consistent-type-assertions`: no exact oxlint equivalent is configured yet. +- `path/no-relative-imports`: no exact oxlint equivalent is configured yet. +- `no-restricted-syntax`, `no-restricted-properties`, `no-restricted-imports`: no exact oxlint config equivalent is configured yet. +- `i18next/no-literal-string` and Storybook-specific overrides: not configured for the initial `webv2` shell. diff --git a/invokeai/frontend/webv2/index.html b/invokeai/frontend/webv2/index.html new file mode 100644 index 00000000000..d5a772e46db --- /dev/null +++ b/invokeai/frontend/webv2/index.html @@ -0,0 +1,48 @@ + + + + + + Invoke V7 Workbench + + + +
+ + + diff --git a/invokeai/frontend/webv2/package.json b/invokeai/frontend/webv2/package.json new file mode 100644 index 00000000000..4bc3b578967 --- /dev/null +++ b/invokeai/frontend/webv2/package.json @@ -0,0 +1,59 @@ +{ + "name": "@invoke-ai/invoke-ai-webv2", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm run lint && vite build", + "dev": "vite dev", + "dev:host": "vite dev --host", + "fix": "pnpm run lint:oxc:fix && pnpm run format:write", + "format:check": "oxfmt --check .", + "format:write": "oxfmt --write .", + "lint": "pnpm run format:check && pnpm run lint:oxc && pnpm run lint:tsc", + "lint:oxc": "oxlint --react-plugin --import-plugin --deny-warnings .", + "lint:oxc:fix": "oxlint --react-plugin --import-plugin --fix .", + "lint:tsc": "tsc --noEmit", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@chakra-ui/react": "^3.36.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@emotion/react": "^11.14.0", + "@fontsource/inter": "^5.2.8", + "@tanstack/react-router": "^1.170.16", + "@tanstack/react-virtual": "^3.14.3", + "@xyflow/react": "^12.11.1", + "lucide-react": "^1.21.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-hook-tanstack-virtual": "^0.0.4", + "react-icons": "^5.6.0", + "socket.io-client": "^4.8.3", + "tinykeys": "^4.0.0", + "use-sync-external-store": "^1.6.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@rolldown/plugin-babel": "^0.2.3", + "@types/node": "^26.0.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@types/use-sync-external-store": "^1.5.0", + "@vitejs/plugin-react": "^6.0.3", + "babel-plugin-react-compiler": "^1.0.0", + "oxfmt": "^0.56.0", + "oxlint": "^1.71.0", + "typescript": "^6.0.3", + "vite": "^8.1.0", + "vitest": "^4.1.9" + }, + "engines": { + "pnpm": "10" + }, + "packageManager": "pnpm@10.12.4" +} diff --git a/invokeai/frontend/webv2/pnpm-lock.yaml b/invokeai/frontend/webv2/pnpm-lock.yaml new file mode 100644 index 00000000000..16860f2e0c1 --- /dev/null +++ b/invokeai/frontend/webv2/pnpm-lock.yaml @@ -0,0 +1,3701 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@chakra-ui/react': + specifier: ^3.36.0 + version: 3.36.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.7) + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.2.17)(react@19.2.7) + '@fontsource/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@tanstack/react-router': + specifier: ^1.170.16 + version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.3 + version: 3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@xyflow/react': + specifier: ^12.11.1 + version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + lucide-react: + specifier: ^1.21.0 + version: 1.21.0(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-hook-tanstack-virtual: + specifier: ^0.0.4 + version: 0.0.4(@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/virtual-core@3.17.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-icons: + specifier: ^5.6.0 + version: 5.6.0(react@19.2.7) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 + tinykeys: + specifier: ^4.0.0 + version: 4.0.0 + use-sync-external-store: + specifier: ^1.6.0 + version: 1.6.0(react@19.2.7) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + '@types/node': + specifier: ^26.0.1 + version: 26.0.1 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@types/use-sync-external-store': + specifier: ^1.5.0 + version: 1.5.0 + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + oxfmt: + specifier: ^0.56.0 + version: 0.56.0 + oxlint: + specifier: ^1.71.0 + version: 1.71.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.1.0 + version: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.0.1)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + +packages: + + '@ark-ui/react@5.37.2': + resolution: {integrity: sha512-Q0R2Ah50kUhup0Ljxg65zGJq5yBV52BLm1coRkjHHid40d1yclaDGfhPL48kcF/xtjAFlGLkL6SiENkGvfh+mw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@chakra-ui/react@3.36.0': + resolution: {integrity: sha512-6AxUbJsC6yyTzPeYL8sxyAL07lflT0NA+S6tcPzEuwdMux+benRMFOpPktnkifWKl/Vq/JD7fhxDyMuDQ4M0gA==} + peerDependencies: + '@emotion/react': '>=11' + react: '>=18' + react-dom: '>=18' + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fontsource/inter@5.2.8': + resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==} + + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.6': + resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + + '@oxfmt/binding-android-arm-eabi@0.56.0': + resolution: {integrity: sha512-CSCxi7ovYojgfdPOdUb9T508HKeAdDIKeRGg7x8IZwVJrWz9gVgX7MbUnFqtQAE4QvoNo07mj2JlwnOzJw4qqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.56.0': + resolution: {integrity: sha512-HYJFnd+PkDwf6S9ZPGzXXtjNqvRWFnnhdbWaouh4mi/SxU8wmDuzlMn3xo/wDTGnr4Q1VA7ZzOaE/D4biW0W6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.56.0': + resolution: {integrity: sha512-sftR/bEOr+t1gs+evwsHi/Xbq2FAPA2uU3VMr8n6ZU9PoK/IMSfnfu7+OEe/uy1+knhrFl4Wvy7Vkm3uo9mJ7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.56.0': + resolution: {integrity: sha512-z66SdjLqa3MUPKvTp3Mbb5nSjKSbnYxJGeB+Wx987s8T5hPcIRiBMfnJ6zcPgYtQn3x5xjvdzNVkXrSeYH6ZFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.56.0': + resolution: {integrity: sha512-t2tkrV1vtZyaItSQ71dTi2ZVKZEI39b/LqLT12V5KMfIeXK6N32TUC1jhOXKVQmhECq9j2ZXMQV3JeT1kh9Vmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.56.0': + resolution: {integrity: sha512-+gCy+Tp3RHeXQ9y/QrS76lXIpZkbziTyp6hIgjB2MssCwfMph3vG/GEfkhO34Rai1vhYIaUkvv8UT1BcDorJPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.56.0': + resolution: {integrity: sha512-0kKkVvQ2I+FJ2sxQyUu1zJ0yWP5kcWse/yVFnGQSFCXMwSSkfEaUGu0dW774O7nyy3jrcBGap7OSc8dZmU/CdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.56.0': + resolution: {integrity: sha512-npkA2siMbyWRh+wEhi1aTAx4RirukGcGNt8V4Ch86pG+xU9aurqS1MZOnKYMu03ISwat3rB6zkQx51SsB9obNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-arm64-musl@0.56.0': + resolution: {integrity: sha512-UekqOjGkV4/MkqreCV9SPIB2jlR3/HbXrmhV1rVXJZ9wfDXMyCMriLtq3tHqLY4PkbVWNtfcm1kMojJ26KLSJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-ppc64-gnu@0.56.0': + resolution: {integrity: sha512-XSzveSpeZMD5XJpew5lRFVtNnT04xd3rJxENXmk7wkZzN9oWzv2aFJyoNDhOtoz69BYaS/fg4SYl+CfEZRpB0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-gnu@0.56.0': + resolution: {integrity: sha512-EkQ0nJa7k7HDDIVuPF7WY+k4k+bzdclLYtyIXNt7/OqVghfNiMym6YGppFBgx1XRIHW6QylxBz5OogumPjPJbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-musl@0.56.0': + resolution: {integrity: sha512-dyjAGW8jKRge0ik6U/dgvQG0nVpA3iBlRskQTz5qJLvQWIrySxX5jpqzPetLBNIIZ231KA82fDdi1nLTk8ENCw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-s390x-gnu@0.56.0': + resolution: {integrity: sha512-60ZGH3LtfqlW8X6vcLdSFY4lvCQYINurttYBKaALnHCDVAUCYJ1LsUgS6p1XOzVlzEDx3yNUZvDF1Lvt59zoZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxfmt/binding-linux-x64-gnu@0.56.0': + resolution: {integrity: sha512-u1suj1tgJHK4ZqB7buCtdbNef2n8+d0lXTPJwLHNmtyK6p+DTpsaoDvmqhQrA56fgKYv4LuRxNtL8YooebKOew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-linux-x64-musl@0.56.0': + resolution: {integrity: sha512-aYGLvlQHt80y+qKEtfJY/Nm27G0125Lv+qyh9SJ4Cjc6lXnXjD+ndfhqQnbV24POpMi7rNRi0jvx/0d70FRpCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-openharmony-arm64@0.56.0': + resolution: {integrity: sha512-H/re/gO+7ysVc+kywHNuzY3C33EN9sQcZhg0kp1ZwOZl7y998ZE5mhnBiuGR/nYI0pqLL5xQfrHVUOJ/cIJsCA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.56.0': + resolution: {integrity: sha512-6qLNXfXmtAs8jXDvYMkxk6Wec5SUJoew+ZX1uOZmqaR7ks0EJFbAohuOCELDyJMWyVlxotVG8Xf8m74Bfq0O2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.56.0': + resolution: {integrity: sha512-UXEXuKphAe15bsob4AswNMArCw38XSmUIs3wk1s6e6MX9OWGW/IRWU95s1hZDiVg09STy1jHgyN2qkqbu1FT0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.56.0': + resolution: {integrity: sha512-HPyNDjky+NIOuaMvHZflR+kst3YWdUOH2JUQYkf99grqZ5mEBTQM6h9iGy501Z8Xt5xMScrwHOuVCOlqDrktRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.71.0': + resolution: {integrity: sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.71.0': + resolution: {integrity: sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.71.0': + resolution: {integrity: sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.71.0': + resolution: {integrity: sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.71.0': + resolution: {integrity: sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.71.0': + resolution: {integrity: sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.71.0': + resolution: {integrity: sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.71.0': + resolution: {integrity: sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.71.0': + resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.71.0': + resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.71.0': + resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.71.0': + resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.71.0': + resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.71.0': + resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.71.0': + resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.71.0': + resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.71.0': + resolution: {integrity: sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.71.0': + resolution: {integrity: sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.71.0': + resolution: {integrity: sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@pandacss/is-valid-prop@1.11.3': + resolution: {integrity: sha512-YaHK+p5DaN8AUpsRx5OqqGxaZzn8uNIdVhP+K1cjvjv3+Qa9D/75/A1dPyLmfKrSRJc8UoR9WN9fxQX0rVzhzQ==} + engines: {node: '>=20'} + + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.170.16': + resolution: {integrity: sha512-w6eq1IJklujs1tESazaK/FxH0+H2l8vm/QPuu1cD3oRW/ubgKneQpd7b64ti/8gUyEimzimJQZDmJr6YHfP5+g==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-virtual@3.14.3': + resolution: {integrity: sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.13': + resolution: {integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-core@3.17.1': + resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/use-sync-external-store@1.5.0': + resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + 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 + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + '@xyflow/react@12.11.1': + resolution: {integrity: sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.78': + resolution: {integrity: sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==} + + '@zag-js/accordion@1.41.2': + resolution: {integrity: sha512-7G//V7svGGT8k5avw7bbQvbRC0Q/9QtX51b4iyAB1alR9E5mFd6Ch8q4njwcXClMQ7xePS3jUfVnzVGiRInEiQ==} + + '@zag-js/anatomy@1.41.2': + resolution: {integrity: sha512-Fm9hqdrvaCzCsdcf19G8WZxYtHElKltkGHdhqMEt4XU+ULTr1DK7KbOtDDv9J27CuzqSLALUz5QfRjPftoKHwg==} + + '@zag-js/angle-slider@1.41.2': + resolution: {integrity: sha512-+7bZHAZx0MEbjTMr2tD+meFJ0EJwFfUEcTqmdLzFGr/ySAMCWlcadDBz+ZmrSn03aKLps8FxliVLzsFJNgUqIQ==} + + '@zag-js/aria-hidden@1.41.2': + resolution: {integrity: sha512-qEcYmwlQr3qjA0T/IZ5a/o7fRUxfQ14tXjAFhR3GXCtxBKaqS+wnq/LN09Xw4bin3QWTINU+Z0oXFs9spWtNwA==} + + '@zag-js/async-list@1.41.2': + resolution: {integrity: sha512-NZZEIGFdeDp2uHjsLVegLAGJOYGwI9HPJI1V2c/P1TQmfmrfWyWELAvnnW4kWYVUKYD9TxKQkm6LvqpHrQzgfQ==} + + '@zag-js/auto-resize@1.41.2': + resolution: {integrity: sha512-CYq+JQ1TTkEiK7OcNcMTS0f4wFvtmManvUltije5o50gDQ7vFYA81oQh4A9pAB1keUi9Zv06CNefFARaTcne5w==} + + '@zag-js/avatar@1.41.2': + resolution: {integrity: sha512-+4K0tRtIQysMGuERh5JRmn46uq7gJ6IjJd5DKj74VBtVVY8T6HGk0D0DZxmOIgADHP1qSvowLDoOX8kUyBHarQ==} + + '@zag-js/carousel@1.41.2': + resolution: {integrity: sha512-H6sDxyWIzkvtHN4M/BYvFy7pi8j+kLEtfPZvgNl2+gRnfAHVK9/XqpoUsjw1DAo5Fh25pPuaTnh52Kml9OK0iA==} + + '@zag-js/cascade-select@1.41.2': + resolution: {integrity: sha512-dBByZmIAJU/B+YIAzczng05lCJXEwEm4df6GmUZtioKHZdeN+WEKUP4qzFDFZdytXympGfJCIBvtgf87gACYVQ==} + + '@zag-js/checkbox@1.41.2': + resolution: {integrity: sha512-aQ5dZWHUHRIw4cbLtzrR+dc8M0N6wSiiCml/zbU3ciHOTBXSrs2rKnp/L99xhi97Lj2uCEhpIZ704Ou/MjGuCg==} + + '@zag-js/clipboard@1.41.2': + resolution: {integrity: sha512-FM+PNGEeY+YpF1dVr9d8kg3DQM66LRnkR4Mva1oprqnrCXTYWj+k7uig893ubSG2xw1GO5b/WJd27kSmNoKnmw==} + + '@zag-js/collapsible@1.41.2': + resolution: {integrity: sha512-uMIp4rqd3iI6VFPMAOcbu9dh9WV4l85nNPYQiBtMKRHgbkfaZdfzF+E+NX3KquIeHW6JiBFUiIyCU0Sf2Cscdw==} + + '@zag-js/collection@1.41.2': + resolution: {integrity: sha512-ZZWuvfPZI8ccWd4aLpuU47k1jSc9eO+F3FM3iBJuvgegCH6g3+HDEwGN6wdePHnYfv7zyIKCGKr816zXcIWQbw==} + + '@zag-js/color-picker@1.41.2': + resolution: {integrity: sha512-UynyJ/bTBeSlq6ziHr9GVrFycDjVxfLFIb8Aivu/XvihrAAvkhXUxwy+1ax+hj7peGlTY590xoQAvQixV+McBA==} + + '@zag-js/color-utils@1.41.2': + resolution: {integrity: sha512-Lsi5c2ztGZqud0oeDtAn3xFhgrYMaeaz1zi4p+mr0zXOuQNuZzfsrJ0stQ45s8L/xT8UJtGhYyEVy1+xjE4ARA==} + + '@zag-js/combobox@1.41.2': + resolution: {integrity: sha512-V9jQteyQHs8ZNQx/FcA98P1NVZas/yjiW1V7s4L+h8MnRS3AK97+FAHAT83KCiZ34/vkpLF6ZEHI2zkcQpNXcg==} + + '@zag-js/core@1.41.2': + resolution: {integrity: sha512-xXTN3zKwOtMI4+5dG2cG+T1B4WR3X9alXiYaPJKaGd4N2eYRj9JEPte3Hv9gtFm+RM0b9VIwksHEg25rqE4apw==} + + '@zag-js/date-input@1.41.2': + resolution: {integrity: sha512-J8PdpEpM9TBxZBEE8/Nxi39LNJOZY7lilTbgcDABR5uiviZUk5++5GSdUTspcjFUIXx5SvqmdCk2RF7hsUEHVQ==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-picker@1.41.2': + resolution: {integrity: sha512-j9LaznCV4QCfrq/y/mNAN9XgIRFFg+414TxGT4TK8mfWouIaFvxEoKdGP0l/LL4DFv1NRDaRdSBBcw3eXtMVnA==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-utils@1.41.2': + resolution: {integrity: sha512-jdcWLa5fYLTsvGWJkx4v/9Hzqf6UHdvJDeP6NxHpwoEmzYy7+AghYKBNSnRrJIBCQJgl1vM9OkpMvYrLNHr9jw==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/dialog@1.41.2': + resolution: {integrity: sha512-t1N72snpFGiKj7cg2PivPdsy+X1YdgXPv7bzovpqjMLy0kN+gYTPoV1Iy/hQA+g021dz9XGgXzkDuU45sJqsFA==} + + '@zag-js/dismissable@1.41.2': + resolution: {integrity: sha512-hO/tFhRZ7S+LOOljGOQJIubbc3MXg41+iWR1yUXKl76cAenbxaCit1LZmUCwQPvRN0GndK6bDQo5ETjHZz/k7A==} + + '@zag-js/dom-query@1.41.2': + resolution: {integrity: sha512-+eBk1nlJA312mNmY/GSThLRwcCRqMIL+A1pLsWvTlQLQjmH1/UxoAuv6l2yvRCT33XmC8FBlBIKnXhOCpDvIZA==} + + '@zag-js/drawer@1.41.2': + resolution: {integrity: sha512-aJql6L0cfHd1wXbemfLcNnjqTA/CbwVgQdWEVn5Qci6zhy4UJXPQeBBfxfgPgEOAbW60gL/gaaXG3d9Vx6+6oA==} + + '@zag-js/editable@1.41.2': + resolution: {integrity: sha512-loTM1lrHBBqYfR8SJZrYayVdWHMpXCLVir+aDTQ8/d4bb/Gfl/L8CJNs3BRj3yt9zvLoWTLO+4LOY3hLyQSR2w==} + + '@zag-js/file-upload@1.41.2': + resolution: {integrity: sha512-WFwIaKvHpCUjyYMvp42VoydT1WIP5DhDlpmG/nrF4i0ro7pLGb1A4dvyBrAfGcozCB88yR1y1iZKPDY1I9/uUw==} + + '@zag-js/file-utils@1.41.2': + resolution: {integrity: sha512-Ih+8ULbId0M+CFR4IsqG5y/0VLCk2l+1rgPH+21L40dlSB6z6qKSP2tG7W69Cj2/3vryZsn67ibn26iCPG/vOw==} + + '@zag-js/floating-panel@1.41.2': + resolution: {integrity: sha512-nJP3oZ4YrJh+7H5YdwNccSzPGXFqjQN1ujZ/xGDhegjz4XtL48QMFnAasxlRI8VGjse9Tj8VXlQZxXPrASGzlA==} + + '@zag-js/focus-trap@1.41.2': + resolution: {integrity: sha512-3QTtGUjFU2OLbyrDlyoYWvKZecCmtn/+bsfsHW159jJHiEGHVYK6CY8AI1ePsMk9gVay48bXh008j+lVli7gAw==} + + '@zag-js/focus-visible@1.41.2': + resolution: {integrity: sha512-oRjwtgafUdGVwLJUN6mKsnBQbez/CHYAPkPg1FxOnr5GFpEpr8oMTOZJ3wdPM2U1ynS9QnUUu/sXc3KQv/jX0Q==} + + '@zag-js/highlight-word@1.41.2': + resolution: {integrity: sha512-95zcZKqNrL7JlqAckfzHa+LRbnfoz1lj6skUhq/uHSnni1vH6+8fNWT8ruo9G7vpGopbyRmmcie5p41SbAwQjQ==} + + '@zag-js/hover-card@1.41.2': + resolution: {integrity: sha512-Xn9RVzgTkKaVzyJTDdBJiXmCleNi1/hmW8z73tC0vOiQvSSvPejg/JkzqTOLFODvQHK3NOw54QHZvmjYp9Mubw==} + + '@zag-js/i18n-utils@1.41.2': + resolution: {integrity: sha512-f1xqaEY79awBxgUyjFso0UEpIoEHZq+zRvB0nUVFHJRW7Ds/QhaFHKSRnf2QBTlP/ObvCT225R+piNAAc17Aaw==} + + '@zag-js/image-cropper@1.41.2': + resolution: {integrity: sha512-750aT4U+J/TJw/Z1QVoLU9JG0luCtf9CyvShtJFIxeS+i25aUoBI9pOKgSFABsOutIqNJTPq4gYpqtuxFjSxRw==} + + '@zag-js/interact-outside@1.41.2': + resolution: {integrity: sha512-dM4Fn9iyqQeqkCMRYZP+bAgWEPKRVQRqMmcPsN0OXBhhFKC31Em15LTIZXaOtVKAjH+iwx+UvSYFRiWwEjkOEA==} + + '@zag-js/json-tree-utils@1.41.2': + resolution: {integrity: sha512-gNaOzsbCwmTd2HM3/u0xQdWX5UDBfl8tCXFavzbamkFH0iYQOXJb7cqUXBVuI4KScIbHPCKwrzZjqA5Sg9qzAQ==} + + '@zag-js/listbox@1.41.2': + resolution: {integrity: sha512-iDGrZleP3ui2Q6Jgmr9RYlbd7njdJHs8Qb3IJrSIZBIeYyYmFvVUfAFjk0g/z/amjTx6uYxRASWSPy/RETd4ug==} + + '@zag-js/live-region@1.41.2': + resolution: {integrity: sha512-7ubIW5AQt1wx9S/gFN+rU4TyvuFWJrL/DhnDWPNlH5g3luDVHSNeYGeeqf4d4tkcibtpYZa0pg9CbXxRxrfwVA==} + + '@zag-js/marquee@1.41.2': + resolution: {integrity: sha512-cT77aMhrtAK3oe2O6+X3TLtQs8wupdPUtZgHMWVxCcQOwagrk7RUBEQwU3Cx7cTswpBdTKSuDYgUggfgCs96wg==} + + '@zag-js/menu@1.41.2': + resolution: {integrity: sha512-wwix8hcAUSi0scpWXCiDppfdZV01Za8nN0gqLt9GdhCiVSlr0rs9pK1ROgPKJTyc43UZfyFPqtTWVHvEHMM70w==} + + '@zag-js/navigation-menu@1.41.2': + resolution: {integrity: sha512-aROB9CHzskZpnoFuGFkp7dbkZdsXvp2nxQgsgld02I1sDqiwcQd+YMdB0/6Ik0oz6X8I70RKdUuQM9WQFQfDnA==} + + '@zag-js/number-input@1.41.2': + resolution: {integrity: sha512-QoQWCiVHO+ciUbq8uL8Kxhtk4o3UpwzYpJkfsOfitzsZoYpPc7V/A6+n5yABV2SOwpqBODwNASZdxiEa60kfow==} + + '@zag-js/pagination@1.41.2': + resolution: {integrity: sha512-vZTxz4DrIDfedMcTjDeEkOc1iXD9wkP8eKuEcDieHOodnjSnNNwtmoFw5DCWv+yEa/TByIamXBZ8ZxHY144JgA==} + + '@zag-js/password-input@1.41.2': + resolution: {integrity: sha512-OWKFl0S12Qnlf4R4WbMCJ/YU0kGfezm5tP0UiyubMO/Fixv6H0twDFaJSPg2F6POv5uomCcGubc1H7gO+fIhsQ==} + + '@zag-js/pin-input@1.41.2': + resolution: {integrity: sha512-Wrn3YDbmWL3qvUIzN4QyLO7PzEhunX7z8DwBpmrK04p7HxR9pniTLVIPk27xk2MzyAPYcl4mwd9/Mc88tBxHsg==} + + '@zag-js/popover@1.41.2': + resolution: {integrity: sha512-h/LlVMIERM+NWzYV0ZHURlJuaqkT8XxwyEV96XfVzjknDRFNoPSl5IttVYtoaPoUqC0p/Y4oTSiee4mUZKOLHw==} + + '@zag-js/popper@1.41.2': + resolution: {integrity: sha512-Iz4D5YAIiIPn4IHGjhX3QatR/RyGaDt43lBSZv0RYcPQYtFg9sUuek3wizjW9qXgdvItevvNMqRdpl7f3es09g==} + + '@zag-js/presence@1.41.2': + resolution: {integrity: sha512-OhOLPAf/DYPmgoEntrlrf3LOrkwA+Y0J3K03NXHXPnkRB7h8jyIbHqzHS0jRTb1pvsO2P/yowRyoYtptY2Zmog==} + + '@zag-js/progress@1.41.2': + resolution: {integrity: sha512-SnzrqN+Z568NoO4rrgjrIc/S4EXMEne5CDgWwt2kQ8yq9VysdH8TtHAjyciFRIio7cdgEZNHKw9jSccpMWgRwA==} + + '@zag-js/qr-code@1.41.2': + resolution: {integrity: sha512-+JLswCNnzf58aQTaX0SMUA9wRC8Hb/a/ZveJIXTz6853Siemay2HqOqB2WQIeF33HNldUFD/a4+4Q8ugg93ubA==} + + '@zag-js/radio-group@1.41.2': + resolution: {integrity: sha512-EZjos61jKHZlNw8ez80vG2T9gUwpooxcVpYOKS2hyhuEXn3wEoefS5v1WFbmpoA/8TUpUQnYxisAeNuQfEQCuQ==} + + '@zag-js/rating-group@1.41.2': + resolution: {integrity: sha512-SbJP4HiK5XRy/oC3xRowjZOCThq1n/QA2Z/XAkvKLJArVQCYjrH3MoWwWvMVNfNC5+ZJluborR2AGF7tlVLzxA==} + + '@zag-js/react@1.41.2': + resolution: {integrity: sha512-5Bx7mQAron4LFWI8Hhs/uw5kwQ19s2Tn30HhctozLqmCu4nnJSTSh7GRvX+uwRZnztGXBXoOrgBWIepU4RXFGg==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@zag-js/rect-utils@1.41.2': + resolution: {integrity: sha512-GWBTamaMLMG1p7Fe6V0dsXeTEmk+tqG3ciovzmjxURCJ3Yq2EkAMRhS0v5DG0oo+PyrPEIzEWukGBQkh/XRgYQ==} + + '@zag-js/remove-scroll@1.41.2': + resolution: {integrity: sha512-ieIrOgPKlCikAGEBIboQJoU7oxrL5BEY670tDOu7Eg7rNOdAwGXLEKPX2A/q+lREhOiVYjx0D3//vHTvdte80Q==} + + '@zag-js/scroll-area@1.41.2': + resolution: {integrity: sha512-hJFAwfIFuS7XmNsS3nB5rPm9OWXfB4d98sID7fjurcaZtDH5LqFriqfXhceNvs7rz7K4f/u8rufXrb6tcvATIA==} + + '@zag-js/scroll-snap@1.41.2': + resolution: {integrity: sha512-+70Al6LSASyEZtFyyffUJlDbE88KgYJDud05z8oZTqyEOLlTqnlSNkXq/P3siO7r3sNykg9H+TmAKn+/dVSKuw==} + + '@zag-js/select@1.41.2': + resolution: {integrity: sha512-3wGaKABILexoNBJ1bJiHqLLTctR/VMZaNA4cyKiqZeBEWtAkdMhgyY2xKobrP6KtUTqAeUFNVSTu4yCDrAQnUw==} + + '@zag-js/signature-pad@1.41.2': + resolution: {integrity: sha512-iNrOxY4gtqhsZdXYvlhF7s+LiOvwV7/kBpNnq8tJ1oYhgTs8wvKtJHrP86/CR05irEXQTaLfmeAJZuBEys9iRA==} + + '@zag-js/slider@1.41.2': + resolution: {integrity: sha512-mKK2BwoDbIGxAdkdKkPZJA1SHtEQt3lS9hJ6WghefYU2vyd0BXoIKvcDV3xJOzly5LXYhH5cJITn6JtGK8353A==} + + '@zag-js/splitter@1.41.2': + resolution: {integrity: sha512-Ubp4hkmzvVysU31jCINYbBXVqruu7rEPGqugWMzeXC5Hwda648aHpbg4Jix/wRtaGLjsyh6KOVEwAmoJU9NwhQ==} + + '@zag-js/steps@1.41.2': + resolution: {integrity: sha512-m0t2r8+FWwa2b2aU5JiNrHVdYHyNZYHK0G3Tq9lCOSQoDeoJIkyta+sIVehLVSY+0Ba9kOlkRUmYLbsnfXaW+Q==} + + '@zag-js/store@1.41.2': + resolution: {integrity: sha512-dVZF7E1ezXzynrKhMH3rfSr2rBbCfvTjvXbXz7//1PNULuq58UU5dG93V+9l834npCZxI2+PrpY45wZLJPTsIA==} + + '@zag-js/switch@1.41.2': + resolution: {integrity: sha512-qHbQK95UUHN0tj+bf9LLphLcMo/uTg2Pvs0c1Gs03Zh4g3NtHf0uYIhMZKYlCH0hNVlKrmdzLKWgeoDxv9gySw==} + + '@zag-js/tabs@1.41.2': + resolution: {integrity: sha512-7YVj2mCcxRbn1wMXP9anaTOVf+J0fa5uaPScr8e4+e2xc+/1WKzqN6V8IDeKS5wV/xzi1r3Ny307sX7Xz4ZJVQ==} + + '@zag-js/tags-input@1.41.2': + resolution: {integrity: sha512-aIPEndSO+9LHxyoXLUr0Uttxw2cYMyDuii5w50wn/N7lFJD49U3Sj+6XaB/oJSbDAwn10WrsRDtb7Gs2kmU+Qw==} + + '@zag-js/timer@1.41.2': + resolution: {integrity: sha512-PRYLWaip0+1FeVGEMNk5wMGAAIYgBIWwulZ4U5I+2Ayjohzp2NUAfwJ3sqoYvRrfjNYUNAPdU4eGu1zetC+oVQ==} + + '@zag-js/toast@1.41.2': + resolution: {integrity: sha512-+F3PsAo6EIz4rh73IOMCb/+FOUp7e3VjUY2q5sdU4IbfOzJBIbVSJxn0PQmHkuxkzWdCom0Lv0qSPFg/UTplnQ==} + + '@zag-js/toggle-group@1.41.2': + resolution: {integrity: sha512-C6wn3A89h24hTs0BN9ryEuKatfR493u7QqxS06TeK9oI/KZBvm5Rwm8FPHSUJvsUTkAkow4PsXC0Ra19duzEdQ==} + + '@zag-js/toggle@1.41.2': + resolution: {integrity: sha512-EFB9pb3pEtwXt7RSivVLWXV64dKUF8gsn75tt8TbYBgfy+zW85MsRLu8U48TXZN5teQWAKKNujr9bxQsW/CWvQ==} + + '@zag-js/tooltip@1.41.2': + resolution: {integrity: sha512-68okWJCFXfW8r0h97kEcU2yKPkq6e0S6QkiYh09ifMXoYjrQw/shPol8PgrS1poqKJigUWtsKm+bw73abhMn3w==} + + '@zag-js/tour@1.41.2': + resolution: {integrity: sha512-Q7UsvuHYYBo1Cs4b4OS0e/D8lxd2GpSRII93s5BQPi5HTcBjaWhVysAykmCFbat6Z56z0NBmFHNdhZ8oVPls1A==} + + '@zag-js/tree-view@1.41.2': + resolution: {integrity: sha512-QNi0VpV+RyzF4NP72+kSleUpauF9SMAzOAe59nxvs8jAUHqV5diDCInnbQos4+cyFDXFzGq3lElot26A1yI+7Q==} + + '@zag-js/types@1.41.2': + resolution: {integrity: sha512-L6CNvK06lIVpy0X8eG3kbDIx8Uuv+3KHElxXYSzRXSJ7/OLCv1sTRgEvnxNtdIWOrksGgxF4JtT7PXtoClGqNQ==} + + '@zag-js/utils@1.41.2': + resolution: {integrity: sha512-Yj8FSrR7vGA6ahUhjrThfHAF+PM2Y1Yv2lkXkqZZd60mPBhixcot1+SHOfEMV63JimQcWrmQ8QbeYYMmF+ZpLQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + baseline-browser-mapping@2.10.40: + resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.379: + resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==} + + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + isbot@5.1.44: + resolution: {integrity: sha512-PGEHtwMnKbZpeSEXW2Utx+/JWed7dp6DiH0WWg33vGSDA7RUvpUeJSVlLrVkQ1RCpvDOUc/eH9ql7VsdbBZ8pA==} + engines: {node: '>=18'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.21.0: + resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + engines: {node: '>=18'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + oxfmt@0.56.0: + resolution: {integrity: sha512-9Dv0wV3zKiyvhjD7bRKaInKmHQ1sPx3RGOjQkGFJbbdQ16576yf8qhMSO9Q9cvHcs+1NpBsRTkuDDYFFPTJ6gw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.71.0: + resolution: {integrity: sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-freehand@1.2.3: + resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + + proxy-memoize@3.0.1: + resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-hook-tanstack-virtual@0.0.4: + resolution: {integrity: sha512-N6OnfdklUygY6K9Ik7SGZlAlc3uVOVm8kp3OP7d4m+0ELGgABm4W/f8kJPcda8CkgQUkFvDO8t8lOzbEsdJG3w==} + peerDependencies: + '@tanstack/react-virtual': ^3.14.2 + '@tanstack/virtual-core': ^3.17.0 + react: ^19.2.0 + react-dom: ^19.2.0 + + react-icons@5.6.0: + resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} + peerDependencies: + react: '*' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinykeys@4.0.0: + resolution: {integrity: sha512-z5bQRQHL1PSx3lGOZEAMM2oKp0EBtn5T5f7HJnaKPOzk6vEH0wUPvdHLeQ7F4tmkek8AgoTQISUFmn/5SNF2xA==} + engines: {node: '>=22'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.1.0: + resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + 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 + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + 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 + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@ark-ui/react@5.37.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@internationalized/date': 3.12.2 + '@zag-js/accordion': 1.41.2 + '@zag-js/anatomy': 1.41.2 + '@zag-js/angle-slider': 1.41.2 + '@zag-js/async-list': 1.41.2 + '@zag-js/auto-resize': 1.41.2 + '@zag-js/avatar': 1.41.2 + '@zag-js/carousel': 1.41.2 + '@zag-js/cascade-select': 1.41.2 + '@zag-js/checkbox': 1.41.2 + '@zag-js/clipboard': 1.41.2 + '@zag-js/collapsible': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/color-picker': 1.41.2 + '@zag-js/color-utils': 1.41.2 + '@zag-js/combobox': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/date-input': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/date-picker': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/dialog': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/drawer': 1.41.2 + '@zag-js/editable': 1.41.2 + '@zag-js/file-upload': 1.41.2 + '@zag-js/file-utils': 1.41.2 + '@zag-js/floating-panel': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/highlight-word': 1.41.2 + '@zag-js/hover-card': 1.41.2 + '@zag-js/i18n-utils': 1.41.2 + '@zag-js/image-cropper': 1.41.2 + '@zag-js/json-tree-utils': 1.41.2 + '@zag-js/listbox': 1.41.2 + '@zag-js/marquee': 1.41.2 + '@zag-js/menu': 1.41.2 + '@zag-js/navigation-menu': 1.41.2 + '@zag-js/number-input': 1.41.2 + '@zag-js/pagination': 1.41.2 + '@zag-js/password-input': 1.41.2 + '@zag-js/pin-input': 1.41.2 + '@zag-js/popover': 1.41.2 + '@zag-js/presence': 1.41.2 + '@zag-js/progress': 1.41.2 + '@zag-js/qr-code': 1.41.2 + '@zag-js/radio-group': 1.41.2 + '@zag-js/rating-group': 1.41.2 + '@zag-js/react': 1.41.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@zag-js/scroll-area': 1.41.2 + '@zag-js/select': 1.41.2 + '@zag-js/signature-pad': 1.41.2 + '@zag-js/slider': 1.41.2 + '@zag-js/splitter': 1.41.2 + '@zag-js/steps': 1.41.2 + '@zag-js/switch': 1.41.2 + '@zag-js/tabs': 1.41.2 + '@zag-js/tags-input': 1.41.2 + '@zag-js/timer': 1.41.2 + '@zag-js/toast': 1.41.2 + '@zag-js/toggle': 1.41.2 + '@zag-js/toggle-group': 1.41.2 + '@zag-js/tooltip': 1.41.2 + '@zag-js/tour': 1.41.2 + '@zag-js/tree-view': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@chakra-ui/react@3.36.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ark-ui/react': 5.37.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@19.2.17)(react@19.2.7) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.7) + '@emotion/utils': 1.4.2 + '@pandacss/is-valid-prop': 1.11.3 + csstype: 3.2.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.29.7 + '@babel/runtime': 7.29.7 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.7) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.2.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@fontsource/inter@5.2.8': {} + + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.6': + dependencies: + '@swc/helpers': 0.5.23 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.137.0': {} + + '@oxfmt/binding-android-arm-eabi@0.56.0': + optional: true + + '@oxfmt/binding-android-arm64@0.56.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.56.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.56.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.56.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.56.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.56.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.56.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.56.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.56.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.56.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.56.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.56.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.56.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.71.0': + optional: true + + '@oxlint/binding-android-arm64@1.71.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.71.0': + optional: true + + '@oxlint/binding-darwin-x64@1.71.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.71.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.71.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.71.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.71.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.71.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.71.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.71.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.71.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.71.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.71.0': + optional: true + + '@pandacss/is-valid-prop@1.11.3': {} + + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))': + dependencies: + '@babel/core': 7.29.7 + picomatch: 4.0.4 + rolldown: 1.1.3 + optionalDependencies: + '@babel/runtime': 7.29.7 + vite: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + + '@rolldown/pluginutils@1.0.1': {} + + '@socket.io/component-emitter@3.1.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tanstack/history@1.162.0': {} + + '@tanstack/react-router@1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-core': 1.171.13 + isbot: 5.1.44 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + '@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/router-core@1.171.13': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-core@3.17.1': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + + '@types/parse-json@4.0.2': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/use-sync-external-store@1.5.0': {} + + '@vitejs/plugin-react@6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + babel-plugin-react-compiler: 1.0.0 + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@xyflow/react@12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@xyflow/system': 0.0.78 + classcat: 5.0.5 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + zustand: 4.5.7(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.78': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + + '@zag-js/accordion@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/anatomy@1.41.2': {} + + '@zag-js/angle-slider@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/aria-hidden@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/async-list@1.41.2': + dependencies: + '@zag-js/core': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/auto-resize@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/avatar@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/carousel@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/scroll-snap': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/cascade-select@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/checkbox@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/clipboard@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/collapsible@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/collection@1.41.2': + dependencies: + '@zag-js/utils': 1.41.2 + + '@zag-js/color-picker@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/color-utils': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/color-utils@1.41.2': + dependencies: + '@zag-js/utils': 1.41.2 + + '@zag-js/combobox@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/core@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/date-input@1.41.2(@internationalized/date@3.12.2)': + dependencies: + '@internationalized/date': 3.12.2 + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/dom-query': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/date-picker@1.41.2(@internationalized/date@3.12.2)': + dependencies: + '@internationalized/date': 3.12.2 + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/date-utils@1.41.2(@internationalized/date@3.12.2)': + dependencies: + '@internationalized/date': 3.12.2 + + '@zag-js/dialog@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/aria-hidden': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/remove-scroll': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/dismissable@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/dom-query@1.41.2': + dependencies: + '@zag-js/types': 1.41.2 + + '@zag-js/drawer@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/aria-hidden': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/remove-scroll': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/editable@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/file-upload@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/file-utils': 1.41.2 + '@zag-js/i18n-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/file-utils@1.41.2': + dependencies: + '@zag-js/i18n-utils': 1.41.2 + + '@zag-js/floating-panel@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/store': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/focus-trap@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/focus-visible@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/highlight-word@1.41.2': {} + + '@zag-js/hover-card@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/i18n-utils@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/image-cropper@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/interact-outside@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/json-tree-utils@1.41.2': {} + + '@zag-js/listbox@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/live-region@1.41.2': {} + + '@zag-js/marquee@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/menu@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/navigation-menu@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/number-input@1.41.2': + dependencies: + '@internationalized/number': 3.6.6 + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/pagination@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/password-input@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/pin-input@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/popover@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/aria-hidden': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/remove-scroll': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/popper@1.41.2': + dependencies: + '@floating-ui/dom': 1.7.6 + '@zag-js/dom-query': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/presence@1.41.2': + dependencies: + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + + '@zag-js/progress@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/qr-code@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + proxy-memoize: 3.0.1 + uqr: 0.1.3 + + '@zag-js/radio-group@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/rating-group@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/react@1.41.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@zag-js/core': 1.41.2 + '@zag-js/store': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@zag-js/rect-utils@1.41.2': {} + + '@zag-js/remove-scroll@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/scroll-area@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/scroll-snap@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/select@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/signature-pad@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + perfect-freehand: 1.2.3 + + '@zag-js/slider@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/splitter@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/steps@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/store@1.41.2': + dependencies: + proxy-compare: 3.0.1 + + '@zag-js/switch@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tabs@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tags-input@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/auto-resize': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/timer@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/toast@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/toggle-group@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/toggle@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tooltip@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tour@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tree-view@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/types@1.41.2': + dependencies: + csstype: 3.2.3 + + '@zag-js/utils@1.41.2': {} + + assertion-error@2.0.1: {} + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.7 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.7 + + baseline-browser-mapping@2.10.40: {} + + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.40 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.379 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001799: {} + + chai@6.2.2: {} + + classcat@5.0.5: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + + csstype@3.2.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.379: {} + + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.0 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + optional: true + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + find-root@1.1.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + is-arrayish@0.2.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + isbot@5.1.44: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + 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 + + lines-and-columns@1.2.4: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.21.0(react@19.2.7): + dependencies: + react: 19.2.7 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + node-releases@2.0.50: {} + + obug@2.1.3: {} + + oxfmt@0.56.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.56.0 + '@oxfmt/binding-android-arm64': 0.56.0 + '@oxfmt/binding-darwin-arm64': 0.56.0 + '@oxfmt/binding-darwin-x64': 0.56.0 + '@oxfmt/binding-freebsd-x64': 0.56.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.56.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.56.0 + '@oxfmt/binding-linux-arm64-gnu': 0.56.0 + '@oxfmt/binding-linux-arm64-musl': 0.56.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.56.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.56.0 + '@oxfmt/binding-linux-riscv64-musl': 0.56.0 + '@oxfmt/binding-linux-s390x-gnu': 0.56.0 + '@oxfmt/binding-linux-x64-gnu': 0.56.0 + '@oxfmt/binding-linux-x64-musl': 0.56.0 + '@oxfmt/binding-openharmony-arm64': 0.56.0 + '@oxfmt/binding-win32-arm64-msvc': 0.56.0 + '@oxfmt/binding-win32-ia32-msvc': 0.56.0 + '@oxfmt/binding-win32-x64-msvc': 0.56.0 + + oxlint@1.71.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.71.0 + '@oxlint/binding-android-arm64': 1.71.0 + '@oxlint/binding-darwin-arm64': 1.71.0 + '@oxlint/binding-darwin-x64': 1.71.0 + '@oxlint/binding-freebsd-x64': 1.71.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.71.0 + '@oxlint/binding-linux-arm-musleabihf': 1.71.0 + '@oxlint/binding-linux-arm64-gnu': 1.71.0 + '@oxlint/binding-linux-arm64-musl': 1.71.0 + '@oxlint/binding-linux-ppc64-gnu': 1.71.0 + '@oxlint/binding-linux-riscv64-gnu': 1.71.0 + '@oxlint/binding-linux-riscv64-musl': 1.71.0 + '@oxlint/binding-linux-s390x-gnu': 1.71.0 + '@oxlint/binding-linux-x64-gnu': 1.71.0 + '@oxlint/binding-linux-x64-musl': 1.71.0 + '@oxlint/binding-openharmony-arm64': 1.71.0 + '@oxlint/binding-win32-arm64-msvc': 1.71.0 + '@oxlint/binding-win32-ia32-msvc': 1.71.0 + '@oxlint/binding-win32-x64-msvc': 1.71.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-freehand@1.2.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-compare@3.0.1: {} + + proxy-memoize@3.0.1: + dependencies: + proxy-compare: 3.0.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-hook-tanstack-virtual@0.0.4(@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/virtual-core@3.17.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@tanstack/react-virtual': 3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/virtual-core': 3.17.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-icons@5.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-is@16.13.1: {} + + react@19.2.7: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + + siginfo@2.0.0: {} + + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + stylis@4.2.0: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinykeys@4.0.0: {} + + tinypool@2.1.0: {} + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: {} + + typescript@6.0.3: {} + + undici-types@8.3.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.4): + dependencies: + browserslist: 4.28.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uqr@0.1.3: {} + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.1.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.0.1 + esbuild: 0.27.7 + fsevents: 2.3.3 + + vitest@4.1.9(@types/node@26.0.1)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.0.1 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.0: {} + + xmlhttprequest-ssl@2.1.2: {} + + yallist@3.1.1: {} + + yaml@1.10.3: {} + + zod@4.4.3: {} + + zustand@4.5.7(@types/react@19.2.17)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 diff --git a/invokeai/frontend/webv2/src/App.tsx b/invokeai/frontend/webv2/src/App.tsx new file mode 100644 index 00000000000..bc0fe6cdcd9 --- /dev/null +++ b/invokeai/frontend/webv2/src/App.tsx @@ -0,0 +1,15 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { RouterProvider } from '@tanstack/react-router'; + +import { router } from './router'; +import { system } from './theme/system'; +import { AppToaster } from './workbench/components/ui/toaster'; +import { ThemeController } from './workbench/ThemeController'; + +export const App = () => ( + + + + + +); diff --git a/invokeai/frontend/webv2/src/WorkbenchApp.tsx b/invokeai/frontend/webv2/src/WorkbenchApp.tsx new file mode 100644 index 00000000000..6ffc11d4c6c --- /dev/null +++ b/invokeai/frontend/webv2/src/WorkbenchApp.tsx @@ -0,0 +1,41 @@ +import { useSearch } from '@tanstack/react-router'; +import { WorkbenchShell } from '@workbench/shell'; +import { useMemo } from 'react'; + +import type { WorkbenchSearch } from './workbench/projects/session'; + +import { SessionExpiryGuard } from './workbench/auth/components/SessionExpiryGuard'; +import { WorkbenchHotkeyRuntime } from './workbench/hotkeys/WorkbenchHotkeyRuntime'; +import { WidgetHosts } from './workbench/widget-frame/WidgetHosts'; +import { WorkbenchProvider } from './workbench/WorkbenchContext'; +import { WorkbenchRuntime } from './workbench/WorkbenchRuntime'; +import { WorkbenchSessionController } from './workbench/WorkbenchSessionController'; + +/** + * The authenticated editor: providers, editor-only runtimes, and the shell. + * The shared backend socket is mounted above this route; `WorkbenchRuntime` + * attaches the generation queue listeners while the editor is open. + * + * The route's search params shape the boot: ?project deep-links a library + * project into the session, ?new starts a fresh draft. Both are consumed by + * the persistence load; the session controller handles params that change + * while the editor is already mounted. + */ +export const WorkbenchApp = () => { + const search = useSearch({ strict: false }) as WorkbenchSearch; + const loadOptions = useMemo( + () => ({ createNew: search.new, openProjectId: search.project }), + [search.new, search.project] + ); + + return ( + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/assets/SplashImage.webp b/invokeai/frontend/webv2/src/assets/SplashImage.webp new file mode 100644 index 00000000000..02ab32d523c Binary files /dev/null and b/invokeai/frontend/webv2/src/assets/SplashImage.webp differ diff --git a/invokeai/frontend/webv2/src/lucide-icons.d.ts b/invokeai/frontend/webv2/src/lucide-icons.d.ts new file mode 100644 index 00000000000..0330f2f1c87 --- /dev/null +++ b/invokeai/frontend/webv2/src/lucide-icons.d.ts @@ -0,0 +1,6 @@ +declare module 'lucide-react/dist/esm/icons/*.mjs' { + import type { ComponentType, SVGProps } from 'react'; + + const Icon: ComponentType>; + export default Icon; +} diff --git a/invokeai/frontend/webv2/src/main.tsx b/invokeai/frontend/webv2/src/main.tsx new file mode 100644 index 00000000000..a4a889073f2 --- /dev/null +++ b/invokeai/frontend/webv2/src/main.tsx @@ -0,0 +1,17 @@ +import '@fontsource/inter/index.css'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './App'; + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Unable to mount Invoke V7: root element was not found.'); +} + +createRoot(rootElement).render( + + + +); diff --git a/invokeai/frontend/webv2/src/router.tsx b/invokeai/frontend/webv2/src/router.tsx new file mode 100644 index 00000000000..b6a06e12be0 --- /dev/null +++ b/invokeai/frontend/webv2/src/router.tsx @@ -0,0 +1,203 @@ +import { + createHashHistory, + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, + Navigate, + Outlet, + redirect, +} from '@tanstack/react-router'; + +import { getCapabilities, type Capabilities } from './workbench/auth/capabilities'; +import { LoginScreen } from './workbench/auth/components/LoginScreen'; +import { SetupScreen } from './workbench/auth/components/SetupScreen'; +import { ensureAuthSession } from './workbench/auth/session'; +import { SocketHubRuntime } from './workbench/backend/SocketHubRuntime'; +import { WorkbenchSplashScreen } from './workbench/components/WorkbenchSplashScreen'; +import { Launchpad } from './workbench/launchpad/Launchpad'; +import { peekOpenProjectIds, type WorkbenchSearch } from './workbench/projects/session'; +import { loadWorkbenchSettings } from './workbench/settings/store'; + +/** + * Code-based route tree. The authenticated layout route owns the auth guard, + * which resolves the backend's multi-user status once per app load: + * + * - multi-user off → authenticated routes render directly; /login + /setup bounce home + * - setup required → everything funnels to /setup + * - signed out → authenticated routes redirect to /login + * + * Under it, `/` is the Launchpad (project library, model manager, profile, + * resources) and `/app` is the editor. The editor bundle — canvas, workflow, + * widgets — is code-split behind `lazyRouteComponent`, so the Launchpad stays + * light; `defaultPreload: 'intent'` starts fetching that chunk the moment a + * project card is hovered. + * + * Hash history is used because the bundle is served with a relative base + * (`./`), so the app cannot rely on server-side fallback for deep paths. + */ + +const rootRoute = createRootRoute({ component: Outlet }); + +/** + * Wraps every authenticated route with the shared backend socket transport. + * Feature runtimes attach their own listeners only where needed, keeping the + * light Launchpad bundle free of editor/model-manager code. + */ +const AuthenticatedLayout = () => ( + <> + + + +); + +const authenticatedRoute = createRoute({ + beforeLoad: async () => { + const session = await ensureAuthSession(); + + if (session.multiuserEnabled) { + if (session.setupRequired) { + throw redirect({ to: '/setup' }); + } + + if (session.user === null) { + throw redirect({ to: '/login' }); + } + } + + await loadWorkbenchSettings(); + }, + component: AuthenticatedLayout, + getParentRoute: () => rootRoute, + id: 'authenticated', +}); + +const requireLaunchpadCapability = async (capability: keyof Capabilities): Promise => { + const session = await ensureAuthSession(); + + if (!getCapabilities(session)[capability]) { + throw redirect({ to: '/projects' }); + } +}; + +// `/` plus a deep-link alias per Launchpad section. All render the same shell; +// it reads the path to pick the active page. +const homeRoute = createRoute({ + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: '/', +}); + +const projectsHomeRoute = createRoute({ + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: 'projects', +}); + +const modelsHomeRoute = createRoute({ + beforeLoad: () => requireLaunchpadCapability('canManageModels'), + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: 'models', +}); + +const nodesHomeRoute = createRoute({ + beforeLoad: () => requireLaunchpadCapability('canManageNodes'), + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: 'nodes', +}); + +const usersHomeRoute = createRoute({ + beforeLoad: () => requireLaunchpadCapability('canManageUsers'), + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: 'users', +}); + +const workbenchRoute = createRoute({ + beforeLoad: async ({ cause, search }) => { + // Photoshop semantics: the editor without documents is Home. A definite + // empty session redirects unless the URL explicitly asks for a project or + // a fresh draft; an unknowable session (first run, legacy blob, backend + // unreachable) falls through and the editor boots from what it can find. + // + // Only actual entries are checked: search-only changes ('stay', e.g. the + // session controller stripping ?new before the draft has autosaved) must + // not re-evaluate a blob that is still catching up, and hover preloads + // should not hit the session endpoint at all. + if (cause !== 'enter' || search.project || search.new) { + return; + } + + const openProjectIds = await peekOpenProjectIds(); + + if (openProjectIds !== null && openProjectIds.length === 0) { + throw redirect({ to: '/' }); + } + }, + component: lazyRouteComponent(() => import('./WorkbenchApp'), 'WorkbenchApp'), + getParentRoute: () => authenticatedRoute, + path: '/app', + validateSearch: (search: Record): WorkbenchSearch => ({ + new: search.new === true || search.new === 'true' || search.new === 1 ? true : undefined, + project: typeof search.project === 'string' && search.project.length > 0 ? search.project : undefined, + }), +}); + +const loginRoute = createRoute({ + beforeLoad: async () => { + const session = await ensureAuthSession(); + + if (session.multiuserEnabled && session.setupRequired) { + throw redirect({ to: '/setup' }); + } + + if (!session.multiuserEnabled || session.user !== null) { + throw redirect({ to: '/' }); + } + }, + component: LoginScreen, + getParentRoute: () => rootRoute, + path: '/login', +}); + +const setupRoute = createRoute({ + beforeLoad: async () => { + const session = await ensureAuthSession(); + + if (!session.multiuserEnabled || !session.setupRequired) { + throw redirect({ to: '/' }); + } + }, + component: SetupScreen, + getParentRoute: () => rootRoute, + path: '/setup', +}); + +const RouterPending = () => ; + +export const router = createRouter({ + defaultNotFoundComponent: () => , + defaultPendingComponent: RouterPending, + defaultPreload: 'intent', + history: createHashHistory(), + routeTree: rootRoute.addChildren([ + authenticatedRoute.addChildren([ + homeRoute, + projectsHomeRoute, + modelsHomeRoute, + nodesHomeRoute, + usersHomeRoute, + workbenchRoute, + ]), + loginRoute, + setupRoute, + ]), +}); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} diff --git a/invokeai/frontend/webv2/src/theme/__fixtures__/legacyTokenBaseline.json b/invokeai/frontend/webv2/src/theme/__fixtures__/legacyTokenBaseline.json new file mode 100644 index 00000000000..906dba0ba17 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/__fixtures__/legacyTokenBaseline.json @@ -0,0 +1,237 @@ +{ + "classic": { + "bg": "oklch(21.074% 0.0087 264.37)", + "bg.subtle": "oklch(26.279% 0.0123 264.34)", + "bg.muted": "oklch(31.237% 0.0157 264.32)", + "bg.panel": "oklch(31.237% 0.0157 264.32)", + "bg.emphasized": "oklch(36.004% 0.019 264.3)", + "bg.inset": "oklch(21.074% 0.0087 264.37)", + "bg.error": "color-mix(in oklab, oklch(70.61% 0.0841 19.38) 14%, oklch(26.279% 0.0123 264.34))", + "bg.success": "color-mix(in oklab, oklch(79.8% 0.1132 141.63) 14%, oklch(26.279% 0.0123 264.34))", + "bg.warning": "color-mix(in oklab, oklch(76.62% 0.0612 62.9) 14%, oklch(26.279% 0.0123 264.34))", + "fg": "oklch(96.017% 0.0029 264.54)", + "fg.muted": "oklch(73.736% 0.0202 264.44)", + "fg.subtle": "oklch(57.965% 0.0337 264.27)", + "fg.grid": "oklch(40.619% 0.0221 264.29)", + "fg.error": "oklch(70.61% 0.0841 19.38)", + "fg.success": "oklch(79.8% 0.1132 141.63)", + "fg.warning": "oklch(76.62% 0.0612 62.9)", + "border": "oklch(40.619% 0.0221 264.29)", + "border.subtle": "oklch(40.619% 0.0221 264.29)", + "border.muted": "oklch(40.619% 0.0221 264.29)", + "border.emphasized": "oklch(45.106% 0.0251 264.28)", + "border.error": "oklch(70.61% 0.0841 19.38)", + "gray.contrast": "oklch(21.074% 0.0087 264.37)", + "gray.fg": "oklch(96.017% 0.0029 264.54)", + "gray.subtle": "oklch(31.237% 0.0157 264.32)", + "gray.muted": "oklch(36.004% 0.019 264.3)", + "gray.emphasized": "oklch(45.106% 0.0251 264.28)", + "gray.solid": "oklch(96.017% 0.0029 264.54)", + "gray.focusRing": "oklch(77.738% 0.1 231.76)", + "gray.border": "oklch(45.106% 0.0251 264.28)", + "brand.solid": "oklch(92.041% 0.2103 116.59)", + "brand.contrast": "oklch(21.074% 0.0087 264.37)", + "brand.fg": "oklch(92.041% 0.2103 116.59)", + "brand.subtle": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 16%, oklch(26.279% 0.0123 264.34))", + "brand.muted": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 26%, oklch(26.279% 0.0123 264.34))", + "brand.emphasized": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 36%, oklch(26.279% 0.0123 264.34))", + "brand.focusRing": "oklch(77.738% 0.1 231.76)", + "brand.border": "oklch(92.041% 0.2103 116.59)", + "accent.solid": "oklch(77.738% 0.1 231.76)", + "accent.contrast": "oklch(21.074% 0.0087 264.37)", + "accent.fg": "oklch(77.738% 0.1 231.76)", + "accent.subtle": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 16%, oklch(26.279% 0.0123 264.34))", + "accent.muted": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 26%, oklch(26.279% 0.0123 264.34))", + "accent.emphasized": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 36%, oklch(26.279% 0.0123 264.34))", + "accent.focusRing": "oklch(77.738% 0.1 231.76)", + "accent.border": "oklch(77.738% 0.1 231.76)" + }, + "light": { + "bg": "oklch(96% 0.0038 264)", + "bg.subtle": "oklch(99.4% 0.002 264)", + "bg.muted": "oklch(97% 0.003 264)", + "bg.panel": "oklch(97% 0.003 264)", + "bg.emphasized": "oklch(93.5% 0.005 264)", + "bg.inset": "oklch(96% 0.0038 264)", + "bg.error": "color-mix(in oklab, oklch(55.509% 0.1707 24.62) 14%, oklch(99.4% 0.002 264))", + "bg.success": "color-mix(in oklab, oklch(53% 0.15 150) 14%, oklch(99.4% 0.002 264))", + "bg.warning": "color-mix(in oklab, oklch(60% 0.13 72) 14%, oklch(99.4% 0.002 264))", + "fg": "oklch(22.5% 0.013 264)", + "fg.muted": "oklch(45% 0.017 264)", + "fg.subtle": "oklch(63% 0.014 264)", + "fg.grid": "oklch(88.5% 0.0075 264)", + "fg.error": "oklch(55.509% 0.1707 24.62)", + "fg.success": "oklch(53% 0.15 150)", + "fg.warning": "oklch(60% 0.13 72)", + "border": "oklch(90.5% 0.0065 264)", + "border.subtle": "oklch(90.5% 0.0065 264)", + "border.muted": "oklch(90.5% 0.0065 264)", + "border.emphasized": "oklch(84% 0.0095 264)", + "border.error": "oklch(55.509% 0.1707 24.62)", + "gray.contrast": "oklch(96% 0.0038 264)", + "gray.fg": "oklch(22.5% 0.013 264)", + "gray.subtle": "oklch(95.5% 0.005 264)", + "gray.muted": "oklch(93.5% 0.005 264)", + "gray.emphasized": "oklch(84% 0.0095 264)", + "gray.solid": "oklch(22.5% 0.013 264)", + "gray.focusRing": "oklch(54.615% 0.2152 262.88)", + "gray.border": "oklch(84% 0.0095 264)", + "brand.solid": "oklch(92.041% 0.2103 116.59)", + "brand.contrast": "oklch(22.5% 0.013 264)", + "brand.fg": "oklch(92.041% 0.2103 116.59)", + "brand.subtle": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 16%, oklch(99.4% 0.002 264))", + "brand.muted": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 26%, oklch(99.4% 0.002 264))", + "brand.emphasized": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 36%, oklch(99.4% 0.002 264))", + "brand.focusRing": "oklch(54.615% 0.2152 262.88)", + "brand.border": "oklch(92.041% 0.2103 116.59)", + "accent.solid": "oklch(54.615% 0.2152 262.88)", + "accent.contrast": "oklch(100% 0 0)", + "accent.fg": "oklch(54.615% 0.2152 262.88)", + "accent.subtle": "color-mix(in oklab, oklch(54.615% 0.2152 262.88) 16%, oklch(99.4% 0.002 264))", + "accent.muted": "color-mix(in oklab, oklch(54.615% 0.2152 262.88) 26%, oklch(99.4% 0.002 264))", + "accent.emphasized": "color-mix(in oklab, oklch(54.615% 0.2152 262.88) 36%, oklch(99.4% 0.002 264))", + "accent.focusRing": "oklch(54.615% 0.2152 262.88)", + "accent.border": "oklch(54.615% 0.2152 262.88)" + }, + "forest": { + "bg": "oklch(17.349% 0.0154 144.88)", + "bg.subtle": "oklch(19.03% 0.0175 144.85)", + "bg.muted": "oklch(20.99% 0.0219 144.74)", + "bg.panel": "oklch(20.99% 0.0219 144.74)", + "bg.emphasized": "oklch(25.62% 0.03 144.64)", + "bg.inset": "oklch(17.349% 0.0154 144.88)", + "bg.error": "color-mix(in oklab, oklch(69.305% 0.1467 35.44) 14%, oklch(19.03% 0.0175 144.85))", + "bg.success": "color-mix(in oklab, oklch(78% 0.16 148) 14%, oklch(19.03% 0.0175 144.85))", + "bg.warning": "color-mix(in oklab, oklch(80% 0.12 76) 14%, oklch(19.03% 0.0175 144.85))", + "fg": "oklch(90.57% 0.0511 134.45)", + "fg.muted": "oklch(71.619% 0.0575 135.25)", + "fg.subtle": "oklch(53.577% 0.0538 136.73)", + "fg.grid": "oklch(34.825% 0.0526 141.63)", + "fg.error": "oklch(69.305% 0.1467 35.44)", + "fg.success": "oklch(78% 0.16 148)", + "fg.warning": "oklch(80% 0.12 76)", + "border": "oklch(27.425% 0.0354 143.04)", + "border.subtle": "oklch(27.425% 0.0354 143.04)", + "border.muted": "oklch(27.425% 0.0354 143.04)", + "border.emphasized": "oklch(36.133% 0.0575 141.06)", + "border.error": "oklch(69.305% 0.1467 35.44)", + "gray.contrast": "oklch(17.349% 0.0154 144.88)", + "gray.fg": "oklch(90.57% 0.0511 134.45)", + "gray.subtle": "oklch(28.489% 0.0483 141.22)", + "gray.muted": "oklch(25.62% 0.03 144.64)", + "gray.emphasized": "oklch(36.133% 0.0575 141.06)", + "gray.solid": "oklch(90.57% 0.0511 134.45)", + "gray.focusRing": "oklch(70.437% 0.1101 178.23)", + "gray.border": "oklch(36.133% 0.0575 141.06)", + "brand.solid": "oklch(79.714% 0.1784 136.37)", + "brand.contrast": "oklch(18.298% 0.0314 147.69)", + "brand.fg": "oklch(79.714% 0.1784 136.37)", + "brand.subtle": "color-mix(in oklab, oklch(79.714% 0.1784 136.37) 16%, oklch(19.03% 0.0175 144.85))", + "brand.muted": "color-mix(in oklab, oklch(79.714% 0.1784 136.37) 26%, oklch(19.03% 0.0175 144.85))", + "brand.emphasized": "color-mix(in oklab, oklch(79.714% 0.1784 136.37) 36%, oklch(19.03% 0.0175 144.85))", + "brand.focusRing": "oklch(70.437% 0.1101 178.23)", + "brand.border": "oklch(79.714% 0.1784 136.37)", + "accent.solid": "oklch(70.437% 0.1101 178.23)", + "accent.contrast": "oklch(17.44% 0.0258 171.89)", + "accent.fg": "oklch(70.437% 0.1101 178.23)", + "accent.subtle": "color-mix(in oklab, oklch(70.437% 0.1101 178.23) 16%, oklch(19.03% 0.0175 144.85))", + "accent.muted": "color-mix(in oklab, oklch(70.437% 0.1101 178.23) 26%, oklch(19.03% 0.0175 144.85))", + "accent.emphasized": "color-mix(in oklab, oklch(70.437% 0.1101 178.23) 36%, oklch(19.03% 0.0175 144.85))", + "accent.focusRing": "oklch(70.437% 0.1101 178.23)", + "accent.border": "oklch(70.437% 0.1101 178.23)" + }, + "mono": { + "bg": "oklch(18.22% 0 0)", + "bg.subtle": "oklch(20.019% 0 0)", + "bg.muted": "oklch(22.213% 0 0)", + "bg.panel": "oklch(22.213% 0 0)", + "bg.emphasized": "oklch(27.274% 0 0)", + "bg.inset": "oklch(18.22% 0 0)", + "bg.error": "color-mix(in oklab, oklch(71.115% 0.0934 19.64) 14%, oklch(20.019% 0 0))", + "bg.success": "color-mix(in oklab, oklch(76% 0.06 150) 14%, oklch(20.019% 0 0))", + "bg.warning": "color-mix(in oklab, oklch(79% 0.07 80) 14%, oklch(20.019% 0 0))", + "fg": "oklch(93.1% 0 0)", + "fg.muted": "oklch(71.547% 0 0)", + "fg.subtle": "oklch(53.824% 0 0)", + "fg.grid": "oklch(32.897% 0 0)", + "fg.error": "oklch(71.115% 0.0934 19.64)", + "fg.success": "oklch(76% 0.06 150)", + "fg.warning": "oklch(79% 0.07 80)", + "border": "oklch(28.908% 0 0)", + "border.subtle": "oklch(28.908% 0 0)", + "border.muted": "oklch(28.908% 0 0)", + "border.emphasized": "oklch(34.846% 0 0)", + "border.error": "oklch(71.115% 0.0934 19.64)", + "gray.contrast": "oklch(18.22% 0 0)", + "gray.fg": "oklch(93.1% 0 0)", + "gray.subtle": "oklch(28.908% 0 0)", + "gray.muted": "oklch(27.274% 0 0)", + "gray.emphasized": "oklch(34.846% 0 0)", + "gray.solid": "oklch(93.1% 0 0)", + "gray.focusRing": "oklch(68.622% 0 0)", + "gray.border": "oklch(34.846% 0 0)", + "brand.solid": "oklch(93.1% 0 0)", + "brand.contrast": "oklch(18.22% 0 0)", + "brand.fg": "oklch(93.1% 0 0)", + "brand.subtle": "color-mix(in oklab, oklch(93.1% 0 0) 16%, oklch(20.019% 0 0))", + "brand.muted": "color-mix(in oklab, oklch(93.1% 0 0) 26%, oklch(20.019% 0 0))", + "brand.emphasized": "color-mix(in oklab, oklch(93.1% 0 0) 36%, oklch(20.019% 0 0))", + "brand.focusRing": "oklch(68.622% 0 0)", + "brand.border": "oklch(93.1% 0 0)", + "accent.solid": "oklch(68.622% 0 0)", + "accent.contrast": "oklch(18.22% 0 0)", + "accent.fg": "oklch(68.622% 0 0)", + "accent.subtle": "color-mix(in oklab, oklch(68.622% 0 0) 16%, oklch(20.019% 0 0))", + "accent.muted": "color-mix(in oklab, oklch(68.622% 0 0) 26%, oklch(20.019% 0 0))", + "accent.emphasized": "color-mix(in oklab, oklch(68.622% 0 0) 36%, oklch(20.019% 0 0))", + "accent.focusRing": "oklch(68.622% 0 0)", + "accent.border": "oklch(68.622% 0 0)" + }, + "ultradark": { + "bg": "oklch(0% 0 0)", + "bg.subtle": "oklch(11.492% 0 0)", + "bg.muted": "oklch(14.479% 0 0)", + "bg.panel": "oklch(14.479% 0 0)", + "bg.emphasized": "oklch(19.125% 0 0)", + "bg.inset": "oklch(0% 0 0)", + "bg.error": "color-mix(in oklab, oklch(71.161% 0.1812 22.84) 14%, oklch(11.492% 0 0))", + "bg.success": "color-mix(in oklab, oklch(76.5% 0.16 150.5) 14%, oklch(11.492% 0 0))", + "bg.warning": "color-mix(in oklab, oklch(79.5% 0.13 80) 14%, oklch(11.492% 0 0))", + "fg": "oklch(88.901% 0.0317 120.83)", + "fg.muted": "oklch(66.382% 0.0192 131.96)", + "fg.subtle": "oklch(47.041% 0.0184 127.12)", + "fg.grid": "oklch(23.929% 0 0)", + "fg.error": "oklch(71.161% 0.1812 22.84)", + "fg.success": "oklch(76.5% 0.16 150.5)", + "fg.warning": "oklch(79.5% 0.13 80)", + "border": "oklch(20.904% 0 0)", + "border.subtle": "oklch(20.904% 0 0)", + "border.muted": "oklch(20.904% 0 0)", + "border.emphasized": "oklch(26.862% 0 0)", + "border.error": "oklch(71.161% 0.1812 22.84)", + "gray.contrast": "oklch(0% 0 0)", + "gray.fg": "oklch(88.901% 0.0317 120.83)", + "gray.subtle": "oklch(21.779% 0 0)", + "gray.muted": "oklch(19.125% 0 0)", + "gray.emphasized": "oklch(26.862% 0 0)", + "gray.solid": "oklch(88.901% 0.0317 120.83)", + "gray.focusRing": "oklch(80.623% 0.1248 228.24)", + "gray.border": "oklch(26.862% 0 0)", + "brand.solid": "oklch(93.444% 0.19 125.56)", + "brand.contrast": "oklch(15.913% 0.0233 128.66)", + "brand.fg": "oklch(93.444% 0.19 125.56)", + "brand.subtle": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 16%, oklch(11.492% 0 0))", + "brand.muted": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 26%, oklch(11.492% 0 0))", + "brand.emphasized": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 36%, oklch(11.492% 0 0))", + "brand.focusRing": "oklch(80.623% 0.1248 228.24)", + "brand.border": "oklch(93.444% 0.19 125.56)", + "accent.solid": "oklch(80.623% 0.1248 228.24)", + "accent.contrast": "oklch(17.416% 0.0256 235.84)", + "accent.fg": "oklch(80.623% 0.1248 228.24)", + "accent.subtle": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 16%, oklch(11.492% 0 0))", + "accent.muted": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 26%, oklch(11.492% 0 0))", + "accent.emphasized": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 36%, oklch(11.492% 0 0))", + "accent.focusRing": "oklch(80.623% 0.1248 228.24)", + "accent.border": "oklch(80.623% 0.1248 228.24)" + } +} diff --git a/invokeai/frontend/webv2/src/theme/__tokenResolve.ts b/invokeai/frontend/webv2/src/theme/__tokenResolve.ts new file mode 100644 index 00000000000..0bdd9a12b38 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/__tokenResolve.ts @@ -0,0 +1,130 @@ +/** + * Test-only helper: resolve every semantic color token to its FINAL literal value + * (following `var(--chakra-colors-…)` references) for each workbench theme, straight + * from `system.getTokenCss()`. This mirrors what the browser computes for a given + * ``, so it is the ground truth used by the + * legacy-value gate in `system.test.ts`. + * + * Not imported by any app entry — excluded from the production bundle. + */ + +type Block = Record; +type Layer = Record; + +export interface TokenSystem { + getTokenCss: () => Record; + tokens: { getByName: (name: string) => { extensions: { cssVar: { var: string } } } | undefined }; +} + +const BASE_SEL = '&:where(html, .chakra-theme)'; +const DARK_SEL = '.dark &, .dark .chakra-theme:not(.light) &'; +const LIGHT_SEL = ':root &, .light &'; +const dataSel = (id: string): string => `&:root[data-theme=${id}]`; + +/** Selectors that apply to each theme, HIGHEST CSS priority first. */ +export const THEME_SELECTORS: Record = { + classic: [DARK_SEL, BASE_SEL], // default theme: no [data-theme=classic] rule + light: [dataSel('light'), LIGHT_SEL, BASE_SEL], + forest: [dataSel('forest'), DARK_SEL, BASE_SEL], + mono: [dataSel('mono'), DARK_SEL, BASE_SEL], + ultradark: [dataSel('ultradark'), DARK_SEL, BASE_SEL], +}; + +export const THEMES = Object.keys(THEME_SELECTORS); + +const VAR_REF = /var\((--chakra-colors-[A-Za-z0-9\\.-]+)\)/g; + +const layerOf = (sys: TokenSystem): Layer => sys.getTokenCss()['@layer tokens']; + +const resolveVar = (layer: Layer, theme: string, varName: string, seen: Set): string | undefined => { + for (const sel of THEME_SELECTORS[theme]) { + const block = layer[sel]; + if (block && varName in block) { + return resolveValue(layer, theme, block[varName], seen); + } + } + return undefined; +}; + +const resolveValue = (layer: Layer, theme: string, value: string, seen: Set): string => + value.replace(VAR_REF, (whole, varName: string) => { + if (seen.has(varName)) { + return whole; + } + const resolved = resolveVar(layer, theme, varName, new Set(seen).add(varName)); + return resolved ?? whole; + }); + +/** Final literal value of `colors.` in `theme`. */ +export const resolveToken = (sys: TokenSystem, theme: string, tokenName: string): string => { + const token = sys.tokens.getByName(`colors.${tokenName}`); + if (!token) { + throw new Error(`token not found: colors.${tokenName}`); + } + const value = resolveVar(layerOf(sys), theme, token.extensions.cssVar.var, new Set()); + if (value === undefined) { + throw new Error(`could not resolve colors.${tokenName} for theme ${theme}`); + } + return value; +}; + +/** Resolve every token across every theme → { [theme]: { [token]: value } }. */ +export const resolveAll = (sys: TokenSystem, tokens: readonly string[]): Record> => { + const out: Record> = {}; + for (const theme of THEMES) { + out[theme] = {}; + for (const token of tokens) { + out[theme][token] = resolveToken(sys, theme, token); + } + } + return out; +}; + +/** The frozen public color-token contract — every name a component/recipe may consume. */ +export const CONSUMER_TOKENS = [ + 'bg', + 'bg.subtle', + 'bg.muted', + 'bg.panel', + 'bg.emphasized', + 'bg.inset', + 'bg.error', + 'bg.success', + 'bg.warning', + 'fg', + 'fg.muted', + 'fg.subtle', + 'fg.grid', + 'fg.error', + 'fg.success', + 'fg.warning', + 'border', + 'border.subtle', + 'border.muted', + 'border.emphasized', + 'border.error', + 'gray.contrast', + 'gray.fg', + 'gray.subtle', + 'gray.muted', + 'gray.emphasized', + 'gray.solid', + 'gray.focusRing', + 'gray.border', + 'brand.solid', + 'brand.contrast', + 'brand.fg', + 'brand.subtle', + 'brand.muted', + 'brand.emphasized', + 'brand.focusRing', + 'brand.border', + 'accent.solid', + 'accent.contrast', + 'accent.fg', + 'accent.subtle', + 'accent.muted', + 'accent.emphasized', + 'accent.focusRing', + 'accent.border', +] as const; diff --git a/invokeai/frontend/webv2/src/theme/recipes.ts b/invokeai/frontend/webv2/src/theme/recipes.ts new file mode 100644 index 00000000000..812a5b4e035 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/recipes.ts @@ -0,0 +1,333 @@ +import { defineRecipe, defineSlotRecipe } from '@chakra-ui/react'; +import { slotRecipes as chakraSlotRecipes } from '@chakra-ui/react/theme'; + +/** + * Reusable, theme-aware recipes for the workbench design system. + * + * Recipes reference semantic tokens only, so every variant automatically tracks + * the active theme. Two kinds live here: + * + * 1. Overrides for Chakra's built-in component recipes (`tooltip`, `menu`, + * `select`, `combobox`, `dialog`) — registered in `system.ts` so every + * instance app-wide gets the workbench chrome with zero props at the call + * site. + * 2. Workbench-specific recipes (`panel`, `row`, `chip`, `fieldLabel`, + * `themeCard`) — consumed via the wrappers in `workbench/components/ui` + * with `useRecipe({ recipe })` / `useSlotRecipe({ recipe })`, which keeps + * them fully typed without the Chakra typegen step. + * + * Either way, this file is the single place where shared component styling is + * edited. + */ + +/* -------------------------------------------------------------------------- * + * Built-in component overrides (registered in system.ts) + * -------------------------------------------------------------------------- */ + +/** Tooltip chrome: raised surface with a hairline stroke instead of inverted fill. */ +export const tooltipSlotRecipe = defineSlotRecipe({ + slots: ['content', 'arrowTip'], + base: { + content: { + '--tooltip-bg': 'colors.bg.muted', + bg: 'var(--tooltip-bg)', + borderColor: 'border.emphasized', + borderWidth: '1px', + boxShadow: 'lg', + color: 'fg', + }, + arrowTip: { + borderColor: 'border.emphasized', + }, + }, +}); + +const dropdownContent = { + bg: 'bg.muted', + borderColor: 'border.emphasized', + borderRadius: 'lg', + borderWidth: '1px', + boxShadow: 'lg', + color: 'fg', +}; + +const dropdownItem = { + _highlighted: { bg: 'bg.subtle' }, + _focusVisible: { outline: '2px solid', outlineColor: 'accent.solid', outlineOffset: '-2px' }, +}; + +const dropdownGroupLabel = { + color: 'fg.subtle', + fontSize: '2xs', + fontWeight: '600', + letterSpacing: '0.02em', + textTransform: 'uppercase', +}; + +/** Menu / context-menu chrome: popover surface with an emphasized stroke. */ +export const menuSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.menu, + base: { + ...chakraSlotRecipes.menu.base, + content: { + ...chakraSlotRecipes.menu.base?.content, + ...dropdownContent, + }, + item: { + ...chakraSlotRecipes.menu.base?.item, + ...dropdownItem, + }, + itemGroupLabel: { + ...chakraSlotRecipes.menu.base?.itemGroupLabel, + ...dropdownGroupLabel, + }, + separator: { + ...chakraSlotRecipes.menu.base?.separator, + bg: 'border.subtle', + }, + }, +}); + +/** Select dropdown chrome: same surface and item treatment as menus. */ +export const selectSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.select, + base: { + ...chakraSlotRecipes.select.base, + content: { + ...chakraSlotRecipes.select.base?.content, + ...dropdownContent, + }, + item: { + ...chakraSlotRecipes.select.base?.item, + ...dropdownItem, + }, + itemGroupLabel: { + ...chakraSlotRecipes.select.base?.itemGroupLabel, + ...dropdownGroupLabel, + }, + }, +}); + +/** Combobox chrome: kept aligned with Select for future searchable fields. */ +export const comboboxSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.combobox, + base: { + ...chakraSlotRecipes.combobox.base, + content: { + ...chakraSlotRecipes.combobox.base?.content, + ...dropdownContent, + }, + input: { + ...chakraSlotRecipes.combobox.base?.input, + _hover: { borderColor: 'border.emphasized' }, + }, + item: { + ...chakraSlotRecipes.combobox.base?.item, + ...dropdownItem, + }, + itemGroupLabel: { + ...chakraSlotRecipes.combobox.base?.itemGroupLabel, + ...dropdownGroupLabel, + }, + }, +}); + +/** Dialog chrome: panel surface with a hairline stroke. */ +export const dialogSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.dialog, + base: { + ...chakraSlotRecipes.dialog.base, + content: { + ...chakraSlotRecipes.dialog.base?.content, + borderColor: 'border.subtle', + borderWidth: '1px', + }, + }, +}); + +/** Slider marks: compact auxiliary labels for dense widget controls. */ +export const sliderSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.slider, + base: { + ...chakraSlotRecipes.slider.base, + markerLabel: { + ...chakraSlotRecipes.slider.base?.markerLabel, + color: 'fg.subtle', + fontSize: '0.5rem', + lineHeight: '1', + }, + }, +}); + +/** Progress circle: add a compact icon-sized variant for dense chrome like tabs. */ +export const progressCircleSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.progressCircle, + variants: { + ...chakraSlotRecipes.progressCircle.variants, + size: { + ...chakraSlotRecipes.progressCircle.variants?.size, + '2xs': { + circle: { + '--size': '16px', + '--thickness': '3px', + }, + valueText: { + textStyle: '2xs', + }, + }, + }, + }, +}); + +/* -------------------------------------------------------------------------- * + * Workbench recipes (consumed through workbench/components/ui wrappers) + * -------------------------------------------------------------------------- */ + +/** Bordered surface container — panels, cards, wells. */ +export const panelRecipe = defineRecipe({ + base: { + bg: 'bg.subtle', + borderColor: 'border.subtle', + borderRadius: 'md', + borderWidth: '1px', + display: 'flex', + flexDirection: 'column', + minH: '0', + minW: '0', + }, + variants: { + tone: { + surface: {}, + raised: { bg: 'bg.muted' }, + inset: { bg: 'bg.inset' }, + control: { bg: 'bg.emphasized', borderColor: 'transparent' }, + }, + density: { + none: {}, + sm: { gap: '1.5', p: '2' }, + md: { gap: '2', p: '3' }, + }, + }, + defaultVariants: { tone: 'surface', density: 'none' }, +}); + +/** Interactive list / table row with hover, focus, and active fills. */ +export const rowRecipe = defineRecipe({ + base: { + alignItems: 'center', + borderRadius: 'sm', + cursor: 'pointer', + display: 'flex', + gap: '2', + textAlign: 'start', + transition: 'background var(--wb-motion-duration-fast) ease, color var(--wb-motion-duration-fast) ease', + w: 'full', + _hover: { bg: 'bg.muted' }, + _focusVisible: { outline: '2px solid', outlineColor: 'accent.solid', outlineOffset: '-2px' }, + _disabled: { cursor: 'not-allowed', opacity: 0.5 }, + }, + variants: { + active: { + none: {}, + muted: { bg: 'bg.muted' }, + brand: { bg: 'brand.subtle', color: 'brand.fg', _hover: { bg: 'brand.subtle' } }, + accent: { bg: 'accent.solid', color: 'accent.contrast', _hover: { bg: 'accent.solid' } }, + }, + }, + defaultVariants: { active: 'none' }, +}); + +/** Compact status chip with intent tones — queue counts, server state, versions. */ +export const chipRecipe = defineRecipe({ + base: { + alignItems: 'center', + borderRadius: 'sm', + display: 'inline-flex', + flexShrink: '0', + fontSize: '2xs', + fontWeight: '500', + gap: '1.5', + px: '2', + py: '0.5', + whiteSpace: 'nowrap', + }, + variants: { + tone: { + neutral: {}, + brand: { bg: 'brand.subtle', color: 'brand.fg' }, + accent: { color: 'accent.solid' }, + error: { color: 'fg.error' }, + success: { color: 'fg.success' }, + warning: { color: 'fg.warning' }, + }, + }, + defaultVariants: { tone: 'neutral' }, +}); + +/** Compact uppercase field label shared across widget forms and the settings modal. */ +export const fieldLabelRecipe = defineRecipe({ + base: { + color: 'fg.muted', + fontSize: '2xs', + fontWeight: '600', + letterSpacing: '0.02em', + textTransform: 'uppercase', + }, +}); + +/** Selectable theme swatch card used by the Settings appearance picker. */ +export const themeCardRecipe = defineSlotRecipe({ + slots: ['root', 'preview', 'swatch', 'body', 'name', 'description', 'indicator'], + base: { + root: { + alignItems: 'stretch', + bg: 'bg.subtle', + borderColor: 'border.subtle', + borderRadius: 'lg', + borderWidth: '1px', + cursor: 'pointer', + display: 'flex', + flexDirection: 'column', + gap: '2.5', + overflow: 'hidden', + p: '3', + textAlign: 'left', + transition: + 'border-color var(--wb-motion-duration-fast) ease, background var(--wb-motion-duration-fast) ease, transform var(--wb-motion-duration-fast) ease', + _hover: { borderColor: 'border.emphasized' }, + _focusVisible: { outline: '2px solid', outlineColor: 'accent.solid', outlineOffset: '2px' }, + }, + preview: { + borderColor: 'border.subtle', + borderRadius: 'md', + borderWidth: '1px', + display: 'flex', + h: '8', + overflow: 'hidden', + }, + swatch: { flex: '1' }, + body: { alignItems: 'flex-start', display: 'flex', flexDirection: 'column', gap: '0.5' }, + name: { color: 'fg', fontSize: 'sm', fontWeight: '600' }, + description: { color: 'fg.subtle', fontSize: '2xs', lineHeight: '1.3' }, + indicator: { + alignItems: 'center', + borderRadius: 'full', + color: 'accent.solid', + display: 'flex', + h: '4', + justifyContent: 'center', + opacity: 0, + w: '4', + }, + }, + variants: { + selected: { + true: { + root: { borderColor: 'accent.solid', bg: 'bg.muted' }, + indicator: { opacity: 1 }, + }, + false: {}, + }, + }, + defaultVariants: { selected: false }, +}); diff --git a/invokeai/frontend/webv2/src/theme/system.test.ts b/invokeai/frontend/webv2/src/theme/system.test.ts new file mode 100644 index 00000000000..a4c76d9de1b --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/system.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import legacyBaseline from './__fixtures__/legacyTokenBaseline.json'; +import { CONSUMER_TOKENS, resolveToken, THEMES, type TokenSystem } from './__tokenResolve'; +import { progressCircleSlotRecipe } from './recipes'; +import { system } from './system'; + +const sys = system as unknown as TokenSystem; +const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const; + +const lightnessOf = (oklch: string): number => { + const match = /oklch\(\s*([\d.]+)%/.exec(oklch); + if (!match) { + throw new Error(`not a plain oklch value: ${oklch}`); + } + return Number(match[1]); +}; + +describe('color token contract — legacy-value gate', () => { + // Primary gate: every consumer token must resolve to the EXACT value it had + // before the ramp refactor. The baseline was captured from the previous system + // on the working tree before any change (see __fixtures__/legacyTokenBaseline.json). + for (const theme of THEMES) { + for (const token of CONSUMER_TOKENS) { + it(`${theme}: ${token} is unchanged`, () => { + const expected = (legacyBaseline as Record>)[theme][token]; + expect(resolveToken(sys, theme, token)).toBe(expected); + }); + } + } +}); + +describe('ramp + mapping structure', () => { + it('emits every neutral ramp step for every theme', () => { + for (const theme of THEMES) { + for (const step of STEPS) { + expect(resolveToken(sys, theme, `neutral.${step}`)).toMatch(/^oklch\(/); + } + } + }); + + it('maps the light surface ladder to the corrected (non-mirrored) steps', () => { + // The point-1 fix: light bg sits at neutral.200, not the lightest step. + expect(resolveToken(sys, 'light', 'bg')).toBe(resolveToken(sys, 'light', 'neutral.200')); + expect(resolveToken(sys, 'light', 'bg.subtle')).toBe(resolveToken(sys, 'light', 'neutral.50')); + expect(resolveToken(sys, 'light', 'bg.muted')).toBe(resolveToken(sys, 'light', 'neutral.100')); + expect(resolveToken(sys, 'light', 'border')).toBe(resolveToken(sys, 'light', 'neutral.300')); + expect(resolveToken(sys, 'light', 'border.emphasized')).toBe(resolveToken(sys, 'light', 'neutral.400')); + }); + + it('maps the dark surface ladder onto the ramp', () => { + expect(resolveToken(sys, 'classic', 'bg')).toBe(resolveToken(sys, 'classic', 'neutral.950')); + expect(resolveToken(sys, 'classic', 'bg.subtle')).toBe(resolveToken(sys, 'classic', 'neutral.900')); + expect(resolveToken(sys, 'classic', 'fg')).toBe(resolveToken(sys, 'classic', 'neutral.50')); + expect(resolveToken(sys, 'classic', 'border')).toBe(resolveToken(sys, 'classic', 'neutral.600')); + }); + + it('emits surface/text/border tokens via per-theme conditions only — never the leaky mode selectors', () => { + // Chakra's `_light` selector (`:root &, .light &`) matches under EVERY theme via its + // `:root &` arm. A surface token placed there leaks its light value into the dark + // themes (the classic-renders-light regression). These tokens must live ONLY in the + // base + `[data-theme=…]` selectors. + const layer = sys.getTokenCss()['@layer tokens']; + const LEAKY = [':root &, .light &', '.dark &, .dark .chakra-theme:not(.light) &']; + const surfaceTokens = [ + 'bg', + 'bg.subtle', + 'bg.muted', + 'bg.panel', + 'fg', + 'fg.muted', + 'fg.subtle', + 'border', + 'border.subtle', + 'border.muted', + 'border.emphasized', + ]; + for (const name of surfaceTokens) { + const token = sys.tokens.getByName(`colors.${name}`); + const cssVar = token?.extensions.cssVar.var ?? ''; + for (const selector of LEAKY) { + expect(layer[selector]?.[cssVar], `${name} must not be emitted under "${selector}"`).toBeUndefined(); + } + } + }); + + it('keeps each ramp strictly decreasing in lightness 50 → 950', () => { + for (const theme of THEMES) { + const ls = STEPS.map((step) => lightnessOf(resolveToken(sys, theme, `neutral.${step}`))); + for (let i = 1; i < ls.length; i++) { + expect(ls[i], `${theme} neutral.${STEPS[i]} should be darker than neutral.${STEPS[i - 1]}`).toBeLessThan( + ls[i - 1] + ); + } + } + }); +}); + +describe('native Chakra integration', () => { + it('adds an icon-sized progress circle variant', () => { + expect(progressCircleSlotRecipe.variants?.size?.['2xs']).toMatchObject({ + circle: { '--size': '16px', '--thickness': '3px' }, + }); + }); + + it('resolves every gray virtual-palette key under both dark and light', () => { + const keys = ['contrast', 'fg', 'subtle', 'muted', 'emphasized', 'solid', 'focusRing', 'border']; + for (const theme of ['classic', 'light']) { + for (const key of keys) { + expect(resolveToken(sys, theme, `gray.${key}`)).toBeTruthy(); + } + } + }); + + it('leaves stock Chakra hue palettes untouched in Phase 1', () => { + // Phase 2 will theme these; for now they must remain Chakra defaults so model + // badges / destructive actions keep their stock colors. + expect(sys.tokens.getByName('colors.red.500')).toBeTruthy(); + expect(sys.tokens.getByName('colors.blue.500')).toBeTruthy(); + expect(sys.tokens.getByName('colors.purple.500')).toBeTruthy(); + }); +}); + +describe('brand palette derives from its two seeds (like accent)', () => { + it('fg/border equal solid; subtle/muted/emphasized mix solid into the surface', () => { + for (const theme of THEMES) { + const solid = resolveToken(sys, theme, 'brand.solid'); + const surface = resolveToken(sys, theme, 'bg.subtle'); + expect(resolveToken(sys, theme, 'brand.fg')).toBe(solid); + expect(resolveToken(sys, theme, 'brand.border')).toBe(solid); + for (const [key, pct] of [ + ['subtle', 16], + ['muted', 26], + ['emphasized', 36], + ] as const) { + expect(resolveToken(sys, theme, `brand.${key}`)).toBe(`color-mix(in oklab, ${solid} ${pct}%, ${surface})`); + } + } + }); +}); diff --git a/invokeai/frontend/webv2/src/theme/system.ts b/invokeai/frontend/webv2/src/theme/system.ts new file mode 100644 index 00000000000..86a769dde05 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/system.ts @@ -0,0 +1,290 @@ +import { createSystem, defaultConfig, defineConfig } from '@chakra-ui/react'; + +import { + comboboxSlotRecipe, + dialogSlotRecipe, + menuSlotRecipe, + progressCircleSlotRecipe, + selectSlotRecipe, + sliderSlotRecipe, + tooltipSlotRecipe, +} from './recipes'; +import { DEFAULT_THEME, DEFAULT_THEME_ID, type NeutralStep, THEMES, type ThemeDefinition } from './themes'; + +/** + * Workbench design system. + * + * Each theme (`themes.ts`) is authored as one neutral ramp (`neutral.50…neutral.950`, + * lightest → darkest) plus a few seeds (brand, accent, status) and four off-ramp + * neutrals (`inset`, `fill`, `grid`, `control`). This module turns that into two + * token layers: + * + * 1. The **ramp** is emitted verbatim as conditional semantic tokens + * (`neutral.*`), one value per theme keyed on a custom `[data-theme=]` + * condition. Chakra base `tokens.colors` only hold plain strings, so anything + * that varies per theme must live in `semanticTokens`. + * 2. The **semantic contract** (`bg`, `fg.muted`, `border.emphasized`, the + * `gray`/`brand`/`accent` palettes, …) is theme-agnostic: it references ramp + * steps and flips by light/dark mode only. `bg` is `neutral.950` in dark mode and + * `neutral.200` in light — the light ramp is not a mirror of the dark one, because + * light panels go *whiter* than the app background. + * + * `ThemeController` sets `data-theme` (and the `.dark`/`.light` class) on ``, + * so switching themes is a single attribute change with zero React re-render. + * Components reference only the semantic tokens — never the ramp or a theme. + * + * Re-pointing Chakra's neutral `gray` palette at the ramp makes every built-in + * component (Dialog, Menu, Input, Button, Tooltip, Badge) follow the active theme + * with no per-component overrides. + */ + +const NON_DEFAULT_THEMES = THEMES.filter((theme) => theme.id !== DEFAULT_THEME_ID); + +/** `light` -> `themeLight`, `ultradark` -> `themeUltradark`. */ +const conditionName = (id: string): string => `theme${id.charAt(0).toUpperCase()}${id.slice(1)}`; + +type TokenValue = { value: Record }; +type Compute = (theme: ThemeDefinition) => string; + +/** Build a semantic-token value object: default theme as `base`, the rest as `[data-theme]` conditions. */ +const colorToken = (compute: Compute): TokenValue => { + const value: Record = { base: compute(DEFAULT_THEME) }; + for (const theme of NON_DEFAULT_THEMES) { + value[`_${conditionName(theme.id)}`] = compute(theme); + } + return { value }; +}; + +/** Blend `pct`% of one computed color into another — used for derived hover/tint steps. */ +const mix = (top: Compute, pct: number, bottom: Compute): TokenValue => + colorToken((theme) => `color-mix(in oklab, ${top(theme)} ${pct}%, ${bottom(theme)})`); + +const LIGHT_FALLBACK_THEME = THEMES.find((theme) => theme.colorScheme === 'light') ?? DEFAULT_THEME; + +/** + * Like `colorToken`, but additionally shadows Chakra's `_light`/`_dark` + * class-conditional values. Nested palette tokens (`gray.*`) deep-merge with + * `defaultConfig` instead of replacing it, so without these keys the default + * gray values would survive the merge and outrank our zero-specificity `base` + * value whenever `ThemeController` sets the `.dark`/`.light` class. The explicit + * `:root[data-theme=…]` conditions still win over both. + */ +const grayToken = (compute: Compute): TokenValue => { + const token = colorToken(compute); + token.value._light = compute(LIGHT_FALLBACK_THEME); + token.value._dark = compute(DEFAULT_THEME); + return token; +}; + +/** + * A token that reads a ramp step, chosen per theme by `colorScheme`. Emitted as + * per-`[data-theme]` conditions, NOT Chakra's `_light`/`_dark`: the built-in + * `_light` selector is `:root &, .light &`, and its `:root &` arm matches under + * EVERY theme — so a mode-flip token leaks its light value into the dark themes. + * Per-theme conditions sidestep the cascade entirely (this is how the pre-refactor + * system worked, and why dark themes rendered correctly). + */ +const ref = (step: NeutralStep): string => `{colors.neutral.${step}}`; +const stepRef = (darkStep: NeutralStep, lightStep: NeutralStep): TokenValue => + colorToken((theme) => ref(theme.colorScheme === 'light' ? lightStep : darkStep)); + +const STEPS: NeutralStep[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]; + +/** The default panel surface of a theme — `bg.subtle`'s step. Used as the floor for tints. */ +const surface: Compute = (theme) => + theme.colorScheme === 'light' ? theme.colors.neutral[50] : theme.colors.neutral[900]; + +// Seed accessors. +const danger: Compute = (theme) => theme.colors.danger; +const success: Compute = (theme) => theme.colors.success; +const warning: Compute = (theme) => theme.colors.warning; +const brandSolid: Compute = (theme) => theme.colors.brand.solid; +const accentSolid: Compute = (theme) => theme.colors.accent.solid; + +/** The neutral ramp, emitted as `neutral.50…neutral.950`, one value per theme. */ +const neutralRamp = Object.fromEntries(STEPS.map((step) => [step, colorToken((theme) => theme.colors.neutral[step])])); + +/** + * The semantic-token contract. Backgrounds/foregrounds/borders reference ramp + * steps (`stepRef`); the four off-ramp neutrals and the status/identity hues read + * their per-theme seed directly. Where a Chakra built-in name exists we use it + * verbatim so built-ins inherit the theme for free. + */ +const semanticColors = { + neutral: neutralRamp, + + // Surface ladder. Light panels are whiter than the app bg, so the light steps + // are not a mirror of the dark ones. + bg: stepRef(950, 200), + 'bg.subtle': stepRef(900, 50), + 'bg.muted': stepRef(800, 100), + 'bg.panel': stepRef(800, 100), + 'bg.emphasized': colorToken((theme) => theme.colors.control), + 'bg.inset': colorToken((theme) => theme.colors.inset), + // Soft status fills for alerts/banners, mixed into the panel surface. + 'bg.error': mix(danger, 14, surface), + 'bg.success': mix(success, 14, surface), + 'bg.warning': mix(warning, 14, surface), + + // Foreground. + fg: stepRef(50, 950), + 'fg.muted': stepRef(300, 700), + 'fg.subtle': stepRef(400, 500), + 'fg.grid': colorToken((theme) => theme.colors.grid), + 'fg.error': colorToken(danger), + 'fg.success': colorToken(success), + 'fg.warning': colorToken(warning), + + // Borders. + border: stepRef(600, 300), + 'border.subtle': stepRef(600, 300), + 'border.muted': stepRef(600, 300), + 'border.emphasized': stepRef(500, 400), + 'border.error': colorToken(danger), + + /** + * Chakra's default `colorPalette` is `gray`; re-pointing its virtual-palette + * keys at the ramp makes every un-palettized component (ghost buttons, menu + * items, badges, …) theme-aware with zero props. + * + * Palette tokens must stay NESTED — the `colorPalette` virtual-token map is + * built from the nested structure and ignores flat dotted keys. Because nesting + * deep-merges with the defaults, every gray key shadows the default + * `_light`/`_dark` values via `grayToken`. + */ + gray: { + contrast: grayToken((theme) => + theme.colorScheme === 'light' ? theme.colors.neutral[200] : theme.colors.neutral[950] + ), + fg: grayToken((theme) => (theme.colorScheme === 'light' ? theme.colors.neutral[950] : theme.colors.neutral[50])), + subtle: grayToken((theme) => theme.colors.fill), + muted: grayToken((theme) => theme.colors.control), + emphasized: grayToken((theme) => + theme.colorScheme === 'light' ? theme.colors.neutral[400] : theme.colors.neutral[500] + ), + solid: grayToken((theme) => (theme.colorScheme === 'light' ? theme.colors.neutral[950] : theme.colors.neutral[50])), + focusRing: grayToken(accentSolid), + border: grayToken((theme) => + theme.colorScheme === 'light' ? theme.colors.neutral[400] : theme.colors.neutral[500] + ), + }, + /** + * Invoke identity palette (lime). Authored from two seeds (`solid` + `contrast`), + * like `accent`; the rest derive. NOTE: `brand.fg` is the bright `solid` fill, so + * `brand.fg` on `brand.subtle` reads well on the dark themes but is low-contrast in + * the light theme — brand is meant for emphasis fills, not body text. + */ + brand: { + solid: colorToken(brandSolid), + contrast: colorToken((theme) => theme.colors.brand.contrast), + fg: colorToken(brandSolid), + subtle: mix(brandSolid, 16, surface), + muted: mix(brandSolid, 26, surface), + emphasized: mix(brandSolid, 36, surface), + focusRing: colorToken(accentSolid), + border: colorToken(brandSolid), + }, + /** Selection / focus palette (blue). Use via `accent.solid` or `colorPalette="accent"`. */ + accent: { + solid: colorToken(accentSolid), + contrast: colorToken((theme) => theme.colors.accent.contrast), + fg: colorToken(accentSolid), + subtle: mix(accentSolid, 16, surface), + muted: mix(accentSolid, 26, surface), + emphasized: mix(accentSolid, 36, surface), + focusRing: colorToken(accentSolid), + border: colorToken(accentSolid), + }, +}; + +// `:root` raises specificity above the `.dark`/`.light` colorScheme classes so +// an explicit theme always beats the class-conditional fallback values. +const themeConditions = Object.fromEntries( + NON_DEFAULT_THEMES.map((theme) => [conditionName(theme.id), `:root[data-theme=${theme.id}]`]) +); + +const motionDurationToken = (base: string): TokenValue => ({ value: { base, _reduceMotion: '1ms' } }); +const motionAnimationToken = (base: string): TokenValue => ({ value: { base, _reduceMotion: 'none' } }); + +const config = defineConfig({ + conditions: { ...themeConditions, reduceMotion: ':root[data-reduce-motion=true]' }, + globalCss: { + 'html, body, #root': { + height: '100%', + }, + body: { + bg: 'bg', + color: 'fg', + fontFamily: 'body', + margin: 0, + minHeight: '720px', + minWidth: '960px', + overflow: 'hidden', + }, + ':root': { + '--wb-motion-duration-fast': '0.12s', + '--wb-motion-duration-medium': '0.15s', + '--wb-motion-duration-slow': '0.2s', + '--wb-motion-animation-iteration-count': 'infinite', + }, + // Keep durations non-zero: Ark presence waits for animation lifecycle events. + // Chakra recipe motion is covered by conditional duration/animation tokens below. + ':root[data-reduce-motion="true"]': { + '--wb-motion-duration-fast': '1ms', + '--wb-motion-duration-medium': '1ms', + '--wb-motion-duration-slow': '1ms', + '--wb-motion-animation-iteration-count': '1', + scrollBehavior: 'auto !important', + }, + ':root[data-reduce-motion="true"] .chakra-skeleton': { + animation: 'none !important', + }, + }, + theme: { + tokens: { + fonts: { + body: { + value: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + }, + heading: { + value: "Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif", + }, + }, + }, + semanticTokens: { + animations: { + bounce: motionAnimationToken('bounce 1s infinite'), + ping: motionAnimationToken('ping 1s cubic-bezier(0, 0, 0.2, 1) infinite'), + pulse: motionAnimationToken('pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'), + spin: motionAnimationToken('spin 1s linear infinite'), + }, + colors: semanticColors, + durations: { + fastest: motionDurationToken('50ms'), + faster: motionDurationToken('100ms'), + fast: motionDurationToken('150ms'), + moderate: motionDurationToken('200ms'), + slow: motionDurationToken('300ms'), + slower: motionDurationToken('400ms'), + slowest: motionDurationToken('500ms'), + }, + }, + // Chrome-level overrides for Chakra's built-in components, so popover and + // dialog surfaces are consistent everywhere without per-instance props. + slotRecipes: { + combobox: comboboxSlotRecipe, + dialog: dialogSlotRecipe, + menu: menuSlotRecipe, + progressCircle: progressCircleSlotRecipe, + select: selectSlotRecipe, + slider: sliderSlotRecipe, + tooltip: tooltipSlotRecipe, + }, + }, +}); + +export const system = createSystem(defaultConfig, config); + +/** Theme metadata re-exported so UI can import a single module. */ +export { THEMES, THEMES_BY_ID, DEFAULT_THEME, DEFAULT_THEME_ID, previewSwatches } from './themes'; +export type { ThemeColors, ThemeDefinition, NeutralStep } from './themes'; diff --git a/invokeai/frontend/webv2/src/theme/themes.ts b/invokeai/frontend/webv2/src/theme/themes.ts new file mode 100644 index 00000000000..082823b5b89 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/themes.ts @@ -0,0 +1,265 @@ +import type { WorkbenchThemeId } from '@workbench/types'; + +/** + * Steps of the neutral ramp. `50` is the lightest, `950` the darkest — the same + * absolute orientation Chakra/Tailwind use, so the ramp can be aliased onto the + * built-in `gray` palette without surprises. + */ +export type NeutralStep = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950; + +/** + * One workbench theme, expressed as a small set of concrete OKLch values. + * + * The bulk of a theme is the `base` ramp — a single neutral scale from which the + * semantic-token layer in `system.ts` derives every background, foreground, and + * border (`bg → neutral.950` in dark mode, `fg → neutral.50`, …). Everything else is a + * handful of seeds: + * + * - `brand` / `accent` — the two identity hues (lime action, blue selection); + * - `danger` / `success` / `warning` — status intents; + * - `inset` / `fill` / `grid` / `control` — four neutrals whose *elevation rank* + * differs from theme to theme, so they cannot sit on a single shared ramp step + * (e.g. `inset` recesses below the app background in the light theme but lifts + * above it in the dark themes). They are kept as explicit per-theme values and + * consumed by name through `bg.inset`, `fg.grid`, `gray.subtle`, `bg.emphasized`. + * + * Adding a theme is therefore: author one ramp + the seeds. No component changes. + */ +export interface ThemeColors { + /** Neutral ramp, lightest (`50`) → darkest (`950`). Source of all bg/fg/border. */ + neutral: Record; + /** Invoke identity (lime). Two seeds; the palette's other steps derive in `system.ts`. */ + brand: { solid: string; contrast: string }; + /** Selection / focus (blue). */ + accent: { solid: string; contrast: string }; + /** Destructive / error intent. */ + danger: string; + /** Positive / success intent. */ + success: string; + /** Caution / warning intent. */ + warning: string; + /** Recessed work-area floor framed by the chrome (`bg.inset`). Off-ramp. */ + inset: string; + /** Subtle neutral fill for hovered/ghost controls (`gray.subtle`). Off-ramp. */ + fill: string; + /** Dot-grid decoration drawn on inset surfaces (`fg.grid`). Off-ramp. */ + grid: string; + /** Control / chip fill inside panels (`bg.emphasized`). Off-ramp. */ + control: string; +} + +export interface ThemeDefinition { + id: WorkbenchThemeId; + label: string; + description: string; + /** Native color-scheme hint for form controls, scrollbars, and ` + + + + + + + + + Keep me signed in for a week + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx new file mode 100644 index 00000000000..84d17865349 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx @@ -0,0 +1,67 @@ +import { Box, HStack, Input, InputGroup, Text } from '@chakra-ui/react'; +import { getPasswordStrength, type PasswordStrength } from '@workbench/auth/schemas'; +import { IconButton } from '@workbench/components/ui'; +import { EyeIcon, EyeOffIcon } from 'lucide-react'; +import { useCallback, useMemo, useState, type ComponentProps } from 'react'; + +type InputProps = ComponentProps; + +/** Password input with an inline visibility toggle. */ +export const PasswordInput = (props: InputProps) => { + const [isVisible, setIsVisible] = useState(false); + const handleToggleVisibility = useCallback(() => setIsVisible((current) => !current), []); + const endElement = useMemo( + () => ( + + {isVisible ? : } + + ), + [handleToggleVisibility, isVisible] + ); + + return ( + + + + ); +}; + +const STRENGTH_META: Record = { + moderate: { filledSegments: 2, label: 'Moderate', tone: 'fg.warning' }, + strong: { filledSegments: 3, label: 'Strong', tone: 'fg.success' }, + weak: { filledSegments: 1, label: 'Weak', tone: 'fg.error' }, +}; + +/** Three-segment strength readout; renders nothing until the user types. */ +export const PasswordStrengthMeter = ({ password }: { password: string }) => { + if (!password) { + return null; + } + + const meta = STRENGTH_META[getPasswordStrength(password)]; + + return ( + + + {[0, 1, 2].map((segment) => ( + + ))} + + + {meta.label} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx new file mode 100644 index 00000000000..2f6f501d190 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx @@ -0,0 +1,205 @@ +import { Dialog, HStack, Input, Portal, Stack, Text } from '@chakra-ui/react'; +import { generatePassword, updateCurrentUser, type ProfileUpdateRequest, type UserDTO } from '@workbench/auth/api'; +import { createProfileSchema, PASSWORD_RULES_HINT, type ProfileFormValues } from '@workbench/auth/schemas'; +import { setSessionUser, useAuthSession } from '@workbench/auth/session'; +import { getApiErrorMessage } from '@workbench/backend/http'; +import { Button, CloseButton, Field, FieldLabel } from '@workbench/components/ui'; +import { useZodForm } from '@workbench/models/useZodForm'; +import { useNotify } from '@workbench/useNotify'; +import { WandSparklesIcon } from 'lucide-react'; +import { useCallback, useMemo, useState, type ChangeEvent } from 'react'; + +import { AuthFormAlert } from './AuthScreen'; +import { PasswordInput, PasswordStrengthMeter } from './PasswordInput'; + +/** Account settings: display name and password change for the signed-in user. */ +export const ProfileDialog = ({ isOpen, onClose, user }: { isOpen: boolean; onClose: () => void; user: UserDTO }) => { + const handleOpenChange = useCallback( + (event: Dialog.OpenChangeDetails) => { + if (!event.open) { + onClose(); + } + }, + [onClose] + ); + + return ( + + + + + + + + + Account + + + Signed in as {user.email} + + + + + + + + + + + + ); +}; + +const ProfileForm = ({ onClose, user }: { onClose: () => void; user: UserDTO }) => { + const session = useAuthSession(); + const notify = useNotify(); + const [isGenerating, setIsGenerating] = useState(false); + const schema = useMemo(() => createProfileSchema(session.strictPasswordChecking), [session.strictPasswordChecking]); + const initialValues: ProfileFormValues = useMemo( + () => ({ confirmPassword: '', currentPassword: '', displayName: user.display_name ?? '', newPassword: '' }), + [user.display_name] + ); + const form = useZodForm(schema, initialValues); + + const fillGeneratedPassword = useCallback(async () => { + setIsGenerating(true); + + try { + const password = await generatePassword(); + + form.setValue('newPassword', password); + form.setValue('confirmPassword', password); + notify.info('Password generated', 'Reveal it with the eye icon and store it somewhere safe.'); + } catch (error) { + notify.error('Could not generate a password', getApiErrorMessage(error, 'The backend rejected the request.')); + } finally { + setIsGenerating(false); + } + }, [form, notify]); + + const submit = useCallback( + () => + form.handleSubmit(async (values) => { + const changes: ProfileUpdateRequest = {}; + const displayName = values.displayName.trim(); + + if (displayName !== (user.display_name ?? '')) { + changes.display_name = displayName; + } + + if (values.newPassword !== '') { + changes.current_password = values.currentPassword; + changes.new_password = values.newPassword; + } + + if (Object.keys(changes).length === 0) { + onClose(); + return; + } + + try { + const updated = await updateCurrentUser(changes); + + setSessionUser(updated); + } catch (error) { + throw new Error(getApiErrorMessage(error, 'Could not update your account.')); + } + + notify.success('Account updated'); + onClose(); + }), + [form, notify, onClose, user.display_name] + ); + const handleDisplayNameChange = useCallback( + (event: ChangeEvent) => form.setValue('displayName', event.target.value), + [form] + ); + const handleCurrentPasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('currentPassword', event.target.value), + [form] + ); + const handleNewPasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('newPassword', event.target.value), + [form] + ); + const handleConfirmPasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('confirmPassword', event.target.value), + [form] + ); + const handleGeneratePassword = useCallback(() => void fillGeneratedPassword(), [fillGeneratedPassword]); + const handleSave = useCallback(() => void submit(), [submit]); + + return ( + <> + + + {form.formError ? : null} + + + + + + Change password + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/SessionExpiryGuard.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/SessionExpiryGuard.tsx new file mode 100644 index 00000000000..9f34f706ba0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/components/SessionExpiryGuard.tsx @@ -0,0 +1,21 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useAuthSession } from '@workbench/auth/session'; +import { useEffect } from 'react'; + +/** + * Watches for mid-session token rejection (a 401 on any authenticated request) + * and routes back to the login screen, which explains the expiry. Mounted once + * inside the workbench route; renders nothing. + */ +export const SessionExpiryGuard = () => { + const session = useAuthSession(); + const navigate = useNavigate(); + + useEffect(() => { + if (session.multiuserEnabled && session.sessionExpired && session.user === null) { + void navigate({ to: '/login' }); + } + }, [navigate, session.multiuserEnabled, session.sessionExpired, session.user]); + + return null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx new file mode 100644 index 00000000000..675f9de9b9e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx @@ -0,0 +1,117 @@ +import { chakra, Input, Stack } from '@chakra-ui/react'; +import { useNavigate } from '@tanstack/react-router'; +import { createSetupSchema, PASSWORD_RULES_HINT, type SetupFormValues } from '@workbench/auth/schemas'; +import { completeAdminSetup, useAuthSession } from '@workbench/auth/session'; +import { getApiErrorMessage } from '@workbench/backend/http'; +import { Button, Field } from '@workbench/components/ui'; +import { useZodForm } from '@workbench/models/useZodForm'; +import { useCallback, useMemo, type ChangeEvent, type FormEvent } from 'react'; + +import { AuthFormAlert, AuthScreen } from './AuthScreen'; +import { PasswordInput, PasswordStrengthMeter } from './PasswordInput'; + +const INITIAL_VALUES: SetupFormValues = { confirmPassword: '', displayName: '', email: '', password: '' }; + +/** + * First-run screen for multi-user mode: creates the administrator account and + * signs straight into it. + */ +export const SetupScreen = () => { + const session = useAuthSession(); + const navigate = useNavigate(); + const schema = useMemo(() => createSetupSchema(session.strictPasswordChecking), [session.strictPasswordChecking]); + const form = useZodForm(schema, INITIAL_VALUES); + + const submit = useCallback( + () => + form.handleSubmit(async (values) => { + try { + await completeAdminSetup(values.email, values.displayName.trim() || null, values.password); + } catch (error) { + throw new Error(getApiErrorMessage(error, 'Could not create the administrator account.')); + } + + await navigate({ to: '/' }); + }), + [form, navigate] + ); + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + void submit(); + }, + [submit] + ); + const handleEmailChange = useCallback( + (event: ChangeEvent) => form.setValue('email', event.target.value), + [form] + ); + const handleDisplayNameChange = useCallback( + (event: ChangeEvent) => form.setValue('displayName', event.target.value), + [form] + ); + const handlePasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('password', event.target.value), + [form] + ); + const handleConfirmPasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('confirmPassword', event.target.value), + [form] + ); + + return ( + + + {form.formError ? : null} + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/schemas.test.ts b/invokeai/frontend/webv2/src/workbench/auth/schemas.test.ts new file mode 100644 index 00000000000..59c5a1c87c1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/schemas.test.ts @@ -0,0 +1,145 @@ +import { ApiError, getApiErrorMessage } from '@workbench/backend/http'; +import { describe, expect, it } from 'vitest'; + +import { + createProfileSchema, + createSetupSchema, + createUserFormSchema, + getPasswordStrength, + loginSchema, +} from './schemas'; + +describe('password strength', () => { + it('mirrors the backend rules', () => { + expect(getPasswordStrength('short')).toBe('weak'); + expect(getPasswordStrength('alllowercase')).toBe('moderate'); + expect(getPasswordStrength('NoDigitsHere')).toBe('moderate'); + expect(getPasswordStrength('Str0ngEnough')).toBe('strong'); + }); +}); + +describe('login schema', () => { + it('requires a valid email and a password', () => { + expect(loginSchema.safeParse({ email: 'not-an-email', password: 'x', rememberMe: false }).success).toBe(false); + expect(loginSchema.safeParse({ email: 'a@b.com', password: '', rememberMe: false }).success).toBe(false); + expect(loginSchema.safeParse({ email: 'a@b.com', password: 'pw', rememberMe: true }).success).toBe(true); + }); + + it('accepts special-use domains, matching the backend', () => { + expect(loginSchema.safeParse({ email: 'admin@localhost', password: 'pw', rememberMe: false }).success).toBe(true); + expect(loginSchema.safeParse({ email: 'dev@invoke.local', password: 'pw', rememberMe: false }).success).toBe(true); + }); +}); + +describe('setup schema', () => { + it('enforces strength only in strict mode', () => { + const base = { confirmPassword: 'simple', displayName: '', email: 'a@b.com', password: 'simple' }; + + expect(createSetupSchema(false).safeParse(base).success).toBe(true); + expect(createSetupSchema(true).safeParse(base).success).toBe(false); + expect( + createSetupSchema(true).safeParse({ ...base, confirmPassword: 'Str0ngEnough', password: 'Str0ngEnough' }).success + ).toBe(true); + }); + + it('rejects mismatched confirmation', () => { + const result = createSetupSchema(false).safeParse({ + confirmPassword: 'other', + displayName: '', + email: 'a@b.com', + password: 'simple', + }); + + expect(result.success).toBe(false); + }); +}); + +describe('profile schema', () => { + it('treats an empty new password as no change', () => { + const result = createProfileSchema(true).safeParse({ + confirmPassword: '', + currentPassword: '', + displayName: 'Me', + newPassword: '', + }); + + expect(result.success).toBe(true); + }); + + it('requires the current password and confirmation when changing', () => { + const schema = createProfileSchema(false); + + expect( + schema.safeParse({ confirmPassword: 'newpw', currentPassword: '', displayName: '', newPassword: 'newpw' }).success + ).toBe(false); + expect( + schema.safeParse({ confirmPassword: 'nope', currentPassword: 'old', displayName: '', newPassword: 'newpw' }) + .success + ).toBe(false); + expect( + schema.safeParse({ confirmPassword: 'newpw', currentPassword: 'old', displayName: '', newPassword: 'newpw' }) + .success + ).toBe(true); + }); +}); + +describe('user form schema', () => { + it('requires email and password when creating', () => { + const schema = createUserFormSchema(true, true); + + expect(schema.safeParse({ displayName: '', email: '', isAdmin: false, password: 'Str0ngEnough' }).success).toBe( + false + ); + expect(schema.safeParse({ displayName: '', email: 'a@b.com', isAdmin: false, password: 'weak' }).success).toBe( + false + ); + expect( + schema.safeParse({ displayName: '', email: 'a@b.com', isAdmin: true, password: 'Str0ngEnough' }).success + ).toBe(true); + }); + + it('allows an empty password when editing', () => { + const schema = createUserFormSchema(true, false); + + expect(schema.safeParse({ displayName: 'New Name', email: '', isAdmin: false, password: '' }).success).toBe(true); + expect(schema.safeParse({ displayName: '', email: '', isAdmin: false, password: 'weak' }).success).toBe(false); + }); +}); + +describe('getApiErrorMessage', () => { + it('unwraps FastAPI detail strings', () => { + expect(getApiErrorMessage(new ApiError('{"detail":"Incorrect email or password"}', 401), 'fallback')).toBe( + 'Incorrect email or password' + ); + }); + + it('unwraps the first validation issue', () => { + const body = JSON.stringify({ detail: [{ loc: ['body', 'email'], msg: 'value is not a valid email address' }] }); + + expect(getApiErrorMessage(new ApiError(body, 422), 'fallback')).toBe('email: value is not a valid email address'); + }); + + it('formats multiple-of validation issues with the field and received value', () => { + const body = JSON.stringify({ + detail: [ + { + ctx: { multiple_of: 16 }, + input: 888, + loc: ['body', 'batch', 'graph', 'nodes', 'denoise_latents', 'flux2_denoise', 'height'], + msg: 'Input should be a multiple of 16', + type: 'multiple_of', + }, + ], + }); + + expect(getApiErrorMessage(new ApiError(body, 422), 'fallback')).toBe( + 'height must be a multiple of 16 (received 888).' + ); + }); + + it('falls back for non-JSON bodies and unknown errors', () => { + expect(getApiErrorMessage(new ApiError('', 500), 'fallback')).toBe('fallback'); + expect(getApiErrorMessage(new ApiError('plain text', 500), 'fallback')).toBe('plain text'); + expect(getApiErrorMessage('nope', 'fallback')).toBe('fallback'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/auth/schemas.ts b/invokeai/frontend/webv2/src/workbench/auth/schemas.ts new file mode 100644 index 00000000000..d1ee8bc4ede --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/schemas.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +/** + * Form schemas for the auth surfaces. Password rules mirror the backend's + * `validate_password_strength`: 8+ characters with an uppercase letter, a + * lowercase letter, and a digit. When strict checking is off the backend + * accepts any non-empty password, so the schemas relax to match. + */ + +export type PasswordStrength = 'weak' | 'moderate' | 'strong'; + +export const getPasswordStrength = (password: string): PasswordStrength => { + if (password.length < 8) { + return 'weak'; + } + + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasDigit = /\d/.test(password); + + return hasUpper && hasLower && hasDigit ? 'strong' : 'moderate'; +}; + +export const PASSWORD_RULES_HINT = 'At least 8 characters, with an uppercase letter, a lowercase letter, and a digit.'; + +/** + * Deliberately lenient: the backend accepts special-use domains (`@localhost`, + * `.local`) for development setups, so the form only catches obvious mistakes + * and leaves real validation to the server. + */ +const emailSchema = z + .string() + .trim() + .refine((value) => { + const atIndex = value.indexOf('@'); + + return atIndex > 0 && atIndex < value.length - 1; + }, 'Enter a valid email address.'); + +const createPasswordSchema = (strict: boolean) => + strict + ? z + .string() + .min(8, 'Password must be at least 8 characters long.') + .refine((value) => getPasswordStrength(value) === 'strong', PASSWORD_RULES_HINT) + : z.string().min(1, 'Enter a password.'); + +/** Optional-password variant: empty string means "leave unchanged". */ +const createOptionalPasswordSchema = (strict: boolean) => + z.string().refine((value) => value === '' || !strict || getPasswordStrength(value) === 'strong', PASSWORD_RULES_HINT); + +export const loginSchema = z.object({ + email: emailSchema, + password: z.string().min(1, 'Enter your password.'), + rememberMe: z.boolean(), +}); + +export type LoginFormValues = z.infer; + +export const createSetupSchema = (strict: boolean) => + z + .object({ + confirmPassword: z.string(), + displayName: z.string(), + email: emailSchema, + password: createPasswordSchema(strict), + }) + .refine((values) => values.password === values.confirmPassword, { + message: 'Passwords do not match.', + path: ['confirmPassword'], + }); + +export type SetupFormValues = z.infer>; + +export const createProfileSchema = (strict: boolean) => + z + .object({ + confirmPassword: z.string(), + currentPassword: z.string(), + displayName: z.string(), + newPassword: createOptionalPasswordSchema(strict), + }) + .refine((values) => values.newPassword === '' || values.currentPassword !== '', { + message: 'Enter your current password to set a new one.', + path: ['currentPassword'], + }) + .refine((values) => values.newPassword === '' || values.newPassword === values.confirmPassword, { + message: 'Passwords do not match.', + path: ['confirmPassword'], + }); + +export type ProfileFormValues = z.infer>; + +/** + * One shape serves both admin user-form modes. Creating requires email and + * password; editing ignores the email field and treats an empty password as + * "leave unchanged". + */ +export const createUserFormSchema = (strict: boolean, requireCredentials: boolean) => + z.object({ + displayName: z.string(), + email: requireCredentials ? emailSchema : z.string(), + isAdmin: z.boolean(), + password: requireCredentials ? createPasswordSchema(strict) : createOptionalPasswordSchema(strict), + }); + +export type UserFormValues = z.infer>; diff --git a/invokeai/frontend/webv2/src/workbench/auth/session.ts b/invokeai/frontend/webv2/src/workbench/auth/session.ts new file mode 100644 index 00000000000..5a6c66eb495 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/session.ts @@ -0,0 +1,171 @@ +import { ApiError, clearAuthToken, getAuthToken, setAuthToken, setUnauthorizedHandler } from '@workbench/backend/http'; +import { socketHub } from '@workbench/backend/socketHub'; +import { createExternalStore } from '@workbench/externalStore'; + +import { getAuthStatus, getCurrentUser, login, logout, setupAdmin, type AuthStatus, type UserDTO } from './api'; + +/** + * Session-lived auth state shared by the router guards and shell chrome. When + * multi-user is disabled on the backend the snapshot stays at its defaults + * (`multiuserEnabled: false`, `user: null`) and the entire auth surface — login + * screen, user menu, expiry handling — stays dormant. + */ +export interface AuthSession { + /** 'unknown' until the first `/auth/status` round-trip resolves. */ + phase: 'unknown' | 'ready'; + multiuserEnabled: boolean; + setupRequired: boolean; + strictPasswordChecking: boolean; + user: UserDTO | null; + /** Set when a stored token is rejected mid-session; shown on the login screen. */ + sessionExpired: boolean; +} + +const store = createExternalStore({ + multiuserEnabled: false, + phase: 'unknown', + sessionExpired: false, + setupRequired: false, + strictPasswordChecking: false, + user: null, +}); + +export const useAuthSession = (): AuthSession => store.useSnapshot(); + +/** + * Imperative read for non-reactive callers (e.g. the widget registry). Safe + * inside the workbench: the route guard resolves the session before mounting, + * and a user change remounts the whole workbench route. + */ +export const getAuthSession = (): AuthSession => store.getSnapshot(); + +/** + * Sticky across sign-out: a debounced autosave can still fire during the + * logout transition, and it must land in the bucket of the user whose data + * was loaded — never the shared single-user bucket. + */ +let activeUserScope = ''; + +const setActiveUserScope = (user: UserDTO | null): void => { + if (user !== null) { + activeUserScope = `:user:${user.user_id}`; + } +}; + +/** + * Suffix for localStorage keys holding user-owned state (projects, account + * preferences, personal API keys — see the spec's State Ownership section). + * Empty in single-user mode, so existing keys keep working unchanged. + */ +export const getUserStorageScope = (): string => (store.getSnapshot().multiuserEnabled ? activeUserScope : ''); + +const fetchAuthStatus = async (): Promise => { + try { + return await getAuthStatus(); + } catch { + // Backend unreachable: let the shell mount in single-user shape. The + // connection banner reports the outage, and a 401 once the backend returns + // in multi-user mode routes through the session-expiry path to login. + return { admin_email: null, multiuser_enabled: false, setup_required: false, strict_password_checking: false }; + } +}; + +const resolveSession = async (): Promise => { + const status = await fetchAuthStatus(); + let user: UserDTO | null = null; + + if (status.multiuser_enabled && !status.setup_required && getAuthToken()) { + try { + user = await getCurrentUser(); + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + clearAuthToken(); + } + // Any other failure leaves `user` null; the route guard lands on login. + } + } + + setActiveUserScope(user); + store.patchSnapshot({ + multiuserEnabled: status.multiuser_enabled, + phase: 'ready', + setupRequired: status.setup_required, + strictPasswordChecking: status.strict_password_checking, + user, + }); + + return store.getSnapshot(); +}; + +let pendingResolve: Promise | null = null; + +/** Resolve the session exactly once per app load; later calls reuse the snapshot. */ +export const ensureAuthSession = (): Promise => { + const current = store.getSnapshot(); + + if (current.phase === 'ready') { + return Promise.resolve(current); + } + + pendingResolve ??= resolveSession().finally(() => { + pendingResolve = null; + }); + + return pendingResolve; +}; + +export const loginWithCredentials = async (email: string, password: string, rememberMe: boolean): Promise => { + // A stale token must not ride along on the login request itself. + clearAuthToken(); + + const result = await login({ email, password, remember_me: rememberMe }); + + setAuthToken(result.token); + setActiveUserScope(result.user); + store.patchSnapshot({ sessionExpired: false, user: result.user }); +}; + +export const logoutSession = async (): Promise => { + try { + await logout(); + } catch { + // Tokens are stateless on the backend; local sign-out always wins. + } + + clearAuthToken(); + // Tear down the shared socket so it does not linger authenticated as the + // previous user; the next authenticated mount reconnects with a fresh token. + socketHub.disconnect(); + store.patchSnapshot({ sessionExpired: false, user: null }); +}; + +/** Create the initial admin account, then sign straight in with it. */ +export const completeAdminSetup = async ( + email: string, + displayName: string | null, + password: string +): Promise => { + await setupAdmin({ display_name: displayName, email, password }); + store.patchSnapshot({ setupRequired: false }); + await loginWithCredentials(email, password, false); +}; + +/** Reflect a profile edit (display name, password) into the session snapshot. */ +export const setSessionUser = (user: UserDTO): void => { + store.patchSnapshot({ user }); +}; + +// A 401 on any authenticated request means the stored token expired or was +// revoked. Only react while an authenticated multi-user session is live, so +// failed login attempts and single-user mode never trip it. +setUnauthorizedHandler(() => { + const session = store.getSnapshot(); + + if (!session.multiuserEnabled || session.user === null) { + return; + } + + clearAuthToken(); + socketHub.disconnect(); + store.patchSnapshot({ sessionExpired: true, user: null }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/backend/SocketHubRuntime.tsx b/invokeai/frontend/webv2/src/workbench/backend/SocketHubRuntime.tsx new file mode 100644 index 00000000000..9c8a4b9c91a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/SocketHubRuntime.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +import { socketHub } from './socketHub'; + +/** + * Renders nothing. Mounted once above both the Launchpad and the editor: opens + * the single backend socket for the authenticated session. Feature-specific + * runtimes attach listeners where needed so this base runtime stays lightweight. + * + * It intentionally does NOT disconnect on unmount — that keeps it StrictMode + * safe and lets the socket persist across Launchpad↔editor navigation. The + * socket is torn down explicitly on logout/expiry (see `auth/session.ts`). + */ +export const SocketHubRuntime = () => { + useEffect(() => { + socketHub.connect(); + }, []); + + return null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/backend/connectionStore.ts b/invokeai/frontend/webv2/src/workbench/backend/connectionStore.ts new file mode 100644 index 00000000000..d2e87c5beb4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/connectionStore.ts @@ -0,0 +1,29 @@ +import type { BackendConnectionStatus } from '@workbench/types'; + +import { createExternalStore } from '@workbench/externalStore'; + +/** + * Provider-free connection status for the shared backend socket. The socket hub + * is the sole writer; surfaces that mount no workbench providers (the Launchpad) + * read it directly, and the editor mirrors it into workbench state via a bridge + * in `WorkbenchRuntime`. Lives outside the reducer so the connection signal is + * available everywhere the socket is, not just inside the editor. + */ +export interface ConnectionSnapshot { + status: BackendConnectionStatus; + error?: string; +} + +const store = createExternalStore({ status: 'connecting' }); + +export const setConnectionStatus = (status: BackendConnectionStatus, error?: string): void => { + store.setSnapshot({ error, status }); +}; + +export const getConnectionStatus = (): ConnectionSnapshot => store.getSnapshot(); + +export const useConnectionStatusSelector = store.useSelector; + +export const useConnectionStatus = (): ConnectionSnapshot => store.useSnapshot(); + +export const subscribeConnection = (listener: () => void): (() => void) => store.subscribe(listener); diff --git a/invokeai/frontend/webv2/src/workbench/backend/events.ts b/invokeai/frontend/webv2/src/workbench/backend/events.ts new file mode 100644 index 00000000000..6b898d181c4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/events.ts @@ -0,0 +1,123 @@ +/** + * Typed contracts for the backend Socket.IO events webv2 consumes. Payload + * shapes mirror the pydantic models in + * `invokeai/app/services/events/events_common.py` (serialized as snake_case). + */ + +/** Backend queue item lifecycle status. Note the backend spells it `canceled`. */ +export type BackendQueueItemStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled'; + +export type TerminalBackendQueueItemStatus = Extract; + +export interface QueueItemEventBase { + queue_id: string; + item_id: number; + batch_id: string; + origin: string | null; + destination: string | null; +} + +export interface QueueItemStatusChangedEvent extends QueueItemEventBase { + status: BackendQueueItemStatus; + error_type: string | null; + error_message: string | null; + created_at: string; + updated_at: string; + started_at: string | null; + completed_at: string | null; + session_id: string; +} + +export interface BatchEnqueuedEvent { + queue_id: string; + batch_id: string; + enqueued: number; + requested: number; + priority: number; + origin: string | null; + user_id: string; +} + +export interface QueueClearedEvent { + queue_id: string; +} + +export interface QueueItemsRetriedEvent { + queue_id: string; + retried_item_ids: number[]; +} + +/** Shared shape of per-invocation lifecycle events (`InvocationEventBase` on the backend). */ +export interface InvocationEventBase extends QueueItemEventBase { + session_id: string; + /** The id of the executing invocation's source node — the editor's node id. */ + invocation_source_id: string; +} + +export interface InvocationStartedEvent extends InvocationEventBase {} + +export interface InvocationProgressEvent extends InvocationEventBase { + message: string; + /** 0..1, or null for indeterminate progress. */ + percentage: number | null; + /** Intermittent denoising preview, when the invocation produces one. */ + image?: { width: number; height: number; dataURL: string } | null; +} + +export interface InvocationCompleteEvent extends InvocationEventBase { + /** The invocation's output, discriminated by its `type` (e.g. `image_output`). */ + result: { type: string } & Record; +} + +export interface InvocationErrorEvent extends InvocationEventBase { + error_type: string; + error_message: string; +} + +export interface BackendSocketEvents { + queue_item_status_changed: QueueItemStatusChangedEvent; + batch_enqueued: BatchEnqueuedEvent; + queue_cleared: QueueClearedEvent; + queue_items_retried: QueueItemsRetriedEvent; + invocation_started: InvocationStartedEvent; + invocation_progress: InvocationProgressEvent; + invocation_complete: InvocationCompleteEvent; + invocation_error: InvocationErrorEvent; +} + +export const isTerminalBackendStatus = (status: BackendQueueItemStatus): status is TerminalBackendQueueItemStatus => + status === 'completed' || status === 'failed' || status === 'canceled'; + +/** + * Queue items enqueued by webv2 carry the local queue item id in their origin + * so that submissions survive a reload: on startup the backend queue is listed + * and items are re-adopted by decoding their origin. + */ +const QUEUE_ITEM_ORIGIN_PREFIX = 'webv2:'; +const PROJECT_QUEUE_ITEM_ORIGIN_PREFIX = 'webv2:p:'; + +export const buildProjectQueueItemOriginPrefix = (projectId: string): string => + `${PROJECT_QUEUE_ITEM_ORIGIN_PREFIX}${projectId}:q:`; + +export const buildQueueItemOrigin = (localQueueItemId: string, projectId?: string): string => + projectId + ? `${buildProjectQueueItemOriginPrefix(projectId)}${localQueueItemId}` + : `${QUEUE_ITEM_ORIGIN_PREFIX}${localQueueItemId}`; + +export const parseQueueItemOrigin = (origin: string | null | undefined): string | null => + origin?.startsWith(PROJECT_QUEUE_ITEM_ORIGIN_PREFIX) + ? origin.slice(origin.lastIndexOf(':q:') + 3) + : origin?.startsWith(QUEUE_ITEM_ORIGIN_PREFIX) + ? origin.slice(QUEUE_ITEM_ORIGIN_PREFIX.length) + : null; + +export const parseQueueItemOriginProjectId = (origin: string | null | undefined): string | null => { + if (!origin?.startsWith(PROJECT_QUEUE_ITEM_ORIGIN_PREFIX)) { + return null; + } + + const rest = origin.slice(PROJECT_QUEUE_ITEM_ORIGIN_PREFIX.length); + const separatorIndex = rest.indexOf(':q:'); + + return separatorIndex === -1 ? null : rest.slice(0, separatorIndex); +}; diff --git a/invokeai/frontend/webv2/src/workbench/backend/http.ts b/invokeai/frontend/webv2/src/workbench/backend/http.ts new file mode 100644 index 00000000000..399c644b561 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/http.ts @@ -0,0 +1,196 @@ +/** + * Shared HTTP client for the InvokeAI backend. Every REST call goes through + * `apiFetch` so authentication matches the WebSocket connection: both read the + * same `auth_token` and send it as a bearer token. The token is read per + * request, so a login that lands mid-session applies without a reload. + */ + +const API_BASE_URL = import.meta.env.VITE_INVOKEAI_API_BASE_URL ?? ''; +const AUTH_TOKEN_STORAGE_KEY = 'auth_token'; + +export const getAuthToken = (): string | null => { + try { + return window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY); + } catch { + return null; + } +}; + +export const setAuthToken = (token: string): void => { + try { + window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token); + } catch { + // Storage unavailable (private mode, quota): the session lasts until reload. + } +}; + +export const clearAuthToken = (): void => { + try { + window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); + } catch { + // Nothing to clear if storage is unavailable. + } +}; + +/** + * Called when an authenticated request comes back 401 — the stored token is no + * longer valid. The auth session store registers itself here so the HTTP layer + * stays unaware of session semantics. + */ +let unauthorizedHandler: (() => void) | null = null; + +export const setUnauthorizedHandler = (handler: (() => void) | null): void => { + unauthorizedHandler = handler; +}; + +export const getBackendSocketUrl = (): string => { + if (!API_BASE_URL.trim()) { + return window.location.origin; + } + + return new URL(API_BASE_URL, window.location.origin).origin; +}; + +export const buildApiUrl = (path: string): string => `${API_BASE_URL}${path}`; + +/** Resolve a backend-relative resource URL (e.g. image URLs in DTOs) against the API host. */ +export const absolutizeApiUrl = (url: string): string => { + if (!API_BASE_URL || url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + + return new URL(url, API_BASE_URL).toString(); +}; + +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +const humanizeFieldName = (value: string): string => value.replaceAll('_', ' '); + +const getLastString = (values: unknown[]): string | null => { + for (let index = values.length - 1; index >= 0; index -= 1) { + const value = values[index]; + + if (typeof value === 'string') { + return value; + } + } + + return null; +}; + +const getValidationIssueMessage = (issue: unknown): string | null => { + if (!issue || typeof issue !== 'object') { + return null; + } + + const record = issue as { ctx?: unknown; input?: unknown; loc?: unknown; msg?: unknown; type?: unknown }; + const loc = Array.isArray(record.loc) ? record.loc : []; + const lastLoc = getLastString(loc); + const field = lastLoc ? humanizeFieldName(lastLoc) : null; + + if (record.type === 'multiple_of') { + const ctx = record.ctx && typeof record.ctx === 'object' ? (record.ctx as { multiple_of?: unknown }) : null; + const multipleOf = ctx?.multiple_of; + + if (field && typeof multipleOf === 'number') { + const received = + typeof record.input === 'number' || typeof record.input === 'string' ? ` (received ${record.input})` : ''; + + return `${field} must be a multiple of ${multipleOf}${received}.`; + } + } + + if (typeof record.msg === 'string' && record.msg) { + return field ? `${field}: ${record.msg}` : record.msg; + } + + return null; +}; + +export const assertOk = async (response: Response): Promise => { + if (response.ok) { + return response; + } + + const text = await response.text(); + throw new ApiError(text || `${response.status} ${response.statusText}`, response.status); +}; + +/** Authenticated fetch that leaves status handling to the caller. */ +export const apiFetchRaw = (path: string, init?: RequestInit): Promise => { + const token = getAuthToken(); + const headers = new Headers(init?.headers); + + if (token && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + } + + return fetch(buildApiUrl(path), { ...init, headers }); +}; + +export const apiFetch = async (path: string, init?: RequestInit): Promise => { + const hadToken = getAuthToken() !== null; + const response = await apiFetchRaw(path, init); + + if (response.status === 401 && hadToken) { + unauthorizedHandler?.(); + } + + return assertOk(response); +}; + +/** + * Backend errors arrive as FastAPI JSON (`{"detail": "..."}`); `ApiError` + * carries the raw body. This unwraps `detail` into a human-readable message. + */ +export const getApiErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof ApiError) { + try { + const parsed = JSON.parse(error.message) as { detail?: unknown }; + + if (typeof parsed.detail === 'string' && parsed.detail) { + return parsed.detail; + } + + // Validation errors come as a list of issues; surface the first one. + if (Array.isArray(parsed.detail)) { + const message = getValidationIssueMessage(parsed.detail[0]); + + if (message) { + return message; + } + } + } catch { + // Not JSON — fall through to the raw message. + } + + return error.message || fallback; + } + + return error instanceof Error && error.message ? error.message : fallback; +}; + +export const apiFetchJson = async (path: string, init?: RequestInit): Promise => { + const headers = new Headers(init?.headers); + + if (init?.body !== undefined && !(init.body instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await apiFetch(path, { ...init, headers }); + + return (await response.json()) as T; +}; + +export const sleep = (ms: number): Promise => + new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); diff --git a/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.test.ts b/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.test.ts new file mode 100644 index 00000000000..91e23d338db --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { itemProgressStore } from './itemProgressStore'; + +describe('itemProgressStore', () => { + beforeEach(() => { + itemProgressStore.clearAll(); + }); + + it('preserves the previous live preview image when a progress update has no image payload', () => { + itemProgressStore.set(42, { + image: { dataUrl: 'data:image/png;base64,step-1', height: 64, width: 64 }, + message: 'Denoising 1/20', + percentage: 0.05, + }); + + itemProgressStore.set(42, { + message: 'Denoising 2/20', + percentage: 0.1, + }); + + expect(itemProgressStore.get(42)).toEqual({ + image: { dataUrl: 'data:image/png;base64,step-1', height: 64, width: 64 }, + message: 'Denoising 2/20', + percentage: 0.1, + }); + }); + + it('clears the live preview image when a progress update explicitly sets image to null', () => { + itemProgressStore.set(42, { + image: { dataUrl: 'data:image/png;base64,step-1', height: 64, width: 64 }, + message: 'Denoising 1/20', + percentage: 0.05, + }); + + itemProgressStore.set(42, { + image: null, + message: '', + percentage: null, + }); + + expect(itemProgressStore.get(42)).toEqual({ + image: null, + message: '', + percentage: null, + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.ts b/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.ts new file mode 100644 index 00000000000..dee301fff10 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.ts @@ -0,0 +1,41 @@ +import { createKeyedTransientStore } from '@workbench/externalStore'; +import { mergeDenoiseProgressImage, type DenoiseProgressImage } from '@workbench/images/streamingImageSource'; + +/** + * Live progress for in-flight queue items keyed by the backend `item_id`. The + * sibling `progressStore` keys by the *local* submission id, which only exists + * for items this client enqueued; the Queue widget shows the whole server queue, + * so its NOW & NEXT card needs progress addressable by the backend id carried on + * the socket's `invocation_started`/`invocation_progress` events. + */ + +export interface ItemProgress { + message: string; + /** 0..1, or null while indeterminate. */ + percentage: number | null; + image?: DenoiseProgressImage | null; +} + +const progressByItemId = createKeyedTransientStore(); + +export const itemProgressStore = { + get(itemId: number): ItemProgress | null { + return progressByItemId.get(itemId) ?? null; + }, + set(itemId: number, progress: ItemProgress): void { + const current = progressByItemId.get(itemId); + const image = mergeDenoiseProgressImage(current?.image, progress.image); + const next = image === undefined ? progress : { ...progress, image }; + + progressByItemId.set(itemId, next); + }, + clear(itemId: number): void { + progressByItemId.delete(itemId); + }, + clearAll(): void { + progressByItemId.clear(); + }, +}; + +export const useItemProgress = (itemId: number | null | undefined): ItemProgress | null => + progressByItemId.useValue(itemId ?? -1) ?? null; diff --git a/invokeai/frontend/webv2/src/workbench/backend/modelLoadStore.ts b/invokeai/frontend/webv2/src/workbench/backend/modelLoadStore.ts new file mode 100644 index 00000000000..d4835cd3314 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/modelLoadStore.ts @@ -0,0 +1,38 @@ +import { createExternalStore } from '@workbench/externalStore'; + +export interface ModelLoadInfo { + label: string; +} + +const store = createExternalStore<{ activeLoads: ModelLoadInfo[] }>({ activeLoads: [] }); + +const getModelLoadLabel = (payload: unknown): string => { + const record = payload && typeof payload === 'object' ? (payload as Record) : {}; + const config = record.config && typeof record.config === 'object' ? (record.config as Record) : {}; + const name = typeof config.name === 'string' && config.name.trim() ? config.name.trim() : 'model'; + const extras = [config.base, config.type, record.submodel_type].filter( + (value): value is string => typeof value === 'string' && value.trim().length > 0 + ); + + return extras.length ? `${name} (${extras.join(', ')})` : name; +}; + +export const modelLoadStore = { + started(payload: unknown): void { + store.patchSnapshot({ activeLoads: [...store.getSnapshot().activeLoads, { label: getModelLoadLabel(payload) }] }); + }, + completed(payload: unknown): void { + const label = getModelLoadLabel(payload); + const { activeLoads } = store.getSnapshot(); + const index = activeLoads.findIndex((load) => load.label === label); + + store.patchSnapshot({ + activeLoads: index >= 0 ? activeLoads.filter((_, loadIndex) => loadIndex !== index) : activeLoads.slice(1), + }); + }, + reset(): void { + store.patchSnapshot({ activeLoads: [] }); + }, +}; + +export const useModelLoads = (): ModelLoadInfo[] => store.useSelector((snapshot) => snapshot.activeLoads); diff --git a/invokeai/frontend/webv2/src/workbench/backend/nodeExecutionStore.ts b/invokeai/frontend/webv2/src/workbench/backend/nodeExecutionStore.ts new file mode 100644 index 00000000000..477f732f64b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/nodeExecutionStore.ts @@ -0,0 +1,100 @@ +import { createKeyedTransientStore } from '@workbench/externalStore'; + +import type { InvocationCompleteEvent, InvocationErrorEvent, InvocationStartedEvent } from './events'; + +import { buildApiUrl } from './http'; + +/** + * Ephemeral per-node execution state, keyed by the invocation's source node id + * (the workflow editor's node id). Like the queue-item progress store, this is + * high-frequency transient data that deliberately lives outside the workbench + * reducer; the editor's nodes subscribe per id and only re-render when their + * own node's state moves. + */ + +export type NodeExecutionStatus = 'running' | 'completed' | 'failed'; + +export interface NodeExecutionState { + status: NodeExecutionStatus; + /** 0..1, or null while indeterminate. Only meaningful while running. */ + progress: number | null; + progressMessage: string | null; + /** Thumbnail of the node's most recent image output, when it produced one. */ + outputImageUrl: string | null; + error: string | null; +} + +const stateByNodeId = createKeyedTransientStore(); + +/** Pull the produced image out of an invocation output, whatever the node type. */ +const getResultImageName = (result: InvocationCompleteEvent['result']): string | null => { + const image = (result as { image?: { image_name?: unknown } }).image; + + return typeof image?.image_name === 'string' ? image.image_name : null; +}; + +export const nodeExecutionStore = { + clearAll(): void { + stateByNodeId.clear(); + }, + completed(event: InvocationCompleteEvent): void { + const imageName = getResultImageName(event.result); + const previous = stateByNodeId.get(event.invocation_source_id); + + stateByNodeId.set(event.invocation_source_id, { + error: null, + outputImageUrl: imageName + ? buildApiUrl(`/api/v1/images/i/${encodeURIComponent(imageName)}/thumbnail`) + : (previous?.outputImageUrl ?? null), + progress: null, + progressMessage: null, + status: 'completed', + }); + }, + failed(event: InvocationErrorEvent): void { + const previous = stateByNodeId.get(event.invocation_source_id); + + stateByNodeId.set(event.invocation_source_id, { + error: event.error_message, + outputImageUrl: previous?.outputImageUrl ?? null, + progress: null, + progressMessage: null, + status: 'failed', + }); + }, + progress(nodeId: string, percentage: number | null, message: string): void { + const previous = stateByNodeId.get(nodeId); + + stateByNodeId.set(nodeId, { + error: null, + outputImageUrl: previous?.outputImageUrl ?? null, + progress: percentage, + progressMessage: message, + status: 'running', + }); + }, + /** A queue item reached a terminal state: nothing can still be running. */ + settleRunning(): void { + for (const [nodeId, state] of stateByNodeId.entries()) { + if (state.status === 'running') { + stateByNodeId.set(nodeId, { ...state, progress: null, progressMessage: null, status: 'completed' }); + } + } + }, + started(event: InvocationStartedEvent): void { + const previous = stateByNodeId.get(event.invocation_source_id); + + stateByNodeId.set(event.invocation_source_id, { + error: null, + outputImageUrl: previous?.outputImageUrl ?? null, + progress: null, + progressMessage: null, + status: 'running', + }); + }, +}; + +export type NodeExecutionSink = typeof nodeExecutionStore; + +export const useNodeExecutionState = (nodeId: string): NodeExecutionState | null => + stateByNodeId.useValue(nodeId) ?? null; diff --git a/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts b/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts new file mode 100644 index 00000000000..6646bb251b8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts @@ -0,0 +1,64 @@ +import type { DenoiseProgressImage } from '@workbench/images/streamingImageSource'; + +import { createExternalStore, createKeyedTransientStore } from '@workbench/externalStore'; + +/** + * The most recent denoising preview image from `invocation_progress` events, + * as a b64 data URL. Cleared when the run settles so consumers (the editor's + * Current Image node, progress surfaces) fall back to the last real output. + */ + +export type ProgressImageSnapshot = DenoiseProgressImage; + +export interface ProgressImageTarget { + queueItemId: string; + itemIndex: number; +} + +type LatestProgressImageSnapshot = ProgressImageSnapshot & { target?: ProgressImageTarget }; + +const latestSnapshotStore = createExternalStore<{ latestSnapshot: LatestProgressImageSnapshot | null }>({ + latestSnapshot: null, +}); +const snapshotsByTarget = createKeyedTransientStore(); + +const getTargetKey = ({ itemIndex, queueItemId }: ProgressImageTarget): string => `${queueItemId}:${itemIndex}`; + +const isLatestTarget = (target: ProgressImageTarget): boolean => + latestSnapshotStore.getSnapshot().latestSnapshot?.target?.queueItemId === target.queueItemId && + latestSnapshotStore.getSnapshot().latestSnapshot?.target?.itemIndex === target.itemIndex; + +export const progressImageStore = { + clear(target?: ProgressImageTarget): void { + if (!target) { + latestSnapshotStore.patchSnapshot({ latestSnapshot: null }); + snapshotsByTarget.clear(); + + return; + } + + const targetKey = getTargetKey(target); + const didClearLatest = isLatestTarget(target); + + snapshotsByTarget.delete(targetKey); + + if (didClearLatest) { + latestSnapshotStore.patchSnapshot({ latestSnapshot: null }); + } + }, + set(image: ProgressImageSnapshot, target?: ProgressImageTarget): void { + latestSnapshotStore.patchSnapshot({ latestSnapshot: target ? { ...image, target } : image }); + + if (target) { + snapshotsByTarget.set(getTargetKey(target), image); + } + }, +}; + +export type ProgressImageSink = typeof progressImageStore; + +export const useProgressImage = (): ProgressImageSnapshot | null => + latestSnapshotStore.useSelector((snapshot) => snapshot.latestSnapshot); + +export const useQueueItemProgressImage = (queueItemId: string, itemIndex: number): ProgressImageSnapshot | null => + snapshotsByTarget.useValue(getTargetKey({ itemIndex, queueItemId })) ?? null; diff --git a/invokeai/frontend/webv2/src/workbench/backend/progressStore.ts b/invokeai/frontend/webv2/src/workbench/backend/progressStore.ts new file mode 100644 index 00000000000..b96ac078846 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/progressStore.ts @@ -0,0 +1,43 @@ +import { createKeyedTransientStore } from '@workbench/externalStore'; + +/** + * Ephemeral live-progress store for in-flight queue items, keyed by the local + * queue item id. Progress is high-frequency transient data, so it deliberately + * lives outside the workbench reducer: routing it through dispatch would + * re-render every consumer of workbench state and churn autosave on each step + * of every generation. Any widget can subscribe to a single item via + * `useQueueItemProgress` and only re-renders when that item's progress moves. + */ + +export interface QueueItemProgress { + /** 1-based image slot currently executing inside this local batch. */ + activeItemIndex?: number; + completedItemCount?: number; + message: string; + /** 0..1, or null while indeterminate. */ + percentage: number | null; + totalItemCount?: number; +} + +export interface QueueItemProgressSink { + clearAll?(): void; + set(queueItemId: string, progress: QueueItemProgress): void; + clear(queueItemId: string): void; +} + +const progressByQueueItemId = createKeyedTransientStore(); + +export const queueItemProgressStore: QueueItemProgressSink = { + clearAll() { + progressByQueueItemId.clear(); + }, + clear(queueItemId) { + progressByQueueItemId.delete(queueItemId); + }, + set(queueItemId, progress) { + progressByQueueItemId.set(queueItemId, progress); + }, +}; + +export const useQueueItemProgress = (queueItemId: string): QueueItemProgress | null => + progressByQueueItemId.useValue(queueItemId) ?? null; diff --git a/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts new file mode 100644 index 00000000000..149824e6510 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts @@ -0,0 +1,627 @@ +import type { EnqueueGenerateRequest, ImageDTO, QueueItemDTO } from '@workbench/generation/types'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { NodeExecutionSink } from './nodeExecutionStore'; +import type { QueueItemProgress, QueueItemProgressSink } from './progressStore'; + +import { buildQueueItemOrigin, type QueueItemStatusChangedEvent } from './events'; +import { ApiError } from './http'; +import { + createQueueCoordinator, + QueueItemCancelledError, + type QueueCoordinator, + type QueueCoordinatorApi, + type QueueCoordinatorCallbacks, +} from './queueCoordinator'; +import { createSocketHub, type BackendSocket } from './socketHub'; + +class FakeSocket implements BackendSocket { + readonly emitted: { event: string; payload: unknown }[] = []; + private readonly handlers = new Map void)[]>(); + + on(event: string, handler: (payload: never) => void): void { + this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]); + } + + off(event: string, handler: (payload: never) => void): void { + this.handlers.set( + event, + (this.handlers.get(event) ?? []).filter((existing) => existing !== handler) + ); + } + + emit(event: string, payload: unknown): void { + this.emitted.push({ event, payload }); + } + + connect(): void { + this.fire('connect', undefined); + } + + disconnect(): void { + this.fire('disconnect', 'io client disconnect'); + } + + fire(event: string, payload: unknown): void { + for (const handler of this.handlers.get(event) ?? []) { + (handler as (value: unknown) => void)(payload); + } + } +} + +const createStatusEvent = (overrides: Partial): QueueItemStatusChangedEvent => ({ + batch_id: 'batch-1', + completed_at: null, + created_at: '2026-06-10T00:00:00Z', + destination: 'gallery', + error_message: null, + error_type: null, + item_id: 1, + origin: null, + queue_id: 'default', + session_id: 'session-1', + started_at: null, + status: 'completed', + updated_at: '2026-06-10T00:00:00Z', + ...overrides, +}); + +const createQueueItemDTO = (overrides: Partial): QueueItemDTO => ({ + item_id: 1, + session: { results: {} }, + status: 'in_progress', + ...overrides, +}); + +const createImage = (imageName: string, sourceQueueItemId: string): ImageDTO => ({ + height: 64, + imageName, + imageUrl: `https://example.test/${imageName}`, + isIntermediate: false, + queuedAt: '2026-06-10T00:00:00Z', + sourceQueueItemId, + thumbnailUrl: `https://example.test/${imageName}/thumb`, + width: 64, +}); + +const generateRequest: EnqueueGenerateRequest = { + batchCount: 1, + destination: 'gallery', + graph: { edges: [], id: 'graph-1', nodes: {} }, + negativePrompt: '', + negativePromptNodeId: 'negative_prompt', + positivePrompt: 'a fjord at dawn', + positivePromptNodeId: 'positive_prompt', + projectId: 'project-1', + seed: 1, + seedNodeId: 'seed', + shouldRandomizeSeed: false, + sourceQueueItemId: 'local-1', +}; + +interface Harness { + api: { [Key in keyof QueueCoordinatorApi]: ReturnType }; + callbacks: { [Key in keyof QueueCoordinatorCallbacks]: ReturnType }; + coordinator: QueueCoordinator; + hub: ReturnType; + nodeExecution: { [Key in keyof NodeExecutionSink]: ReturnType }; + progressImage: { clear: ReturnType; set: ReturnType }; + progressEntries: Map; + socket: FakeSocket; +} + +const createHarness = (options: { galleryRefreshCoalesceMs?: number } = {}): Harness => { + const socket = new FakeSocket(); + const progressEntries = new Map(); + const progress: QueueItemProgressSink = { + clear: (queueItemId) => { + progressEntries.delete(queueItemId); + }, + set: (queueItemId, value) => { + progressEntries.set(queueItemId, value); + }, + }; + const api = { + cancelQueueItems: vi.fn(() => Promise.resolve()), + cancelQueueItemsByBatchIds: vi.fn(() => Promise.resolve()), + enqueueGenerateGraph: vi.fn(() => Promise.resolve({ batchId: 'batch-1', enqueued: 1, itemIds: [1], requested: 1 })), + enqueueWorkflowGraph: vi.fn(() => Promise.resolve({ batchId: 'batch-1', enqueued: 1, itemIds: [1], requested: 1 })), + getQueueItem: vi.fn((itemId: number) => Promise.resolve(createQueueItemDTO({ item_id: itemId }))), + getQueueItemResultImages: vi.fn((itemId: number, sourceQueueItemId: string) => + Promise.resolve([createImage(`image-${itemId}.png`, sourceQueueItemId)]) + ), + listAllQueueItems: vi.fn((): Promise => Promise.resolve([])), + }; + const callbacks = { + onGalleryRefresh: vi.fn(), + }; + const nodeExecution = { + clearAll: vi.fn(), + completed: vi.fn(), + failed: vi.fn(), + progress: vi.fn(), + settleRunning: vi.fn(), + started: vi.fn(), + }; + const progressImage = { clear: vi.fn(), set: vi.fn() }; + const hub = createSocketHub({ createSocket: () => socket }); + + hub.connect(); + + const coordinator = createQueueCoordinator(callbacks, { + api, + galleryRefreshCoalesceMs: options.galleryRefreshCoalesceMs ?? 1, + hub, + nodeExecution, + progress, + progressImage, + }); + + return { api, callbacks, coordinator, hub, nodeExecution, progressImage, progressEntries, socket }; +}; + +describe('queueCoordinator', () => { + let harness: Harness; + + beforeEach(() => { + harness = createHarness(); + }); + + afterEach(() => { + harness.coordinator.dispose(); + harness.hub.disconnect(); + vi.useRealTimers(); + }); + + it('settles submitted runs from terminal socket events without polling', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2 })); + + const images = await resultsPromise; + + expect(images.map((image) => image.imageName)).toEqual(['image-1.png', 'image-2.png']); + expect(harness.api.getQueueItem).not.toHaveBeenCalled(); + }); + + it('rejects with the backend error message when a run fails', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire( + 'queue_item_status_changed', + createStatusEvent({ error_message: 'CUDA out of memory', item_id: 1, status: 'failed' }) + ); + + await expect(resultsPromise).rejects.toThrow('CUDA out of memory'); + }); + + it('rejects with QueueItemCancelledError when the backend cancels a run', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1, status: 'canceled' })); + + await expect(resultsPromise).rejects.toBeInstanceOf(QueueItemCancelledError); + }); + + it('returns completed batch item results when sibling backend items are canceled', async () => { + harness.callbacks.onBackendItemCancelled = vi.fn(); + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 3, + itemIds: [1, 2, 3], + requested: 3, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1, status: 'canceled' })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2 })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 3, status: 'canceled' })); + + const images = await resultsPromise; + + expect(images.map((image) => image.imageName)).toEqual(['image-2.png']); + expect(harness.api.getQueueItemResultImages).toHaveBeenCalledTimes(1); + expect(harness.api.getQueueItemResultImages).toHaveBeenCalledWith(2, 'local-1', '2026-06-10T00:00:00Z'); + expect(harness.callbacks.onBackendItemCancelled).toHaveBeenCalledWith('local-1', 1); + expect(harness.callbacks.onBackendItemCancelled).toHaveBeenCalledWith('local-1', 3); + }); + + it('rejects with QueueItemCancelledError when every backend item in a batch is canceled', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1, status: 'canceled' })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2, status: 'canceled' })); + + await expect(resultsPromise).rejects.toBeInstanceOf(QueueItemCancelledError); + expect(harness.api.getQueueItemResultImages).not.toHaveBeenCalled(); + }); + + it('settles runs whose terminal event arrived before tracking began', async () => { + harness.coordinator.connect(); + + // The event for item 1 lands while enqueue_batch is still resolving. + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + const images = await harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + expect(images.map((image) => image.imageName)).toEqual(['image-1.png']); + }); + + it('rejects runs when the backend queue accepts no items', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ batchId: 'batch-1', enqueued: 0, itemIds: [], requested: 1 }); + + await expect(harness.coordinator.submitGenerate('local-1', generateRequest)).rejects.toThrow( + 'The backend queue did not accept this generation.' + ); + }); + + it('rejects runs when the backend queue accepts only part of the batch', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ batchId: 'batch-1', enqueued: 1, itemIds: [1], requested: 2 }); + + await expect(harness.coordinator.submitGenerate('local-1', generateRequest)).rejects.toThrow( + 'The backend queue accepted 1 of 2 requested items.' + ); + }); + + it('coalesces gallery refreshes across a burst of completions', async () => { + vi.useFakeTimers(); + harness = createHarness({ galleryRefreshCoalesceMs: 400 }); + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 10, + itemIds: Array.from({ length: 10 }, (_, index) => index + 1), + requested: 10, + }); + harness.coordinator.connect(); + await harness.coordinator.submitGenerate('local-1', generateRequest); + await vi.advanceTimersByTimeAsync(500); // flush the on-connect refresh + harness.callbacks.onGalleryRefresh.mockClear(); + + for (let itemId = 1; itemId <= 10; itemId += 1) { + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: itemId })); + } + + await vi.advanceTimersByTimeAsync(500); + + expect(harness.callbacks.onGalleryRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not refresh the gallery for failed or canceled items', async () => { + vi.useFakeTimers(); + harness = createHarness({ galleryRefreshCoalesceMs: 400 }); + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + await harness.coordinator.submitGenerate('local-1', generateRequest); + await vi.advanceTimersByTimeAsync(500); // flush the on-connect refresh + harness.callbacks.onGalleryRefresh.mockClear(); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1, status: 'failed' })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2, status: 'canceled' })); + + await vi.advanceTimersByTimeAsync(500); + + expect(harness.callbacks.onGalleryRefresh).not.toHaveBeenCalled(); + }); + + it('routes progress events to the tracked item and clears them on completion', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + harness.socket.fire('invocation_progress', { + ...createStatusEvent({ item_id: 1 }), + message: 'Denoising', + percentage: 0.5, + }); + + expect(harness.progressEntries.get('local-1')).toEqual({ + activeItemIndex: 1, + completedItemCount: 0, + message: 'Denoising', + percentage: 0.5, + totalItemCount: 1, + }); + + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + await resultsPromise; + + expect(harness.progressEntries.has('local-1')).toBe(false); + expect(harness.progressImage.clear).toHaveBeenCalledWith({ itemIndex: 1, queueItemId: 'local-1' }); + }); + + it('keeps the completed progress image until backend item result routing finishes', async () => { + let finishRouting: () => void = () => undefined; + const routingPromise = new Promise((resolve) => { + finishRouting = resolve; + }); + + harness.callbacks.onBackendItemComplete = vi.fn(() => routingPromise); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('invocation_progress', { + ...createStatusEvent({ item_id: 1 }), + image: { dataURL: 'data:image/png;base64,final-denoise', height: 32, width: 64 }, + invocation_source_id: 'denoise', + message: 'Denoising', + percentage: 1, + }); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + await resultsPromise; + + expect(harness.callbacks.onBackendItemComplete).toHaveBeenCalledWith('local-1', 1); + expect(harness.progressImage.clear).not.toHaveBeenCalled(); + + finishRouting(); + await routingPromise; + await Promise.resolve(); + + expect(harness.progressImage.clear).toHaveBeenCalledWith({ itemIndex: 1, queueItemId: 'local-1' }); + }); + + it('notifies when one backend item in a batch completes', async () => { + harness.callbacks.onBackendItemComplete = vi.fn(); + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + expect(harness.callbacks.onBackendItemComplete).toHaveBeenCalledWith('local-1', 1); + }); + + it('routes progress images to the active image slot inside a submitted batch', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + harness.socket.fire('invocation_progress', { + ...createStatusEvent({ item_id: 2 }), + image: { dataURL: 'data:image/png;base64,abc', height: 32, width: 64 }, + invocation_source_id: 'denoise', + message: 'Denoising', + percentage: 0.5, + }); + + expect(harness.progressImage.set).toHaveBeenCalledWith( + { dataUrl: 'data:image/png;base64,abc', height: 32, width: 64 }, + { itemIndex: 2, queueItemId: 'local-1' } + ); + }); + + it('ignores untracked queue events before mutating local execution state', () => { + vi.useFakeTimers(); + harness = createHarness({ galleryRefreshCoalesceMs: 400 }); + harness.coordinator.connect(); + harness.callbacks.onGalleryRefresh.mockClear(); + + harness.socket.fire('invocation_started', { + ...createStatusEvent({ item_id: 99 }), + invocation_source_id: 'node-1', + }); + harness.socket.fire('invocation_progress', { + ...createStatusEvent({ item_id: 99 }), + invocation_source_id: 'node-1', + message: 'other user progress', + percentage: 0.5, + }); + harness.socket.fire('invocation_complete', { + ...createStatusEvent({ item_id: 99 }), + invocation_source_id: 'node-1', + result: { type: 'image_output' }, + }); + harness.socket.fire('invocation_error', { + ...createStatusEvent({ item_id: 99 }), + error_message: 'other user failure', + error_type: 'Error', + invocation_source_id: 'node-1', + }); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 99 })); + + expect(harness.nodeExecution.started).not.toHaveBeenCalled(); + expect(harness.nodeExecution.progress).not.toHaveBeenCalled(); + expect(harness.nodeExecution.completed).not.toHaveBeenCalled(); + expect(harness.nodeExecution.failed).not.toHaveBeenCalled(); + expect(harness.nodeExecution.settleRunning).not.toHaveBeenCalled(); + expect(harness.progressImage.clear).not.toHaveBeenCalled(); + expect(harness.progressImage.set).not.toHaveBeenCalled(); + expect(harness.callbacks.onGalleryRefresh).not.toHaveBeenCalled(); + }); + + it('tracks the active image index inside a submitted batch', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + expect(harness.progressEntries.get('local-1')).toEqual({ + activeItemIndex: 1, + completedItemCount: 0, + message: '', + percentage: null, + totalItemCount: 2, + }); + + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + expect(harness.progressEntries.get('local-1')).toEqual({ + activeItemIndex: 2, + completedItemCount: 1, + message: '', + percentage: null, + totalItemCount: 2, + }); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2 })); + + await resultsPromise; + }); + + describe('reconcile', () => { + it('adopts pending items the backend already accepted, by origin', async () => { + harness.api.listAllQueueItems.mockResolvedValue([ + createQueueItemDTO({ batch_id: 'batch-9', item_id: 7, origin: buildQueueItemOrigin('local-1') }), + ]); + + const outcomes = await harness.coordinator.reconcile([{ id: 'local-1', status: 'pending' }]); + + expect(outcomes.get('local-1')).toEqual({ backendBatchId: 'batch-9', backendItemIds: [7], kind: 'adopted' }); + expect(harness.api.enqueueGenerateGraph).not.toHaveBeenCalled(); + }); + + it('resumes running items and settles them from their listed terminal status', async () => { + harness.api.getQueueItem.mockResolvedValue(createQueueItemDTO({ item_id: 7, status: 'completed' })); + + const outcomes = await harness.coordinator.reconcile([{ backendItemIds: [7], id: 'local-1', status: 'running' }]); + + expect(outcomes.get('local-1')).toEqual({ kind: 'resumed' }); + expect(harness.api.listAllQueueItems).not.toHaveBeenCalled(); + + const images = await harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + expect(images.map((image) => image.imageName)).toEqual(['image-7.png']); + }); + + it('marks running items missing when their backend items vanished', async () => { + harness.api.getQueueItem.mockRejectedValue(new ApiError('not found', 404)); + + const outcomes = await harness.coordinator.reconcile([{ backendItemIds: [7], id: 'local-1', status: 'running' }]); + + expect(outcomes.get('local-1')).toEqual({ kind: 'missing' }); + expect(harness.api.listAllQueueItems).not.toHaveBeenCalled(); + }); + + it('asks for a fresh enqueue when a pending item left no backend trace', async () => { + harness.api.listAllQueueItems.mockResolvedValue([]); + + const outcomes = await harness.coordinator.reconcile([{ id: 'local-1', status: 'pending' }]); + + expect(outcomes.get('local-1')).toEqual({ kind: 'enqueue' }); + }); + + it('skips the backend round-trip when there is nothing to reconcile', async () => { + const outcomes = await harness.coordinator.reconcile([]); + + expect(outcomes.size).toBe(0); + expect(harness.api.listAllQueueItems).not.toHaveBeenCalled(); + }); + }); + + it('settles missed events through the safety sweep on reconnect', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + // The completion event was lost to a disconnect; the reconnect sweep + // re-checks every outstanding item. + harness.api.getQueueItem.mockResolvedValue(createQueueItemDTO({ item_id: 1, status: 'completed' })); + harness.socket.fire('connect', undefined); + + const images = await resultsPromise; + + expect(images.map((image) => image.imageName)).toEqual(['image-1.png']); + }); + + it('fails runs whose backend items were pruned (404) during a sweep', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.api.getQueueItem.mockRejectedValue(new ApiError('not found', 404)); + harness.socket.fire('connect', undefined); + + await expect(resultsPromise).rejects.toThrow('no longer on the backend queue'); + }); + + it('prefers precise item ids for cancellation, falling back to batch id', async () => { + await harness.coordinator.cancelRun({ backendBatchId: 'batch-1', backendItemIds: [1, 2] }); + + expect(harness.api.cancelQueueItems).toHaveBeenCalledWith([1, 2]); + expect(harness.api.cancelQueueItemsByBatchIds).not.toHaveBeenCalled(); + + await harness.coordinator.cancelRun({ backendBatchId: 'batch-2' }); + + expect(harness.api.cancelQueueItemsByBatchIds).toHaveBeenCalledWith(['batch-2']); + }); + + it('treats stale missing backend items as already cancelled', async () => { + harness.api.cancelQueueItems.mockRejectedValue( + new ApiError('Queue item with id 42 not found in queue default', 404) + ); + + await expect(harness.coordinator.cancelRun({ backendItemIds: [42] })).resolves.toBeUndefined(); + }); + + it('detaches socket listeners after dispose', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + harness.coordinator.dispose(); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + expect(harness.nodeExecution.settleRunning).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.ts b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.ts new file mode 100644 index 00000000000..7ae831a083a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.ts @@ -0,0 +1,673 @@ +import type { + EnqueueGenerateRequest, + EnqueueGenerateResult, + EnqueueWorkflowRequest, + ImageDTO, + QueueItemDTO, +} from '@workbench/generation/types'; +import type { BackendConnectionStatus } from '@workbench/types'; + +import { + cancelQueueItems, + cancelQueueItemsByBatchIds, + enqueueGenerateGraph, + enqueueWorkflowGraph, + getQueueItem, + getQueueItemResultImages, + listAllQueueItems, +} from '@workbench/generation/api'; + +import type { SocketHub } from './socketHub'; + +import { + isTerminalBackendStatus, + parseQueueItemOrigin, + type InvocationCompleteEvent, + type InvocationErrorEvent, + type InvocationProgressEvent, + type InvocationStartedEvent, + type QueueItemStatusChangedEvent, + type TerminalBackendQueueItemStatus, +} from './events'; +import { ApiError } from './http'; +import { modelLoadStore } from './modelLoadStore'; +import { nodeExecutionStore, type NodeExecutionSink } from './nodeExecutionStore'; +import { progressImageStore, type ProgressImageSink, type ProgressImageTarget } from './progressImageStore'; +import { queueItemProgressStore, type QueueItemProgressSink } from './progressStore'; + +const GALLERY_REFRESH_COALESCE_MS = 400; +const SAFETY_SWEEP_INTERVAL_MS = 30_000; +const TERMINAL_EVENT_BUFFER_LIMIT = 256; + +export interface QueueCoordinatorApi { + cancelQueueItems: typeof cancelQueueItems; + cancelQueueItemsByBatchIds: typeof cancelQueueItemsByBatchIds; + enqueueGenerateGraph: typeof enqueueGenerateGraph; + enqueueWorkflowGraph: typeof enqueueWorkflowGraph; + getQueueItem: typeof getQueueItem; + getQueueItemResultImages: typeof getQueueItemResultImages; + listAllQueueItems: typeof listAllQueueItems; +} + +export interface QueueCoordinatorCallbacks { + /** Fired when one backend item in a local batch completes, before the whole local batch necessarily settles. */ + onBackendItemComplete?(localQueueItemId: string, backendItemId: number): void | Promise; + /** Fired when one backend item in a local batch is canceled. */ + onBackendItemCancelled?(localQueueItemId: string, backendItemId: number): void; + /** Coalesced signal that completed generations may have added gallery images. */ + onGalleryRefresh(): void; +} + +type TerminalOutcome = { status: 'completed' } | { status: 'failed'; error: string } | { status: 'canceled' }; + +/** Thrown by `waitForResults` when the backend reports the run was canceled. */ +export class QueueItemCancelledError extends Error { + constructor(localQueueItemId: string) { + super(`Queue item ${localQueueItemId} was canceled.`); + this.name = 'QueueItemCancelledError'; + } +} + +export interface ReconcileInput { + id: string; + status: 'pending' | 'running'; + backendItemIds?: number[]; + backendBatchId?: string; +} + +export type ReconcileOutcome = + /** A pending item the backend already accepted before the reload; do not re-enqueue. */ + | { kind: 'adopted'; backendItemIds: number[]; backendBatchId?: string } + /** A running item whose backend items were found again; its results are awaitable. */ + | { kind: 'resumed' } + /** A running item whose backend items no longer exist (queue cleared or pruned). */ + | { kind: 'missing' } + /** A pending item the backend has never seen; submit it normally. */ + | { kind: 'enqueue' }; + +export interface CancelRunRequest { + backendBatchId?: string; + backendItemIds?: number[]; +} + +export interface QueueCoordinator { + connect(): void; + dispose(): void; + /** + * Match persisted pending/running queue items against the live backend queue + * so a reload neither double-submits nor orphans work. Adopted and resumed + * items are tracked and can be awaited with `waitForResults`. + */ + reconcile(items: ReconcileInput[]): Promise>; + /** Enqueue a generate batch and track its backend items for event-driven settlement. */ + submitGenerate(localQueueItemId: string, request: EnqueueGenerateRequest): Promise; + /** Enqueue a compiled workflow graph and track its backend items the same way. */ + submitWorkflow(localQueueItemId: string, request: EnqueueWorkflowRequest): Promise; + /** + * Resolve once every backend item of the run reaches a terminal status — + * driven by socket events, with a slow safety sweep as the only polling. + * Resolves with the result images, throws on failure, and throws + * `QueueItemCancelledError` on backend-side cancellation. + */ + waitForResults(localQueueItemId: string, queuedAt: string): Promise; + cancelRun(request: CancelRunRequest): Promise; +} + +interface RunState { + backendItemIds: number[]; + backendBatchId?: string; + outcomePromises: Promise[]; +} + +interface RunProgressState { + activeBackendItemId?: number; + backendItemIds: number[]; + cancelledBackendItemIds: Set; + completedBackendItemIds: Set; + message: string; + percentage: number | null; +} + +interface WaitState { + localQueueItemId: string; + settle: (outcome: TerminalOutcome) => void; +} + +const defaultApi: QueueCoordinatorApi = { + cancelQueueItems, + cancelQueueItemsByBatchIds, + enqueueGenerateGraph, + enqueueWorkflowGraph, + getQueueItem, + getQueueItemResultImages, + listAllQueueItems, +}; + +const toTerminalOutcome = ( + status: TerminalBackendQueueItemStatus, + error?: string | null, + errorType?: string | null +): TerminalOutcome => { + if (status === 'completed') { + return { status: 'completed' }; + } + + if (status === 'failed') { + return { error: error ?? errorType ?? 'Generation failed.', status: 'failed' }; + } + + return { status: 'canceled' }; +}; + +export const createQueueCoordinator = ( + callbacks: QueueCoordinatorCallbacks, + options: { + hub: Pick; + api?: Partial; + galleryRefreshCoalesceMs?: number; + nodeExecution?: NodeExecutionSink; + progress?: QueueItemProgressSink; + progressImage?: ProgressImageSink; + sweepIntervalMs?: number; + } +): QueueCoordinator => { + const api: QueueCoordinatorApi = { ...defaultApi, ...options.api }; + const hub = options.hub; + const progress = options.progress ?? queueItemProgressStore; + const nodeExecution = options.nodeExecution ?? nodeExecutionStore; + const progressImage = options.progressImage ?? progressImageStore; + const galleryRefreshCoalesceMs = options.galleryRefreshCoalesceMs ?? GALLERY_REFRESH_COALESCE_MS; + const sweepIntervalMs = options.sweepIntervalMs ?? SAFETY_SWEEP_INTERVAL_MS; + + const runs = new Map(); + const runProgress = new Map(); + const waits = new Map(); + /** + * Terminal events that arrived for items nobody tracks yet. Closes the race + * where a very fast generation finishes between `enqueue_batch` resolving + * and the run being registered. + */ + const recentTerminalOutcomes = new Map(); + + const detachers: Array<() => void> = []; + let isAttached = false; + let isDisposed = false; + let galleryRefreshTimer: ReturnType | null = null; + let sweepTimer: ReturnType | null = null; + let isSweeping = false; + + const scheduleGalleryRefresh = (): void => { + if (isDisposed || galleryRefreshTimer !== null) { + return; + } + + galleryRefreshTimer = setTimeout(() => { + galleryRefreshTimer = null; + callbacks.onGalleryRefresh(); + }, galleryRefreshCoalesceMs); + }; + + const bufferTerminalOutcome = (backendItemId: number, outcome: TerminalOutcome): void => { + recentTerminalOutcomes.delete(backendItemId); + recentTerminalOutcomes.set(backendItemId, outcome); + + while (recentTerminalOutcomes.size > TERMINAL_EVENT_BUFFER_LIMIT) { + const oldestId = recentTerminalOutcomes.keys().next().value; + + if (oldestId === undefined) { + return; + } + + recentTerminalOutcomes.delete(oldestId); + } + }; + + const publishRunProgress = (localQueueItemId: string): void => { + const state = runProgress.get(localQueueItemId); + + if (!state) { + return; + } + + const terminalBackendItemIds = new Set([...state.completedBackendItemIds, ...state.cancelledBackendItemIds]); + const activeBackendItemId = + state.activeBackendItemId !== undefined && !terminalBackendItemIds.has(state.activeBackendItemId) + ? state.activeBackendItemId + : undefined; + const activeItemIndex = activeBackendItemId + ? state.backendItemIds.indexOf(activeBackendItemId) + 1 + : Math.min(terminalBackendItemIds.size + 1, state.backendItemIds.length); + + progress.set(localQueueItemId, { + activeItemIndex: Math.max(1, activeItemIndex), + completedItemCount: terminalBackendItemIds.size, + message: state.message, + percentage: state.percentage, + totalItemCount: state.backendItemIds.length, + }); + }; + + const getProgressImageTarget = (localQueueItemId: string, backendItemId: number): ProgressImageTarget => { + const backendItemIds = runProgress.get(localQueueItemId)?.backendItemIds ?? [backendItemId]; + const itemIndex = backendItemIds.indexOf(backendItemId); + + return { itemIndex: itemIndex === -1 ? 1 : itemIndex + 1, queueItemId: localQueueItemId }; + }; + + const settleWait = (backendItemId: number, outcome: TerminalOutcome): void => { + const wait = waits.get(backendItemId); + + if (!wait) { + bufferTerminalOutcome(backendItemId, outcome); + return; + } + + waits.delete(backendItemId); + const progressTarget = getProgressImageTarget(wait.localQueueItemId, backendItemId); + const clearProgressImage = (): void => progressImage.clear(progressTarget); + const state = runProgress.get(wait.localQueueItemId); + + if (state) { + state.activeBackendItemId = undefined; + if (outcome.status === 'completed') { + state.completedBackendItemIds.add(backendItemId); + } + if (outcome.status === 'canceled') { + state.cancelledBackendItemIds.add(backendItemId); + } + state.message = ''; + state.percentage = null; + publishRunProgress(wait.localQueueItemId); + } + + if (outcome.status === 'completed') { + const routingPromise = callbacks.onBackendItemComplete?.(wait.localQueueItemId, backendItemId); + + if (routingPromise) { + void Promise.resolve(routingPromise) + .finally(clearProgressImage) + .catch(() => undefined); + } else { + clearProgressImage(); + } + } + + if (outcome.status === 'canceled') { + clearProgressImage(); + callbacks.onBackendItemCancelled?.(wait.localQueueItemId, backendItemId); + } + + if (outcome.status === 'failed') { + clearProgressImage(); + } + + wait.settle(outcome); + }; + + const settleFromQueueItem = (queueItem: QueueItemDTO): void => { + if (queueItem.status !== 'pending' && queueItem.status !== 'in_progress') { + settleWait(queueItem.item_id, toTerminalOutcome(queueItem.status, queueItem.error_message, queueItem.error_type)); + } + }; + + const isTrackedEvent = (event: { item_id: number }): boolean => waits.has(event.item_id); + + const trackBackendItem = (localQueueItemId: string, backendItemId: number): Promise => { + const bufferedOutcome = recentTerminalOutcomes.get(backendItemId); + + if (bufferedOutcome) { + recentTerminalOutcomes.delete(backendItemId); + + return Promise.resolve(bufferedOutcome); + } + + return new Promise((settle) => { + waits.set(backendItemId, { localQueueItemId, settle }); + }); + }; + + const beginRun = (localQueueItemId: string, backendItemIds: number[], backendBatchId?: string): void => { + runs.set(localQueueItemId, { + backendBatchId, + backendItemIds, + outcomePromises: backendItemIds.map((backendItemId) => trackBackendItem(localQueueItemId, backendItemId)), + }); + + runProgress.set(localQueueItemId, { + backendItemIds, + cancelledBackendItemIds: new Set(), + completedBackendItemIds: new Set(), + message: '', + percentage: null, + }); + publishRunProgress(localQueueItemId); + }; + + /** Slow safety net for events lost to disconnects; runs on reconnect and on a long interval. */ + const sweep = async (): Promise => { + if (isSweeping || waits.size === 0) { + return; + } + + isSweeping = true; + + try { + await Promise.all( + [...waits.keys()].map(async (backendItemId) => { + try { + settleFromQueueItem(await api.getQueueItem(backendItemId)); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + settleWait(backendItemId, { + error: `Queue item ${backendItemId} is no longer on the backend queue.`, + status: 'failed', + }); + } + } + }) + ); + } finally { + isSweeping = false; + } + }; + + const handleStatusChanged = (event: QueueItemStatusChangedEvent): void => { + if (!isTerminalBackendStatus(event.status)) { + return; + } + + if (!isTrackedEvent(event)) { + bufferTerminalOutcome(event.item_id, toTerminalOutcome(event.status, event.error_message, event.error_type)); + return; + } + + nodeExecution.settleRunning(); + settleWait(event.item_id, toTerminalOutcome(event.status, event.error_message, event.error_type)); + + if (event.status === 'completed') { + scheduleGalleryRefresh(); + } + }; + + const handleProgress = (event: InvocationProgressEvent): void => { + const wait = waits.get(event.item_id); + + if (!wait) { + return; + } + + nodeExecution.progress(event.invocation_source_id, event.percentage, event.message); + + if (event.image?.dataURL) { + progressImage.set( + { dataUrl: event.image.dataURL, height: event.image.height, width: event.image.width }, + getProgressImageTarget(wait.localQueueItemId, event.item_id) + ); + } + + const state = runProgress.get(wait.localQueueItemId); + + if (state) { + state.activeBackendItemId = event.item_id; + state.message = event.message; + state.percentage = event.percentage; + publishRunProgress(wait.localQueueItemId); + } else { + progress.set(wait.localQueueItemId, { message: event.message, percentage: event.percentage }); + } + }; + + /** + * React to the shared socket's connection lifecycle. The hub owns the socket, + * the connection store, and `modelLoadStore.reset()`; the coordinator only + * clears its generation-scoped stores and, on (re)connect, schedules a gallery + * refresh and sweeps for events missed while disconnected. + */ + const handleConnectionChange = (status: BackendConnectionStatus): void => { + progress.clearAll?.(); + nodeExecution.clearAll(); + + if (status === 'connected') { + scheduleGalleryRefresh(); + void sweep(); + } + }; + + /** Attach generation listeners to the shared socket hub. */ + const connect = (): void => { + if (isDisposed || isAttached) { + return; + } + + isAttached = true; + + detachers.push( + hub.on('queue_item_status_changed', handleStatusChanged), + hub.on('invocation_progress', handleProgress), + hub.on('invocation_started', (event: InvocationStartedEvent) => { + if (!isTrackedEvent(event)) { + return; + } + + nodeExecution.started(event); + }), + hub.on('invocation_complete', (event: InvocationCompleteEvent) => { + if (!isTrackedEvent(event)) { + return; + } + + nodeExecution.completed(event); + }), + hub.on('invocation_error', (event: InvocationErrorEvent) => { + if (!isTrackedEvent(event)) { + return; + } + + nodeExecution.failed(event); + }), + hub.on('model_load_started', (payload: never) => { + modelLoadStore.started(payload); + }), + hub.on('model_load_complete', (payload: never) => { + modelLoadStore.completed(payload); + }) + ); + + // Fires synchronously with the current status, so attaching after the hub + // has already connected still triggers the initial clear + sweep. + detachers.push(hub.onConnectionChange(handleConnectionChange)); + + sweepTimer = setInterval(() => { + void sweep(); + }, sweepIntervalMs); + }; + + /** Detach generation listeners; the hub keeps the socket alive. */ + const dispose = (): void => { + isDisposed = true; + + for (const detach of detachers) { + detach(); + } + + detachers.length = 0; + + if (galleryRefreshTimer !== null) { + clearTimeout(galleryRefreshTimer); + galleryRefreshTimer = null; + } + + if (sweepTimer !== null) { + clearInterval(sweepTimer); + sweepTimer = null; + } + }; + + const reconcile = async (items: ReconcileInput[]): Promise> => { + const outcomes = new Map(); + + if (items.length === 0) { + return outcomes; + } + + const backendItems = items.every((item) => item.backendItemIds?.length) + ? ( + await Promise.all( + items + .flatMap((item) => item.backendItemIds ?? []) + .map(async (itemId) => { + try { + return await api.getQueueItem(itemId); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return undefined; + } + + throw error; + } + }) + ) + ).filter((item) => item !== undefined) + : await api.listAllQueueItems(); + const backendItemsById = new Map(backendItems.map((item) => [item.item_id, item])); + const backendItemsByLocalId = new Map(); + + for (const backendItem of backendItems) { + const localQueueItemId = parseQueueItemOrigin(backendItem.origin); + + if (localQueueItemId) { + backendItemsByLocalId.set(localQueueItemId, [ + ...(backendItemsByLocalId.get(localQueueItemId) ?? []), + backendItem, + ]); + } + } + + for (const item of items) { + const knownBackendItems = item.backendItemIds?.length + ? item.backendItemIds.map((backendItemId) => backendItemsById.get(backendItemId)) + : (backendItemsByLocalId.get(item.id) ?? []); + const foundBackendItems = knownBackendItems.filter((backendItem) => backendItem !== undefined); + + if (foundBackendItems.length !== knownBackendItems.length || foundBackendItems.length === 0) { + // A pending item with no backend trace was never accepted and is safe + // to submit; a running item with (partially) vanished backend items is + // unrecoverable. + outcomes.set(item.id, item.status === 'pending' ? { kind: 'enqueue' } : { kind: 'missing' }); + continue; + } + + const backendItemIds = foundBackendItems.map((backendItem) => backendItem.item_id); + const backendBatchId = item.backendBatchId ?? foundBackendItems[0]?.batch_id; + + beginRun(item.id, backendItemIds, backendBatchId); + + for (const backendItem of foundBackendItems) { + settleFromQueueItem(backendItem); + } + + outcomes.set( + item.id, + item.status === 'running' ? { kind: 'resumed' } : { backendBatchId, backendItemIds, kind: 'adopted' } + ); + } + + return outcomes; + }; + + const submitGenerate = async ( + localQueueItemId: string, + request: EnqueueGenerateRequest + ): Promise => { + const result = await api.enqueueGenerateGraph(request); + + if (result.enqueued === 0) { + throw new Error('The backend queue did not accept this generation. The queue may be full.'); + } + + if (result.requested !== result.enqueued) { + throw new Error(`The backend queue accepted ${result.enqueued} of ${result.requested} requested items.`); + } + + beginRun(localQueueItemId, result.itemIds, result.batchId); + + return result; + }; + + const submitWorkflow = async ( + localQueueItemId: string, + request: EnqueueWorkflowRequest + ): Promise => { + const result = await api.enqueueWorkflowGraph(request); + + if (result.enqueued === 0) { + throw new Error('The backend queue did not accept this workflow. The queue may be full.'); + } + + if (result.requested !== result.enqueued) { + throw new Error(`The backend queue accepted ${result.enqueued} of ${result.requested} requested items.`); + } + + beginRun(localQueueItemId, result.itemIds, result.batchId); + + return result; + }; + + const waitForResults = async (localQueueItemId: string, queuedAt: string): Promise => { + const run = runs.get(localQueueItemId); + + if (!run) { + throw new Error(`Queue item ${localQueueItemId} has no tracked backend run.`); + } + + try { + const outcomes = await Promise.all(run.outcomePromises); + const failure = outcomes.find((outcome) => outcome.status === 'failed'); + + if (failure) { + throw new Error(failure.error); + } + + const completedBackendItemIds = run.backendItemIds.filter( + (_backendItemId, index) => outcomes[index]?.status === 'completed' + ); + + if (completedBackendItemIds.length === 0 && outcomes.some((outcome) => outcome.status === 'canceled')) { + throw new QueueItemCancelledError(localQueueItemId); + } + + const imagesPerItem = await Promise.all( + completedBackendItemIds.map((backendItemId) => + api.getQueueItemResultImages(backendItemId, localQueueItemId, queuedAt) + ) + ); + + return imagesPerItem.flat(); + } finally { + runs.delete(localQueueItemId); + runProgress.delete(localQueueItemId); + progress.clear(localQueueItemId); + } + }; + + const cancelRun = async ({ backendBatchId, backendItemIds }: CancelRunRequest): Promise => { + try { + if (backendItemIds?.length) { + await api.cancelQueueItems(backendItemIds); + return; + } + + if (backendBatchId) { + await api.cancelQueueItemsByBatchIds([backendBatchId]); + } + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return; + } + + throw error; + } + }; + + return { cancelRun, connect, dispose, reconcile, submitGenerate, submitWorkflow, waitForResults }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/backend/socketHub.test.ts b/invokeai/frontend/webv2/src/workbench/backend/socketHub.test.ts new file mode 100644 index 00000000000..3f2a574786b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/socketHub.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { BackendSocket } from './socketHub'; + +import { getConnectionStatus } from './connectionStore'; +import { modelLoadStore } from './modelLoadStore'; +import { createSocketHub } from './socketHub'; + +class FakeSocket implements BackendSocket { + readonly emitted: { event: string; payload: unknown }[] = []; + private readonly handlers = new Map void>>(); + + on(event: string, handler: (payload: never) => void): void { + let handlers = this.handlers.get(event); + + if (!handlers) { + handlers = new Set(); + this.handlers.set(event, handlers); + } + + handlers.add(handler); + } + + off(event: string, handler: (payload: never) => void): void { + this.handlers.get(event)?.delete(handler); + } + + emit(event: string, payload: unknown): void { + this.emitted.push({ event, payload }); + } + + connect(): void { + this.fire('connect', undefined); + } + + disconnect(): void { + this.fire('disconnect', 'io client disconnect'); + } + + fire(event: string, payload: unknown): void { + for (const handler of this.handlers.get(event) ?? []) { + (handler as (value: unknown) => void)(payload); + } + } +} + +describe('socketHub', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('connects, reports connected status, and subscribes to the queue', () => { + const socket = new FakeSocket(); + const hub = createSocketHub({ createSocket: () => socket }); + const onChange = vi.fn(); + + hub.onConnectionChange(onChange); + onChange.mockClear(); + hub.connect(); + + expect(getConnectionStatus().status).toBe('connected'); + expect(onChange).toHaveBeenNthCalledWith(1, 'connecting', undefined); + expect(onChange).toHaveBeenLastCalledWith('connected', undefined); + expect(socket.emitted).toContainEqual({ event: 'subscribe_queue', payload: { queue_id: 'default' } }); + }); + + it('fires the current status synchronously on subscribe', () => { + const socket = new FakeSocket(); + const hub = createSocketHub({ createSocket: () => socket }); + + hub.connect(); + + const late = vi.fn(); + + hub.onConnectionChange(late); + + expect(late).toHaveBeenCalledWith('connected', undefined); + }); + + it('is idempotent — repeated connect keeps one socket', () => { + let created = 0; + const hub = createSocketHub({ + createSocket: () => { + created += 1; + + return new FakeSocket(); + }, + }); + + hub.connect(); + hub.connect(); + + expect(created).toBe(1); + }); + + it('delivers consumer listeners and detaches them on unsubscribe', () => { + const socket = new FakeSocket(); + const hub = createSocketHub({ createSocket: () => socket }); + + hub.connect(); + + const handler = vi.fn(); + const off = hub.on('queue_item_status_changed', handler); + + socket.fire('queue_item_status_changed', { item_id: 1 }); + expect(handler).toHaveBeenCalledTimes(1); + + off(); + socket.fire('queue_item_status_changed', { item_id: 2 }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('re-binds consumer listeners after a reconnect', () => { + const sockets: FakeSocket[] = []; + const hub = createSocketHub({ + createSocket: () => { + const socket = new FakeSocket(); + + sockets.push(socket); + + return socket; + }, + }); + + hub.connect(); + + const handler = vi.fn(); + + hub.on('queue_item_status_changed', handler); + hub.disconnect(); + hub.connect(); + + expect(sockets).toHaveLength(2); + + sockets[1]!.fire('queue_item_status_changed', { item_id: 1 }); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('resets model loads and reports a disconnect', () => { + const resetSpy = vi.spyOn(modelLoadStore, 'reset'); + const socket = new FakeSocket(); + const hub = createSocketHub({ createSocket: () => socket }); + + hub.connect(); + expect(resetSpy).toHaveBeenCalled(); + + socket.fire('disconnect', 'transport close'); + + expect(getConnectionStatus().status).toBe('disconnected'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/backend/socketHub.ts b/invokeai/frontend/webv2/src/workbench/backend/socketHub.ts new file mode 100644 index 00000000000..344d236bb80 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/socketHub.ts @@ -0,0 +1,150 @@ +import type { BackendConnectionStatus } from '@workbench/types'; + +import { io } from 'socket.io-client'; + +import { setConnectionStatus } from './connectionStore'; +import { getAuthToken, getBackendSocketUrl } from './http'; +import { modelLoadStore } from './modelLoadStore'; + +const SOCKET_PATH = '/ws/socket.io'; + +/** + * The minimal Socket.IO surface the hub uses; tests substitute a fake. + */ +export interface BackendSocket { + on(event: string, handler: (payload: never) => void): unknown; + off(event: string, handler: (payload: never) => void): unknown; + emit(event: string, payload: unknown): unknown; + connect(): unknown; + disconnect(): unknown; +} + +export type ConnectionListener = (status: BackendConnectionStatus, error?: string) => void; + +/** + * Owns the single backend socket for the whole authenticated app. It is only a + * transport/status hub; feature runtimes attach their own listeners so admin + * model code and editor queue code do not leak into the base Launchpad bundle. + */ +export interface SocketHub { + /** Idempotent: connects the single socket if one is not already live. */ + connect(): void; + /** Tears down the socket (identity change / logout); a later `connect` rebuilds it. */ + disconnect(): void; + /** Attach a raw socket listener; returns an unsubscribe. Survives socket recreation. */ + on(event: string, handler: (payload: never) => void): () => void; + emit(event: string, payload: unknown): void; + /** Subscribe to connection transitions; fires synchronously with current status on subscribe. */ + onConnectionChange(handler: ConnectionListener): () => void; +} + +const createDefaultSocket = (): BackendSocket => { + const token = getAuthToken(); + + // Socket.IO's generic `off` overload does not structurally match our minimal + // facade; the socket satisfies the surface we actually use, so narrow it here. + return io(getBackendSocketUrl(), { + auth: token ? { token } : undefined, + autoConnect: false, + extraHeaders: token ? { Authorization: `Bearer ${token}` } : undefined, + path: SOCKET_PATH, + timeout: 60000, + }) as unknown as BackendSocket; +}; + +export const createSocketHub = (options: { createSocket?: () => BackendSocket } = {}): SocketHub => { + const createSocket = options.createSocket ?? createDefaultSocket; + + let socket: BackendSocket | null = null; + let status: BackendConnectionStatus = 'connecting'; + let lastError: string | undefined; + + /** Registered consumer listeners, kept so they can be re-bound to a fresh socket. */ + const eventHandlers = new Map void>>(); + const connectionListeners = new Set(); + + const publishStatus = (next: BackendConnectionStatus, error?: string): void => { + status = next; + lastError = error; + setConnectionStatus(next, error); + + for (const listener of connectionListeners) { + listener(next, error); + } + }; + + const connect = (): void => { + if (socket) { + return; + } + + const nextSocket = createSocket(); + + socket = nextSocket; + publishStatus('connecting'); + + nextSocket.on('connect', () => { + modelLoadStore.reset(); + publishStatus('connected'); + nextSocket.emit('subscribe_queue', { queue_id: 'default' }); + }); + nextSocket.on('connect_error', (error: { message: string }) => { + modelLoadStore.reset(); + publishStatus('disconnected', error.message); + }); + nextSocket.on('disconnect', (reason: string) => { + modelLoadStore.reset(); + publishStatus('disconnected', reason); + }); + + // Re-bind consumer listeners so they survive a socket recreation. + for (const [event, handlers] of eventHandlers) { + for (const handler of handlers) { + nextSocket.on(event, handler); + } + } + + nextSocket.connect(); + }; + + const disconnect = (): void => { + socket?.disconnect(); + socket = null; + publishStatus('connecting'); + }; + + const on = (event: string, handler: (payload: never) => void): (() => void) => { + let handlers = eventHandlers.get(event); + + if (!handlers) { + handlers = new Set(); + eventHandlers.set(event, handlers); + } + + handlers.add(handler); + socket?.on(event, handler); + + return () => { + eventHandlers.get(event)?.delete(handler); + socket?.off(event, handler); + }; + }; + + const emit = (event: string, payload: unknown): void => { + socket?.emit(event, payload); + }; + + const onConnectionChange = (handler: ConnectionListener): (() => void) => { + connectionListeners.add(handler); + handler(status, lastError); + + return () => { + connectionListeners.delete(handler); + }; + }; + + return { connect, disconnect, emit, on, onConnectionChange }; +}; + +/** The app-wide socket hub singleton, connected by `SocketHubRuntime`. */ +export const socketHub = createSocketHub(); diff --git a/invokeai/frontend/webv2/src/workbench/components/InvokeMark.tsx b/invokeai/frontend/webv2/src/workbench/components/InvokeMark.tsx new file mode 100644 index 00000000000..d77971e5ac4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/InvokeMark.tsx @@ -0,0 +1,14 @@ +import { Box } from '@chakra-ui/react'; + +/** The Invoke logomark, drawn in the theme brand color. */ +export const InvokeMark = ({ size = 36 }: { size?: number }) => ( + + + +); diff --git a/invokeai/frontend/webv2/src/workbench/components/QueueProgressIndicator.tsx b/invokeai/frontend/webv2/src/workbench/components/QueueProgressIndicator.tsx new file mode 100644 index 00000000000..8e1278eabdf --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/QueueProgressIndicator.tsx @@ -0,0 +1,65 @@ +import type { QueueProgressBarState } from '@workbench/queueSummary'; +import type { ComponentProps } from 'react'; + +import { Progress, ProgressCircle } from '@chakra-ui/react'; + +const getProgressValuePercent = (state: QueueProgressBarState): number | null => + state.kind === 'determinate' ? state.value * 100 : state.value; + +type ProgressCircleRootProps = ComponentProps; +type QueueCircularProgressSize = NonNullable | '2xs'; + +export const QueueCircularProgress = ({ + size = '2xs', + state, + ...props +}: Omit & { + size?: QueueCircularProgressSize; + state: QueueProgressBarState; +}) => { + if (state.kind === 'idle') { + return null; + } + + return ( + + + + + + + ); +}; + +export const QueueTabBackgroundProgress = ({ + state, + ...props +}: Omit, 'value'> & { state: QueueProgressBarState }) => { + if (state.kind === 'idle') { + return null; + } + + return ( + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx b/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx new file mode 100644 index 00000000000..55273c07721 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx @@ -0,0 +1,39 @@ +import splashImageUrl from '@assets/SplashImage.webp'; +import { Box, Flex, Heading, HStack, Spinner, Text, VStack } from '@chakra-ui/react'; +import { InvokeMark } from '@workbench/components/InvokeMark'; + +const splashImageStyle = { backgroundPosition: 'center', backgroundSize: 'cover' } as const; + +export const WorkbenchSplashScreen = ({ message = 'Loading workspace' }: { message?: string }) => ( + + + + + + + + Invoke AI 7.0 + Image Generation for Creatives + Artwork by John Smith + + + + {message} + + + + © {new Date().getFullYear()} Invoke AI | All rights reserved + + + + + +); diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Button.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Button.tsx new file mode 100644 index 00000000000..29d6140be1f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Button.tsx @@ -0,0 +1,30 @@ +import type { ComponentProps } from 'react'; + +import { + Button as ChakraButton, + CloseButton as ChakraCloseButton, + IconButton as ChakraIconButton, +} from '@chakra-ui/react'; + +type ButtonProps = ComponentProps; +type IconButtonProps = ComponentProps; +type CloseButtonProps = ComponentProps; + +/** + * Workbench buttons. Solid buttons default to the blue `accent` palette; every + * other variant stays on the neutral, theme-aware `gray` palette. Pass + * `colorPalette` explicitly to override (e.g. `brand` for the global Invoke + * action, `red` for destructive actions). + */ +const defaultPalette = (variant: ButtonProps['variant']): ButtonProps['colorPalette'] => + variant === undefined || variant === 'solid' ? 'accent' : 'gray'; + +export const Button = ({ colorPalette, ...props }: ButtonProps) => ( + +); + +export const IconButton = ({ colorPalette, ...props }: IconButtonProps) => ( + +); + +export const CloseButton = (props: CloseButtonProps) => ; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/ConfirmDialog.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/ConfirmDialog.tsx new file mode 100644 index 00000000000..cc6766b68aa --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/ConfirmDialog.tsx @@ -0,0 +1,91 @@ +/* eslint-disable react/react-compiler */ +import { Dialog, Portal, Stack, Text } from '@chakra-ui/react'; +import { useCallback, useState, type ReactNode } from 'react'; + +import { Button, CloseButton } from './Button'; + +/** + * Controlled confirmation dialog for consequential actions. The confirm + * button shows a pending state while `onConfirm` runs; errors are left to the + * caller (usually surfaced as a notification) and the dialog closes either way. + */ +export const ConfirmDialog = ({ + body, + confirmLabel, + isDestructive = true, + isOpen, + onClose, + onConfirm, + title, +}: { + body: ReactNode; + confirmLabel: string; + isDestructive?: boolean; + isOpen: boolean; + onClose: () => void; + onConfirm: () => Promise | void; + title: string; +}) => { + const [isPending, setIsPending] = useState(false); + + const handleConfirm = useCallback(async () => { + setIsPending(true); + + try { + await onConfirm(); + } finally { + setIsPending(false); + onClose(); + } + }, [onClose, onConfirm]); + + const handleOpenChange = useCallback( + (event: { open: boolean }) => { + if (!event.open) { + onClose(); + } + }, + [onClose] + ); + + const handleConfirmClick = useCallback(() => { + void handleConfirm(); + }, [handleConfirm]); + + return ( + + + + + + + + {title} + + + + {typeof body === 'string' ? {body} : body} + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/EmptyState.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/EmptyState.tsx new file mode 100644 index 00000000000..fb37633fbb4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/EmptyState.tsx @@ -0,0 +1,30 @@ +import { EmptyState as ChakraEmptyState, VStack } from '@chakra-ui/react'; +import * as React from 'react'; + +export interface EmptyStateProps extends ChakraEmptyState.RootProps { + title: string; + description?: string | null; + icon?: React.ReactNode; + danger?: boolean; +} + +export const EmptyState = React.forwardRef(function EmptyState(props, ref) { + const { title, description, icon, children, danger, ...rest } = props; + const fgColor = danger ? 'fg.error' : undefined; + return ( + + + {icon && {icon}} + {description ? ( + + {title} + {description} + + ) : ( + {title} + )} + {children} + + + ); +}); diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx new file mode 100644 index 00000000000..153073fa9c4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react'; + +import { HStack, Stack, Text, useRecipe, type StackProps } from '@chakra-ui/react'; +import { fieldLabelRecipe } from '@theme/recipes'; + +/** + * The shared, theme-aware uppercase field label. Backed by `fieldLabelRecipe` so + * every form across the workbench renders an identical label without repeating + * the same five style props inline. + */ +export const FieldLabel = ({ children }: { children: ReactNode }) => { + const recipe = useRecipe({ recipe: fieldLabelRecipe }); + + return ( + + {children} + + ); +}; + +export interface FieldProps extends Omit { + label: string; + labelEnd?: ReactNode; + /** Validation error, shown in place of `helpText`. Mark the control itself invalid via `aria-invalid`. */ + error?: string | null; + helpText?: string; + children: ReactNode; +} + +/** A labelled form field: an uppercase label stacked above its control, with an optional help/error line below. */ +export const Field = ({ children, error, helpText, label, labelEnd, ...rest }: FieldProps) => ( + + + {label} + {labelEnd} + + {children} + {error ? ( + + {error} + + ) : helpText ? ( + + {helpText} + + ) : null} + +); diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/JsonPreview.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/JsonPreview.tsx new file mode 100644 index 00000000000..5d4f34131b8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/JsonPreview.tsx @@ -0,0 +1,112 @@ +import { Box, Code, Icon, ScrollArea } from '@chakra-ui/react'; +import { CheckIcon, CopyIcon } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { IconButton } from './Button'; +import { toaster } from './toaster'; + +/** + * The workbench's standard JSON preview: a monospace block with a copy button + * that owns its scrolling in both axes — long strings scroll horizontally + * instead of stretching the surrounding layout. Pass `value` to serialize, or + * `text` when the JSON string already exists (an export payload that must be + * copied byte-for-byte). Defaults to a bounded height; pass `maxH` (or wrap in + * a flex parent and pass `maxH="100%"`) to control it. + */ +export const JsonPreview = ({ + h, + label = 'JSON preview', + maxH = '24rem', + text, + value, +}: { + h?: string; + /** Accessible name for the scroll viewport. */ + label?: string; + maxH?: string; + text?: string; + value?: unknown; +}) => { + const [hasCopied, setHasCopied] = useState(false); + const copyResetTimerRef = useRef | null>(null); + const json = useMemo(() => text ?? JSON.stringify(value, null, 2) ?? 'null', [text, value]); + + useEffect( + () => () => { + if (copyResetTimerRef.current !== null) { + clearTimeout(copyResetTimerRef.current); + } + }, + [] + ); + + const copy = useCallback(() => { + navigator.clipboard + .writeText(json) + .then(() => { + setHasCopied(true); + + if (copyResetTimerRef.current !== null) { + clearTimeout(copyResetTimerRef.current); + } + + copyResetTimerRef.current = setTimeout(() => setHasCopied(false), 1500); + }) + .catch(() => toaster.create({ title: 'Failed to copy JSON', type: 'error' })); + }, [json]); + + return ( + + + + + + + + + {json} + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Menu.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Menu.tsx new file mode 100644 index 00000000000..4d295159567 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Menu.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'react'; + +import { Menu } from '@chakra-ui/react'; + +type MenuContentProps = ComponentProps; + +/** + * Menu.Content passthrough. The workbench popover chrome (surface, stroke, + * radius, shadow) is applied globally by the `menu` slot-recipe override in + * `theme/recipes.ts`; this wrapper only exists as the single import point + * for future menu-wide behavior. + */ +export const MenuContent = (props: MenuContentProps) => ; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Panel.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Panel.tsx new file mode 100644 index 00000000000..9f92013a96b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Panel.tsx @@ -0,0 +1,19 @@ +import { Box, type BoxProps, type RecipeVariantProps, useRecipe } from '@chakra-ui/react'; +import { panelRecipe } from '@theme/recipes'; +import { useMemo } from 'react'; + +export type PanelProps = BoxProps & RecipeVariantProps; + +/** + * Bordered surface container backed by `panelRecipe` — the one styling + * contract for panels, cards, and wells across the workbench. + * + * - `tone`: `surface` (default) | `raised` | `inset` | `control` + * - `density`: `none` (default) | `sm` | `md` — padding + gap presets + */ +export const Panel = ({ css, density, tone, ...rest }: PanelProps) => { + const recipe = useRecipe({ recipe: panelRecipe }); + const panelCss = useMemo(() => [recipe({ density, tone }), css], [css, density, recipe, tone]); + + return ; +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/RenameDialog.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/RenameDialog.tsx new file mode 100644 index 00000000000..eaf0fc42ab3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/RenameDialog.tsx @@ -0,0 +1,112 @@ +import { chakra, Dialog, Input, Portal, Stack } from '@chakra-ui/react'; +import { useCallback, useState, type FormEvent } from 'react'; + +import { Button, CloseButton } from './Button'; +import { Field } from './Field'; + +/** + * Controlled single-field rename dialog. `onSubmit` only fires for a + * non-empty name that actually changed; it may be async, in which case the + * submit button shows a pending state and errors keep the dialog open so the + * caller's message (usually a toast) can be acted on. + */ +export const RenameDialog = ({ + initialName, + isOpen, + label = 'Project name', + onClose, + onSubmit, + submitLabel = 'Rename', + submitUnchanged = false, + title = 'Rename project', +}: { + initialName: string; + isOpen: boolean; + label?: string; + onClose: () => void; + onSubmit: (name: string) => Promise | void; + submitLabel?: string; + submitUnchanged?: boolean; + title?: string; +}) => { + const [isPending, setIsPending] = useState(false); + + const commit = useCallback( + async (value: string) => { + const name = value.trim(); + + if (!name || (!submitUnchanged && name === initialName.trim())) { + onClose(); + + return; + } + + setIsPending(true); + + try { + await onSubmit(name); + onClose(); + } catch { + // The caller surfaced the failure (toast/notification); stay open so + // the name is not lost. + } finally { + setIsPending(false); + } + }, + [initialName, onClose, onSubmit, submitUnchanged] + ); + + const handleOpenChange = useCallback( + (event: { open: boolean }) => { + if (!event.open) { + onClose(); + } + }, + [onClose] + ); + + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + void commit(new FormData(event.currentTarget).get('renameValue')?.toString() ?? ''); + }, + [commit] + ); + + return ( + + + + + + + + + {title} + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/ResizableTextarea.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/ResizableTextarea.tsx new file mode 100644 index 00000000000..9fede592834 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/ResizableTextarea.tsx @@ -0,0 +1,164 @@ +/* eslint-disable react/react-compiler */ +import type { + ComponentProps, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, + Ref, + ReactNode, +} from 'react'; + +import { Box, Textarea } from '@chakra-ui/react'; +import { useCallback, useState } from 'react'; + +type TextareaProps = ComponentProps; + +const DEFAULT_STEP_PX = 12; +const DEFAULT_LARGE_STEP_PX = 48; + +const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); + +const resizeHandleAfter = { + bg: 'border.emphasized', + borderRadius: 'full', + bottom: '1px', + content: '""', + h: '1px', + left: '25%', + opacity: 0.55, + position: 'absolute', + right: '25%', +} as const; + +const resizeHandleFocusVisible = { bg: 'accent.solid/20', outline: '2px solid {colors.accent.solid}' } as const; +const resizeHandleHover = { bg: 'accent.solid/12' } as const; + +export interface ResizableTextareaProps extends Omit< + TextareaProps, + 'h' | 'height' | 'maxH' | 'maxHeight' | 'minH' | 'minHeight' | 'resize' +> { + defaultHeightPx: number; + maxHeightPx?: number; + minHeightPx: number; + resizeHandleAriaLabel: string; + largeStepPx?: number; + stepPx?: number; + textareaRef?: Ref; + underlay?: ReactNode; + onResizeEnd?: (heightPx: number) => void; +} + +export const ResizableTextarea = ({ + defaultHeightPx, + largeStepPx = DEFAULT_LARGE_STEP_PX, + maxHeightPx = 420, + minHeightPx, + onResizeEnd, + resizeHandleAriaLabel, + stepPx = DEFAULT_STEP_PX, + textareaRef, + underlay, + ...textareaProps +}: ResizableTextareaProps) => { + const initialHeightPx = clamp(defaultHeightPx, minHeightPx, maxHeightPx); + const [heightPx, setHeightPx] = useState(initialHeightPx); + const [dragHeightPx, setDragHeightPx] = useState(null); + const displayHeightPx = dragHeightPx ?? heightPx; + + const commitHeight = useCallback( + (nextHeightPx: number) => { + const clampedHeightPx = clamp(nextHeightPx, minHeightPx, maxHeightPx); + + setHeightPx(clampedHeightPx); + onResizeEnd?.(clampedHeightPx); + }, + [maxHeightPx, minHeightPx, onResizeEnd] + ); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + + const startY = event.clientY; + const startHeightPx = displayHeightPx; + let nextHeightPx = startHeightPx; + + const handlePointerMove = (moveEvent: PointerEvent) => { + nextHeightPx = clamp(startHeightPx + moveEvent.clientY - startY, minHeightPx, maxHeightPx); + setDragHeightPx(nextHeightPx); + }; + + const handlePointerUp = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + setDragHeightPx(null); + commitHeight(nextHeightPx); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); + }, + [commitHeight, displayHeightPx, maxHeightPx, minHeightPx] + ); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + const step = event.shiftKey ? largeStepPx : stepPx; + const heightChange = + event.key === 'ArrowDown' + ? step + : event.key === 'ArrowUp' + ? -step + : event.key === 'End' + ? maxHeightPx - displayHeightPx + : event.key === 'Home' + ? minHeightPx - displayHeightPx + : undefined; + + if (heightChange === undefined) { + return; + } + + event.preventDefault(); + commitHeight(displayHeightPx + heightChange); + }, + [commitHeight, displayHeightPx, largeStepPx, maxHeightPx, minHeightPx, stepPx] + ); + + return ( + + {underlay} +